Hubot スクリプトの読み込み処理を読んでみよう

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

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

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

前回は [Hubot でホームページをつくろう][hubot-adventar-2014-10] ということで、`robot.router` を使った HTTP リクエストへの応答する Hubot スクリプトをつくりました。

今回は、ちょっと違った観点で Hubot スクリプトの読み込みの流れを追いかけてみましょう。

# Procfile

さて Hubot はどうやって起動されているのでしょう。ここでは Heroku にデプロイされる想定で考えてみましょう。

Heroku はルートに `Procfile` があれば、それを見て動きを変えます (実は省略可能ですが、それは後述) 。ひとまず `Procfile` から追ってみましょう。

`yo hubot` で生成される `Procfile` の例は次のとおりです。

```:Procfile
web: bin/hubot -a slack -n hubot
```

`./bin/hubot` にオプションを与えて起動しています。これは[第2回][hubot-adventar-2014-2]でも使っていたと思います。

# `./bin/hubot`

次は `./bin/hubot` を見ていきましょう。

```:./bin/hubot
#!/bin/sh

set -e

npm install
export PATH="node_modules/.bin:node_modules/hubot/node_modules/.bin:$PATH"

exec node_modules/.bin/hubot "$@"
```

`npm install` し、PATH を通して、`./node_modules/.bin/hubot` を起動しています。

この PATH に追加されている `./node_modules/.bin` は `$(npm bin)` と同じです。また、`./node_modules/hubot_node_modules/.bin` も追加しているのは `coffee` コマンドを使えるようにするためです。

`./node_modules/.bin/hubot` は実行に `coffee-script` を必要とするシェルスクリプトだからです。めんどくさいシェルスクリプトですね。ちょっと工夫すれば Node.js だけで実行できると思うんですが……。

# `./node_modules/.bin/hubot` 

次は `./node_modules/.bin/hubot` を見ていきましょう。

`npm` を知っていれば当たり前のことなのですが、`package.json` の `bin` に設定されたものはこのディレクトリに配置されます。実物を見ましょう。

https://github.com/github/hubot/blob/v2.10.0/package.json#L37-L39

`package.json` によると、実体は [github/hubot][] の `./bin/hubot` みたいです (さきの `./bin/hubot` とは [github/generator-hubot][] のものであり、これとは違います) 。

https://github.com/github/hubot/blob/v2.10.0/bin/hubot

これはちょっとボリュームがありますね。かいつまんでいきましょう。

## `#!/usr/bin/env coffee`

https://github.com/github/hubot/blob/v2.10.0/bin/hubot#L1

```coffeescript:./bin/hubot
#!/usr/bin/env coffee
```

これがにくい `coffee` 依存シェルスクリプトの実体ですね。

## `require`

https://github.com/github/hubot/blob/v2.10.0/bin/hubot#L5

```coffeescript:./bin/hubot
Hubot = require '..'
```

まず読み込みます。`Hubot` として読み込むのは `'../'` つまり `./index.coffee` です。これをいま追いかけるとキリがないので、ひとまず飛ばしていきます。

https://github.com/github/hubot/blob/v2.10.0/index.coffee


## コマンドラインオプションの解釈

次はコマンドラインオプションの解釈です。

https://github.com/github/hubot/blob/v2.10.0/bin/hubot#L11-L37

```coffeescript:./bin/hubot
Switches = [
  [ "-a", "--adapter ADAPTER", "The Adapter to use" ],
  [ "-c", "--create PATH",     "Create a deployable hubot" ],
  [ "-d", "--disable-httpd",   "Disable the HTTP server" ],
  [ "-h", "--help",            "Display the help information" ],
  [ "-l", "--alias ALIAS",     "Enable replacing the robot's name with alias" ],
  [ "-n", "--name NAME",       "The name of the robot in chat" ],
  [ "-r", "--require PATH",    "Alternative scripts path" ],
  [ "-t", "--config-check",    "Test hubot's config to make sure it won't fail at startup"]
  [ "-v", "--version",         "Displays the version of hubot installed" ]
]

Options =
  adapter:     process.env.HUBOT_ADAPTER or "shell"
  alias:       process.env.HUBOT_ALIAS   or false
  create:      process.env.HUBOT_CREATE  or false
  enableHttpd: process.env.HUBOT_HTTPD   or true
  scripts:     process.env.HUBOT_SCRIPTS or []
  name:        process.env.HUBOT_NAME    or "Hubot"
  path:        process.env.HUBOT_PATH    or "."
  configCheck: false

Parser = new OptParse.OptionParser(Switches)
Parser.banner = "Usage hubot [options]"

Parser.on "adapter", (opt, value) ->
  Options.adapter = value
```

