読者です 読者をやめる 読者になる 読者になる

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を覚えなくてはいけないとか、場合によってはカスタマイズできないみたいな状況は大きな問題だと思う。だから、動的にいろいろ生成する方針は管理画面の実装には適していないのではないかと思った。なんでこれがこんなに支持されているのかよくわからない。