Mobb 0.3.0 をリリースしました
今回のリリースでは、 cron/every キーワードが Mobb DSL に追加されています。
https://github.com/kinoppyd/mobb/pull/5
これらのキーワードは、 Mobb(およびRepp) になにかを定期実行させるためのものであり、このリリースによってようやく Mobb は実用的で最も簡易な Bot フレームワークを名乗ることができるようになったと思います。
最初に Mobb を作ろうと考えたときに、秒でクソボットを作れるようになるために必要なものはなんだろうと考えました。そして、必要だと考えたのは、シンプルなシンタックスで書けること、条件分岐がフレームワークの機能に備わっていること、なにかを定期実行できること、の三点でした。 Mobb 0.3 のリリースにより、これら3つの要素が全て揃い、ようやく Mobb が実用的な Botフレームワークとして皆さんに使ってもらえる機能を備えました。
Syntax
cron/every キーワードは、名前の通りなにかを定期実行するためのものです。具体的なシンタックスは、次のように書きます。
cron '0 12-21 * * *', dest_to: 'times_kinoppyd' do
"Hi bro, wazup?
end
この例では、毎日12時から21時までの毎事0分に、botが times_kinoppyd という配信先(Slackであれば #times_kinoppyd) に対して、 “Hi bro, wazup?” というメッセージを配信します。
cron キーワードは、そのまま Cron の文法が使えます。具体的には、 parse-cron というGemを使ってパースできるものであれば動作します。
また、 cron の簡易的な使い方を目的として、 every というキーワードも追加しています。
every :day, at: '14:00', dest_to: 'times_kinoppyd' do
"ぞい"
end
cron よりも、より直感的に定期実行を記述できます。これも、 whenever というGemを内部で利用しており、 whenever が解釈できる引数をそのまま渡すことができます。例えば次の例では、2というIntegerオブジェクトへのWheneverの拡張により、hoursというメソッドが追加されていて、それをそのまま使用することができます。
every 2.hours, dest_to: 'times_kinoppyd' do
'ぞい'
end
every キーワードが受け取る事のできるシンタックスに関しては、 whenever のREADMEを参考にしてください。
注意点として、 cron/every キーワードでは、必ず dest_to というコンディションが必要な点に注意してください。これを設定しないと、Botはどこのチャネルにポストすれば良いのかがわからず、メッセージが闇に消えることになります。(Shellアダプタなど、チャネルの区別が無い環境であれば必要ありません)
cron/every の実装
この機能の実装には、非常に頭を悩ませる次のような問題がありました。
-
この機能はMobb(アプリケーションフレームワーク)が持つのか、Repp(Botフレームワークインターフェイス)が持つのか
-
どちらに持つとしても、実際にどのような実装にするのか
-
Sinatra-ishなDLSや実装とどのように共存するのか
-
cron文法の解釈に、どのGemを選択するのか
-
実装に際して、スレッドの管理にはどのような方法をとるのか
Mobbの機能なのか、Reppの機能なのか
cronは、Mobbの実装です。しかし同時に、Reppの実装でもあるべきです。
Repp に関して簡単に説明すると、 Mobb が Sinatra だとすると Repp は Rack です。
まず大前提として cron の文法は、 Mobb アプリの定義に書かれます。何故かと言うと、ユーザーが書くのはMobb アプリケーションであり、Repp ではないからです。しかし、 cron の定期実行の機能自体をどちらに書くかは、大きな問題です。
この問題に関して、 Mobb と Repp は、Mobb が cron の解釈を行うが、 Repp が定期実行のトリガーを送信する、という方法をとって実装をすることになりました。これは、 Mobb がアプリケーションフレームワークであるのに対して、 Repp がインターフェイスであることが理由です。実際に定期実行を行う方法はアプリケーションに任せるが、そのトリガーとなるイベントはインターフェイスである Reppが提供するということです。
また、 Repp が送信するトリガーも、通常のチャットサービスから送られるメッセージ類と区別しないようにしました。つまり、 Repp の世界観に置いて、すべてのイベントはアプリケーションの call メソッドを呼び出すことで実行されます。0.2 までのリリースでは、 Mobb のコードにはコメントアウトされた tick というメソッドが存在していました。しかし、 0.3 ではそのメソッドは消去されています。これは何故かと言うと、Repp が Rack を参考にしているためで、 Rack はアプリケーションの最小単位として 一つの引数を受け配列を返すcall メソッドを持つオブジェクト(つまり、 Proc オブジェクトが事実上の最小単位)として定義しています。Repp でも、 Mobb のような FW を使うことなく Bot を記述できるインターフェイスであるべきだと思い、このような形にしました。
定期実行をどのような実装にするのか
定期実行のトリガーそのものは、 Repp に実装することに決めました。しかし、どのように実装するのでしょうか?
Repp 0.3 では、この実装の一つの実験として、毎秒Tickerイベントをアプリケーションに送信するという方法をとっています。Tickerイベントは、イベントが発生した時間を同時に送ります。
これをどのように解釈するかはアプリケーション側の自由ですが、 Mobb では Ticker イベントを受け取ると、 cron/every で定義されたイベントと一致するかを確認した上で、イベントを実行します。
cronの文法(定期実行の最小単位が毎分)を使っているのに、なぜ毎秒イベントを送っているかというと、あくまで Mobb は cron の文法を採用しただけであり、 Repp はただのインターフェイスなので、可能な限り細かい単位での実行をしたほうが、 Repp を利用する他のアプリケーションに対して優しいと思ったからです。
Sinatra-ish な DLS との共存
定期実行という概念は、 Mobb が参考にしている Sinatra の世界観には存在しません。なぜなら、Webの世界では入力(リクエスト)と出力(レスポンス)がかならず対になるからです。一方で、Botフレームワークの世界では、入力と出力は必ずしも対ではありません。その一つが、この定期実行という概念です。
前述の通り、 Repp から Mobb に送られるトリガーは、通常のチャットサービスと区別をしません。なので、毎秒の Ticker イベントを処理することで、擬似的に入力と出力の対を作り出しています。
Sinatraでは、 @routes という名前のハッシュのキーに、HTTPのverb(GETとか)を使い、それぞれのverbごとにイベントを持ちわけています。同じように Mobb でも、 Repp から来るイベントの種類によって、 @events という名前のハッシュにイベントを持ちわけています。具体的には、チャットサービスからくる message というイベントと、 Repp の定期実行タイマーからくる Ticker というイベントです。
これにより、 Repp から届くイベントを混線させることなく振り分けることが可能になっています。
どのGemを選択するか
cron文法のコンパイル、つまり every キーワードで簡易的に cron 文法を扱うためには、 whenever というGemを使用しており、そのほかに選択肢はありませんでした。しかしながら、 whenever そのものはcrontab を効率的に編集するツールであり、自分が欲しかったのは cron syntax のコンパイラ部分だけだったので、これを採用するかは少し悩みました。幸い、whenever はMITであるため、cron syntax を生成する部分だけを自分のコードに転用することも考えましたが、ひとまず最初のリリースではそれを見送り、素直に whenever に依存しています。
また、 whenever が生成した cron syntax の文字列を、Ruby で扱うために使うGemに関しては少し悩みがありました。それは、 perse-cron と、 Ruboty-cron のためにつくられた chrono という選択肢があったためです。
この選択肢で悩んだ理由は、最初の「Mobb の機能なのか、 Repp の機能なのか」という問題があります。もし cron のトリガーを Repp に持たせずに Mobb に持たせる決定をしていた場合、 chrono を選択していたと思います。なぜなら、 chrono は cron syntax を渡すと、単体で定期実行まで行ってくれるためです。
しかし、実際には Repp 側に定期実行のトリガー発行を任せることに決めたため、 parse-cron を採用しました。実際に cron syntax を操作するのは Mobb なので、 Repp 側に cronのパーサーが必要なくなり、また Mobb 側には定期実行の能力は必要なくなりました。そうなると、 chrono はオーバースペックだったため、シンプルに parse-corn を使うことにしました。
スレッド管理にはどのような方法を使ったのか
結論から言えば、今回は concurrent-ruby を使いました。しかし、これは 0.3 のリリースでの話であり、今後もこれを維持していくかは不明です。
まず最初に、Eventmachineを使う案がありました。なぜならば、 Repp が標準で組み込んでいる Slack のアダプタに使用している slack-ruby-client というgemでは、EMを内部で使用している(正確には選択できる)からです。
しかし、次の2つの理由により、EMの使用は見送ることになりました。
1つ目に、既に slack-ruby-client が使用しているEMに対して、新しく外側からなにかを追加するのは危うく見えたからです。これは私自身がEMに対する理解があまりないというのも問題ですが、まだrunしていないEMのリアクタに対して、定期的になにかを非ブロッキングで実行し続けるコードを書くことは、困難に思えたからです。実際には、おそらく何かしらの解決策があるのでしょうが、私自身の理解の不足で諦めました。
2つ目に、 slack-ruby-client の使用するEMは、正確にはEMを使用することを選択できるという言い方が正しく、実際に slack-ruby-client を使用するときに非同期用のライブラリを2つから選択することができ、その依存を gemspec に書くことになるからです。ちなみに、他の選択肢として ruby-async と celluloid を選ぶことができます。
slack-ruby-client のこの実装を見たときに、同じように Repp も長期的にはユーザーが非同期ライブラリになにを使用するかを選択できるような方針を取るべきだと考え、ひとまずEventmachineを見送ることにしました。
EMを使わないことは決めましたが、実際になにを使うかは全く思いつきませんでした。なんなら、 chrono のように自分で Thread を書いてもいいかなとも思いました。しかし、ノンブロッキングで毎秒定期実行する処理を自分の手できちんと書くことはあまり気の乗る作業ではありませんでした。
そのため、今回はEMと比較用にいろいろコードを書いて試していた concurrent-ruby を使いました。ひとまずEMに依存せず(concurrent-rubyには依存しますが)、自分の手で複雑な非同期プログラミングのロジックを書かなくていいという妥協の選択でした。
おそらく、 Repp のバージョンが上がっていく中で、この選択は見直されることになり、他の非同期の方針を模索するときが来ると思います。
まとめ
実用的なクソボットフレームワークとして成長した Mobb をよろしくお願いいたします。