ステートマシンとWebアプリケーション開発

2018年が来て、アプリケーションを作成する多くの素晴らしい方法が見つかりましたが、フロントエンド開発者の無数の軍隊は、Webプロジェクトのシンプルさと柔軟性のためにまだ戦い続けています。 彼らは、エラーのないソフトウェアアーキテクチャを見つけて、迅速かつ効率的に作業を行えるようにするという、大切な目標を達成するために、毎月費やしています。 私はこれらの開発者の一人です。 勝つチャンスを与えてくれる面白いものを見つけることができた。









ReactReduxなどのツールにより、Web開発は正しい方向に大きな一歩を踏み出すことできました。 ただし、それらだけでは大規模なアプリケーションを作成するには不十分です。 Webアプリケーションのクライアント側部分の開発状況は、ステートマシンの使用を大幅に改善できるようです。 それらについては、この資料で説明します。 ところで、おそらくこれらのマシンのいくつかを既に構築しているが、それについてはまだ知らない。



ステートマシンの紹介



ステートマシンは、計算の数学モデルです。 これは抽象的な概念であり、マシンはさまざまな状態を持つことができますが、ある時点ではそのうちの1つだけにとどまります。 最も有名なステートマシンはチューリングマシンだと思います。 これは、無制限の数の状態を持つマシンです。つまり、無制限の数の状態を持つことができます。 ほとんどの場合、状態の数が有限であるため、チューリングマシンは最新のインターフェイス開発のニーズを十分に満たしていません。 そのため、状態の数が限られているマシン、またはしばしば呼ばれるように、有限状態マシンは、私たちにとってより良いです。 これはMily マシンMooreマシンです。



2つの違いは、ムーアオートマトンが以前の状態のみに基づいて状態を変更することです。 ただし、ネットワーク上で行われるユーザーのアクションやプロセスなど、多くの外部要因があります。つまり、ムーアマシンも私たちには適していないということです。 私たちが探しているのは、Milesマシンに非常によく似ています。 この有限状態マシンには初期状態があり、その後、入力データとその現在の状態に基づいて、新しい状態になります。



ステートマシンの動作を説明する最も簡単な方法の1つは、回転式改札機との類推を通じてそれを考慮することです。 限られた状態のセットがあります:それは閉じているか開いていることができます。 私たちの回転式改札口は、特定のエリアへの入り口をブロックします。 彼に3つのスラットを備えたドラムと、お金を受け取るメカニズムを持たせてください。 コインをコインアクセプターに降ろすと閉じたターンスタイルを通過できます。コインアクセプターはデバイスを開いた状態にし、ターンスタイルを通過するためにバーを押します。 改札口を通過した後、再び閉じます。 以下に、これらの状態、および可能な入力信号と状態間の遷移を示す簡単な図を示します。









回転式改札口の初期状態は「クローズ」(ロック)です。 何回彼のバーを押しても、彼は閉じたままになります。 ただし、コインをドロップすると、ターンスタイルは「オープン」(ロック解除)状態になります。 現時点では、回転式改札口は開いたままなので、別のコインは何も変更しません。 一方、ターンスタイルバーを押すことは理にかなっており、それを通り抜けることができます。 さらに、このアクションは、有限状態マシンを初期の「閉じた」状態に移行します。



回転式改札口を制御する唯一の関数を実装する必要がある場合は、おそらく2つの引数に焦点を当てる必要があります。これは現在の状態とアクションです。 Reduxを使用している場合は、これに慣れているかもしれません。 これは、現在の状態を取得し、アクションペイロードに基づいて次の状態を決定する既知のレデューサー関数に似ています。 レデューサーは、ステートマシンのコンテキストにおける遷移です。 実際、何らかの方法で変更できる状態を持つアプリケーションは、ステートマシンと呼ばれます。 実際、これらすべてが何度も何度も手動で実装されています。



ステートマシンの長所は何ですか?



職場ではReduxを使用していますが、これは私たちにぴったりです。 しかし、気に入らない点に気づき始めました。 何かが好きではないからといって、それが機能しないという意味ではありません。 これはすべて、プロジェクトに複雑さを追加し、より多くのコードを書くことを余儀なくされるという事実に関するものです。 サードパーティのプロジェクトを開始し、そこで実験の余地があったので、ReactとReduxでの開発アプローチを再考することにしました。 気になっていることについてメモを取り始め、ステートマシンの抽象化がこれらの問題のいくつかを解決する可能性が高いことに気付きました。 ビジネスに取り掛かり、JavaScriptでステートマシンを実装する方法を見てみましょう。



