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)を追記しているようだ。selfはRails.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つまり:adminがnsに入る。#namespaceはnamespaces[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_resourceはAA::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
delegateはActiveSupportが拡張しているメソッドで、メソッドの呼び出しを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が定義される。
routerはRouterインスタンスなので、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
このrouterはActionDispatch::Routing::Mapperであり、@application.namespaces.valuesはAA::Namespaceインスタンスの配列だ。
ActiveAdmin.registerに特にoptionを指定しない場合、namespace.root?はtrueとなる。namespace.root_to_optionsとnamespace.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_routerはAA::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を覚えなくてはいけないとか、場合によってはカスタマイズできないみたいな状況は大きな問題だと思う。だから、動的にいろいろ生成する方針は管理画面の実装には適していないのではないかと思った。なんでこれがこんなに支持されているのかよくわからない。