Hubot

robot.brain を永続化する Hubot スクリプトをつくろう

More than 3 years have passed since last update.

これは Hubot Advent Calendar 2014 の 19 日目の記事です。

また、今回は @bouzuya の Hubot 連載の第 12 回です。目次は、第 1 回の記事にあるので、そちらをどうぞ。


前回まで、そして今回は

さて、前回は robot.brain を使った Hubot スクリプトをつくろう ということで、robot.brain で永続化する Hubot スクリプトをつくりました。

今回は robot.brain を永続化する、永続化される側ではなく永続化する側の Hubot スクリプトをつくりましょう。

テキストファイル (JSON) に brain の内容を書き出して、永続化する Hubot スクリプトをつくります。Heroku にデプロイする場合には(Heroku はEphemeral filesystemなので)実用的ではありませんが、ローカルで robot.brain の挙動を学ぶにはちょうど良いと思います。


サンプルコード

以下のサンプルコードは、bouzuya/hubot-file-brain のです。


file-brain.coffee

# Description

# A Hubot script to persist hubot's brain using text-file
#
# Configuration:
# HUBOT_FILE_BRAIN_PATH
#
# Commands:
# None
#
# Author:
# bouzuya <m@bouzuya.net>

Fs = require 'fs'

config =
path: process.env.HUBOT_FILE_BRAIN_PATH

module.exports = (robot) ->
unless config.path?
robot.logger.error 'process.env.HUBOT_FILE_BRAIN_PATH is not defined'
return

robot.brain.setAutoSave false

load = ->
if Fs.existsSync config.path
data = JSON.parse Fs.readFileSync config.path, encoding: 'utf-8'
robot.brain.mergeData data
robot.brain.setAutoSave true

save = (data) ->
Fs.writeFileSync config.path, JSON.stringify data

robot.brain.on 'save', save

load()



動かしてみよう

bouzuya/hubot-file-brain を取得して実行します。

$ # GitHub からクローン

$ git clone bouzuya/hubot-file-brain

$ # 依存パッケージを取得
$ cd hubot-file-brain
$ npm install

$ # 実行 (5s以上立ってから終了すること)
$ HUBOT_FILE_BRAIN_PATH=./brain.json HUBOT_SHELL_USER_NAME='bouzuya' PATH="./node_modules/hubot/node_modules/.bin:$PATH" $(npm bin)/hubot -a shell -n hubot -r src
hubot> exit

$ # ファイルを確認する
$ cat ./brain.json
{"users":{},"_private":{}}

もうちょっとわかりやすいようにスクリプトを足してみるといいです。

前回もつくったので、簡単ですね。


./src/example.coffee

module.exports = (robot) ->

robot.respond /(\S+)$/, (msg) ->
message = msg.match[1]
robot.brain.set 'example', message
msg.send message

こんな感じ。再実行。

$ HUBOT_FILE_BRAIN_PATH=./brain.json HUBOT_SHELL_USER_NAME='bouzuya' PATH="./node_modules/hubot/node_modules/.bin:$PATH" $(npm bin)/hubot -a shell -n hubot -r src

hubot> hubot クリスマスなんて大嫌い!!なんちゃって
クリスマスなんて大嫌い!!なんちゃって
hubot> exit

$ cat ./brain.json
{"users":{"1":{"id":1,"name":"bouzuya","room":"Shell"}},"_private":{"example":"クリスマスなんて大嫌い!! なんちゃって"}}

なるほど。


解説

まず robot.brain のことを知りたいなら、ソースコードを読むと良いです。

https://github.com/github/hubot/blob/v2.10.0/src/brain.coffee

あとは参考になりそうな brain 系スクリプトである hubot-scripts/hubot-redis-brain あたりも読むと良いです。

真面目にリンク先を読んだら、もう解説とか要らないんですけど、一応、説明します。


設定の読み込み

config =

path: process.env.HUBOT_FILE_BRAIN_PATH

module.exports = (robot) ->
unless config.path?
robot.logger.error 'process.env.HUBOT_FILE_BRAIN_PATH is not defined'
return

第 10 回と同じように設定を読み込んでいます。


オートセーブの一時停止

  robot.brain.setAutoSave false

robot.brain にはオートセーブ機能があります。デフォルトでは 5s ごとに robot.brain.save() が呼び出され、'save' イベントが発生し、それに応じて上記のような brain 系スクリプトがファイルなどに書き込むことで永続化します。

これを一旦オフにしておけば書き込みはだいたいは起きません。(robot.brain.save() を明示的に呼んだり robot.brain.close() が呼ばれたら破綻します。ヒューッ!) 読み込みが終わるまでは書き込みを止めておきましょう。


読み込み

  load = ->

if Fs.existsSync config.path
data = JSON.parse Fs.readFileSync config.path, encoding: 'utf-8'
robot.brain.mergeData data
robot.brain.setAutoSave true

...

load()

読み込み処理です。起動時に設定されたファイルを読み込んで robot.brain.mergeData(data) します。読み込みが終われば autoSave をオンに戻しています。

robot.brain.mergeData(data)robot.braindata をマージしてくれます。名前どおりです。ただ、クソみたいなマージの仕方なので、クソみたいな結果をもたらす可能性があります。詳細は後述します。


書き込み

  save = (data) ->

Fs.writeFileSync config.path, JSON.stringify data

robot.brain.on 'save', save

robot.brain.on'save' イベントのタイミングでファイルに書き込んでいます。

