これは Hubot Advent Calendar 2014 の 11 日目の記事です。
また、今回は @bouzuya の Hubot 連載の第 9 回です。目次は、第 1 回の記事にあるので、そちらをどうぞ。
前回まで、そして今回は
前回は Hubot でホームページをつくろう ということで、robot.router
を使った HTTP リクエストへの応答する Hubot スクリプトをつくりました。
今回は、ちょっと違った観点で Hubot スクリプトの読み込みの流れを追いかけてみましょう。
Procfile
さて Hubot はどうやって起動されているのでしょう。ここでは Heroku にデプロイされる想定で考えてみましょう。
Heroku はルートに Procfile
があれば、それを見て動きを変えます (実は省略可能ですが、それは後述) 。ひとまず Procfile
から追ってみましょう。
yo hubot
で生成される Procfile
の例は次のとおりです。
web: bin/hubot -a slack -n hubot
./bin/hubot
にオプションを与えて起動しています。これは第2回でも使っていたと思います。
./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
に設定されたものはこのディレクトリに配置されます。実物を見ましょう。
package.json
によると、実体は github/hubot の ./bin/hubot
みたいです (さきの ./bin/hubot
とは github/generator-hubot のものであり、これとは違います) 。
これはちょっとボリュームがありますね。かいつまんでいきましょう。
#!/usr/bin/env coffee
#!/usr/bin/env coffee
これがにくい coffee
依存シェルスクリプトの実体ですね。
require
Hubot = require '..'
まず読み込みます。Hubot
として読み込むのは '../'
つまり ./index.coffee
です。これをいま追いかけるとキリがないので、ひとまず飛ばしていきます。
コマンドラインオプションの解釈
次はコマンドラインオプションの解釈です。
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 ...
robot = Hubot.loadBot adapterPath, Options.adapter, Options.enableHttpd, Options.name
Hubot のインスタンスをつくります。変数名は robot
ですね。
loadScripts
loadScripts を定義しています。ここは重要です。
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
まとめると次のような処理です。
-
./scripts
をrobot.load
-
./src/scripts
をrobot.load
-
./hubot-scripts.json
をrobot.loadHubotScripts 'node_modules/hubot-scripts/src/scripts', scripts
-
./external-scripts.json
をrobot.loadExternalScripts scripts
-
Options.scripts
(-r
オプション ) をrobot.load scriptsPath
Robot#load
Robot#loadHubotScripts
Robot#loadExternalScripts
の動きが気になるところですが。後まわししましょう。
robot.adapter.on 'connected', loadScripts
robot.adapter.on 'connected', loadScripts
さきほどの loadScripts
の実行タイミングを設定しています。 robot.adapter.on 'connected'
は adapter
の接続が完了したときに発生するイベントです。そのリスナーとして loadScripts
を指定しています。
アダプター読み込みが完了してから、スクリプトを読み込むということですね。
robot.run()
robot.run()
ここまでで下準備は完了です。あとは起動するだけ。robot.run()
しましょう。
起動スクリプトを終えて
起動スクリプトはこんな感じです。
-
./index.coffee
のloadBot
でrobot
をつくり -
Robot#run
で Hubot を起動し (アダプターを読み込み) - アダプター読み込みが完了したら
Robot#load
などでスクリプトを読み込む
ということですね。もうすこし、読み込みまわりの処理を見てみましょう。
./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
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 の起動です。
# 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
です。
# 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
のまとめを再掲します。
-
./scripts
をrobot.load
-
./src/scripts
をrobot.load
-
./hubot-scripts.json
をrobot.loadHubotScripts 'node_modules/hubot-scripts/src/scripts', scripts
-
./external-scripts.json
をrobot.loadExternalScripts scripts
-
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
を眺めてみましょう。
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
と順に起動を追いました。シンプルなつくりであることがわかったと思います。
わざわざ、こんなことを書いたのは、
- アダプターや brain の紹介の前に Hubot の起動 (スクリプトの読み込みまで) の流れを知っておくと、理解がしやすいから。
- Hubot がシンプルなつくりになっていることを知っていると、ソースコードを追おうという気になるから。
- Hubot スクリプトはたかだか
require()
してRobot
のインスタンスを渡しているに過ぎないということを知ってほしいから。
です。
Hubot のソースコードは大した量ではないので、ぜひ、暇なときにでも眺めてみてください。
最後に
brain
が嫌いでしょうがないので、一旦、Hubot の起動処理のコードリーディングをしました。ここを押さえるだけで、「 Hubot なんて簡単だよ」とか「 Hubot とかクソだよ」とか言えるようになるので、オススメです。
この連載の最初の頃に書いた気がするのですが、Hubot はソースコードは大した量ではないので、こんな解説を読むより本体を読んだ方が詳しくなれます。
では、明日はついに @hiconyan さんの記事ですね。