activeadmin読んだ

activeadmin/activeadminを初めて使うことになったので、どういう仕組みになっているのか調べてみた。

TL;DR

  • rails g active_admin:installを実行するとlib/generators/active_admin/install_generator.rbが実行され、ActiveAdmin.routes(self)がconfig/routes.rbに追加される。
  • app/admin/以下にあるResource定義ファイル内で実行されるActiveAdmin.registerでは、ActiveAdmin::Resourceインスタンスが生成され、動的にResourceごとのcontrollerが生成される。それらはすべてActiveAdmin::ResourceControllerを継承している。
  • config/routes.rbに追加されたActiveAdmin.routes(self)は内部でapp/admin/以下のファイルをロードし(このタイミングで上述のActiveAdmin.registerが実行される)、ActiveAdmin::Resourceインスタンスから動的にroutingが定義される。

長いので、以下ActiveAdmin::AA::と略記する。

Generator

Active Adminをセットアップするにはまずrails g active_admin:installを実行する。

このとき、lib/generators/active_admin/install_generator.rbに定義されたRails::Generators::Baseのサブクラスにあるpublicメソッドが上から順番に実行される。Railsはlib/generators/**/*_generator.rbにマッチするファイルに定義されたRails::Generators::BaseのサブクラスをRails Generatorとして実行することができる。

# lib/generators/active_admin/install_generator.rb

module ActiveAdmin
  module Generators
    class InstallGenerator < ActiveRecord::Generators::Base
      # ...

      def setup_routes
        if options[:user]
          inject_into_file "config/routes.rb", "\n  ActiveAdmin.routes(self)", after: /devise_for .*, ActiveAdmin::Devise\.config/
        else
          route "ActiveAdmin.routes(self)"
        end
      end

      # ...
    end
  end
end

いくつかメソッドが定義されている中でsetup_routesを見ると、config/routes.rbにActiveAdmin.route(self)を追記しているようだ。selfRails.application.routes.draw do ... endのブロック内でのselfなのでActionDispatch::Routing::Mapperインスタンスを表している。

Register a resource

Generatorでファイルの追加と変更を行ったあとは、管理画面で管理するResourceを作成する。例えば、rails g active_admin:resource Postを実行すると以下のようなapp/admin/post.rbが生成される。

# app/admin/post.rb

ActiveAdmin.register Post do
end

このブロックの中にviewやcontrollerの設定を追加していくのだけど、まずActiveAdmin.registerの定義を調べる。

# lib/active_admin.rb

module ActiveAdmin
  class << self
    # ...

    def application
      @application ||= ::ActiveAdmin::Application.new
    end

    # ...

    delegate :register, to: :application

    # ...
  end
end

ActiveSupportが拡張したメソッドdelegateによって、ActiveAdmin.registerの処理は実際にはAA::Application#registerが行っている。

# lib/active_admin/application.rb

def register(resource, options = {}, &block)
  ns = options.fetch(:namespace){ default_namespace }
  namespace(ns).register resource, options, &block
end

options[:namespace]がなければdefault_namespaceつまり:adminnsに入る。#namespacenamespaces[ns]があればそれを返し、なければAA::Namespaceインスタンスを初期化しnamespacesに追加した上で返す。よって、AA::Namespace#registerが処理が渡っている。

# lib/active_admin/namespace.rb

def register(resource_class, options = {}, &block)
  config = find_or_build_resource(resource_class, options)

  register_resource_controller(config)
  parse_registration_block(config, resource_class, &block) if block_given?
  reset_menu!

  ActiveAdmin::Event.dispatch ActiveAdmin::Resource::RegisterEvent, config

  config
end

find_or_build_resourceAA::Resourceインスタンスを返す。#register_resource_controllerは以下のように定義されており、Resourceインスタンスから動的にAA::ResourceControllerを継承するResourceごとのcontrollerを定義している。

# lib/active_admin/namespace.rb

def register_resource_controller
  eval "class ::#{config.controller_name} < ActiveAdmin::ResourceController; end"
  config.controller.active_admin_config = config
end

parse_registration_blockは上述の例のapp/admin/post.rbでActiveAdmin.registerに渡されていたブロックを評価する部分だと思う。ブロックの中身を独自のDSLとして評価してカスタマイズを行っていると思う。

Routing

Generatorによってconfig/routes.rbに追加されたActiveAdmin.routesの定義を調べる。

# lib/active_admin.rb

module ActiveAdmin
  # ...

  def application
    @application ||= ::ActiveAdmin::Application.new
  end

  # ...

  delegate :routes, to: :application

  # ...
end

delegateActiveSupportが拡張しているメソッドで、メソッドの呼び出しをtoで指定したオブジェクトに委譲する。なので、ActiveAdmin.routesは実際にはAA::Application#routesを指している。

