Tbpgr Blog

Employee Experience Engineer tbpgr(てぃーびー) のブログ

3つのgemの事例から学ぶRubyにおけるPlugin設計技法

f:id:tbpg:20161024231610p:plain

RubyにおけるPluginの設計技法について、実際に多くのユーザーに利用されている
3つの gem を参考にしてみます。

1. Minitest

github.com

Minitest とは?

Minitest は UnitTest を行うためのライブラリです。

Plugin機構のルール

  • ファイルは minitest/XXX_plugin.rb で作成する
  • RubyのLOAD_PATHに配置する(rubygemsやその他)
  • plugin_XXX_init メソッドを定義すると初期化処理として実行される
  • plugin_XXX_options メソッドを定義するとオプションパースの処理として実行される

Plugin機構のコード

  • Minitest の Plugin 関連のコード

minitest/lib/minitest.rb L77-202 - seattlerb/minitest - GitHub

# 略
module Minitest
# 略
  # 各Pluginの初期化
  def self.init_plugins options # :nodoc:
    self.extensions.each do |name|
      msg = "plugin_#{name}_init"
      send msg, options if self.respond_to? msg
    end
  end

  # Plugin の読み込み
  def self.load_plugins # :nodoc:
    return unless self.extensions.empty?

    seen = {}

    require "rubygems" unless defined? Gem

    Gem.find_files("minitest/*_plugin.rb").each do |plugin_path|
      name = File.basename plugin_path, "_plugin.rb"

      next if seen[name]
      seen[name] = true

      require plugin_path
      self.extensions << name
    end
  end
  # 略
    # 各Pluginのoption設定
    unless extensions.empty?
      opts.separator ""
      opts.separator "Known extensions: #{extensions.join(", ")}"

      extensions.each do |meth|
        msg = "plugin_#{meth}_options"
        send msg, opts, options if self.respond_to?(msg)
      end
  end
  # 略
end

2. Pry

github.com

Pryとは?

Pry は Ruby の REPL ツールです。同分野の irb の上位版。

Plugin機構のルール

  • gem 名が pry- ではじまること
  • lib 配下に gem 名と同じ名前の .rb ファイルが存在すること
  • customization, hooks, and command system などを任意のAPIを利用して拡張する
    • Config の場合
      • Pry.config を利用する
    • Hook の場合
      • Pry.hooks.add_hook を利用する
    • Command の場合
      • Pry::CommandSet.block_command, Pry::Commands.block_command, Pry::Commands.create_command 等を利用する

Plugin機構のコード

Plugins - Wiki - pry/pry - GitHub

  • 読み込み処理

pry/lib/pry/plugins.rb L80-88 - pry/pry - GitHub

gem のリストの中から「pry-」に一致するgemならばPluginとみなして、
Pluginのインスタンスを生成し、 @plugins 配列に追加している。

  # Find all installed Pry plugins and store them in an internal array.
  def locate_plugins
    gem_list.each do |gem|
      next if gem.name !~ PRY_PLUGIN_PREFIX
      plugin_name = gem.name.split('-', 2).last
      plugin = Plugin.new(plugin_name, gem.name, gem, false)
      @plugins << plugin.tap(&:enable!) if plugin.supported? && !plugin_located?(plugin)
    end
    @plugins
  end
  • 呼び出し処理

pry/lib/pry/command_set.rb L78-84 - pry/pry - GitHub

block_command API から追加された処理を @command Hash に追加している

  def block_command(match, description="No description.", options={}, &block)
    description, options = ["No description.", description] if description.is_a?(Hash)
    options = Pry::Command.default_options(match).merge!(options)

    @commands[match] = Pry::BlockCommand.subclass(match, description, options, helper_module, &block)
  end
  alias_method :command, :block_command

3. Ruboty

github.com

Rubotyとは?

Ruboty は Ruby製のチャットボットフレームワーク です

Plugin機構のルール

  • gem 名が ruboty- ではじまること
  • Handler, Adapter, and Brain などを任意のクラスを継承して拡張する
    • Handler の場合
      • Ruboty::Handlers::Base を継承し、 on クラスメソッドと任意の名前のインスタンスメソッドを実装する
    • Adapter の場合
      • Ruboty::Adapters::Base を継承し、 say / run 等を実装する

Plugin機構のコード

  • 読み込み処理1

ruboty/lib/ruboty/handlers/base.rb L7-9 - r7kamura/ruboty - GitHub

Ruboty::Handlers::Base の継承時に Ruboty.handlers に継承したクラスを追加しています

def inherited(child)
  Ruboty.handlers << child
end
  • 読み込み処理2

ruboty/lib/ruboty/robot.rb L80-83 - r7kamura/ruboty - GitHub

Ruboty の起動処理中に Handlers に追加された Plugin を初期化しています

def handlers
  Ruboty.handlers.map { |handler_class| handler_class.new(self) }
end
memoize :handlers
  • 呼び出し処理

ruboty/lib/ruboty/robot.rb L25-32 - r7kamura/ruboty - GitHub

メッセージ受信時に Ruboty.handlers から対応コマンドがあるかどうか検索し、
対象であれば handler plugin を呼び出します。

    def receive(attributes)
      message = Message.new(attributes.merge(robot: self))
      unless handlers.inject(false) { |matched, handler| matched | handler.call(message) }
        handlers.each do |handler|
          handler.call(message, missing: true)
        end
      end
    end

まとめ

全体として必要となるのは

  • plugin の名前を規約で決定する
  • 起動時に命名規約に基づいて Plugin を取得し、Pluginを保持するための配列やHashに追加する
  • 呼び出し時に保存しておいたPluginを取得し、処理を呼び出す

ことです。

外部資料

Minitest

Pry

Ruboty

Plugin 設計全般