Ruboty のコードリーディングで Ruboty の仕組みを理解すると共に Ruby の設計・実装の定石を学ぶ #ruboty

  • 131
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

Ruboty のコードリーディングで Ruboty の仕組みを理解すると共に Ruby の設計・実装の定石を学ぶ #ruboty

概要

Ruboty のコードリーディングで Ruboty の仕組みを理解すると共に Ruby の設計・実装の定石を学びます。
確認対象は、コマンドラインで ruboty を実行して Ruboty が起動するまでの部分です。

はじめに

Ruboty の構成を理解するために、この解説を作ろうと思ったのですが、

  • Plugin による拡張などを含めた高レイヤの設計技法
  • 適切な命名により、単一責務で分割されたメソッド群
  • メモ化、 alias method chain , ..etc など様々な Ruby のイディオム

など、 Ruby の良い作法を 高レイヤの設計技法 から 低レイヤのイディオム まで幅広く学ぶ素材として
非常に魅力的なソフトウェアであることに気づきました。

そのため、 Ruboty 自体には興味がない方も是非 Ruboty のコードリーディングをしてみることをおすすめします。
他の Rubyist にも読んでもらいたい! と思ったら拡散をお願いします。

前提

  • Ruboty の全体的な構成の概要についてはこちらを参照。
  • コマンドラインからの Ruboty 起動をエントリポイントとして、そこを起点に説明をします
  • ruboty --generatr 側の処理については詳細について触れません

構成

ざっと眺めて次に進みましょう。

$ tree
.
┣ bin
┃ ┗ ruboty
┣ CHANGELOG.md
┣ Gemfile
┣ lib
┃ ┣ ruboty
┃ ┃ ┣ action.rb
┃ ┃ ┣ actions
┃ ┃ ┃ ┣ base.rb
┃ ┃ ┃ ┣ help.rb
┃ ┃ ┃ ┣ ping.rb
┃ ┃ ┃ ┗ whoami.rb
┃ ┃ ┣ adapter_builder.rb
┃ ┃ ┣ adapters
┃ ┃ ┃ ┣ base.rb
┃ ┃ ┃ ┗ shell.rb
┃ ┃ ┣ brains
┃ ┃ ┃ ┣ base.rb
┃ ┃ ┃ ┗ memory.rb
┃ ┃ ┣ command_builder.rb
┃ ┃ ┣ commands
┃ ┃ ┃ ┣ base.rb
┃ ┃ ┃ ┣ generate.rb
┃ ┃ ┃ ┗ run.rb
┃ ┃ ┣ env
┃ ┃ ┃ ┣ missing_required_key_error.rb
┃ ┃ ┃ ┣ validatable.rb
┃ ┃ ┃ ┗ validation_error.rb
┃ ┃ ┣ env.rb
┃ ┃ ┣ handlers
┃ ┃ ┃ ┣ base.rb
┃ ┃ ┃ ┣ help.rb
┃ ┃ ┃ ┣ ping.rb
┃ ┃ ┃ ┗ whoami.rb
┃ ┃ ┣ logger.rb
┃ ┃ ┣ message.rb
┃ ┃ ┣ robot.rb
┃ ┃ ┗ version.rb
┃ ┗ ruboty.rb
┣ LICENSE.txt
┣ Rakefile
┣ README.md
┣ ruboty.gemspec
┣ spec
┃ ┣ ruboty
┃ ┃ ┣ adapter_builder_spec.rb
┃ ┃ ┣ adapters
┃ ┃ ┃ ┗ shell_spec.rb
┃ ┃ ┣ commands
┃ ┃ ┃ ┣ generate_spec.rb
┃ ┃ ┃ ┗ run_spec.rb
┃ ┃ ┣ env
┃ ┃ ┃ ┗ validatable_spec.rb
┃ ┃ ┣ handlers
┃ ┃ ┃ ┣ base_spec.rb
┃ ┃ ┃ ┣ help_spec.rb
┃ ┃ ┃ ┣ ping_spec.rb
┃ ┃ ┃ ┗ whoami_spec.rb
┃ ┃ ┗ robot_spec.rb
┃ ┗ spec_helper.rb
┗ templates
    ┗ Gemfile

bin/ruboty

bin/ruboty のソースコード

ruboty | ./bin/ruboty

bin/ruboty について

ruboty コマンドを実行した時のエントリポイントになります
Ruboty::CommandBuilder.new(ARGV).build.call でコマンドラインからの呼び出しに対応しています。

例えば、コマンドラインで

$ ruboty --generate

を呼び出せば、 ARGV--generate が引き渡され、以降の処理で
Commands::Generate.call が呼び出され、 ruboty のディレクトリや Gemfile などのひな形生成が実行されることになります。

lib/command_builder.rb

lib/command_builder.rb のソースコード

ruboty | ./lib/ruboty/command_builder.rb

lib/command_builder.rb について

command_builder はコマンドラインの操作を受け持ちます。

