Hubot

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

More than 3 years have passed since last update.

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

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


前回まで、そして今回は

前回は Hubot でホームページをつくろう ということで、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回でも使っていたと思います。


./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.jsonbin に設定されたものはこのディレクトリに配置されます。実物を見ましょう。

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


./bin/hubot

#!/usr/bin/env coffee


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


require

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


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


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


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


./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. ./scriptsrobot.load


  2. ./src/scriptsrobot.load


  3. ./hubot-scripts.jsonrobot.loadHubotScripts 'node_modules/hubot-scripts/src/scripts', scripts


  4. ./external-scripts.jsonrobot.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


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


./bin/hubot

  robot.run()


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


起動スクリプトを終えて

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



  1. ./index.coffeeloadBotrobot をつくり


  2. Robot#run で Hubot を起動し (アダプターを読み込み)

  3. アダプター読み込みが完了したら Robot#load などでスクリプトを読み込む

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


./index.coffee

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


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


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


./src/robot.coffee

  # Public: Kick off the event loop for the adapter

#
# Returns nothing.
run: ->
@emit "running"
@adapter.run()

これだけです。Robot#emitEventEmitter#emit へ処理を投げています。Robot#adapterRobot#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


./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/hubotloadScripts のまとめを再掲します。



  1. ./scriptsrobot.load


  2. ./src/scriptsrobot.load


  3. ./hubot-scripts.jsonrobot.loadHubotScripts 'node_modules/hubot-scripts/src/scripts', scripts


  4. ./external-scripts.jsonrobot.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 を眺めてみましょう。


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 日目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 さんの記事ですね。