こんな感じでコマンドラインオプションを `parse` しています。

## `robot = Hubot.loadBot ...`

https://github.com/github/hubot/blob/v2.10.0/bin/hubot#L83

```coffeescript:./bin/hubot
 robot = Hubot.loadBot adapterPath, Options.adapter, Options.enableHttpd, Options.name
```

Hubot のインスタンスをつくります。変数名は `robot` ですね。

# `loadScripts`

loadScripts を定義しています。ここは重要です。

https://github.com/github/hubot/blob/v2.10.0/bin/hubot#L91-L126

```coffeescript:./bin/hubot
  loadScripts = ->
    scriptsPath = Path.resolve ".", "scripts"
    robot.load scriptsPath

    scriptsPath = Path.resolve ".", "src", "scripts"
    robot.load scriptsPath

    hubotScripts = Path.resolve ".", "hubot-scripts.json"
    if Fs.existsSync(hubotScripts)
      data = Fs.readFileSync(hubotScripts)
      if data.length > 0
        try
          scripts = JSON.parse data
          scriptsPath = Path.resolve "node_modules", "hubot-scripts", "src", "scripts"
          robot.loadHubotScripts scriptsPath, scripts
        catch err
          console.error "Error parsing JSON data from hubot-scripts.json: #{err}"
          process.exit(1)

    externalScripts = Path.resolve ".", "external-scripts.json"
    if Fs.existsSync(externalScripts)
      Fs.readFile externalScripts, (err, data) ->
        if data.length > 0
          try
            scripts = JSON.parse data
          catch err
            console.error "Error parsing JSON data from external-scripts.json: #{err}"
            process.exit(1)
          robot.loadExternalScripts scripts

    for path in Options.scripts
      if path[0] == '/'
        scriptsPath = path
      else
        scriptsPath = Path.resolve ".", path
      robot.load scriptsPath
```

まとめると次のような処理です。

1. `./scripts` を `robot.load`
2. `./src/scripts` を `robot.load`
3. `./hubot-scripts.json` を `robot.loadHubotScripts 'node_modules/hubot-scripts/src/scripts', scripts` 
4. `./external-scripts.json` を `robot.loadExternalScripts scripts`
5. `Options.scripts` ( `-r` オプション ) を `robot.load scriptsPath`

- `Robot#load`
- `Robot#loadHubotScripts`
- `Robot#loadExternalScripts`

の動きが気になるところですが。後まわししましょう。

## `robot.adapter.on 'connected', loadScripts`

https://github.com/github/hubot/blob/v2.10.0/bin/hubot#L133

```coffeescript:./bin/hubot
  robot.adapter.on 'connected', loadScripts
```

さきほどの `loadScripts` の実行タイミングを設定しています。 `robot.adapter.on 'connected'` は `adapter` の接続が完了したときに発生するイベントです。そのリスナーとして `loadScripts` を指定しています。

**アダプター読み込みが完了してから、スクリプトを読み込む**ということですね。

## `robot.run()`

https://github.com/github/hubot/blob/v2.10.0/bin/hubot#L135

```coffeescript:./bin/hubot
  robot.run()
```

ここまでで下準備は完了です。あとは起動するだけ。`robot.run()` しましょう。

# 起動スクリプトを終えて

起動スクリプトはこんな感じです。

1. `./index.coffee` の `loadBot` で `robot` をつくり
3. `Robot#run` で Hubot を起動し (アダプターを読み込み)
4. アダプター読み込みが完了したら `Robot#load` などでスクリプトを読み込む

ということですね。もうすこし、読み込みまわりの処理を見てみましょう。

# `./index.coffee`

https://github.com/github/hubot/blob/v2.10.0/index.coffee

```coffeescript:./index.coffee
User                                                                 = require './src/user'
Brain                                                                = require './src/brain'
Robot                                                                = require './src/robot'
Adapter                                                              = require './src/adapter'
Response                                                             = require './src/response'
{Listener,TextListener}                                              = require './src/listener'
{TextMessage,EnterMessage,LeaveMessage,TopicMessage,CatchAllMessage} = require './src/message'

module.exports = {
  User
  Brain
  Robot
  Adapter
  Response
  Listener
  TextListener
  TextMessage
  EnterMessage
  LeaveMessage
  TopicMessage
  CatchAllMessage
}

module.exports.loadBot = (adapterPath, adapterName, enableHttpd, botName) ->
  new Robot adapterPath, adapterName, enableHttpd, botName
```

