Refinery CMS の機能を Engine で拡張する

Written by @dr_taka_n at 2011/02/24 22:38 [, , , ]

Refinery CMS における Engine

Refinery CMS の機能を拡張するには、Engine と呼ばれるものを追加する。

Engine という言葉を使っているが、その実体は、RAILS_ROOT/vendor/engines に配置されて動作する Rails アプリのミニ版であり、独自のルーティング、コントローラ、ビューなどを持たせることができる。ディレクトリ構成も同様のものとなる。

Refinery 本体も Engine で構成されており、以下の7つの Engine で構成されている。

  • authentication
  • core
  • dashboard
  • images
  • pages
  • resources
  • settings

独自の機能を Engine を使って作成する

Refinery は scaffold のような Engine の足場を生成するための generator の機能を用意してくれている。 詳細は以下のサイトが参考になる。

ここでは、上記のガイドにそって、イベントを追加できる機能を Engine で作成する。

これから作成しようとしているイベントの情報管理に必要な項目は以下の4点になる。

  • イベントのタイトル
  • イベントの日時
  • 写真
  • イベントのちょっとした宣伝文句

rails generate refinery_engine コマンドを使用して、イベント用の Engine を作成する。簡単なヘルプは以下で確認できる。

$ rails generate refinery_engine
Usage:
  rails generate refinery_engine NAME [field:type field:type] [options]

Runtime options:
  -s, [--skip]     # Skip files that already exist
  -f, [--force]    # Overwrite files that already exist
  -p, [--pretend]  # Run but do not make any changes
  -q, [--quiet]    # Supress status output

Description:
    Generates a custom plugin for Refinery automatically. It works very similar
    to the Rails scaffold generator.

    A generated plugin gives you all the basic files needed to manage the model
    the plugin will be for.

    The first attribute should always be the one which is the title or name of
    the model.

    There must be at least one attribute.

Additional Supported Field Types

  All field types that are supported by the Rails Scaffold generator are supported with the addition
  of these Refinery specific ones:

  text            - text area with a visual editor
  image           - link to an image picker dialogue
  resource        - link to a resource picker dialogue

Example:
    rails generate refinery_engine product title:string description:text image:image brochure:resource

rails generate model と同じようなフィールド指定になるが、この generator は管理画面のフィールドまで自動で生成してくる。 指定したフィールドのタイプによって、管理画面では以下のフィールドが自動生成される。

  • text
    WYSIWYG エディタで編集可能な複数行のテキストフィールド
  • image
    画像のアップロード、既にアップロード(登録)済みの画像を選択する画面が表示される
  • resource
    リソース(ファイル)のアップロード、既にアップロード(登録)済みのファイルを選択する画面が表示される
  • string and integer
    標準的な1行のインプットフィールド
  • datetime, date and time
    標準的な日付、日時の選択フィールド

実行してみる。

$ rails generate refinery_engine event title:string date:datetime photo:image blurb:text
      create  vendor/engines/events/app/controllers/admin/events_controller.rb
      create  vendor/engines/events/app/controllers/events_controller.rb
      create  vendor/engines/events/app/models/event.rb
      create  vendor/engines/events/app/views/admin/events/_form.html.erb
      create  vendor/engines/events/app/views/admin/events/_events.html.erb
      create  vendor/engines/events/app/views/admin/events/_event.html.erb
      create  vendor/engines/events/app/views/admin/events/_sortable_list.html.erb
      create  vendor/engines/events/app/views/admin/events/edit.html.erb
      create  vendor/engines/events/app/views/admin/events/index.html.erb
      create  vendor/engines/events/app/views/admin/events/new.html.erb
      create  vendor/engines/events/app/views/events/index.html.erb
      create  vendor/engines/events/app/views/events/show.html.erb
      create  vendor/engines/events/config/locales/en.yml
      create  vendor/engines/events/config/locales/lolcat.yml
      create  vendor/engines/events/config/locales/nb.yml
      create  vendor/engines/events/config/locales/nl.yml
      create  vendor/engines/events/config/routes.rb
      create  vendor/engines/events/db/migrate/create_events.rb
      create  vendor/engines/events/db/seeds/events.rb
      create  vendor/engines/events/features/manage_events.feature
      create  vendor/engines/events/features/step_definitions/event_steps.rb
      create  vendor/engines/events/features/support/paths.rb
      create  vendor/engines/events/lib/generators/refinerycms_events_generator.rb
      create  vendor/engines/events/lib/refinerycms-events.rb
      create  vendor/engines/events/lib/tasks/events.rake
      create  vendor/engines/events/readme.md
      create  vendor/engines/events/refinerycms-events.gemspec
      create  vendor/engines/events/spec/models/event_spec.rb
------------------------
Now run:
bundle install
rails generate refinerycms_events
rake db:migrate
------------------------

