ボットをHaskellで書きたくて、フレームワークから作ってみた
2019年02月21日 木曜日
皆さん、仕事でボット、使ってますか?
昨今のビジネス向けチャットサービスの普及により、チャットやその上で動作するボットを業務で活用することは一般的になりつつあります。 私たちも日々の仕事にボットを使用しています。 ジョークに近いたわいもないものから、ワークフローの一部になっているものまで、ボットはもはや仕事に欠かせないツールとなっています。
そんなボットの魅力として、開発や導入が手軽なことも挙げられると思います。 インターネット上でも多くのボットやサンプルコードが公開されているので、ちょっとしたもの(例えば、辞書や電卓代わりになるユーティリティボット等)は、すぐにでも導入可能なケースもあるでしょう。 ただ、もっと業務に密着したボットとなると、社内システムとの連携やアクセス権限管理等、個別の要件が発生し、プログラミングが少なからず必要となります。
利用するチャットサービスにもよりますが、本格的なボットを作る場合は、ボットフレームワークを使って開発することが一般的です。 現在、hubot、Botkit、Bot Framework等が広く使われているようです。 私たちの部署でもhubotを使ってボットをいくつか開発したことがあります。 その経験から、ボットの開発には次のような課題があると感じていました。
- 軽い気持ちでボットを作り始めがちだけれど、業務で使うことを考えると、それなりに堅牢なプログラムにせねばならず、意外と時間がかかる……。
- ボットとユーザが複数ターンの対話を行う場合、状態管理が複雑になって、コードを読んだだけではボットの挙動が理解できない……。
ユーザからのコマンドメッセージに対し、特定の処理を実行して完了するだけの単純なボットの場合は、いずれも表面化することはないでしょう。 しかし、少し複雑な振る舞いのボットを作ろうとすると、どんなフレームワークを使うにせよ、上記の課題と直面せざるを得なくなってきます。
これらを克服できないか。 あれこれ検討した結果、私たちは独自のボットフレームワークをHaskellで実装するという方法を取ることにしました。 一見、遠回りな選択に思えますが、それなりの理由があります。
まず、そもそも私たちの部署の普段の開発業務において、メインで使用しているプログラミング言語がHaskellであるということ。 たしかに、CoffeeScript/JavaScriptでのプログラミングは手軽ではあります。 ただ、不慣れなNode.js環境で信頼性の高いプログラムを開発するために悪戦苦闘するより、独自フレームワークを作ってでも、慣れ親しんだ言語で開発できることの方が効率が良いと判断しました。 何より、静的型言語に慣れきった私たちにとって、コンパイル時の検査無しに信頼性の高いプログラムを書くことは、片手間に取り組めるような作業ではありません。 また、チャットボットはメッセージや通知を待機、受信するサーバプログラムのような挙動も求められますが、Haskellなら、そのようなコードを簡単に実装できることも理由の一つです。 特に並行性に関しては、スレッドの扱いやすさやSTM(Software Transactional Memory)等のおかげで、性能と安全性を兼ね備えたフレームワークを簡単に実装できました。
さて、あとはいかにして、複雑な対話シナリオを直感的に記述できるようにするか、です。 ここであらためて、何が難しいのか、例を挙げてみます。 例えば、以下のような振る舞いをするボットを作るとします。
話しかけると、2つ質問をする、ただそれだけのボットです。 このようなボットをhubotを使ってCoffeeScriptで実装するとしたら、以下のようなコードになるでしょう。
# ユーザとボットの対話処理部分のみを抜粋 states = {} names = {} robot.respond /こんにちは/, (res) -> unless states[res.userId] res.send "お名前は?" res.finish() states[res.userId] = "ASKING_NAME" robot.respond /(.*)/, (res) -> msg = res.match[1] switch states[res.userId] when "ASKING_NAME" res.send msg + "さんの趣味は何ですか?" names[res.userId] = msg states[res.userId] = "ASKING_HOBBY" when "ASKING_HOBBY" res.send names[res.userId] + "さんは" + msg + "がお好きなんですね!" delete names[res.userId] delete states[res.userId]
説明用にかなり簡易なコードにしています。 それでも、このコードをパッと見ただけで、ボットの振る舞いをイメージすることは難しくないでしょうか? 対話が長くなればなるほど、状態管理の困難さは増し、さらに複雑なコードになります。 Stateパターン等を使って実装すれば、多少はスマートになりますが、直感的に理解できるコードとはならないでしょう。
実はこのような対話シナリオの記述に、BotkitではConversation、Bot FrameworkではDialogという仕組みが用意されています。 hubotにもhubot-conversationという同様の目的のプラグインがあります。 これらを使うと、対話の記述がある程度簡単にはなります。 しかし、それでもまだ、できあがるコードが複雑すぎるように私たちには感じられました。 言語仕様上、止むを得ないとは言え、コールバックの多用を強制される時点で、対話シナリオの直感的な記述を妨げているように見えるのです。
私たちの作ったHaskell用のフレームワークを使うと、上記のボットは以下のように記述できます。
-- ユーザとボットの対話処理部分のみを抜粋 handler :: Client -> (Message, MessageId, TalkRoom, User) -> IO () handler cli (Txt t, _, room, user) | "こんにちは" `isInfixOf` t = void (withChannel cli (pinPointChannel room user) botMain) handler _ _ = return () botMain :: Channel -> IO () botMain chan = do _ <- send chan (Txt (pack "お名前は?")) name <- extract =<< recv chan _ <- send chan (Txt (pack (name ++ "さんの趣味は何ですか?"))) hobby <- extract =<< recv chan _ <- send chan (Txt (pack (name ++ "さんは" ++ hobby ++ "がお好きなんですね!"))) return ()
botMain関数内の処理に注目してください。 Haskellの文法に馴染みの無い方でも、ボットとユーザの対話がそのまま記述できている雰囲気を理解いただけると思います。 もちろん、このボットは複数のユーザと同時に対話セッションを実行可能です。 私たちはこの対話セッションを直感的に記述できる仕組みをChannelと呼んでいます。ChannelはHaskellの軽量スレッドを用いたスレッドプログラミングで実装しています。コールバックを用いたイベント駆動プログラミングでは実現が難しい、上から下へ流れるような対話の記述が可能になっています。 結果、ボット開発者は状態管理に悩まされることなく、本来の関心事である対話シナリオの記述に集中することができるのです。 私たちはChannelを使うことで、対話型ボット開発の生産性を大幅に改善することができました。
いかがでしょうか。 堅牢なボットでも手軽に作れること、雰囲気だけでもわかっていただけたでしょうか。 私たちが開発したこのフレームワークはgithub上で公開しています。 2019年2月現在、対応しているチャットサービスはビジネスチャットdirectのみとなっています。 したがって、実際に使ってみていただける方は限られているとは思いますが、よろしければ、是非、試してみてください。