簡単な問題を解決します。 サーバーAPIからデータを取得し、ユーザーに表示する必要があります。 最初のステップは、問題について考えるとき、遷移ではなく状態の観点から考える方法を理解することです。 ステートマシンに移る前に、作成したいものがどのように見えるかについて、高レベルで説明します。











ここで、問題を分析し、直線的に考え、実際、最終結果へのすべての可能な経路をカバーしようとします。 あるステップは別のステップにつながり、次のステップは別のステップにつながります。 コードでは、これは分岐演算子として表現できます。 ユーザーとシステムのアクションに基づいて構築されたプログラムで思考実験を行ってみましょう。



ユーザーがボタンを2回クリックする状況はどうですか? サーバーからの応答を待っている間にユーザーがボタンをクリックするとどうなりますか? 要求は成功したが、データが破損した場合、システムはどのように動作しますか?

このような状況を処理するには、何が起こっているのかを示すさまざまなフラグが必要になるでしょう。 フラグの存在は、 if



の数の増加を意味し、より複雑なアプリケーションでは、競合の数の増加を意味します。









それは、移行期に考えるからです。 プログラムの変更が正確にどのように発生するか、およびそれらが発生する順序に焦点を当てます。 代わりに、アプリケーションのさまざまな状態に焦点を合わせると、すべてが大幅に簡素化されます。 いくつの条件がありますか? 彼らのインプットは何ですか? 同じ例を使用します。





ここでは、同じプロセスについて説明しましたが、現在は状態と入力データを使用しています。









これにより、ロジックが簡素化され、予測しやすくなりました。 さらに、上記の問題のいくつかを解決しました。 マシンがfetching



状態にあるとき、ボタンのマウスクリックに関連するイベントを受け入れないことに注意してください。 したがって、ユーザーがボタンをクリックしても、マシンはfetching



状態にあるときにこのアクションに反応するように構成されていないため、何も起こりません。 このアプローチにより、コードロジックの予期しない分岐が自動的に排除されます。



これは、テスト中により少ないコードをカバーする必要があることを意味します。 さらに、統合テストなどの一部のタイプのテストは自動化できます。 このアプローチを使用すると、アプリケーションの実行内容を非常に明確に理解でき、定義済みの状態と遷移を通過してステートメントを生成するスクリプトを作成できると考えてください。 これらのステートメントは、可能性のある各状態に到達したこと、または特定の遷移シーケンスを完了したことを証明できます。



実際、どの状態が必要か、どの状態を持っているかを知っているので、可能なすべての状態を書き出す方が、可能なすべての遷移を書き出すよりも簡単です。 ところで、ほとんどの場合、状態はアプリケーションの機能のロジックを記述します。 しかし、トランジションについて話す場合、それらの意味はしばしば作業の開始時に不明です。 プログラムのエラーは、アプリケーションがこれらのアクション用に設計されていない状態にあるときにアクションが実行されるという事実の結果です。 さらに、アプリケーションが適切な状態にある場合でも、アクションが間違った時間に実行される可能性があります。 このようなアクションにより、アプリケーションは不明な状態になり、これによりプログラムが無効になるか、正しく動作しないという事実につながります。 もちろん、必要ありません。 ステートマシンは、このような問題に対する優れた対策です。 これにより、発生する可能性のある状況を明確に指定することなく、発生する可能性のある境界を設定するため、未知の状態に到達することを防ぎます。 ステートマシンの概念は、単方向のデータストリームに適しています。 一緒に、コードの複雑さを軽減し、システムが特定の状態になった方法に関する質問に明確な回答を提供します。



JavaScriptを使用してステートマシンを作成する



十分な話-それはプログラムする時間です。 同じ例を使用します。 上記のリストに基づいて、次のコードから始めましょう。



 const machine = { 'idle': {   click: function () { ... } }, 'fetching': {   success: function () { ... },   failure: function () { ... } }, 'error': {   'retry': function () { ... } } }
      
      





状態はオブジェクトによって表され、可能な入力状態信号はオブジェクトのメソッドによって表されます。 ただし、初期状態はここにはありません。 上記のコードを変更して、次の形式にします。



 const machine = { state: 'idle', transitions: {   'idle': {     click: function() { ... }   },   'fetching': {     success: function() { ... },     failure: function() { ... }   },   'error': {     'retry': function() { ... }   } } }
      
      