# lib/active_admin/application.rb

def routes(rails_router)
  load!
  router.apply(rails_router)
end

load!はapp/admin/**/*.rbをKernel.loadする。このとき上述したapp/admin/post.rbのようなResource定義ファイルがロードされる。そして、ActiveAdmin.registerが実行され各Resourceのcontrollerが定義される。

routerRouterインスタンスなので、Router#applyを調べる。

# lib/active_admin/router.rb

def apply(router)
  define_root_routes router
  define_resource_routes router
end

まずdefine_root_routesは以下のように定義されている。

# lib/active_admin/router.rb

def define_root_routes(router)
  router.instance_exec @application.namespaces.values do |namespaces|
    namespaces.each do |namespace|
      if namespace.root?
        root namespace.root_to_options.merge(to: namespace.root_to)
      else
        namespace namespace.name do
          root namespace.root_to_options.merge(to: namespace.root_to)
        end  
      end
    end
  end
end

このrouterActionDispatch::Routing::Mapperであり、@application.namespaces.valuesAA::Namespaceインスタンスの配列だ。

ActiveAdmin.registerに特にoptionを指定しない場合、namespace.root?trueとなる。namespace.root_to_optionsnamespace.root_toがどこで定義されているのか不明。。。なんだけど、AA::Application内にこれらが定義されており、root_to_options{}root_to"dashboard#index"となっている。どのようにしてAA::Namespaceにそれらが定義されるのか不明ではあるが、おそらくこれらの値が使われるのだと思う。よって、結局このメソッドroot to: "dashboard#index"としているだけだ。

# lib/active_admin/router.rb

def define_resource_routes(router)
  router.instance_exec @application.namespaces, self do |namespaces, aa_router|
    resources = namespaces.value.flat_map{ |n| n.resources.values }
    resources.each do |config|
      routes = aa_router.resource_routes(config)

      # ...

      instance_exec &routes
    end
  end
end

configは先述したAA::Resourceインスタンスだ。aa_routerAA::RouterインスタンスなのでAA::Router#resource_routesを見る。

# lib/active_admin/router.rb

def resource_routes(config)
  Prox.new do
    build_route = proc{ |verbs, *args|
      [*verbs].each{ |verb| send verb, *args }
    }
    build_action = proc{ |action|
      build_route.call(action.http_verb, action.name)
    }
    case config
    when ::ActiveAdmin::Resource
      resources config.resource_name.route_key, only: config.defined_actions do
        member do
          config.member_actions.each &build_action
        end

        collection do
          config.collection_actions.each &build_action
          post :batch_action if config.batch_action_enabled?
        end
      end
    when ::ActiveAdmin::Page
      # ...
    else
      # ...
    end
  end
end

このメソッドで返されるProcオブジェクトはActionDispatch::Routing::Mapperインスタンスのコンテキストでinstance_execされるため、要するにこのProcオブジェクト内の処理がそのままconfig/routes.rb内のroutingの設定となる。Resourceインスタンスの情報から動的にroutingを組み立てているようだ。

所感

軽く触ってみたけど、Resource定義ファイルに独自DSLでviewを書いていくのが非常にカスタマイズが大変だし覚えることが多そうだな、あまり筋がよくなさそうという印象を受けた。

で、調べてみた結果、Resource定義ファイルから動的にcontrollerとかroutingとかを定義していて、それらをカスタマイズするのに独自DSLを使うという構図になっていることが分かった。管理画面って、ビジネスサイドの要求によってどんどんカスタマイズが必要になるので、カスタマイズに独自のDSLを覚えなくてはいけないとか、場合によってはカスタマイズできないみたいな状況は大きな問題だと思う。だから、動的にいろいろ生成する方針は管理画面の実装には適していないのではないかと思った。なんでこれがこんなに支持されているのかよくわからない。

RailsのReloaderの仕組み

Resqueのworker上で実行されるコードが古いまま更新されないというような問題があって、意味もわからず書いたコードでなんとかその場を収めたんだけど、気持ち悪いのでRailsでいかにしてコードが更新されるのか調べてみることにした。

TL;DR

未定義の定数が参照されると、ActiveSupport::ModuleConstMissingによって拡張されたModule#const_missingが呼ばれる。命名規則に基いて定数名からファイル名が推測され、autoload_pathsに存在するファイルを見つける。そのファイルがロード済みであればそこで終了。ロード済みでなければKernel.loadでロードし、ロードされた定数は配列で管理される。

development環境では、Railsのmiddleware stackにActionDispatch::Reloaderというmiddlewareがあり、リクエストごとにファイルの最終更新日時を確認し、変更されていればActiveSupport::Dependencies.clearを呼ぶ。これによって、ロード済みのファイルが空っぽになり、ロードされた定数もすべて削除される(=未定義状態に戻る)。なので、ファイルが変更されるたびにconst_missingから始まる一連のフローが起こり、Kernel.loadによって最新のコードがロードされるようになっている。

