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

これは [Hubot Advent Calendar 2014][hubot-adventar-2014] の 19 日目の記事です。

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

# 前回まで、そして今回は

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

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

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

# サンプルコード

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

```coffeescript: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":{}}
```

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

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

```coffeescript:./src/example.coffee
module.exports = (robot) ->
  robot.respond /(\S+)$/, (msg) ->
    message = msg.match[1]
    robot.brain.set 'example', message
    msg.send message
```

こんな感じ。再実行。

```bash
$ 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][] あたりも読むと良いです。

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

## 設定の読み込み

```coffeescript
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 回][hubot-adventar-2014-16]と同じように設定を読み込んでいます。

## オートセーブの一時停止

```coffeescript
  robot.brain.setAutoSave false
```

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

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

## 読み込み

```coffeescript
  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.brain` に `data` をマージしてくれます。名前どおりです。ただ、クソみたいなマージの仕方なので、クソみたいな結果をもたらす可能性があります。詳細は後述します。

## 書き込み

```coffeescript
  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.brain` は `EventEmitter` なので `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 系スクリプトを優先的に読み込み、ストレージを準備することはできません。また読み込みが完了したことを伝える標準的な方法はありません。要するに、いつ読み込みが終わるのかも終わったのかも分からないままに使わざるを得ません。

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

```coffeescript: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

```coffeescript
# 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` したデータは消し飛ぶと思って間違いないです。また、上記のとおり `loaded` は `robot.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][] はこれが言いたかっただけ) 


[getting-started-with-hubot]: https://github.com/github/hubot/tree/v2.9.3/docs#getting-started-with-hubot
[bouzuya/hubot-hello-world]: https://github.com/bouzuya/hubot-hello-world/
[bouzuya/hubot-choice]: https://github.com/bouzuya/hubot-choice/
[bouzuya/hubot-adventar]: https://github.com/bouzuya/hubot-adventar/
[bouzuya/hubot-homepage]: https://github.com/bouzuya/hubot-homepage/
[bouzuya/hubot-script-gist]: https://github.com/bouzuya/hubot-script-gist/
[bouzuya/hubot-brainfxxk]: https://github.com/bouzuya/hubot-brainfxxk/
[bouzuya/hubot-7days-later]: https://github.com/bouzuya/hubot-7days-later/
[bouzuya/hubot-file-brain]: https://github.com/bouzuya/hubot-file-brain/
[hubot-scripts/hubot-redis-brain]: https://github.com/hubot-scripts/hubot-redis-brain
[hubot-script-per-day-61]: http://bouzuya.hatenablog.com/entry/2014/09/12/224215
[github/hubot]: https://github.com/github/hubot/
[github/generator-hubot]: https://github.com/github/generator-hubot/
[ops-for-everyone]: https://speakerdeck.com/johndbritton/ops-for-everyone
[adventar]: http://www.adventar.org/
[express-reference]: http://expressjs.com/3x/api.html
[hubot.github.com]: https://hubot.github.com/
[hubot-adventar-2014]: http://www.adventar.org/calendars/384
[hubot-adventar-2014-bouzuya-index]: http://qiita.com/bouzuya/items/c7d0ad80c357aab6b696
[hubot-adventar-2014-1]: http://qiita.com/bouzuya/items/c7d0ad80c357aab6b696
[hubot-adventar-2014-2]: http://qiita.com/bouzuya/items/11c0c6da2b3ad54b827f
[hubot-adventar-2014-3]: http://qiita.com/bouzuya/items/2a200c9e8a45e2478bc2
[hubot-adventar-2014-4]: http://qiita.com/bouzuya/items/4c0206d72ff22ade9339
[hubot-adventar-2014-5]: http://pasero.net/~mako/blog/s/685
[hubot-adventar-2014-6]: http://qiita.com/bouzuya/items/4e051f68f8d68a2944b0
[hubot-adventar-2014-7]: http://qiita.com/bouzuya/items/2e935c99647b93d8ee1d
[hubot-adventar-2014-8]: http://udzura.hatenablog.jp/entry/2014/12/08/182744
[hubot-adventar-2014-9]: http://qiita.com/bouzuya/items/587c4e920151342e6f1d
[hubot-adventar-2014-10]: http://qiita.com/bouzuya/items/8e8ee8e2e8f83513ad35
[hubot-adventar-2014-11]: http://qiita.com/bouzuya/items/3e239bc530e7c2c3a26c
[hubot-adventar-2014-12]: http://qiita.com/hiconyan/items/3e5481a61657890624dc
[hubot-adventar-2014-13]: http://pasero.net/~mako/blog/s/689
[hubot-adventar-2014-16]: http://qiita.com/bouzuya/items/d65a394cac9e76b56d3d
[hubot-adventar-2014-17]: http://qiita.com/hiconyan/items/baf6ac56129a26d8ac0c
[hubot-adventar-2014-18]: http://qiita.com/bouzuya/items/d5fd0ef9a8cb100933f4