インスタンスの Build

def build
  command_class.new(options)
end

command_builder の名前の通り、オプション引数の内容に応じて
Commands::Generate Commands::Run のインスタンスの生成 = build を行っています。
両 Commands はダックタイピング可能なように call メソッドでインターフェースを揃えてあります。

メモ化

Ruboty ではいたるところで mem gem を利用した メモ化を行っています。

module Ruboty
  class Robot
    DEFAULT_ENV = "development"
    DEFAULT_ROBOT_NAME = "ruboty"

    include Mem
# 以下略

Mem module を MixIn しています。
Mem module はメモ化のための gem で、以降のコードでも頻繁に利用されています。
Mem module は mem gem に含まれる Module であり、こちらも Ruboty と同様 @r7kamura さん製です。
この gem を活用することで、同様の処理が何度も呼ばれる場合の性能を向上させています。

Mem module を MixIn するとインスタンスメソッドとして

  • has_memoized?(key)
  • memoize(key, value)
  • memoized(key)
  • memoized_table
  • unmemoize(key)

を利用可能になります。
命名が適切なので、内容は想像がつきますね。

memoize クラスマクロに保存するメソッドをシンボルで指定すると、

  • "#{method_name}_with_memoize"
  • "unmemoize_#{method_name}"

を動的に定義します。
前者は Hash へのメモ化。
後者は Hash に保存した情報の解放(削除)を行います。

"#{method_name}_with_memoize" はメモリに結果があれば、メモ化された結果を利用、
なければ処理を実行して、結果をメモ化しています。

そして、直後に alias_method_chain のイディオムで既存メソッドと、メモ化機能付きの新規メソッドに別名を与えています。
alias_method_chain についてはこちらが簡潔で分かりやすいです。
(゚∀゚)o彡 sasata299's blog | rails の alias_method_chain が素晴らしすぎる

これにより、既存メソッドと動的に追加したメモ化機能付きのメソッドの両方を呼び分けられるようになりました。
memoize :some_method_name を設定したことで、既存のメソッドに対して呼び出しを行うとメモ化機能を付加されて、
処理が実行されるようになりました。

@r7kamura さんによる Mem の簡単な説明は以下のページの中程にあります。
Railsアプリつくった

Option の Parse

Slop gem を利用しています。
詳細は Slop gem の README をご確認ください。

def options
  Slop.parse!(arguments, help: true) do
    on("dotenv", "Load .env before running.")
    on("g", "generate", "Generate a new chatterbot with ./ruboty/ directory if specified.")
    on("l", "load=", "Load a ruby file before running.")
  end.to_hash
end

lib/commands/run.rb

lib/commands/run.rb のソースコード

ruboty | ./lib/commands/run.rb

lib/commands/run.rb について

Robot の run メソッドを呼び出しているだけです。

def call
  Robot.new(options).run
end

lib/ruboty/robot.rb

lib/ruboty/robot.rb のソースコード

ruboty | ./lib/ruboty/robot.rb

lib/ruboty/robot.rb について

Ruboty の起動本処理です。

デプロイ種別 ( production / development 等 )

Ruboty のデプロイモードの切り替えは ENV['RUBOTY_ENV'] で行われています。
Ruboty::DEFAULT_ENV に、デフォルトのデプロイモードが development で設定されています。

ロボット名

Ruboty のロボット名は以下の順で決定されます。

  1. ENV["RUBOTY_NAME"]
  2. ENV["ROBOT_NAME"]
  3. Ruboty::DEFAULT_ROBOT_NAME

Ruboty::DEFAULT_ROBOT_NAME は、 ruboty です。
コメントに ROBOT_NAME is deprecated. と書いてあるので, 環境変数 RUBOTY_NAME
好きなロボット名をつけるのが正しい作法ということでしょう。

  • or を利用したデフォルト値設定のイディオム 上記の設定の実コードが以下になります。
def name
  ENV["RUBOTY_NAME"] || ENV["ROBOT_NAME"] || DEFAULT_ROBOT_NAME
end

値がなかった場合のみ、デフォルト値を指定する場合に

return specific_value || default_value

というイディオムがよく利用されます。

run

ここから、 Ruboty 起動のメイン処理の始まりです。
run メソッドの中は、 抽象度を合わせたメソッド群の集まりになっていて、
低レイヤーの処理はありません。
これだけでも、起動処理中にどんな順で何を行っているか想像が付きます。
Composed Method Pattern ですね。

def run
  dotenv
  bundle
  setup
  remember
  handle
  adapt
end

run : dotenv

ruboty コマンド呼び出し時に --dotenv オプションを指定していた場合に、
.env ファイルから環境変数をロードする処理です。
dotenv gem については、 こちらを参照

Ruby でよく利用する、後置の if によるコンパクトな記法です。

def dotenv
  Dotenv.load if options[:dotenv]
end

run : bundle

Gemfile に指定した 各種 Plugin などの gem を development や production などの環境に
応じて require しています。

