XrcというRubyのXMPPクライアントライブラリをつくったので、XMPP界の知見を共有します。
WHY
EllenというBOT開発用のフレームワークを最近つくっていて、 これをSlackというチャットサービスで利用していた。 SlackにはXMPP GatewayとIRC Gatewayが用意されており、 どちらかのプロトコルを利用すればBOTとして動作するにはひとまず十分だった。
Rubyで一般的なIRCライブラリと言えばnet-ircだけど、 自分でZirconというIRCクライアント用ライブラリを作って、 ellen-slackでは最初はこれを使ってた。 IRCは雑に全部屋に適当にJOINしてくれたりするのでBOTとして運用するにはわりと楽だったんだけど、 メッセージに改行を簡単に含められないというところが気に入らなくてXMPPを検討することにした。 改行が含められないとどういうときに困るかと言うと、 例えばコードや等幅で表示したいメッセージを整形して表示することが出来ない。
XMPP
RubyでXMPPと言えばxmpp4rというライブラリを利用するのが一般的。 だけど少し前までマルチバイト文字列が扱えなかったり、結構古くてとにかく大変そうという印象があったので 本題の通り自分で最低限必要なものを作り直すことにした。
XMPPがどういう風に動くかちょっと勉強したので、接続の様子と仕組みを簡単にまとめておく。 ちなみにO'REILLYのマスタリングXMPPっていうのを買って読んだ。 薄いしすぐ読めるけど恐らく意図的に表面的なことしか載ってない (全部説明するとウワーッてなるから) ので実装するときは自分で既存ライブラリ読んだりRFC読んだりしないといけない。 実装しようと思うと初めて分かることだけど、XMPPが少し古いので検索してもXMPPに関する情報があまり出てこない。 イメージで言うとStackoverflowが出てきてほしいところでMLの過去ログが出てくる感じ。 2014年にもなってIRCとかXMPPとか再実装してるのわりとウケる。
JabberID
まず個々のアカウント用にJabberIDというのが割り当てられる (XMPPは昔Jabberって呼ばれてたのでその名残が見られる)。 alice@example.com/botみたいな形式で、メールアドレスの末尾に"/xxx"が付いたような形になっている。 aliceの部分をノード、example.comの部分をドメイン、botの部分をリソースと呼ぶ。 /botの部分は大体任意の文字列を指定することが出来る。 この部分は省略可能で、alice@example.comでもJabberIDとして有効なものになる。 指定せずにサーバに接続するとサーバが勝手に割り当ててくれる。 リソースを省略せずに記述した形式のJabberIDのことをFull JabberIDと呼ぶこともあるらしい。
どういう場合に使われるかと言うと、 例えばHipChatではリソースに"bot"を指定しておくとBOTだと認識して挙動を少し変えてくれる。 具体的には、通常HipChatのサーバに接続すると過去ログを数件送ってくれるのだけど、 BOTが接続したときにはこれを送らないようにしてくれる。 BOTは大概「XXXというメッセージが来たときにYYYをする」という感じの動作をするので、 過去のメッセージに反応しておかしなことになってしまわないという意味でこの動きはありがたい。
Host
自分のアカウント用のJabberIDのドメイン部分を見れば、接続先のサーバが分かる。 alice@example.comであれば、DNSで_xmpp-client._tcp.example.comを名前解決すると、接続先のホストが分かる。 具体的には、SRVを引くと幾つかレコードが得られるので、 これを優先度順に並び替えて先頭からXMPP用の5222番ポートに接続を試みる。 最初に繋がった先が接続先ということになる。 まあ大体SRVの情報とか返ってこないのでexample.com:5222に接続することになる。
Stream
example.com:5222にTCPSocket(以下ソケット)で接続する。 Slackだとドメインは xxx.xmpp.slack.com とかになる。xxxにはチームの名前が入る。 ソケットに開始用のメッセージをwriteしてreadするとメッセージが返ってくる。 大体XML表現での文字列でのwriteとreadの応酬で対話的に物事が進んでいく。 Xrcのコードもこれに合わせてややイベント駆動っぽいスタイルにした。 XMLを部分的に読んでいくので、解析にもSAX形式のパーサを使うことになる。 最初はこちらから次のようなメッセージをクライアントから送る。 (以下サーバとクライアント間で送受信するXML形式の文字列をメッセージと呼ぶ)
<stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' to='xxx.example.com' xml:lang='en' version='1.0' >
XMLは普通開始要素があると対応する終了要素が存在するけど、 接続中のときは開始要素しか存在しないっていうのがお洒落ポイントらしい。 サーバからはこういうメッセージが返ってくる。
<stream:stream from='xxx.example.com' id='2e4c08c9-5455-41a3-9a02-2a34660a2dac' xml:lang='en' xmlns:stream='http://etherx.jabber.org/streams' version='1.0' xmlns='jabber:client'> <stream:features> <starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'> <required/> </starttls> <mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'> <mechanism>PLAIN</mechanism> </mechanisms> <auth xmlns='http://jabber.org/features/iq-auth'/> </stream:features>
TLS
接続時にstream:featuresという要素を送ってきて、サーバが提供しているサービスを教えてくれるという構図。 ここではTLS(=SSL)接続を要求していることと、PLAIN形式での認証方式をサポートしていることが伺える。 この要素がこういう意味を表しています的な情報はRFC3920とかその辺に書いてあるけど読むのつらいと思う。 とりあえずSSL接続要求に応えるために次のようメッセージをクライアントから送る。
<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>
するとサーバが了承した旨のメッセージを返してくれるので、 ここで接続中のソケットをSSLを利用したものに取り替える。 RubyだとOpenSSL::SSL::SSLSocketを使えば良い。 使えば良いが、受信した文字列からエンコーディング情報が失われてしまう (=これが理由でxmpp4rが最近までマルチバイト文字列に対応できていなかった)ので、 OpenSSL::SSL::SSLSocketを継承してUTF-8を強制するようなクラスを定義して利用している。 ちなみにSlackはTLS v1.2を利用しているが、最近のOpenSSLを使っていれば勝手に判断してくれると思う。
<proceed xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>
Auth
ソケットを取り替えて再接続したら、stream:streamを送るところからやり直す。 stream:featuresを受け取るところまで進んだら、今度は認証を求められているので、 認証情報(=パスワード)を指定された方法で暗号化してクライアントから送信する。 サーバはPLAINという暗号化方式を許可しているので、 JabberIDとパスワードを単純に繋いでBase64エンコードしたものを認証情報として送る。
<auth mechanism='PLAIN' xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>YWxpY2VAZXhhbXBsZS5jb20AYWxpY2UAa29nYWlkYW4=</auth>
認証に成功するとサーバからその旨のメッセージが送られてくる。 認証が済んだら再度stream:streamを送るところからやり直す。
<success xmlns='urn:ietf:params:xml:ns:xmpp-sasl'/>
Resource Binding
stream:featuresを確認するところまで進む。 次はリソースバインディングという処理を行う。 これは自分のJabberIDを送って、Full JabberIDを確定させる処理。 ここでリソースを指定していない場合はサーバが勝手に割り当ててくれる。 サーバによっては指定している場合でも問答無用で新しいものが割り当てられる可能性もある。 その辺はサーバの運用ポリシー次第とのこと。Slackは自分で宣言したものが優先される。 今回は「わたしのリソース識別子はbotですよ」という旨のメッセージを送る。
<iq id='1091' type='set' xmlns='jabber:client'> <bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'> <resource>bot</resource> </bind> </iq>
id属性には任意のIDを指定できる。 クライアントから複数のメッセージをサーバに送ることになるが、 サーバはそれぞれのメッセージに対して個別に返事を送る。 このとき、このメッセージはこのメッセージに対する返答ですよというのを表すのにIDが利用される。 クライアントからのメッセージにはtype属性にsetが入っているけど、これはHTTPで言うところのPUTリクエストに相当する。 サーバからのレスポンスのtype属性にはresultという値が入る。 サーバからは確定したJabberIDが通知されるので、クライアントはこの値をメモリとかに入れて覚えておく。
<iq id='1091' to='alice@xxx.example.com/bot' type='result' xmlns='jabber:client'> <bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'> <jid>alice@xxx.example.com/bot</jid> </bind> </iq>
Roster
XMPPではRosterというアドレス帳みたいな概念があって、 サーバが知っているアカウント、つまり接続中のユーザに関する情報がRosterに載っている。 例えばHipChatだと、各ユーザのJabberIDの他にmention用の名前とかも記載されている (BOTでmentionしたいときに便利)。 こういうメッセージを送るとレスポンスでRosterの情報が返ってくるが、 サーバのポリシーやユーザの設定次第でそこまで詳細なデータが返ってこなかったりする。 HipChatだと色々返ってくるけどSlackはあまり返ってこない。
<iq id='ca31d7c4cec7e6b3' type='get'> <query xmlns='jabber:iq:roster'/> </iq>
Precense
Resource Bindingが終われば大体接続時にやるべきことは終わったので、 最後に自分の接続状況をサーバに伝える。つまりステータスをオンライン状態にする。 これはpresenceという空の要素を送るだけでいい。
<presence xmlns='jabber:client'/>
Ping
XMPPサーバは大体一定時間音沙汰が無いと向こうから接続を切ってくるので、 定期的に何かメッセージを送る必要がある。 手を抜きたい場合は半角スペースを送るみたいなことも出来るが、 一応PINGのためのメッセージの形式が定義されているので、それに従ったメッセージを送るのが吉。 XrcではPingの用途にだけThreadを使った。他は全て1つのプロセス、1つのスレッドで動いている。 EventMachineとかを使って非同期I/Oにも出来るけど、そういう段階では全くないので素直に実装してる。 SlackもHipChatもおよそ60秒通信がないと接続を切られるので、それより短い周期でPINGを送り続けている。
<iq from='alice@example.com/bot' to='example.com' type='get'> <ping xmlns='urn:xmpp:ping'/> </iq>
Join
XMPPのコアの概念では、基本的には通信はノード間の通信として実現される。 複数人でのチャットはMulti-User-Chatという拡張仕様として定義されている。 例えばSlackでは、hackという部屋への参加は、 hack@conference.xxx.xmpp.slack.com にprecenseを送ることで実現される。 またhackという部屋でのaliceとbobとの会話は、 hacx@conference.xxx.xmpp.slack.com/aliceとhack@conference.xxx.xmpp.slack.com/bobとの通信として表現される。 aliceがhackにJoinするときにサーバに送るメッセージはこんな感じ。
<presence from='alice@xxx.xmpp.slack.com' to='hack@conference.xxx.xmpp.slack.com'> <x xmlns='http://jabber.org/protocol/muc'/> </presence>
部屋に参加すると、同じ部屋に参加している人の接続状況がpresenceメッセージとしてサーバから送られてきたりする。
<presence from='hack@conference.xxx.example.com/bob' to='c2s@ip-1-2-3-4.ec2.internal/1.2.3.4_5222_5.6.7.8_21900' xmlns='jabber:client'> <show> chat </show> <x xmlns='http://jabber.org/protocol/muc#user'> <item name='bob' nick='bob' affiliation='member' jid='bob@xxx.example.com' role='participant'/> </x> <x xmlns='vcard-temp:x:update'> <photo> sha1-hash-of-image_12345 </photo> <FN> bob </FN> </x> </presence>
Message
会話はmessage要素で表現される。 複数人チャットではtype属性の値がgroupchat、1vs1チャット(a.k.a. Privateチャット)ではchatになる。 発言の内容はbody要素に含まれる。 hackという部屋でaliceからbobにThank youという文字列を送る場合はこういう感じ。
<message from='alice@example.com/bot' to='hack@conference.xxx.example.com/bob' type='groupchat'> <body>Thank you</body> </message>
このとき幾つかのHTMLの文字列はHTMLエンティティに変換されるので、 受け取るときは適当にデコードしてやる必要がある。 botからaliceにYou're welcomeという文字列が送られてくるとこういうメッセージを受け取ることになる。
<message from='hack@conference.xxx.example.com/bob' type='groupchat' to='alice@xxx.example.com/bot'> <body> You're welcome </body> </message>
Abstraction
Ellenで扱うには、 IRCやXMPP、Twitterなど幾つかのメッセージングサービスを抽象化して扱う必要がある。 Ellenの中で「メッセージ」は以下の属性を持つオブジェクトとして表現されている。
- from
- to
- body
- payload
fromやtoは送り元や送り先を表す識別子で、XMPPならalice@example.com/bot、IRCなら@aliceなど。 bodyは本文。payloadはその他の任意の情報を入れられるところ。 例えばXMPPにはIRCと違ってtypeという概念がある(groupchatとchatを区別するのとかに使う)ので、そういう値を入れる。 送られてきたメッセージのfromがbob、toがalice、bodyが@alice pingでtypeがgroupchatであれば、 fromがalice、toがbob、bodyがpongでtypeがgroupchatとなるような返信用メッセージをつくり、 各メッセージングサービス用のアダプタ(ellen-slackやellen-hipchatなど)に渡せば良い、という仕組みになっている。
こういう感じでXMPPやIRCなどのレイヤはある程度抽象化できたので、 便利プラグインをつくっていろんなチャットで再利用できるようにしたい。 今はチャットからDigitalOceanのインスタンス立ち上げるAPI叩くやつとか、 デプロイ用Pull Requestをつくってチャット経由でデプロイさせるやつとかをいろいろ作ってる。 現場からは以上です。