The jonki

呼ばれて飛び出てじょじょじょじょーんき

JavaScript/Node.jsで使えるステートマシンライブラリMachina.jsを試してみた

Machina.jsJavaScript/Node.jsでステートマシンを扱えるフレームワークです。最近私が書いているNode.jsのコードでステートマシンを導入したら、便利になりそうなところがあったので導入してみました。Machina.jsを日本語で取り扱っている記事が見当たらなかったので、参考になればと思います。なんと呼べばよくわかりませんが、とりあえず私はマキナと呼んでいます。

ステートマシンって?

いわゆる有限オートマトンFSM:Finite State Machine)です。システムの有限個の状態とその関係を遷移という矢印でつないだものですね。if文をたくさん書いて汚いコードになってしまった時、UMLのステートマシン図とかに落として整理しますね。

Machina.jsを選んだ理由

npmで'fsm'などの検索ワードで検索してダウンロードが多いモノを選びました。Machina.jsのgithubリポジトリを見ると現在の時点でスター数が893でした。これは間違いなさそうです。ライセンスもMITです。

インストール

Node.jsを利用している場合はnpmで入ります。通常のJavaScriptで使う場合は、公式ページサイトから、あるいはgithubからcloneしてきて、中にあるlib/machina,min.jsを使えば良さそうです。

$ npm install machina

基本的な使い方

公式のReadmeでも良いのですが、他にも例を探していたらわかりやすい記事があったのでそれをベースに解説します。

たとえば車のギヤのステートマシンを定義したいと思います。状態はN(ニュートラル)がデフォルト、1速、R(バック)の3つのギヤを考えます。下記の例ではそれぞれの状態においてアクセルを踏んだ時(accelerate)したときに車がどのように振る舞うかがわかります。かなりシンプルですし、コメントをたくさん書いたので見てみてください。

// コンストラクタ版 
var machina = require('machina'); 
var Car = machina.Fsm.extend({ 
  initialState: 'N',  // ステートマシンの初期状態
    states: {  // 状態を定義
      'N': {  // 各状態
        accelerate: function() { // その状態が扱う振る舞い(関数) 
          console.log('the gear is not set'); 
        } 
      }, 
      'R': { 
        accelerate: function() { 
          console.log('move back'); 
        } 
      }, 
      '1': { 
        accelerate: function() { 
          console.log('move forward'); 
        } 
      }, 
    }, 
}); 

var car = new Car(); 

car.on('transition', function(data) {
  console.log('state change:', data.fromState, 'to', data.toState);
});

// handleはcarインスタンスの現在の状態の関数(振る舞い)accelerateを探して、実行します
// transitionは引数で与えられたステートに遷移します
car.handle('accelerate'); // -> 'the gear is not set'
car.transition('1');      // -> 'state change: N to 1'
car.handle('accelerate'); // -> 'move forward'
car.transition('R');      // -> 'state change: 1 to R'
car.handle('accelerate'); // -> 'move back'


上記の例はコンストラクタ版ですが、インスタンス版でも良いと思います。

var machina = require('machina'); 
var car = new machina.Fsm({
  ///////略
});


またhandleをラップするとタイポなどがあった場合に早く見つけられそうです。

var machina = require('machina'); 
var Car = machina.Fsm.extend({ 
  initialState: 'N', 
    states: { 
      ///////略
    }, 
    accelerate: function() { 
      this.handle('accelerate'); 
    }, 
    shift: function(newGear) { 
      this.transition(newGear); 
    }, 
}); 

var car = new Car();  
car.shift('1'); 
car.accelerate(); 
car.shift('N'); 
car.accelerate();

よく使いそうなもの

machina.Fsm Prototypeとして公式に一覧がありますが、よく使いそうなものを上げていきます。initialState、handle、transitionは既に前例で登場済みなのでスキップします。

_onEnter/_onExit

こちらはそのステートに入ったタイミングと次のステートに切り替わるタイミングで呼ばれます。例えば先程のRのステートのところにこれを追加すると下記のような結果になります。そのステートにおけるinitialize/finalizeはこの中ですると良さそうです。

      'R': { 
        _onEnter: function() {
          console.log('R: _onEnter');
        },
        accelerate: function() { 
          console.log('move back'); 
        },
        _onExit: function() {
          console.log('R: _onExit');
        },
      }, 
  • 出力結果
state change: N to R                                                                           
R: _onEnter
R: _onExit
state change: R to 1
emit/on/off

emitではオリジナルのイベントを呼び出すことができます。作ったイベントはon/offでコールバックの登録、解除ができます。ここでは例で'broken'イベントを作ってみます。非常にJavaScriptらしい書き方でイベントとコールバックを登録できますね。

///////略
      'R': { 
        accelerate: function() { 
          this.emit('broken', 'aiueo');
        },
      }, 
///////略

var car = new Car(); 

car.on('broken', function(data) {
  console.log('broken!', data);
});

car.shift('R'); 
car.accelerate();
  • 出力結果
broken! aiueo
deferUntilTransition

これは次のTransitionまでその関数(振る舞い)をキューイングしてくれます。例を見るのが一番分かりやすい思います。例えばネットワークのステートを考えた時にrequestHttpという何かしらのサーバーにリクエストするような関数を考えてみます。ただし、これがoffline状態の時に呼ばれてしまうとクライアント側は再度requestHttpをしないといけません。そこで、このdeferUntilTransitionを呼んでおけばrequestHttpという振る舞いがmachinaにキューに積まれます。そして、deferUntilTransitionの引数にはonlineを与えているので、ステートがonlineになっただけでonlineステートのrequestHttpが実行され、安全に処理できます。
これを使いこなせれば上級者の仲間入りができそうです。ちなみにdeferUntilTransitionの引数に何も入れないと、何らかのステートに変わったら発動する、という条件に変わるようです。

var machina = require('machina'); 
var Network = machina.Fsm.extend({ 
  initialState: 'offline', 
    states: { 
      'offline': { 
        'requestHttp': function() {
          console.log('requestHttp is deferred');
          this.deferUntilTransition('online'); 
        },
      }, 
      'online': { 
        'requestHttp': function() {
          console.log('request...');
        },
      }, 
    }, 
}); 
var network = new Network(); 

network.on('transition', function(data) {
  console.log('state change:', data.fromState, 'to', data.toState);
});

network.handle('requestHttp');
setTimeout(function() {
  network.transition('online');
}, 1000);
  • 出力結果
requestHttp is deferred                                                                        
state change: offline to online
request... // -> deferされてたrequestHttpが1秒後のステートがonlineに変わったところで呼ばれる
アスタリスク(*)

関数としてアスタリスク(*)を書いておけば、定義されていない関数が呼ばれた場合にそれで受け止めることができます。色々例を探していると前述のdeferUntilTransitionを組み合わせて、ステートが変わったタイミングで再度その関数を呼ぼうとする例が多かったですね。これでクライアントからの呼び出しをちゃんとキャッチできる訳です。

///////略
      'N': { 
        '*': function() {
          console.log('handler is not defined');
        },
///////略

var car = new Car(); 
car.handle('hogehoge');
  • 出力結果
handler is not defined 

まとめ

JavaScript/Node.jsで使えるステートマシンライブラリのMachina.jsを試してみました。非常にシンプルでカスタマイズ性のあるフレームワークになっているのが分かりました。ただ一番重要なのはステートマシンをしっかり設計し、それを実際にコードに落とすことですね。結局設計がしょぼいと純粋にif文を羅列したほうがわかりやすいときもあるので、本末転倒にならないようにしたいところです。