`new Robot` する `loadBot` とその他のオブジェクトを `module.exports` しています。

`module.exports` したオブジェクトはスクリプトをつくる分には、テストなどの例外をのぞけば不要ですが、アダプターをつくる際に必要です。

`./index.coffee` を読む限り Hubot の実体は `Robot` (`./src/robot.coffee` ) にあるようです。いままで説明なしに `Robot#load` などと書いていましたがここまで読めば明らかですね。

# `./src/robot.coffee`

さて、Hubot の実体を追いかけていきましょう。

## `Robot#constructor`

https://github.com/github/hubot/blob/v2.10.0/src/robot.coffee#L31-L67

```coffeescript:./src/robot.coffee
class Robot
  # Robots receive messages from a chat source (Campfire, irc, etc), and
  # dispatch them to matching listeners.
  #
  # adapterPath - A String of the path to local adapters.
  # adapter     - A String of the adapter name.
  # httpd       - A Boolean whether to enable the HTTP daemon.
  # name        - A String of the robot name, defaults to Hubot.
  #
  # Returns nothing.
  constructor: (adapterPath, adapter, httpd, name = 'Hubot') ->
    @name      = name
    @events    = new EventEmitter
    @brain     = new Brain @
    @alias     = false
    @adapter   = null
    @Response  = Response
    @commands  = []
    @listeners = []
    @logger    = new Log process.env.HUBOT_LOG_LEVEL or 'info'
    @pingIntervalId = null

    @parseVersion()
    if httpd
      @setupExpress()
    else
      @setupNullRouter()

    @loadAdapter adapterPath, adapter

    @adapterName   = adapter
    @errorHandlers = []

    @on 'error', (err, msg) =>
      @invokeErrorHandlers(err, msg)
    process.on 'uncaughtException', (err) =>
      @emit 'error', err
```

細かい部分は適宜に追ってください。概要だけ説明すると、コマンドラインオプションにしたがって、Hubot を初期化しています。昨日使った `express` の初期化や、`adapter` の読み込み ( ちなみにここでは `require` のみでアダプターの起動はしません ) などです。

## `Robot#run`

では次は Robot の起動です。

https://github.com/github/hubot/blob/v2.10.0/src/robot.coffee#L432-L437

```coffeescript:./src/robot.coffee
  # Public: Kick off the event loop for the adapter
  #
  # Returns nothing.
  run: ->
    @emit "running"
    @adapter.run()
```

これだけです。`Robot#emit` は `EventEmitter#emit` へ処理を投げています。`Robot#adapter` は `Robot#constructor` で出てきましたね。アダプターの起動処理はこのタイミングで行います。

アダプターは接続を試み、成功すれば `@emit 'connected'` することになっています。参考に以下のディレクトリのサンプルアダプターを見れば良いと思います。

https://github.com/github/hubot/tree/v2.10.0/src/adapters
https://github.com/github/hubot/blob/v2.10.0/src/adapters/campfire.coffee#L114
https://github.com/github/hubot/blob/v2.10.0/src/adapters/shell.coffee#L59

これで、`./bin/hubot` に出てきた `robot.adapter.on 'connected', loadScripts` の意味が分かると思います。

# やっと本題 `Robot#load` / `Robot#loadHubotScripts` / `Robot#loadExternalScripts`

やっと今日の本題に入ります。`Robot#load` / `Robot#loadHubotScripts` / `Robot#loadExternalScripts` とその下で動く `Robot#loadFile` です。

https://github.com/github/hubot/blob/v2.10.0/src/robot.coffee#L206-L263