意味のある状態をすべて特定したら、入力信号を送信してシステムの状態を変更する準備が整います。 これを行うには、次の2つの補助的な方法を使用します。



 const machine = { dispatch(actionName, ...payload) {   const actions = this.transitions[this.state];   const action = this.transitions[this.state][actionName];   if (action) {     action.apply(machine, ...payload);   } }, changeStateTo(newState) {   this.state = newState; }, ... }
      
      





dispatch



機能は、現在の状態の遷移の中に、指定された名前のアクションがあるかどうかを確認します。 その場合、彼女はこのアクションを呼び出し、呼び出し中に転送されたデータを彼に渡します。 さらに、 action



ハンドラはmachine



をコンテキストとして呼び出されるため、 this.dispatch(<action>)



を使用して別のアクションをthis.dispatch(<action>)



this.dispatch(<action>)



this.changeStateTo(<new state>)



を使用して状態を変更したりできます。



この例のユーザーパスに従って、ディスパッチする必要がある最初のアクションはclick



です。 このアクションのハンドラーは次のようになります。



 transitions: { 'idle': {   click: function () {     this.changeStateTo('fetching');     service.getData().then(       data => {         try {           this.dispatch('success', JSON.parse(data));         } catch (error) {           this.dispatch('failure', error)         }       },       error => this.dispatch('failure', error)     );   } }, ... } machine.dispatch('click');
      
      





最初に、マシンの状態をfetching



ます。 次に、サーバーへのリクエストを実行します。 promiseを返すgetData



メソッドを持つサービスがあるとします。 このプロミスが解決され、データが正常に解析された後、 succes



イベントをディスパッチします。



すべてが正常に進行している間。 次に、 success



アクションとfailure



アクションを実装し、 fetching