以下は、上述の結論に至るまでのソースコードリーディングのメモです。分かりにくいかも。

ActionDispatch::Reloader

ActionDispatch::ReloaderはRackミドルウェアなので、#callの中で初期化時に受け取った他のRackミドルウェア#callを呼んでいる。その前後でリロードに関する処理を実行しているはずだ。

def call(env)
  @validated = @condition.call
  prepare!

  response = @app.call(env)
  response[2] = ::Rack::BodyProxy.new(response[2]) { cleanup! }

  response
rescue Exception
  cleanup!
  raise
end

def prepare!
  run_callbacks :prepare if validated?
end

def cleanup!
  run_callbacks :cleanup if validated?
ensure
  @validated = true
end

次のRackミドルウェアが処理を行う前後でprepare!cleanup!を呼んでいる。その中身はrun_callbacksを呼んでいる。これはActiveSupport::Callbacksで定義されているメソッドで、set_callbacksで登録されたcallbackを実行する。なので、:prepare:cleanupというイベントに対してどこかで登録されたcallbackがされている。このcallbackの登録を行うメソッドActionDispatch::Reloaderに含まれている。

def self.to_prepare(*args, &block)
  # ...
  set_callbacks(:prepare, *args, &block)
end

def self.to_cleanup(*args, &block)
  # ...
  set_callbacks(:cleanup, *args, &block)
end

この2つのメソッドを使ってcallbackの登録が行われている。これらを呼び出している箇所を探すと、Rails::Application::Finisherで呼ばれていることがわかる。

Rails::Application::Finisher

initializer :set_clear_dependencies_hook, group: :all do
  callback = lambda do
    ActiveSupport::DescendantsTracker.clear
    ActiveSupport::Dependencies.clear
  end

  if config.reload_classes_only_on_change
    reloader = config.file_watcher.new(*watchable_args, &callback)
    self.reloaders << reloader

    ActionDispatch::Reloader.to_prepare(prepend: true) do
      reloader.execute
    end
  else
    ActionDispatch::Reloader.to_cleanup(&callback)
  end
end

initializerRails::Initializableで定義されているメソッドRailsの初期化時に実行される処理を登録することができる。つまり、Railsの初期化時に:prepareまたは:cleanupのcallbackを登録しているということになる。

reload_classes_only_on_changeという設定はデフォルトでtrueになっていて、依存するファイルが変更されたときだけクラスを再読み込みするかどうかを制御する。file_watcherはデフォルトではActiveSupport::FileUpdateCheckerを指している。つまり、デフォルトでは、:prepareのときにActiveSupport::FileUpdateChecker#executeが実行されるように設定されていることになる。

ActiveSupport::FileUpdateCheckerは初期化時に渡されたファイルを配列として受け取り、またそれらが更新されたときに実行されるブロックを受け取る。#executeはファイルが更新されているかどうかに関わらずブロックを実行する。ここで実行されるのは、以下のブロックとなる。

callback = lambda do
  ActiveSupport::DescendantsTracker.clear
  ActiveSupport::Dependencies.clear
end

ここまでをまとめると、リクエストを受けるごとに上の2つのメソッドが実行されコードのリロードが行われるということになる。

ActiveSupport::DescendantsTracker

def clear
  if defined? ActiveSupport::Dependencies
    @@direct_descendants.each do |klass, descendants|
      if ActiveSupport::Dependencies.autoloaded?(klass)
        @@direct_descendants.delete(klass)
      else
        descendants.reject! { |v| ActiveSupport::Dependencies.autoloaded?(v) }
      end
    end
  else
    @@direct_descendants.clear
  end
end

@@direct_descendantsの中身を消去しているようだ。これはHashであり、中身がキーがクラスで、値がそのクラスを継承したサブクラスの配列となっている。Class#inheritedをoverrideしており、ActiveSupport::DescendantsTrackerextendしているクラスを継承したタイミングで@@direct_descendantsに追加される。ActiveSupport::DescendantsTrackerは例えばActiveRecord::Baseextendされているため、ActiveRecord::Baseのサブクラス、つまり通常のModelクラスはActiveRecord::Base.descendantsから取得できる。これを利用しているのが先述したActiveSupport::Callbacksで、callbackをサブクラスから親クラスへ辿っていくときに利用されている。

ActiveSupport::Dependencies

def clear
  log_call
  loaded.clear
  loading.clear
  remove_unloadable_constants!
end

loadedloadingはクラス変数@@loadedおよび@@loadingへのアクセサでmattr_accessorによって定義されている。そして、これらのクラス変数の実体はSetオブジェクトだ。