```coffeescript:./src/robot.coffee
  # Public: Loads a file in path.
  #
  # path - A String path on the filesystem.
  # file - A String filename in path on the filesystem.
  #
  # Returns nothing.
  loadFile: (path, file) ->
    ext  = Path.extname file
    full = Path.join path, Path.basename(file, ext)
    if require.extensions[ext]
      try
        require(full) @
        @parseHelp Path.join(path, file)
      catch error
        @logger.error "Unable to load #{full}: #{error.stack}"
        process.exit(1)

  # Public: Loads every script in the given path.
  #
  # path - A String path on the filesystem.
  #
  # Returns nothing.
  load: (path) ->
    @logger.debug "Loading scripts from #{path}"

    if Fs.existsSync(path)
      for file in Fs.readdirSync(path).sort()
        @loadFile path, file

  # Public: Load scripts specfied in the `hubot-scripts.json` file.
  #
  # path    - A String path to the hubot-scripts files.
  # scripts - An Array of scripts to load.
  #
  # Returns nothing.
  loadHubotScripts: (path, scripts) ->
    @logger.debug "Loading hubot-scripts from #{path}"
    for script in scripts
      @loadFile path, script

  # Public: Load scripts from packages specfied in the
  # `external-scripts.json` file.
  #
  # packages - An Array of packages containing hubot scripts to load.
  #
  # Returns nothing.
  loadExternalScripts: (packages) ->
    @logger.debug "Loading external-scripts from npm packages"
    try
      if packages instanceof Array
        for pkg in packages
          require(pkg)(@)
      else
        for pkg, scripts of packages
          require(pkg)(@, scripts)
    catch err
      @logger.error "Error loading scripts from npm package - #{err.stack}"
      process.exit(1)
```

`./bin/hubot` の `loadScripts` のまとめを再掲します。

1. `./scripts` を `robot.load`
2. `./src/scripts` を `robot.load`
3. `./hubot-scripts.json` を `robot.loadHubotScripts 'node_modules/hubot-scripts/src/scripts', scripts` 
4. `./external-scripts.json` を `robot.loadExternalScripts scripts`
5. `Options.scripts` ( `-r` オプション ) を `robot.load scriptsPath`

- `./scripts` / `./src/scripts` / `Options.scripts` の各ディレクトリに対して `robot.load` し、
- `hubot-scripts.json` に対して `robot.loadHubotScripts` し、
- `external-scritps.json` に対して `robot.loadExternalScripts` する、と。

実際の動きがわかると思います。

`Robot#loadFile` はファイルを `require(script)(@)` して、`Robot#parseHelp` します。

`Robot#load` はディレクトリ内の各ファイルを `sort()` して、`Robot#loadFile` します。

`Robot#loadHubotScripts` は指定された各ファイルを `Robot#loadFile` します。

`Robot#loadExternalScripts` は指定されたパッケージを `require(pkg)(@)` または `require(pkg)(@, scripts)` します。

簡単ですね。

# 応用問題

これを踏まえて、`yo hubot:script` で生成された `index.coffee` を眺めてみましょう。

```coffeescript:index.coffee
fs = require 'fs'
path = require 'path'

module.exports = (robot, scripts) ->
  scriptsPath = path.resolve(__dirname, 'src')
  fs.exists scriptsPath, (exists) ->
    if exists
      for script in fs.readdirSync(scriptsPath)
        if scripts? and '*' not in scripts
          robot.loadFile(scriptsPath, script) if script in scripts
        else
          robot.loadFile(scriptsPath, script)
```

どんな動きをするか分かると思います。

Hubot スクリプトのディレクトリにある `src` ディレクトリの中身に対して、 `robot.loadFile` して読み込ませているだけです。これだけで Hubot に Hubot スクリプトを追加できるわけです。

この仕組みを知っていれば、「**Hubot に動的に Hubot スクリプトを追加する Hubot スクリプト**」なんてのも可能です。

[1 日 1 Hubot スクリプトの 61 日目][hubot-script-per-day-61] の [bouzuya/hubot-script-gist][] はそれを利用したスクリプトの一例です。

# まとめ

起動スクリプト (`./bin/hubot`) から `./index.coffee` 、そして `./src/robot.coffee` と順に起動を追いました。シンプルなつくりであることがわかったと思います。

わざわざ、こんなことを書いたのは、

1. アダプターや brain の紹介の前に Hubot の起動 (スクリプトの読み込みまで) の流れを知っておくと、理解がしやすいから。
2. Hubot がシンプルなつくりになっていることを知っていると、ソースコードを追おうという気になるから。
3. Hubot スクリプトはたかだか `require()` して `Robot` のインスタンスを渡しているに過ぎないということを知ってほしいから。

です。

Hubot のソースコードは大した量ではないので、ぜひ、暇なときにでも眺めてみてください。

# 最後に

`brain` が嫌いでしょうがないので、一旦、Hubot の起動処理のコードリーディングをしました。ここを押さえるだけで、「 Hubot なんて簡単だよ」とか「 Hubot とかクソだよ」とか言えるようになるので、オススメです。

この連載の最初の頃に書いた気がするのですが、Hubot はソースコードは大した量ではないので、こんな解説を読むより本体を読んだ方が詳しくなれます。

では、明日はついに @hiconyan さんの記事ですね。

[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/
[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