Elixir 製チャット Bot フレームワーク (以降 Bot フレームワークと称する) Hobotを作った.
以下のとおり bot_name
adapter_conf
そして handlers_conf
を Hobot.create/3
に渡すと動作する.例では入力した内容をそのまま返すエコー( Handler.Echo )をコンソールから動かして( Adapter.Shell )いる.
iex(1)> bot_name = "EchoBot"
"EchoBot"
iex(2)> adapter_conf = %{module: Hobot.Plugin.Adapter.Shell, args: []}
%{args: [], module: Hobot.Plugin.Adapter.Shell}
iex(3)> handlers_conf = [%{module: Hobot.Plugin.Handler.Echo, args: [["on_message"]]}]
[%{args: [["on_message"]], module: Hobot.Plugin.Handler.Echo}]
iex(4)> {:ok, echo_bot} = Hobot.create(bot_name, adapter_conf, handlers_conf)
{:ok, #PID<0.1832.0>}
iex(5)> context = Hobot.context(echo_bot)
%{adapter: "EchoBot.Adapter", context: "EchoBot.Context",
handler: #Function<0.112664684/1 in Hobot.Bot.make_context/4>,
middleware: %{"EchoBot.Adapter" => %{before_publish: [], before_receive: [],
before_reply: []},
"EchoBot.Handler0" => %{before_publish: [], before_receive: [],
before_reply: []}}, name_registry: Hobot.NameRegistry,
pub_sub: Hobot.PubSub,
publish: #Function<2.112664684/3 in Hobot.Bot.make_context/4>,
reply: #Function<4.112664684/2 in Hobot.Bot.make_context/4>,
subscribe: #Function<1.112664684/1 in Hobot.Bot.make_context/4>,
task_supervisor: Hobot.TaskSupervisor,
unsubscribe: #Function<3.112664684/1 in Hobot.Bot.make_context/4>}
iex(6)> adapter_pid = Hobot.pid(context.adapter)
#PID<0.1834.0>
iex(7)> Hobot.Plugin.Adapter.Shell.gets("> ", adapter_pid)
> hello
"hello"
> hi
"hi"
> quit
nil
Adapter プラグイン Adapter.Shell は
defmodule Hobot.Plugin.Adapter.Shell do
@moduledoc """
Adapts stdio for hobot.
"""
use GenServer
def gets(device \\ :stdio, prompt, send_to) do
with x when is_binary(x) <- IO.gets(device, prompt),
line when line !== "quit" <- String.trim_trailing(x) do
send(send_to, line)
gets(device, prompt, send_to)
else
_ ->
nil
end
end
def init({context, device}) do
{:ok, {context, device}}
end
def init(context) do
{:ok, {context, Process.group_leader()}}
end
def handle_cast({:reply, _ref, data}, {_context, device} = state) do
IO.puts(device, inspect(data))
{:noreply, state}
end
def handle_info(data, {context, _device} = state) do
apply(context.publish, ["on_message", make_ref(), data])
{:noreply, state}
end
end
Handler プラグイン Handler.Echo は
defmodule Hobot.Plugin.Handler.Echo do
@moduledoc """
Echoes input.
"""
use GenServer
def init({context, topics} = args) do
for topic <- topics, do: apply(context.subscribe, [topic])
{:ok, args}
end
def handle_cast({:broadcast, _topic, ref, data}, {context, _topics} = state) do
apply(context.reply, [ref, data])
{:noreply, state}
end
def terminate(reason, {context, topics}) do
for topic <- topics, do: apply(context.unsubscribe, [topic])
reason
end
end
という実装でできている.
Bot フレームワーク作りで参考にしたもの
Hubot や Ruboty のコードを参考にした.以前は Hubot が CoffeeScript で書かれていたので読むのが大変そうであまり積極的には読んでいなかったのだが,今年 ( 2017 年) に ES2015 にリライトされて読みやすくなった.Ruboty は元々 Ruby 製なため,Ruby に慣れ親しんでいる私には読みやすかった.
また Ruboty の作者である r7akamura さんが書いた記事チャットボットフレームワーク Ruboty を振り返るは,コードには直接あらわれない,ボットフレームワークを作るときの仕組みの検討や気持ちが書かれており,大変参考になった.今でも答え合わせ的に読みなおすことがある.
Bot フレームワークの基本的な作り
Bot フレームワークを使ったアプリケーションと聞いて期待することは,
Bot を Slack に繋いで監視しておく.メッセージが投稿されたらそれに応じた処理をするといった類のことだろう.
これを抽象化すると「Botを外部の何かに繋いで監視しておく.そこに変化(イベント)があったら処理をする」ということになる.
外部の何かに繋いで監視する 部分を Adapter
と呼ぶことにする.
変化(イベント)を受けて処理をする 部分を Handler
と呼ぶことにする.
これだけのことを行うフレームワークであれば単純そうにも思えるが,実装するときには以下を考慮しなければいけなかった.
- フレームワークによるプラグイン読み込みと初期化
- フレームワークとプラグイン間の協調動作
- プラグインはどのようにイベントを待ち続けるか
- プラグインでエラーが起きたときフレームワークはどう対処するか
- Adapter が検知したイベントの Handler への受け渡し
- Handler はイベントへの反応/無視をどのように決めるか
- Handler で反応した結果の Adapter への受け渡し
- Adapter と Handler 間におけるデータ形式のミスマッチ解消
それぞれどう実装したかみていこう.
実装
フレームワークによるプラグイン読み込みと初期化
Bot フレームワークに限らず,プラグインを利用したフレームワークを作ると避けられない課題に「フレームワークによるプラグイン読み込みと初期化」がある.
Hobot では,個別の Bot プロセスツリーを作るときに,利用したい Adapter の名前を渡す( Hobot.create/4).するとコンパイル済モジュールの中から該当するモジュールを探し出す.フレームワークでは Adapter プラグインとして利用可能なモジュールは gen_server
の behaviour (ビヘイビア:規定の関数呼び出し = 規定のインターフェース) を持つモジュールであることをプラグイン実装者に課している.そこでフレームワークは見つけたモジュールを gen_server
として利用なモジュールであると決めつけて,プロセス起動,初期化している( Hobot.Bot.Supervisor.init/1 ).
フレームワークとプラグイン間の協調動作
プラグインを利用したフレームワークを作ると避けられない課題のもう一つに,プラグインからフレームワークへと何かを働きかけたい場合,逆にフレームワークからプラグインに何かを働きかけたい場合のことを考えておかなければならない.
Hobot ではプラグインからフレームワークへの働きかけは全て初期化時に受けとった context
経由で行っている.(例:プラグインからフレームワークへのデータの送信を context.publish/3 で行う)
フレームワークからプラグインへの働きかけは,フレームワークはプラグインを特定の形式の関数で呼び出すという規定を作っておき,プラグインはその関数を実装している前提で呼び出しを行う.(例:フレームワークからプラグインへのデータの送信を handle_cast/2 で行う).
プラグインはどのようにイベントを待ち続けるか
プラグインはイベントを待ち続けなければいけない.つまりループあるいはブロックして,外部から呼ばれるのを待つという処理が必要になる.
Erlang には都合良く,処理を待ち続けるサーバーに適した gen_server
(Generic Server = 汎用的なサーバー) という behaviour がある.プラグインはこの gen_server
にいくつかの規約を加えたものとして実装した,具体的には gen_server
初期化の処理である init/1
で Hobot から context
を必ず受けとるものとして実装した(例: Hobot.Plugin.Adapter.Shell.init/1 ).
プラグインでエラーが起きたときフレームワークはどう対処するか
プラグインでエラーが起きたとき,フレームワークはどう対処するか.いくつか方針があると思う.フレームワークで特に対処せず一緒に落ちる.エラーは検知し,エラーをおこしたプラグインは利用できなくする.エラーを検知し,エラーをおこしたプラグインを再起動するなどだ.後にいけばいくほど難しくなる.
Hobot のプラグインは gen_server
という behaviour を実装しているという話をした.gen_server
を実装しているモジュールから生成したプロセスには監視者( 以下 Supervisor )をつけることができる.Hobot フレームワークでも Supervisor を複数箇所で利用している.Supervisor はプロセスを監視し続ける役割を担っている.もし監視対象のプロセスがエラーになったら即座に再起動してくれる.そのためもし意図せぬエラーでサーバーが落ちてしまったとしても素早く復活させられ比較的安定した動作を見込める.20.2. スーパバイザの概念を読むと丁寧に書いてあるが,ここだけ読んでもピンとこないかもしれないので,BEAM に詳しそうな人やコミュニティで聞いてみると理解が早いだろう.
ErlangVM でのプログラミングではこうした監視と実際の処理を行う監視対象による木構造の構築が設計の重要な面を担っていると思う.余談だが Hobot のプロセスの木構造はこんな感じだ.図中にある 0.189.0
や 0.268.0
というのがそれぞれ独立した Bot を表している.ここでは 2 つの Bot を動かしている.
Adapter が検知したイベントの Handler への受け渡し
Adapter と Handler を持つ Bot フレームワークでは,Adapter が検知したイベントを Handler にどのように渡すかという点を考えなければいけない.また,複数の Handler を持ち,順番にイベントを Handler に渡していくような実装にした場合,1 つめの Handler が遅かったら,後続の Handler の反応も遅れるのか.また 1 つめの Handler でエラーが起きた場合,後続の Handler の反応はどうなるのか.ということを考えなければいけない.
Elixir には都合良く,データを受け手に一斉送信できる仕組み( PubSub )に使えるライブラリ Registry が標準添付されている( Elixir標準ライブラリRegistryを使ったPub/Sub ).Hubot ではこれを使って Adapter が検知したイベントを各 Handler が独立して受け取れるようにした.そのため 1 つめの Handler が遅くても後続の Handler は影響を受けないし,1 つめの Handler でエラーが起きても後続の Handler は影響を受けない.
Handler はイベントへの反応/無視をどのように決めるか
Handler は Adapter が検知したイベントに反応,あるいは無視することができる.
一般的に PubSub では,データの受け手(購読者: Subscriber と呼ぶ)があらかじめ興味のありそうなデータについて仲介者( Broker と呼ぶ)に伝えておく.データの出し手(出版者: Publisher と呼ぶ)がデータを仲介者に送ると,仲介者はこれまであった申し込みを元に,そのデータに興味がありそうな受け手へ一斉送信する.
Hubot では Adapter が Publisher ,Registry が Broker,Handler が Subscriber に相当する.Handler が興味のありそうなイベントをあらかじめ Registry へ登録しておく.Adapter は何かイベントが発生したら Registry に送る.Registry はそのデータに興味がありそうな Handler へ一斉に送る.という仕組みになっている.もちろん Handler は受けとったイベントを無視することもできる.「興味がありそう」と書いたのはそのせいで,Handler が反応したいかは受け取ってからでないとわからないイベントは,受けとった後にどうするか自由に決めることができる.
Handler で反応した結果の Adapter への受け渡し
Adapter と Handler を持つ Bot フレームワークでは,Handler が反応した結果を Adapter にどのように渡すかという点を考えなければいけない.もちろん Handler を書いたときには どの Adapter と繋がることになるかはわからないので,何か間接的な方法で反応を返さなくてはいけない.
Hobot ではプラグイン初期化時に context を渡す規約にしていると書いた.この context に関数を登録しておき,Handler が Adapter に返したい反応を引数にした関数呼び出しを context 経由で行い apply(context.reply, [ref, data])
といった形で反応を Adapter へと送っている.
Adapter と Handler 間におけるデータ形式のミスマッチ解消
Adapter と Handler は互いに独立しているため,Adapter が送るイベントと Handler が受け取りたいイベント形式は異なる.そのためイベントの様式を統一するか,これを吸収する仕組みを用意しなければならない.Handler から返す反応を受けとる Adapter についても同様のことが言える.
Hobot では Adapter が Registry 経由で Handler へイベントを送る,また Handler が Adapter へ反応を返す中間に Middleware という変換層を設けた.Adapter と Handler を組み合わせて Hobot を立ちあげるユーザーは,Adapter がどんなイベント形式を送るか,Handler がどんなイベント方式を受け取りたいかを知っている.そこでミスマッチを解消する Middleware を書くのは Hobot を立ちあげるユーザーの責務とした.
例えば Twitter ストリーミングのメッセージを RT を除いて Html 形式に整形して idobata へ投稿する場合,Twitter ストリーミングを取得する Adapter と idobata.io へ投稿する Handler の間の middleware は
middleware: %{
before_publish: [fn {:broadcast, "on_message", ref, tweet} ->
Logger.debug(inspect tweet)
if tweet.retweeted_status do
# Ignore a retweet.
# See the `retweeted_status` column in the API reference: https://dev.twitter.com/overview/api/tweets
# "Retweets can be distinguished from typical Tweets by the existence of a retweeted_status attribute."
Logger.info("Ignore a retweet. tweet id: #{tweet.id}")
{:halt, :ignore_a_retweet}
else
# TODO: update here
decorated_tweet = build_html_from_tweet(tweet)
Logger.debug("Post a tweet to idobata.io: #{decorated_tweet}")
{:ok, {:broadcast, "on_message", ref, decorated_tweet}}
end
end]
}
となる.
補遺
自分が使えればいいやからはじまったプロジェクトで,ドキュメントを書くのが後回しになってしまっている.しかしもし今より広く使ってもらいたいならそれを丁寧にやるのと,テストをもっと充実させないといけないだろう.ドキュメントでは,特にプラグインやミドルウェアの作り方について丁寧に解説しないといけないだろう.
Hobot プラグインはいくつかの規約を持たせた :gen_server
にすぎないので ErlangVM(BEAM) 上で動作して Erlang/OTP の :gen_server
が動かせるならどんな言語を用いても書けるだろう.こんど書いてみよう.
何かを待ち続けるための振舞いを一般化した gen_server
や PubSub に利用できる Registry
をよくメンテされるであろう標準ライブラリに備えている EralngVM(BEAM) や Elixir は便利だ.
実装を簡単にするため,メッセージは at most once で送っているので,送られてきたときに反応できなかった(例えばエラーがおきて再起動中だった)ら消えてしまう.再送の仕組みを入れて at least once にするべきだろうか.ここは悩み中.Bot フレームワークでそんなの求めてられてないんじゃないかな.シンプルを保ちたいという気持ちと,これを実装しておくと他のところで再送制御するときの練習になっていいなという気持ちの両方がある.
プラグインがエラーで落ちたときの再起動時には,プラグインが落ちる前の最後の状態を渡せるといいだろうか.まだやっていないのだけれど,比較的簡単にできそうな気もする.知見のある人からのアドバイス求む.
多くの場合,プラグインの品質はフレームワークからコントロールできないだろう.ErlangVM だと Supervisor を使って特定のプラグインがクラッシュしたり処理が遅い場合でも,本体や他のプラグインは影響を受けないようなフレームワークを作ることができるので,プラガブルなフレームワークを作るのに向いているのではないかなあ.
とはいえ悪意のあるプラグインがフレームワークのモジュール名を知ることは簡単なので,直接フレームワークのモジュールを呼び出されたときには無力だ.こういった隠蔽の仕組みを用意するべきだっただろうか.私はそこはやらないでその分コードを単純にしようと決めた.
動作する Bot 単位で名前空間を分けた話を入れるのを忘れてた.PubSub とは別の Registry プロセスを作り, GenServer
の {:via, module, term}
という形式と組み合わせている.
Adapter がめちゃくちゃイベントを送ってきて,後続の処理能力を超えた場合,後続から Adapter へと何かを送る仕組みはまだ作っていない.イベントの受け手が処理できなくなったのでイベント発生元に「送る量を減らして」と伝える仕組みのことをバックプレッシャーと言うそうだ.これについても私はそこはやらないでその分コードを単純にしようと決めた.
Node や MRI で作られた Bot フレームワークを参考に実装したので現在はこういうと設計なっているが,並列で独立した処理の行える ErlangVM の場合これ以外の作り方もあるのではないか.具体的には Handler と Adapter を分ける意味はあるだろうか?作りやすさ,利用しやすさの観点からは分けておいた方がいい気もしているが,分けないことも一度考えてみたい.
こういった内容をブラッシュアップして機会があればどこかで話せるといいなあという気持があるので興味がある人はお声がけください.あんまり話がうまくないけれど練習していきます.
Bot フレームワークを作るのはとても楽しかった.みなさんもお気にいりの言語で作ってみるといい.ちょうどよいトピックの量でプラガブルなフレームワークを作る体験ができる.Bot フレームワーク座談会などあれば呼んでほしい.実装で悩んだ点や調べたこと,そしてそれを踏まえての決定など雑談しましょう.