次にloadedloadingにいつ何が追加されるのか調べると、ActiveSupport::Dependencies.require_or_loadというメソッドで呼ばれている。

def require_or_load(file_name, const_path = nil)
  # ...
  file_name = $` if file_name =~ /\.rb\z/
  expanded = File.expanded_path(file_name)
  return if loaded.include?(expanded)

  loaded << expanded
  loading << expanded

  begin
    if load?
      # ...

      load_args = ["#{file_name}.rb"]
      load_args << const_path unless const_path.nil?

      if !warnings_on_first_load or history.include?(expanded)
        result = load_file(*load_args)
      else
        enable_warnings { result = load_file(*load_args) }
      end      
    else
      # ...
    end
  rescue Exception
    loaded.delete expanded
    raise
  ensure
    loading.pop
  end

  history << expanded
  result
end

loadedloadingに追加されているのはおそらくロードするファイルの絶対パスと思われる。そして、一連のロードが完了したらloadingからは削除されるようだ。loadingは読み込み中の再読み込みを防ぐために一時的に利用される変数らしい。一方、loadedは既にロード済みかどうかをチェックして、ロード済みであればrequire_or_loadを中断させるために使われているようだ。実際のロードの処理はload_fileで行われるようだ。ActiveSupport::Dependencies.clearによってloadedが空になると、require_or_load内で再度load_fileを実行することになる。

def load_file(path, const_paths = loadable_constants_for_path(path))
  # ...
  const_paths = [const_paths].compact unless const_paths.is_a? Array
  parent_paths = const_paths.collect { |const_path| const_path[/.*(?=::)/] || ::Object }

  result = nil
  newly_defined_paths = new_constants_in(*parent_paths) do
    result = Kernel.load path
  end

  autoloaded_constants.concat newly_defined_paths unless load_once_path?(path)
  autoloaded_constants.uniq!
  # ...
  result
end

const_pathsapp/models/user.rbapp/controllers/users_controller.rbいうファイルがあれば"User""UsersController"という表す文字列を含む配列となる。parent_pathsconst_pathsの中で"Admin::UsersController"のようなネストするものと"::Object"を抽出した配列となる。new_constants_inは渡したブロックを実行し、その中で新たにロードされた定数を返す。なので、Kernel.loadがとりあえず実行されるようだ。

話を少し戻してrequire_or_loadはどこで呼ばれているかを調べると、load_missing_constantで呼ばれており、さらにこのメソッドActiveSupport::ModuleConstMissing#const_missingで呼ばれている。

module ModuleConstMissing
  # ...

  def const_missing(const_name)
    from_mod = anonymous ? guess_for_anonymous(const_name) : self
    Dependencies.load_missing_constant(from_mod, const_name)
  end
end

そしてこのmoduleは以下のようにしてModuleクラスにincludeされるため、デフォルトのconst_missingの挙動をoverrideすることになる。

module ActiveSupport
  module Dependencies
    # ...

    def hook!
      Object.class_eval { include Loadable }
      Module.class_eval { include ModuleConstMissing }
      Exception.class_eval { include Blamable }
    end

    # ...
  end
end

ActiveSupport::Dependencies.hook!

つまり、定義されていない定数を参照する→const_missingload_missing_constantrequire_or_loadload_fileという順番で呼ばれることになる。

ここで、ActiveSupport::Dependencies.clearの定義に戻る。

def clear
  log_call
  loaded.clear
  loading.clear
  remove_unloadable_constants!
end

見ていなかったremove_unloadable_constants!について見ていく。

def remove_unloadable_constants!
  autoloaded_constants.each { |const| remove_constant const }
  autoloaded_constants.clear
  Reference.clear!
  explicitly_unloadable_constants.each { |const| remove_constant const }
end

autoloaded_constantsは上述のload_fileでロードされた定数を含む配列だ。remove_constantはその名の通り定数を削除するメソッドで内部でModule#remove_constを呼んでいる。

RubyのWebSocketサーバー「pingpong」を作った

最近、「Working with TCP Sockets」って本を読んだ。Rubyでソケットと戯れつつ、7つくらいのWebサーバーのアーキテクチャを概観できるいい本だった。で、その中にイベント駆動モデルの実装とかノンブロッキングIOの実装について紹介されてて面白かったので、練習がてらWebSocketサーバーを作ることにした。

PingPong

f:id:naoty_k:20131011013521g:plain

https://github.com/naoty/pingpong

卓球ハウスっぽい名前にした。数日で作ったので、他のクライアントへpush通知を行うことしかできない。たぶん大きいデータも送れない気がする。

WebSocketサーバーの実装とは

まずはRFC 6455のサーバーに関する部分を読んだ。最低限必要な部分をRubyで実装していった。例えば、以下のコードはHandshake(websocket接続の確立)の際にサーバーがクライアントに返すレスポンスヘッダーを作っている。

def response_headers
  [
    ["Upgrade", "websocket"],
    ["Connection", "Upgrade"],
    ["Sec-WebSocket-Accept", signature]
  ]
end

def signature
  value = @header["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
  hash = Digest::SHA1.digest(value)
  Base64.strict_encode64(hash)
end

ご覧のとおりハードコーディングがたくさん出てくる。RFCを読むと、このヘッダーにはこの値を入れなさいって書いてあることが多い。なので、それぞれの値の意味はわからないけどとりあえずRFCに従ってハードコーディングしている。signatureというメソッドはあるヘッダーの値をRFCで以下のように定められた形式で生成している。(余談だけど、ここでBase64.encode64を使って小1時間ハマった。これは改行コードを入れるためここでは使えない。)

A |Sec-WebSocket-Accept| header field. The value of this header field is constructed by concatenating /key/, defined above in step 4 in Section 4.2.2, with the string "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", taking the SHA-1 hash of this concatenated value to obtain a 20-byte value and base64-encoding (see Section 4 of [RFC4648]) this 20-byte hash.

イベント駆動モデルとノンブロッキングIO

push通知はイベント駆動モデルというアーキテクチャを使って実装した。イベント駆動モデルはマルチプロセスやマルチスレッドとは違ってシングルスレッドで多数のリクエストを並行処理する。具体的には、websocket接続の確立に成功したソケットを配列に入れておき、ループ内でそれらのソケットにread/writeしていく。このとき、read/writeがブロッキングしてしまうとすべての処理がそこで止まってしまうので、read/writeの前にselect(2)等を使ってread/write可能なソケットだけ選択してread/writeを行う。これがノンブロッキングIOだと思う(だよね…?)。

実際のコードは以下の通り。

def start
  @sockets = {}
  @message_queue = []

  loop do
    to_read = @sockets.values << @server
    to_write = @sockets.values
    readables, writables, _ = IO.select(to_read, to_write)

    readables.each do |socket|
      if socket == @server
        establish_connection
      else
        begin
          request = socket.read_nonblock(CHUNK_SIZE)
          message = Frame::Request.new(request).message
          # the message may be passed to a web application.
          @message_queue << Message.new(socket.fileno, message)
        rescue EOFError
          @sockets.delete(socket.fileno)
        end
      end
    end

    message = @message_queue.shift
    next if message.nil? || message.empty?

    writables.each do |socket|
      if socket.fileno != message.from
        data = Frame::Response.new(message.body).data
        socket.write_nonblock(data)
      end
    end
  end
end

感想

WebSocket、イベント駆動モデル、ノンブロッキングIO…という言葉はよく耳にしてきたけど理解したとは言えなかった。実際にWebSocketサーバーを書いてみると、コードに基づいて何が行われているのか正確に理解することができた。push通知も何やら凄そうな響きがするけど、実際に実装してみると特に難しいことはしていなかった。また、websocketの弱点と言われている、CPUヘビーな処理がなぜ弱点なのかも合点がいった。シングルスレッドで処理しているので、例えばレンダリングのような重い処理がひとつでも走ると、全体に悪影響が出るということだと理解した。

mrb_valueについて調べてみた

昨日の続き。

mrubyのソースコードを読むと、mrb_valueという構造体がよく出てくるのでソースコードを追いかけて使い方を調べてみた。参照しているコミット番号は昨日と同じく「9663a7」です。

mrb_valueの定義

// include/mruby/value.h:40

typdef struct mrb_value {
  union {
    mrb_float f;
    void *p;
    mrb_int i;
    mrb_sym sym;
  } value;
  enum mrb_vtype tt;
} mrb_value;

mrb_value構造体は値とその値のデータ型をもつ。enum mrb_vtypeにはMRB_TT_FIXNUMとかMRB_TT_STRINGなどが入る。valuettは適切な組み合わせにする必要があるはず。

mrb_valueMRB_NAN_BOXINGが定義されているかどうかでその定義が変わるんだけど、MRB_NAN_BOXINGはmrbconf.hでコメントアウトされていたので、mrb_valueのデフォルトの定義は上のようになる。

// include/mrbconf.h:23

/* represent mrb_value in boxed double; conflict with MRB_USE_FLOAT */
//#define MRB_NAN_BOXING

どういうときにこれを使うのかはまだよくわかってない。

mrb_valueとデータ型の変換

int型、char型などとmrb_valueを変換する方法も調べた。まず、変換する関数によく使われているmrb_value構造体に値をセットするマクロがある。

// include/mruby/value.h:53

#define MRB_SET_VALUE(o, ttt, attr, v) do {\
  (o).tt = ttt;\
  (o).attr = v;\
} while (0)

これを使って変換する関数が実装されているっぽい。とりあえず見つけたのは以下の通り。

int -> mrb_value

// include/mruby/value.h:205

static inline mrb_value
mrb_fixnum_value(mrb_int i)
{
  mrb_value v;

  MRB_SET_VALUE(v, MRB_TT_FIXNUM, value.i, i);
  return v;
}

mrb_value -> int

// include/mruby/value.h:145

#define mrb_fixnum(o) (o).value.i

float -> mrb_value

// include/mruby/value.h:58

static inline mrb_value
mrb_float_value(mrb_float f)
{
  mrb_value v;

  MRB_SET_VALUE(v, MRB_TT_FLOAT, value.f, f);
  return v;
}

mrb_value -> float

// include/mruby/value.h:51

#define mrb_float(o) (o).value.f

char[] -> mrb_value

// src/string.c:670

char *
mrb_string_value_ptr(mrb_state *mrb, mrb_value ptr)
{
  mrb_value str = mrb_str_to_str(mrb, ptr);
  return RSTRING_PTR(str);
}

char[] -> mrb_value

// src/string.c:232

mrb_value
mrb_str_new(mrb_state *mrb, const char *p, size_t len)
{
  struct RString *s;

  s = str_new(mrb, p, len);
  return mrb_obj_value(s);
}

mrubyで定義したクラスとメソッドをCから呼び出す

mrubyで書いた方がいいところはmrubyで書いてそうじゃないところはCで書く、という開発をするには、Cで定義した関数をRubyから実行させたり、逆にRubyで定義したクラスやメソッドをCから呼び出せるようにする必要があると思った。前者のような実装はmrbgemsを読めばたくさんある一方で、後者の実装は調べたけどあんまりなかった。そこで、先日「Head First C」でCの初歩を学んだことだし、mrubyのソースコードを読みながら後者の「mrubyで定義したクラスとメソッドをCから呼び出す」実装を試行錯誤してみた。

試行錯誤してみてとりあえず動いたというだけで、正しいやり方じゃないかもしれないので、コメントか@naoty_k宛にメッセージをいただけるとありがたいです。また、参照しているmrubyのコミット番号は「9663a7」です。

Rubyのクラスとメソッドを用意

適当にPersonクラスとメソッド2つを用意する。あとでこれらをCから呼び出す。

// person.rb

class Person
  attr_accessor :name, :age

  def initialize(name, age)
    @name = name
    @age = age
  end

  def greeting
    "Hello, my name is #{name}, #{age} years old."
  end
end

mrbcでコンパイル

RubyのファイルをCからロードするにはいくつか方法があるようだけど、今回はmrbcで*.mrb形式にコンパイルしてCからロードするようにする。

$ ls
person.rb
$ mrbc person.rb
$ ls
person.mrb person.rb

Cから定義したクラスとメソッドを呼び出す

CからRubyで定義したPersonインスタンスを生成してgreetingメソッドの結果を標準出力に表示してみる。

// greeting.c

#include <stdio.h>
#include <mruby.h>
#include <mruby/string.h>

int main()
{
    mrb_state* mrb = mrb_open();

    // mrubyファイルをロードする
    FILE *fd = fopen("person.mrb", "r");
    mrb_load_irep_file(mrb, fd);

    // クラスオブジェクトを取得する
    struct RClass *person = mrb_class_obj_get(mrb, "Person");

    // 引数をmrb_valueに変換する
    mrb_value person_value = mrb_obj_value(person);
    mrb_value name_value = mrb_str_new(mrb, "naoty", 5);
    mrb_value age_value = mrb_fixnum_value(25);

    // Person#newを呼び出す
    mrb_value naoty = mrb_funcall(mrb, person_value, "new", 2, name_value, age_value);

    // Person#greetingを呼び出す
    mrb_value greeting_value = mrb_funcall(mrb, naoty, "greeting", 0);

    // 返り値をchar*に変換して出力する
    char *greeting = mrb_string_value_ptr(mrb, greeting_value);
    printf("%s\n", greeting);

    mrb_close(mrb);
    return 0;
}
  • *.mrb形式のファイルをロードするにはmrb_load_irep_file()を実行する。
  • 次にクラスを取得するにはmrb_class_obj_get()を実行し、メソッドを呼び出すにはmrb_funcall()を実行する。
  • mrb_funcall()には、第2引数にメソッドのレシーバ、第3引数にメソッド名、第4引数にメソッドの引数の数、第5引数以降にはメソッドの引数を渡す。第2引数と第5引数以降はintchar*などをそのまま渡すことはできなくて、mrb_valueという構造体に変換する必要がある。変換するための関数については長くなりそうなので、別の記事にしようと思う。
  • mrb_funcall()の返り値もmrb_value構造体なので、標準出力をするためにchar*に変換する。

Cをコンパイルして実行

Cのソースコードをmrubyのヘッダーファイルやスタティックライブラリと一緒にコンパイルする。僕の環境だと以下のコマンドでコンパイルできた。

$ gcc -I ~/mruby/include greeting.c ~/mruby/build/host/lib/libmruby.a -lm -o greeting
$ ./greeting
Hello, my name is naoty, 25 years old.

greeting.cはperson.mrbに依存し、person.mrbはperson.rbに依存しているので、一連のビルドはMakefileRakefileで自動化したほうがいいと思う。

// Rakefile

require "rake/clean"

CC = "gcc"
MRBC = "mrbc"

CLEAN.include("person.mrb")
CLOBBER.include("greeting")

task default: "greeting"

file "greeting" => ["greeting.c", "person.mrb"] do |t|
  sh "#{CC} -I ~/mruby/include #{t.prerequisites[0]} ~/mruby/build/host/lib/libmruby.a -lm -o #{t.name}"
end

file "person.mrb" => ["person.rb"] do |t|
  sh "#{MRBC} #{t.prerequisites[0]}"
end
$ rake
$ ./greeting

参考

Railsに組み込むgemを作るためのTips

params_inquirerというgemを作りました。何ができるかと言うと、文で説明するのがなかなか難しいので、下のコードを見てください。

# users_controller.rb

def index
  if params[:status].accepted?    # params[:status] == 'accepted' と同じ
    @users = User.accepted
  elsif params[:status].rejected? # params[:status] == 'rejected' と同じ
    @users = User.rejected
  else
    @users = User.all
  end
end

params_inquirerを使うと上のaccepted?のようなメソッドがparamsに対して呼べるようになります。すでにrubygemsで公開してるので、ちょっと試してみたい場合は、irbで試してもらうこともできます。

$ gem install params_inquirer
$ irb
irb > require 'params_inquirer'
irb > params = ParamsInquirer::Parameters.new({ name: 'naoty' })
irb > params[:name].naoty?
 => true

paramsの中身を文字列で比較するのがなんとなくダサいと感じていたので、作ってみました。あとは、Railsの中身について勉強してみたかったというのもあります。

Railsに組み込みgemを作るにあたって知っておいた方がいいポイントについてまとめてみます。

Bundlerでgemのひな形を作る

gemを作るとき、まず最初にBundlerを使ってgemのひな形を作ります。

$ gem install bundler
$ bundle gem params_inquirer

これでgemのひな形ができます。作ったgemをローカル環境にインストールしたりrubygems.orgにリリースするためのRaketaskもここに含まれるので、かなり便利です。

Bundlerを使ったgemの開発についてはこの記事を参考にしました。

Railtie

Railtieは、Railsを起動するときにgemのコードをActionController::Baseincludeさせるために使いました。これによって、自分のgemをRailsアプリケーションに組み込むことができます。

下のコードでは、Railsプロセスが起動するときにinitializerブロック内の処理が実行されて、自分で作ったParamsInquirer::ActionController::BaseActionController::Baseincludeされるようになります。

# lib/params_inquirer/railtie.rb

require 'params_inquirer/action_controller/base'

module ParamsInquier
  class Railtie < ::Rails::Railtie
    initializer 'Initialize params_inquirer' do
      ::ActionController::Base.send :include, ParamsInquirer::ActionController::Base
    end
  end
end

ただ、このファイルがRails起動時にrequireされている必要があります。

インストールされたgemをrequireするときlib/<gem_name>.rbrequireされます。このgemであればlib/params_inquirer.rbです。なので、ここでrailtieをrequireしておく必要があります。

# lib/params_inquirer.rb

if defined?(Rails)
  require 'lib/params_inquirer/railtie'
else
  require 'lib/params_inquirer/parameters'
end

require 'params_inquirer'が実行されるとこのファイルが実行されます。もしRailsアプリケーション内であればrailtieをrequireし、最初に見せたirbのような場合は必要なファイルだけrequireするようにしています。

以上のようすることで、Rails起動時にrailtieをrequireしrailtieから自分で作ったコードをRailsアプリケーション内にincludeさせることができました。

ActiveSupport::Concern

ここからは実際に使ったというよりは、actionpackactivesupportなどのgemを読んでいくときに必要になったtipsです。

includeしたモジュールを使ってクラスメソッドをmixinしたい場合、下のようにModule#.includedをオーバーライドしその中で内部モジュールをextendするテクニックが定石みたいです。

module M
  def self.included(base)
    # extendによってクラスメソッドとしてmixinされる
    base.extend ClassMethods
    scope :disabled, where(disabled: true)
  end

  # クラスメソッドを定義する内部モジュール
  module ClassMethods
    ...
  end
end

上のようなコードはActiveSupport::Concernを使うと下のように書けます。

module M
  extend ActiveSupport::Concern

  included do
    scope :disabled, where(disabled: true)
  end

  module ClassMethods
    ...
  end
end

一見すると、ClassMethodsモジュールがextendされていないように見えますが、内部的にClassMethodsという名前のモジュールがextendされます。「設定より規約」に従ってるんだと思います。

これを知らないと、クラスメソッドがextendされていることに気づきにくいかもしれないです。また、ActiveSupport::Concernはいろんなところに頻出するので、知っておいた方がいいと思いました。

ActiveSupport::Autoload

ActiveSupport::Autoload#autoloadModule#autoloadの拡張で、Module#autoloadは必要なファイルを必要なタイミングでrequireするメソッドです。

autoload(:Hoge, 'hoge') # 'hoge.rb'はこの時点ではrequireされていない
p Hoge # ここで'hoge.rb'がrequireされる

ActiveSupport::Autoload#autoloadは、「Hogeはhoge.rbにあるはず」という「設定より規約」に従って、Module#autoloadの第2引数を省略できるメソッドなので、上のコードは下のように書けます。

extend ActiveSupport::Autoload
autoload :Hoge
p Hoge # ここで'hoge.rb'がrequireされる

これもファイル名が省略されているということを知らないと、どのファイルをrequireしているか見えづらいと思います。

最後に

あまりまとまらなくてすごい量になってしまいました。簡単なgemを作るのに知っておくべきことがいろいろあって大変でした。間違っていることがあれば修正しますので、コメントいただけると助かります。また、params_inquirerもまだ未完成なので、pull requestも待ってます。

最近のテスト事情

むかしに比べると、かなりテストが書けるようになってきたし、TDDもだんだん慣れてきた。最近テスト書いてて便利だと思ったことについてメモっておく。

スタブを使ってbefore_filterをスキップする

describe 'GET index' do
  context 'ログインしている場合' do
    before(:each) do
      controller.any_instances.stub(:authenticate_user).and_return(true)
    end

    it 'hogehogeなfugafugaを取得する' do
      get index, params
      assigns[:fugafuga].should be_hogehoge
    end
  end
end

ログイン判定のような、リクエストをはじく処理をbefore_filterで実装することがよくあるけど、そういうコントローラーをテストする場合、スタブが便利だということにようやく気づいた。スタブによって、メソッドの中身をごまかして好きな値を返すようにできる。だから、before_filterをスキップしたい場合は、とにかくスタブしてtrueを返すようにしとけばいい。skip_before_filterでもスキップすることはできるけど、僕はスタブを使う方が好み。

FactoryGirlを使いこなす

FactoryGirl.define do
  factory :user do
    # 連番を使えばuniquenessのバリデーションにかからなくなる
    sequence(:name)  {|n| "user #{n}" }
    sequence(:email) {|n| "user#{n}@example.com" }
    age { rand(18..30) }

    after(:build) do |user|
      # 余計なデータを作るコールバックがあればスキップできる
      User.skip_callback(:after, :create, :create_data)
    end

    # ネストしたfactoryで上書きできる
    factory :naoty do
      name 'naoty'
      email 'naoty@example.com'
      age 18
    end

    # traitで属性のグループに名前をつけられる
    trait :resigned do
      resigned_at { Time.now }
    end
  end
end

user = FactoryGirl.create(:user)
p user.name #=> "user 1"

naoty = FactoryGirl.create(:naoty)
p naoty.name #=> "naoty"

resigned_user = FactoryGirl.create(:user, :resigned)
p resigned_user.name #=> "user 2"
p resigned_user.resigned_at #=> "2013-01-19 00:43:59 +0900"

factory_girlはテスト用のデータを簡単につくるためのgem。似たようなgemは他にもあるけど、こういうgemを使うと、テストデータを作るロジックとテストコードを分離できる。なので、いろんなテストで使われるテストデータを重複なく簡単に作ることができる。

FactoryGirlでテストデータを作成するときに、よくひっかかるのがバリデーションやafter_saveなどのコールバック内の余計な処理だと思う。こういう鬱陶しい処理は、FactoryGirlのコールバックを使ってスキップしてる。

traitは特殊なデータを作る場合にすごく役に立つ。上記の例のような「退会ユーザー」をテストに使いたいときなど、特殊なデータの属性をひとまとめにしてFactoryGirl.create(:user, :resigned)のように簡単に作成できる。

changeマッチャが便利

describe '#resign' do
  let(:user) { FactoryGirl.create(:user) }

  it 'resigned_atを更新する' do
    lambda {
      user.resign
    }.should change(user, :resigned?).from(false).to(true)
  end
end

モデルの更新系のメソッドをテストするとき、changeマッチャが非常に便利。上の例で言うと、user.resigned?の結果がlambda内の処理を実行した前後でfalseからtrueに変わることをテストしている。