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 のソースコード
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 のロボット名は以下の順で決定されます。
ENV["RUBOTY_NAME"]
ENV["ROBOT_NAME"]
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 を利用し、抽象度を揃えつつ、単一機能で細かく別れたメソッド群
その他
- 設計に一貫性があるため、類似コードを見かけると処理を類推しやすく、すぐに理解しやすい
- 命名が適切なため、コメントが非常に少ないが問題なく読むことができる