ステートマシンの台頭
公開: 2022-03-10すでに2018年であり、数え切れないほどのフロントエンド開発者が依然として複雑さと不動との戦いをリードしています。 毎月、彼らは聖杯を探してきました。それは、バグのないアプリケーションアーキテクチャであり、迅速かつ高品質で提供するのに役立ちます。 私はそれらの開発者の1人であり、役立つかもしれない何か面白いものを見つけました。
ReactやReduxなどのツールで大きな前進を遂げました。 ただし、大規模なアプリケーションでは、それだけでは十分ではありません。 この記事では、フロントエンド開発のコンテキストでのステートマシンの概念を紹介します。 あなたはおそらくそれを実現することなくすでにそれらのいくつかを構築しました。
ステートマシンの紹介
ステートマシンは、計算の数学的モデルです。 これは、マシンがさまざまな状態を持つことができるという抽象的な概念ですが、ある時点でそれらの1つだけを満たします。 ステートマシンにはさまざまな種類があります。 最も有名なのはチューリングマシンだと思います。 これは無限ステートマシンであり、無数の状態を持つことができることを意味します。 ほとんどの場合、状態の数には限りがあるため、チューリングマシンは今日のUI開発にうまく適合しません。 これが、MealyやMooreなどの有限状態マシンがより理にかなっている理由です。
それらの違いは、ムーアマシンが以前の状態のみに基づいて状態を変更することです。 残念ながら、ユーザーの操作やネットワークプロセスなど、多くの外部要因があります。つまり、ムーアマシンも十分ではありません。 私たちが探しているのはミーリマシンです。 初期状態があり、入力とその現在の状態に基づいて新しい状態に遷移します。
ステートマシンがどのように機能するかを説明する最も簡単な方法の1つは、回転式改札口を調べることです。 ロックとロック解除の有限数の状態があります。 これは、これらの状態と、考えられる入力および遷移を示す簡単な図です。
回転式改札口の初期状態はロックされています。 何度押してもロック状態のままです。 ただし、コインを渡すと、ロック解除状態に移行します。 この時点で別のコインは何もしません。 それはまだロック解除された状態のままです。 反対側からのプッシュが機能し、通過することができます。 このアクションにより、マシンは初期ロック状態に移行します。
改札口を制御する単一の関数を実装したい場合、おそらく現在の状態とアクションの2つの引数になります。 また、Reduxを使用している場合、これはおそらくおなじみのように聞こえます。 これはよく知られているレデューサー関数に似ており、現在の状態を受け取り、アクションのペイロードに基づいて、次の状態を決定します。 レデューサーは、ステートマシンのコンテキストでの遷移です。 実際、何らかの形で変更できる状態を持つアプリケーションは、ステートマシンと呼ばれることがあります。 すべてを手動で何度も何度も実装しているだけです。
ステートマシンはどのように優れていますか?
職場ではReduxを使用しており、非常に満足しています。 しかし、私は自分が好きではないパターンを見始めました。 「嫌い」とは、機能しないという意味ではありません。 それは、それらが複雑さを追加し、私にもっと多くのコードを書くことを強いるということです。 実験の余地があるサイドプロジェクトに着手する必要があり、ReactとReduxの開発手法を再考することにしました。 私は自分に関係することについてメモを取り始めました、そして私はステートマシンの抽象化がこれらの問題のいくつかを本当に解決するだろうと気づきました。 ジャンプして、JavaScriptでステートマシンを実装する方法を見てみましょう。
簡単な問題を攻撃します。 バックエンドAPIからデータを取得し、ユーザーに表示したいと考えています。 最初のステップは、遷移ではなく、状態で考える方法を学ぶことです。 ステートマシンに入る前は、このような機能を構築するためのワークフローは次のようになりました。
- データの取得ボタンを表示します。
- ユーザーがデータのフェッチボタンをクリックします。
- バックエンドにリクエストを送信します。
- データを取得して解析します。
- ユーザーに見せてください。
- または、エラーが発生した場合は、エラーメッセージを表示し、データのフェッチボタンを表示して、プロセスを再度トリガーできるようにします。
私たちは直線的に考え、基本的に最終結果へのすべての可能な方向をカバーしようとしています。 あるステップが別のステップにつながり、すぐにコードの分岐を開始します。 ユーザーがボタンをダブルクリックしたり、バックエンドの応答を待っている間にユーザーがボタンをクリックしたり、リクエストは成功したがデータが破損したりするなどの問題についてはどうでしょうか。 このような場合、おそらく何が起こったかを示すさまざまなフラグがあります。 フラグがあるということは、より多くのif
句を意味し、より複雑なアプリでは、より多くの競合を意味します。
これは、私たちが移行を考えているためです。 これらの遷移がどのように、どのような順序で発生するかに焦点を当てています。 代わりに、アプリケーションのさまざまな状態に焦点を当てる方がはるかに簡単です。 州はいくつあり、それらの可能な入力は何ですか? 同じ例を使用して:
- アイドル
この状態で、データのフェッチボタンを表示し、座って待機します。 可能なアクションは次のとおりです。- クリック
ユーザーがボタンをクリックすると、リクエストがバックエンドに送信され、マシンが「フェッチ中」の状態に移行します。
- クリック
- フェッチ
リクエストは処理中です。私たちは座って待っています。 アクションは次のとおりです。- 成功
データは正常に到着し、破損していません。 何らかの方法でデータを使用し、「アイドル」状態に戻ります。 - 失敗
リクエストの実行中またはデータの解析中にエラーが発生した場合、「エラー」状態に移行します。
- 成功
- エラー
エラーメッセージを表示し、データのフェッチボタンを表示します。 この状態は、次の1つのアクションを受け入れます。- リトライ
ユーザーが再試行ボタンをクリックすると、リクエストが再度実行され、マシンが「フェッチ中」の状態に移行します。
- リトライ
ほぼ同じプロセスを説明しましたが、状態と入力を使用します。
これにより、ロジックが簡素化され、より予測可能になります。 また、上記の問題のいくつかを解決します。 「フェッチ中」の状態では、クリックを受け付けていないことに注意してください。 したがって、ユーザーがボタンをクリックしても、マシンがその状態にある間はそのアクションに応答するように構成されていないため、何も起こりません。 このアプローチにより、コードロジックの予測できない分岐が自動的に排除されます。 これは、テスト中にカバーするコードが少なくなることを意味します。 また、統合テストなどの一部のタイプのテストは自動化できます。 アプリケーションが何をするのかを本当に明確に理解する方法を考えてください。定義された状態と遷移を調べ、アサーションを生成するスクリプトを作成できます。 これらの主張は、私たちがすべての可能な状態に到達したか、特定の旅をカバーしたことを証明する可能性があります。
実際、必要な状態または持っている状態がわかっているため、考えられるすべての状態を書き留める方が、考えられるすべての遷移を書き留めるよりも簡単です。 ちなみに、ほとんどの場合、状態はアプリケーションのビジネスロジックを記述しますが、遷移は最初はほとんど不明です。 私たちのソフトウェアのバグは、間違った状態で、および/または間違った時間にディスパッチされたアクションの結果です。 彼らは私たちのアプリを私たちが知らない状態のままにします、そしてこれは私たちのプログラムを壊すか、それを間違って振る舞わせます。 もちろん、私たちはそのような状況になりたくありません。 ステートマシンは優れたファイアウォールです。 明確に方法を言わずに、何がいつ起こるかについて境界を設定するため、未知の状態に到達することから私たちを保護します。 ステートマシンの概念は、単方向のデータフローと非常によく対になっています。 一緒に、それらはコードの複雑さを減らし、州がどこから始まったのかという謎を解き明かします。
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
関数は、現在の状態の遷移に指定された名前のアクションがあるかどうかをチェックします。 もしそうなら、それは与えられたペイロードでそれを起動します。 また、 machine
をコンテキストとして使用してaction
ハンドラーを呼び出しているため、 this.dispatch(<action>)
を使用して他のアクションをディスパッチしたり、this.changeStateTo( 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
を持つサービスがあると仮定しましょう。 それが解決され、データの解析がOKになったら、 failure
ではないにしても、 success
をディスパッチします。
ここまでは順調ですね。 次に、 fetching
状態でsuccess
とfailure
のアクションと入力を実装する必要があります。
transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the 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
ハンドラーで記述したロジックを複製する必要があります。 これを回避するには、ハンドラーを両方のアクションにアクセスできる関数として定義するか、最初にidle
状態に移行してからclick
アクションを手動でディスパッチする必要があります。
動作中のステートマシンの完全な例は、私のCodepenにあります。
ライブラリを使用したステートマシンの管理
有限状態マシンのパターンは、React、Vue、Angularのいずれを使用するかに関係なく機能します。 前のセクションで見たように、ステートマシンはそれほど問題なく簡単に実装できます。 ただし、ライブラリの方が柔軟性が高い場合もあります。 良いもののいくつかはMachina.jsとXStateです。 ただし、この記事では、有限状態マシンの概念を取り入れたReduxのようなライブラリであるStentについて説明します。
ステントは、ステートマシンコンテナの実装です。 これは、ReduxおよびRedux-Sagaプロジェクトのアイデアの一部に従いますが、私の意見では、より単純で定型的なプロセスを提供しません。 これはreadme主導の開発を使用して開発されており、私は文字通りAPI設計にのみ数週間を費やしました。 ライブラリを書いていたので、ReduxとFluxのアーキテクチャを使用しているときに遭遇した問題を修正する機会がありました。
機械の作成
ほとんどの場合、アプリケーションは複数のドメインをカバーしています。 1台の機械だけでは行けません。 したがって、Stentでは多くのマシンを作成できます。
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) => { ... rendering here });
どのマシンが私たちにとって重要であるかを言い、それらの名前を付けます。 map
に渡すコールバックは、最初に1回発生し、その後、一部のマシンの状態が変化するたびに発生します。 ここでレンダリングをトリガーします。 この時点で、接続されているマシンに直接アクセスできるため、現在の状態とメソッドを取得できます。 コールバックを1回だけ起動するためのmapSilent
と、その最初の実行をスキップするためのmapOnce
もあります。
便宜上、React統合専用のヘルパーがエクスポートされます。 これは、Reduxのconnect(mapStateToProps)
に非常によく似ています。
import React from 'react'; import { connect } from 'stent/lib/react'; class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... } } // MachineA and MachineB are machines defined // using Machine.create function export default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });
Stentはマッピングコールバックを実行し、オブジェクト(Reactコンポーネントにprops
として送信されるオブジェクト)を受信することを期待します。
ステントの文脈における状態とは何ですか?
これまで、私たちの状態は単純な文字列でした。 残念ながら、現実の世界では、文字列以上の状態を維持する必要があります。 これが、Stentの状態が実際には内部にプロパティを持つオブジェクトである理由です。 予約済みのプロパティは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' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } } });
run
のアクションを受け入れる初期状態idle
があります。 マシンがrunning
状態になると、 stop
アクションを実行できるようになり、 idle
状態に戻ります。
おそらく、以前の実装のdispatch
ヘルパーとchangeStateTo
ヘルパーを覚えているでしょう。 このライブラリは同じロジックを提供しますが、内部的に隠されているため、考える必要はありません。 便宜上、 transitions
プロパティに基づいて、Stentは以下を生成します。
- マシンが特定の状態にあるかどうかを確認するためのヘルパーメソッド—
idle
状態はisIdle()
メソッドを生成しますが、running
にはisRunning()
があります。 - アクションをディスパッチするためのヘルパーメソッド:
runPlease()
およびstopNow()
。
したがって、上記の例では、これを使用できます。
machine.isIdle(); // boolean machine.isRunning(); // boolean machine.runPlease(); // fires action machine.stopNow(); // fires action
自動生成されたメソッドとconnect
ユーティリティ機能を組み合わせることで、円を閉じることができます。 ユーザーの操作により、マシンの入力とアクションがトリガーされ、状態が更新されます。 その更新により、 connect
に渡されたマッピング関数が起動され、状態の変化が通知されます。 次に、再レンダリングします。
入力およびアクションハンドラー
おそらく最も重要なビットはアクションハンドラーです。 これは、入力状態と変更された状態に応答するため、ほとんどのアプリケーションロジックを作成する場所です。 私がReduxで本当に気に入っているものもここに統合されています。それは、レデューサー関数の不変性と単純さです。 Stentのアクションハンドラーの本質は同じです。 現在の状態とアクションペイロードを受け取り、新しい状態を返す必要があります。 ハンドラーが何も返さない場合( undefined
)、マシンの状態は同じままです。
transitions: { 'fetching': { 'success': function (state, payload) { const todos = [ ...state.todos, payload ]; return { name: 'idle', todos }; } } }
リモートサーバーからデータをフェッチする必要があると仮定しましょう。 リクエストを起動し、マシンをfetching
状態に移行します。 データがバックエンドから取得されると、次のように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 Hinkのブログシリーズは、sagasがどのように実装されているかを理解するのに役立ちました。それを読むことを強くお勧めします。 私は同じアイデアをStentに持ち込みました。この記事の目的上、私たちはものを生み出すことによって、実際にそれを行わずに、私たちが望むものについて指示を与えていると言います。 アクションが実行されると、コントロールが返されます。
現時点では、いくつかのことが送信(生成)される可能性があります。
- マシンの状態を変更するための状態オブジェクト(または文字列)。
-
call
ヘルパーの呼び出し(promiseまたは別のジェネレーター関数を返す関数である同期関数を受け入れます)—基本的に、「これを実行し、非同期の場合は待機します。 終わったら、結果を教えてください。」; -
wait
ヘルパーの呼び出し(別のアクションを表す文字列を受け入れます)。 このユーティリティ関数を使用する場合、ハンドラーを一時停止し、別のアクションがディスパッチされるのを待ちます。
バリアントを示す関数は次のとおりです。
const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... }); } ... transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const [ data, isError ] = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } } }
ご覧のとおり、コードは同期しているように見えますが、実際はそうではありません。 解決された約束を待つか、別のジェネレーターを反復処理するという退屈な部分を行うのは、ステントだけです。
ステントは私のReduxの懸念をどのように解決していますか
ボイラープレートコードが多すぎます
Redux(およびFlux)アーキテクチャは、システム内を循環するアクションに依存しています。 アプリケーションが大きくなると、通常、多くの定数とアクションクリエーターが存在することになります。 これら2つのことは非常に多くの場合異なるフォルダーにあり、コードの実行の追跡には時間がかかることがあります。 また、新しい機能を追加するときは、常に一連のアクション全体を処理する必要があります。つまり、より多くのアクション名とアクション作成者を定義する必要があります。
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自体にはありませんが、開発者はいつでもアクションをディスパッチできるという点で問題があります。 ライトをオンにするアクションがあると言えば、そのアクションを2回続けて実行しても大丈夫ですか? そうでない場合、Reduxでこの問題をどのように解決することになっていますか? おそらく、ロジックを保護し、ライトがすでにオンになっているかどうかをチェックするコードをレデューサーに配置します。おそらく、現在の状態をチェックするif
句です。 さて、問題は、これはレデューサーの範囲を超えているのではないかということです。 レデューサーはそのようなエッジケースについて知っている必要がありますか?
私がReduxに欠けているのは、条件付きロジックでレデューサーを汚染することなく、アプリケーションの現在の状態に基づいてアクションのディスパッチを停止する方法です。 そして、アクションクリエーターが解雇されるビューレイヤーにもこの決定を下したくありません。 Stentを使用すると、マシンが現在の状態で宣言されていないアクションに応答しないため、これは自動的に行われます。 例えば:
const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } } }); // this is fine machine.run(); // This will do nothing because at this point // the machine is in a 'running' state and there is // only 'stop' action there. machine.jump();
マシンが特定の時間に特定の入力のみを受け入れるという事実は、奇妙なバグから私たちを保護し、アプリケーションをより予測可能にします。
遷移ではなく状態
Reduxは、Fluxと同様に、遷移の観点から考えさせられます。 Reduxで開発するメンタルモデルは、アクションと、これらのアクションがレデューサーの状態をどのように変換するかによって大きく左右されます。 それは悪いことではありませんが、代わりに状態の観点から考える方が理にかなっていることがわかりました。アプリがどの状態にあるのか、そしてこれらの状態がビジネス要件をどのように表しているのか。
結論
プログラミング、特にUI開発におけるステートマシンの概念は、私にとって目を見張るものでした。 私はどこでもステートマシンを見始めました、そして私は常にそのパラダイムに移行したいといういくつかの願望があります。 より厳密に定義された状態とそれらの間の遷移を持つことの利点を確実に理解しています。 私はいつも自分のアプリをシンプルで読みやすくする方法を探しています。 ステートマシンはこの方向への一歩だと思います。 コンセプトはシンプルであると同時に強力です。 多くのバグを排除する可能性があります。