最後の指示に従って、コマンドを実行していく。

$ bundle install
...
$ rails generate refinerycms_events
      create  db/migrate/20110224062315_create_events.rb
      create  db/seeds/events.rb
------------------------
Now run:
rake db:migrate
------------------------

$ rake db:migrate
(in /home/taka/Dropbox/work/try-refinery)
==  CreateEvents: migrating ===================================================
-- create_table(:events)
   -> 0.0017s
-- add_index(:events, :id)
   -> 0.0007s
==  CreateEvents: migrated (0.3079s) ==========================================

Gemfile を見てみると、現在作成した Engine が、path 指定での gem として登録されたことがわかる。

$ git diff Gemfile
diff --git a/Gemfile b/Gemfile
index c166aa8..024b813 100644
--- a/Gemfile
+++ b/Gemfile
@@ -57,3 +57,5 @@ end
 gem 'refinerycms-i18n',         '~> 0.9.9.9'
 
 # END USER DEFINED
+
+gem 'refinerycms-events', '1.0', :path => 'vendor/engines'

ちなみに、上記コマンドを実行したことによる変更点は以下の通りとなっていた。

$ git st
# On branch master
# Changed but not updated:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
#
#       modified:   Gemfile
#       modified:   Gemfile.lock
#       modified:   db/schema.rb
#       modified:   index/development/RefinerySetting/size
#
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#       db/migrate/20110224062315_create_events.rb
#       db/seeds/events.rb
#       index/development/RefinerySetting/100_101_102.ind
#       vendor/engines/

一旦ここでサーバを再起動して、http://localhost:3000/events にアクセスしてみる。

Refinery Engine 1

Events のメニューが勝手に追加されている。また管理画面の方を見てみると、

Refinery Engine 2

こちらも Events タブが追加され、編集機能ができあがっている。試しに “Add New Event” をクリックすると、

Refinery Engine 3

画像もアップロードできるようになっている。大したもんだ。。

この時点でイベントの情報に関する基本的な CRUD の機能は実装済みとなっている。

RAILS_ROOT/vendor/engines/events/ 配下に Rails アプリそのものが出来上がっているので、カスタマイズはそこで自由にやれる。

作成された Engine の仕組みを少しみてみる

試しに、generator で自動で作成された EventsController を見てみる。

RAILS_ROOT/vendor/engines/events/app/controllers/events_controller.rb:

class EventsController < ApplicationController

  before_filter :find_all_events
  before_filter :find_page

  def index
    # you can use meta fields from your model instead (e.g. browser_title)
    # by swapping @page for @event in the line below:
    present(@page)
  end

  def show
    @event = Event.find(params[:id])

    # you can use meta fields from your model instead (e.g. browser_title)
    # by swapping @page for @event in the line below:
    present(@page)
  end

protected

  def find_all_events
    @events = Event.find(:all, :order => "position ASC")
  end

  def find_page
    @page = Page.find_by_link_url("/events")
  end

end

上記の index アクション、show アクションの中で、通常の Rails では現れないインスタンスメソッドの present が使用されている。継承元の ApplicationController は、Refinery の作成されたプロジェクト内では空のままだ。これはどこで定義されているのだろうか?

実体は、refinerycms-core の gem 内で確認できる。

GEM_PATH/gems/refinerycms-core-0.9.9.3/lib/refinery/application_controller.rb:

module Refinery
  module ApplicationController

    def self.included(controller)
      controller.send :include, ::Refinery::ApplicationController::InstanceMethods
      controller.send :include, ::Refinery::ApplicationController::ClassMethods
    end

    module ClassMethods
      def self.included(c) # Extend controller
        c.helper_method :home_page?,
                        :local_request?,
                        :just_installed?,
                        :from_dialog?,
                        :admin?,
                        :login?

        c.protect_from_forgery # See ActionController::RequestForgeryProtection

        c.send :include, Crud # basic create, read, update and delete methods

        c.send :before_filter, :find_pages_for_menu,
                               :show_welcome_page?

        c.send :after_filter, :store_current_location!,
                              :if => Proc.new {|c| c.send(:refinery_user?) rescue false }

        if Refinery.rescue_not_found
          c.send :rescue_from, ActiveRecord::RecordNotFound,
                               ActionController::UnknownAction,
                               ActionView::MissingTemplate,
                               :with => :error_404
        end
      end
    end

    module InstanceMethods
      def admin?
        ...
      end

      def error_404(exception=nil)
        ...
      end

      def from_dialog?
        ...
      end

      def home_page?
        ...
      end

      def just_installed?
        ...
      end

      def local_request?
        ...
      end

      def login?
        ...
      end

    protected

      # get all the pages to be displayed in the site menu.
      def find_pages_for_menu
        ...
      end

      # use a different model for the meta information.
      def present(model)
        presenter = (Object.const_get("#{model.class}Presenter") rescue ::Refinery::BasePresenter)
        @meta = presenter.new(model)
      end
      ...