上記で書いたとおり、デフォルトでは 5s おきに保存されます。保存タイミングを変えるには robot.brain.resetSaveInterval(seconds) を呼ぶと良いです。保存に 5s 以上かかる環境だとひどい目にあうと思います。ないと信じたいですが。

今回のサンプルコードの説明は以上ですね。じゃあ、さらに、いくつか補足で説明し(disり)ます。


robot.brain のほかいろいろ(適当)


イベント

robot.brainEventEmitter なので on / emit できます。発生するイベントは次のとおりです。

イベント名
説明

'loaded'

robot.brain.set() / robot.brain.mergeData() で発生。読み込み時完了時、と思いきや、set されるたびに呼ばれます。robot.brain の起動を待つためのものではありません。brain に変更があったことを通知するものです。

'save'

robot.brain.save() で発生。 robot.brain.close() でも発生します。このタイミングで書き込み処理を実行します。普段は autoSave が有効なので 5s おきに呼び出されます。

'close'

robot.brain.close() で発生。終了時に呼ばれます。今回の例ではないですが、 DB などとの接続を閉じると良いと思います。

'loaded' が割とクソいです。いっそ 'changed' くらいにしてくれれば良いと思うのですが。


各スクリプトで起動時にキャッシュすべきでない

robot.brain の根本的な問題として brain 系 Hubot スクリプトが特別扱いされていないことが挙げられます。

hubot-xxx-brain は他のスクリプトと同じ Hubot スクリプトなので、決まった順序で読み込まれるだけです。brain 系スクリプトを優先的に読み込み、ストレージを準備することはできません。また読み込みが完了したことを伝える標準的な方法はありません。要するに、いつ読み込みが終わるのかも終わったのかも分からないままに使わざるを得ません。

よって、以下のスクリプトは危険です。


ahoo.coffee

module.exports = (robot) ->

count = robot.brain.get('count')

robot.respond /hello/, (msg) ->
count += 1
msg.send "#{count} 回呼び出されたよ!ハッピー"
robot.brain.set('count', count)


このスクリプトはほとんどの brain 系スクリプト (ahoo.coffee より前に位置し、同期的に読み込みを完了する brain なら問題ありません。そんな brain はまずありえませんが。) よりも先に実行されます。

robot.brain.set により 'loaded' イベントが発生するため、'loaded' を brain 系スクリプトの読み込み完了イベントだと勘違いしているとさらに事故が起きますね。

まとめると robot.brain.get(key) した値を各スクリプトが起動時にキャッシュするのは危険です。取得した値は期待したストレージから読み込まれた値とは限りません。brain 系スクリプトの準備完了はきっとまだまだ先です。


すべてのスクリプトは共通の領域に格納する

robot.brain はデータを { users: {}, _private: {} } の形で保持しており、robot.brain.get()robot.brain.set() で操作できるのは _private: {} の箇所です。

この領域は全スクリプトで共通です。上記の robot.brain.get('count') がどのスクリプトが登録した 'count' なのかは分かりません。また robot.brain.set('count') は別のスクリプトが登録した 'count' を潰しているかもしれません。

面倒でもスクリプト名などをキーに含めたり、オブジェクトにまとめてください。例えば robot.brain.set('bouzuya-script-hoge', { foo: 123, bar: 456, baz: 789 }) のようにすれば良いでしょう。

特にルールはないので、嫌われない範囲で自由にしてください。


mergeData のマージはワイルド

何も言わず、ソースコードを読んでください。

https://github.com/github/hubot/blob/v2.10.0/src/brain.coffee#L83-L92

# Public: Merge keys loaded from a DB against the in memory representation.

#
# Returns nothing.
#
# Caveats: Deeply nested structures don't merge well.
mergeData: (data) ->
for k of (data or { })
@data[k] = data[k]

@emit 'loaded', @data

ヒューッ!

ぼくがレビュアーならリジェクトしたいです。data を一層だけ for of でなめるということは、実質 users_private を代入して終わりってことです。ほぼ上書きですね。マージなのか、それ。もう @data = data でいいんじゃないか、と思ってしまいますね。

というわけで、起動してから brain 系スクリプトが mergeData するまでに robot.brain.set したデータは消し飛ぶと思って間違いないです。また、上記のとおり loadedrobot.brain.set() でも発生するので、読み込み完了タイミングを知る方法はありません。 brain 系スクリプトがいつ mergeData を完了するのかは分かりません。諦めて好きなタイミングで robot.brain.set() してください。

想像してください。

あなたのスクリプトで robot.brain.get() したあと、非同期処理しているうちに brain 系スクリプトが robot.brain.mergeData() して、そのあとあなたのスクリプトが robot.brain.set() したらどうなりますか?

想像するだけで寒くなります(もうすぐクリスマスですね!)。


まとめ

robot.brain に詳しくなれました。大切なデータはどんどん永続化してください!ぼくは事故が起きるのを楽しみにしています。

robot.brain を使うのに必要なのは知識ではなく勇気だと思っています。割とまじめに。

今回のスクリプトは bouzuya/hubot-file-brain にあります。動かない場合などは参考にしてください。

次回はアダプターかテストか、そのあたりですね。ちなみに generator-hubot@0.1.4 のスクリプトはテストが動かない状態なんですけどね。いいかげんぼくの PR 取り込んで欲しいです。


最後に

brain に f*ck って叫びたくなるのが Hubot の brain です。(bouzuya/hubot-brainfxxk はこれが言いたかっただけ)