ステータスの入力を記述する必要があります。



 transitions: { 'idle': { ... }, 'fetching': {   success: function (data) {     //       this.changeStateTo('idle');   },   failure: function (error) {     this.changeStateTo('error');   } }, ... }
      
      





以前のプロセスについて考える必要から自分を救ったことに注意してください。 ユーザーがボタンをクリックすることや、HTTPリクエストで何が起こるかは気にしません。 アプリケーションがfetching



状態にあることはわかっており、これら2つのアクションのみが表示されることを期待しています。 これは、分離して動作する新しいアプリケーションエンジンを作成することに少し似ています。



最後に対処する必要があるのは、 error



状態です。 ここでコードを作成して再試行を実装すると非常に便利です。その結果、エラーが発生した後、アプリケーションを回復できます。



 transitions: { 'error': {   retry: function () {     this.changeStateTo('idle');     this.dispatch('click');   } } }
      
      





ここでは、 click



ハンドラーで既に記述されているコードをコピーする必要がありclick



。 これを回避するには、両方のアクションで使用可能な関数としてハンドラーを宣言するか、最初にidle



状態に切り替えてからclick



アクションを自分でディスパッチする必要がありclick







動作状態マシンの完全な例は、CodePenプロジェクトにあります。



ライブラリを使用してステートマシンを管理する



ステートマシンテンプレートは、React、Vue、Angularのいずれを使用しても機能します。 前のセクションで見たように、純粋なJSにステートマシンを実装することはそれほど困難ではありません。 ただし、これを特殊なライブラリに委任すると、プロジェクトの柔軟性が向上します。 ステートマシンの実装に適したライブラリの例には、 Machina.jsおよびXStateが含まれます。 ただし、この記事では、ステートマシンの概念を実装するReduxに似たライブラリであるStentについて説明します。



ステントは、ステートマシンコンテナの実装です。 このライブラリは、ReduxおよびRedux-Sagaプロジェクトからのいくつかのアイデアに従いますが、私の意見では、使いやすく、テンプレートによる制約が少なくなります。 最初にプロジェクトのドキュメントを作成し、次にコードを作成するという事実に基づいたアプローチを使用して開発されています。 このアプローチに続いて、APIの設計のみを数週間行いました。 ライブラリを自分で書いたので、ReduxおよびFluxアーキテクチャを使用して遭遇した問題を修正する機会がありました。



ステントでのステートマシンの作成



ほとんどの場合、アプリケーションは多くの機能を実行します。 その結果、1台のマシンだけを実行することはできません。 したがって、ステントを使用すると、必要な数のマシンを作成できます。



 import { Machine } from 'stent'; const machineA = Machine.create('A', { state: ..., transitions: ... }); const machineB = Machine.create('B', { state: ..., transitions: ... });
      
      





後でMachine.get



メソッドを使用してこれらのマシンにアクセスできます。



 const machineA = Machine.get('A'); const machineB = Machine.get('B');
      
      





マシンをレンダリングロジックに接続する



私の場合のレンダリングはReactツールを使用して行われますが、他のライブラリを使用することもできます。 すべては、レンダリングを開始するコールバックを呼び出すことです。 私が作成したライブラリの最初の機能の1つは、 connect



機能でした。



 import { connect } from 'stent/lib/helpers'; Machine.create('MachineA', ...); Machine.create('MachineB', ...); connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => {   ...      });
      
      





どのマシンを操作したいかをシステムに通知し、名前を示します。 map



メソッドに渡すコールバックはすぐに呼び出され、これは1回行われます。 その後、いずれかのマシンの状態が変化するたびに呼び出されます。 これがレンダリング関数を呼び出す場所です。 この場所では、接続されたマシンに直接アクセスできるため、マシンとそのメソッドの現在の状態を取得できます。 このライブラリには、一度だけ呼び出す必要があるコールバックを処理するために使用されるmapOnce



メソッドと、この最初の1回限りのコールバックの実行をスキップするためのmapSilent



ます。



便宜上、Reactとの統合のために補助関数がエクスポートされています。 これは、 接続(mapStateToProps) Reduxコンストラクトに非常に似ています。



 import React from 'react'; import { connect } from 'stent/lib/react'; class TodoList extends React.Component { render() {   const { isIdle, todos } = this.props;   ... } } // MachineA  MachineB -  ,  //    Machine.create export default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => {   isIdle: MachineA.isIdle,   todos: MachineB.state.todos });
      
      





ステントはコールバックし、オブジェクトを受け取ることを期待します。 つまり、Reactコンポーネントにprops



として送信されるオブジェクト。



ステントのコンテキストにおける状態とは何ですか?



これまでのところ、状態は単純な文字列です。 残念ながら、現実の世界では、通常の文字列以外の状態で保存する必要があります。 それが、ステント状態がプロパティを持つオブジェクトである理由です。 予約されているプロパティはname



のみです。 それ以外はすべてアプリケーション固有のデータです。 例:



 { name: 'idle' } { name: 'fetching', todos: [] } { name: 'forward', speed: 120, gear: 4 }
      
      





Stentの私の経験では、状態オブジェクトが大きくなりすぎると、おそらくこれらの追加プロパティを処理できる別の状態マシンが必要になることがわかります。 さまざまな状態を特定するには時間がかかりますが、これは管理が容易なアプリケーションを作成する上で大きな前進だと思います。 これは、システムの動作を事前に計画し、将来のアクションのためのスペースを準備する試みのようなものです。



ステートマシンを操作する



材料の冒頭に示した例とほぼ同じ方法で、ステントを使用する場合、マシンの可能な(最終)状態を設定し、可能な入力信号を記述する必要があります。



 import { Machine } from 'stent'; const machine = Machine.create('sprinter', { state: { name: 'idle' }, //   transitions: {   'idle': {     'run please': function () {       return { name: 'running' };     }   },   'running': {     'stop now': function () {       return { name: 'idle' };     }   } } });
      
      





run



アクションをrun



する初期状態idle



があります。 マシンがrunning



状態になったら、 stop



アクションを開始して、マシンをidle



状態に戻すことができます。



上記の例のヘルパー関数dispatch



およびchangeStateTo



思い出してください。 ステントは同じ機能を提供しますが、ライブラリ内に隠されているため、自分で対処する必要はありません。 便宜上、 transitions



プロパティに基づいて、Stentは以下を生成します。





その結果、この例では次の構成が利用できます。



 machine.isIdle(); //   machine.isRunning(); //   machine.runPlease(); //   machine.stopNow(); //  
      
      





自動生成されたメソッドをサービスconnect



機能と組み合わせることにより、既製のソリューションを思い付くことができます。 ユーザーは、マシンの入力と状態の変化につながるアクションに影響を与えます。 状態の変化により、 connect



渡されたマッピング関数が呼び出され、状態の変化に関する通知を受け取ります。 次に、データが画面に出力されます。



入力データとアクションハンドラー



おそらく、この例で最も重要なのはアクションハンドラーです。 これは、システムが入力データおよび変更された状態にどのように反応するかを説明するため、ほとんどのアプリケーションロジックを記述する場所です。 Reduxの設計中に行われた最も成功したアーキテクチャの決定は、reducer関数の不変性と単純さであると時々思えます 。 本質的に、ステントアクションハンドラーは同じものです。 ハンドラーは現在の状態とアクションに関連付けられたデータを受け取り、その後新しい状態を返す必要があります。 ハンドラーが何も返さない場合( undefined



)、マシンの状態は変更されません。



 transitions: { 'fetching': {   'success': function (state, payload) {     const todos = [ ...state.todos, payload ];     return { name: 'idle', todos };   } } }
      
      





リモートサーバーからデータをダウンロードするとします。 これを行うには、リクエストを実行し、マシンをfetching



状態にします。 サーバーからデータが到着するとすぐに、 success



イベントが発生success



ます。



 machine.success({ label: '...' });
      
      





その後、 idle



状態に戻り、一部のデータをtodos



配列として保存します。 アクションハンドラを構成するためのオプションがいくつかあります。 最初の最も単純なケースは、新しい状態になる単純な文字列での作業です。



 transitions: { 'idle': {   'run': 'running' } }
      
      





ここに示されているのは、 run()



アクションを使用した{ name: 'idle' }



状態から{ name: 'running' }



状態への遷移です。 このアプローチは、状態に追加のデータがなく、状態間の同期遷移が使用される場合に役立ちます。 したがって、状態オブジェクトに他の何かが格納されている場合、状態間のこのような遷移は、そのような追加データを破壊します。 同様に、状態オブジェクトを直接渡すことができます。



 transitions: { 'editing': {   'delete all todos': { name: 'idle', todos: [] } } }
      
      





ここでは、 deleteAllTodos



アクションを使用して、 editing



状態からidle



への移行を観察できます。



ハンドラー関数は既に見ており、アクションハンドラーの最後のバージョンはジェネレーター関数です。 Redux-Sagaプロジェクトは、このメカニズムのイデオロギー的刺激源になりました。次のようになります。



 import { call } from 'stent/lib/helpers'; Machine.create('app', { 'idle': {   'fetch data': function * (state, payload) {     yield { name: 'fetching' }     try {       const data = yield call(requestToBackend, '/api/todos/', 'POST');       return { name: 'idle', data };     } catch (error) {       return { name: 'error', error };     }   } } });
      
      





ジェネレーターの経験がない場合、上記のコードフラグメントは少し不思議に見えるかもしれません。 ただし、JavaScriptジェネレーターは強力なツールです。 彼らの助けを借りて、アクションハンドラを一時停止し、状態を数回変更し、非同期メカニズムを処理できます。



発電機実験



Redux-Sagaに初めて会ったとき、非同期操作をサポートするための非常に複雑な方法を提示することにしました。 実際、これはコマンドテンプレートの非常に機知に富んだ実装です。 このテンプレートの主な利点は、メカニズムとその実際の実装の課題を共有していることです。



つまり、システムに必要なことを伝えますが、これがどのように行われるべきかについては話しません。 Matt Hicks による一連の資料は、これを理解するのに役立ちました。 同じアイデアをステントにもたらしました。 システムに制御を移し、必要なものを通知しますが、実際には行いません。 アクションが完了すると、制御が戻ります。



現時点でシステムに転送できるものは次のとおりです。





上記の例を示す関数を次に示します。



 const fireHTTPRequest = function () { return new Promise((resolve, reject) => {   // ... }); } ... transitions: { 'idle': {   'fetch data': function * () {     yield 'fetching'; //    { name: 'fetching' }     yield { name: 'fetching' }; //   ,        //       //  getTheData  checkForErrors     const [ data, isError ] = yield wait('get the data', 'check for errors');     //   ,     //  fireHTTPRequest     const result = yield call(fireHTTPRequest, '/api/data/users');     return { name: 'finish', users: result };   } } }
      
      





, , , . , Stent .



Redux Stent





Redux ( Flux) , . , , . , . , , , .



Stent . :



 const machine = Machine.create('todo-app', { state: { name: 'idle', todos: [] }, transitions: {   'idle': {     'add todo': function (state, todo) {       ...     }   } } }); machine.addTodo({ title: 'Fix that bug' });
      
      





machine.addTodo



, . , : , . React addToDo



. , . . .





, Redux . Redux, , . , , , ? — Redux? , , , - . , , . , - , .



, ? ?



Redux, , . , . Stent , , . 例:



 const machine = Machine.create('app', { state: { name: 'idle' }, transitions: {   'idle': {     'run': 'running',     'jump': 'jumping'   },   'running': {     'stop': 'idle'   } } }); //   machine.run(); //     , //       // running       stop. machine.jump();
      
      





, .





Redux, Flux, . , Redux-, , , , . , , , , , . , , , .



まとめ



, — , . , , , . . , , , — . , — . — , — . - , .



親愛なる読者! ?






All Articles