上記の Refinery::ApplicationController が Mix-In されることで、コントローラから使用できるようになっている。

作成されたコントローラの before_filter で Event の全データを引いている点と合わせて、present メソッドでやっていることを確認したいと思ったのだが、追々確認することとして、この Refinery::ApplicationController::ApplicationController に Mix-In される流れを追ってみた。

以下のような流れだった。

RAILS_ROOT/Gemfile では Refinery CMS に関する Gem は、

gem 'refinerycms',              '= 0.9.9.3'

で読まれることになる。内容は以下の通り。

GEM_PATH/gems/refinerycms-0.9.9.3/lib/refinerycms.rb:

%w(base core settings authentication dashboard images pages resources).each do |engine|
  require "refinerycms-#{engine}"
end

Refinery の分割された各種 gem たちを require している。

先ほどの Refinery::ApplicationController は、GEM_PATH/gems/refinerycms-core-0.9.9.3/lib/refinerycms-core.rb で読み込まれる。

GEM_PATH/gems/refinerycms-core-0.9.9.3/lib/refinerycms-core.rb

module Refinery
  autoload :Activity, File.expand_path('../refinery/activity', __FILE__)
  autoload :Application, File.expand_path('../refinery/application', __FILE__)
  autoload :ApplicationController, File.expand_path('../refinery/application_controller', __FILE__)
  autoload :ApplicationHelper, File.expand_path('../refinery/application_helper', __FILE__)
  autoload :Plugin,  File.expand_path('../refinery/plugin', __FILE__)
  autoload :Plugins, File.expand_path('../refinery/plugins', __FILE__)
end
...
module Refinery

  module Core
    class << self
      def attach_to_application!
        ::Rails::Application.subclasses.each do |subclass|
          begin
            subclass.send :include, ::Refinery::Application
          rescue
            $stdout.puts "Refinery CMS couldn't attach to #{subclass.name}."
            $stdout.puts "Error was: #{$!.message}"
            $stdout.puts $!.backtrace
          end
        end
        ...
    end
...
    class Engine < ::Rails::Engine

      config.autoload_paths += %W( #{config.root}/lib )

      # Attach ourselves to the Rails application.
      config.before_configuration do
        ::Refinery::Core.attach_to_application!
      end

さて、どこで Mix-In されるのだろうか?

Rails::Engine を継承した Refinery::Core::Engine クラスを見ると、Rails::Engine::Configuration クラスの before_configuration メソッドにて Refinery::Core.attach_to_application! の呼び出しを行っているブロックが登録されている。名前からして、Rails の起動プロセスの中で実行されるのだろう。その実行時に、subclass.send :include, ::Refinery::Application が実行されている。Refinery::Application がインクルードされた時のフック処理に仕掛けがありそうだ。

GEM_PATH/gems/refinerycms-core-0.9.9.3/lib/refinery/application.rb:

module Refinery
  module Application
    class << self
      def refine!
        ::ApplicationHelper.send :include, ::Refinery::ApplicationHelper

        [::ApplicationController, ::Admin::BaseController].each do |c|
          c.send :include, ::Refinery::ApplicationController
          c.send :helper, :application
        end

        ::Admin::BaseController.send :include, ::Refinery::Admin::BaseController
      end
...
      def included(base)
        self.instance_eval %(
          def self.method_missing(method_sym, *arguments, &block)
            #{base}.send(method_sym)
          end
        )

        # JavaScript files you want as :defaults (application.js is always included).
        base.config.action_view.javascript_expansions[:defaults] = %w()

        # Configure the default encoding used in templates for Ruby 1.9.
        base.config.encoding = "utf-8"

        # Configure sensitive parameters which will be filtered from the log file.
        base.config.filter_parameters += [:password, :password_confirmation]

        # Specify a cache store to use
        base.config.cache_store = :memory_store

        # Include the refinery controllers and helpers dynamically
        base.config.to_prepare do
          ::Refinery::Application.refine!
        end
...

いた。Refinery::Application.refine! で、::ApplicationControllerRefinery::ApplicationController を Mix-In している。Refinery::Application.refine! は、Refinery::Application がインクルードされる時に動作するフックメソッドを見てみると、Rails::Application::Configuration クラスの to_prepare メソッドにてその呼び出しがブロックとして登録されている。これも名前からして Rails の起動時のプロセスで処理されるのだろう。

Rails3 をまだちゃんと把握できておらず、起動プロセスの流れが曖昧なので、完全に読み切れてはいないが、この手の CMS でカスタマイズを行う場合、全体的な構成と仕組みをある程度頭に入れておくと触り易くなる。 折を見ながらまた覗いてみよう。

blog comments powered by Disqus