def bundle
  Bundler.require(:default, env)
rescue Bundler::GemfileNotFound
end

rescue Bundler::GemfileNotFound の部分は、 Gemfile がなかった場合でも
エラーが発生せずに動作が続くようにしています。

ちなみに通常、 Ruby の例外処理については

begin
  0/0
rescue => e
  puts e.message # => divided by 0
end

のように、 begin-rescue-end の形式で紹介されることが多いですが、
メソッド内の処理のトップレベルで利用する場合は、begin-end を省略することで
コード全体のブロックを1階層下げることができます。
これにより、コードの見通しが良くなり、可読性があがります。

書籍 「 Confident Ruby」の中でも Prefer top-level rescue clause という名前のテクニックとして紹介されています。

run : setup

ruboty コマンド呼び出し時に --load オプションを指定していた場合に、
対象ファイルをロードする処理です。

def setup
  load(options[:load]) if options[:load]
end

run : remember

remember メソッドは brain メソッドを呼び出しているのみです。

def remember
  brain
end

brain メソッドは以下の様な処理になっています。

def brain
  Brains::Base.find_class.new
end
memoize :brain

Ruboty::Brains::Base クラスの find_class メソッドを呼び出し、結果をメモリにメモ化します。
Ruboty::Brains::Base クラスは内部に @brain_classes というインスタンス変数を配列形式で保持しています。
この配列は、 Ruboty::Brains::Base を継承する度に継承したクラスが追加されています。
find_class は最後に継承されたクラスを返却します。
特に何も拡張しなければ、 Ruboty はメモリに Hash として値を保持する Ruboty::Brains::Memory クラスのインスタンスを生成し、データを記憶します。
Gemfile で ruboty-redis gem のような Ruboty::Brains::Base を継承した gem を指定していた場合は、
Ruboty::Brains::Redis のインスタンスを生成し、データを記憶します。

run : handle

Handler の初期化処理を行います。
Handler は ruboty を利用する際に、メッセージに反応して何かしらの処理を行う個別の機能です。
例えば ping に対して pong を返却する lib/ruboty/handlers/ping.rb など。

handle メソッドは handlers メソッドを呼び出しているのみです。

def handle
  handlers
end

以下は handlers メソッドの処理です。

def handlers
  Ruboty.handlers.map {|handler_class| handler_class.new(self) }
end
memoize :handlers

Ruboty クラスの handlers は以下の様な処理になっています。

    def handlers
      []
    end
    memoize :handlers

これだけ見ると、空配列に見えますが既に説明済みの run : bundle の実行時に Bundler.require(:default, env) で読み込まれた
Handler Plugin にからくりがあります。

Ruboty の Handler は Ruboty::Handlers::Base を継承して作成されます。
Ruboty::Handlers::Base は継承時に 継承先のクラスを Ruboty の handlers 配列に追加しています。

つまり、

Ruboty.handlers.map {|handler_class| handler_class.new(self) }

という処理は、 組み込みの Handler Plugin (ping, whoami, help) と Bundler.require(:default, env) で読み込まれた
Gemfile に指定した全ての Hanlder Plugin のインスタンスを生成して、 Ruboty::Brain を継承したクラスのメモリに記憶し、
Ruboty::Robot#handlers で取得可能にしています。

run : adapt

adapte は Ruboty が動作するチャットツールのアダプタをロードする処理です。
adapter の層があることで、様々んチャットクライアントに対応することができています。

adapt メソッドは adapter メソッドで Gemfile に指定した Adapter のインスタンスを取得し、
run メソッドを呼び出しているのみです。

def adapt
  adapter.run
end

adapter メソッドの内容は以下です。

def adapter
  AdapterBuilder.new(self).build
end
memoize :adapter

Ruboty::AdapterBuilder からインスタンスを取得してメモリに記憶しています。
インスタンス取得の処理は以下です。

def build
  adapter_class.new(robot)
end

adapter_class メソッドの本体は以下です。

def adapter_class
  self.class.adapter_classes.last
end

仕組みは Handlers と同じなので説明を割愛します。

総括

以上で、 Ruboty の起動の仕組みをソースコードから読み取ることが出来ました。
また、下記のような点を学び取ることができました。

設計手法

  • gem を利用した Handler / Adapter / Brain の Plugin 拡張のための仕組み
  • mem gem を利用したメモ化
  • slop を利用した option の parse
  • dotenv を利用した環境変数による設定

イディオム

  • alias_method_chain
  • 後置の if
  • Bundle.require を用いた gem の一括 require
  • Prefer top-level rescue clause を利用した例外処理
  • or を利用したデフォルト値設定
  • compose method pattern を利用し、抽象度を揃えつつ、単一機能で細かく別れたメソッド群

その他

  • 設計に一貫性があるため、類似コードを見かけると処理を類推しやすく、すぐに理解しやすい
  • 命名が適切なため、コメントが非常に少ないが問題なく読むことができる

参照