LoginSignup
39
43

More than 5 years have passed since last update.

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

Posted at

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

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

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

#!/usr/bin/env coffee

./bin/hubot
#!/usr/bin/env coffee

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

require

./bin/hubot
Hubot = require '..'

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

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

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

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

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

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

loadScripts

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

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

./bin/hubot
  robot.adapter.on 'connected', loadScripts

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

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

robot.run()

./bin/hubot
  robot.run()

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

起動スクリプトを終えて

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

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

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

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

./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 の起動です。

./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 です。

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

39
43
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
43