先日 activerecord-blockwhere の記事を書きましたが、
実は同じ日に active_modularity というGemライブラリも公開しました。
ライブラリの紹介
active_modularityは一つのRailsアプリで管理機能と公開機能など、複数の機能で同じモデル(テーブル)を操作したい場合に利用するライブラリです。
このライブラリを利用する場合、アプリケーションを以下の構造にすることが前提となります。
- 公開機能、管理機能などのコントローラはPublic,Adminなどのモジュール配下とする
- トップレベルに前テーブルの継承元となるモデル(基底モデル)を作成する
- 同じようにモデルもモジュール配下とし、トップレベルの基底モデルを継承する
active_modularityを利用しなくても、上記構成のRailsアプリを構築することは可能ですが、何点か問題が発生します。
この問題点を解消してくれるのがactive_modularityです。
コントローラ、モデルをモジュール分割する
実際にコントローラ、モデルのモジュール分割を行ってみましょう。
例として、簡単なブログサイトの管理機能と公開機能を作る場合を考えます。
コントローラの分割
まずはコントローラをモジュール単位で分割します。
Public::ApplicationControllerのように、モジュール以下で基底となるコントローラを作って共通のフィルタやメソッドを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
# app/controllers/public/application_controller.rb module Public class ApplicationController < ::ApplicationController # 共通機能等を実装... end end # app/controllers/public/entries_controller.rb module Public class EntriesController < Public::ApplicationController def index @entries = Entry.all end def show @entry = Entry.find(params[:id]) end end end # app/controllers/admin/application_controller.rb module Admin class ApplicationController < ::ApplicationController # 認証用のフィルタや共通機能等を実装... end end # app/controllers/admin/entries_controller.rb module Admin class EntriesController < Admin::ApplicationController def index @entries = Entry.all end def create # paramsのキーがadmin_entryになることに注意 @entry = Entry.create(params[:admin_entry]) respond_with(@entry) end # その他RUDアクションを実装... end end |
注意点として、継承する基底コントローラは、「ApplicationController」ではなく「Public::ApplicationController」のようにモジュール名を省略せず記述する必要があります。
モジュール名を指定しない場合、読み込みの順序次第でトップレベルのApplicationControllerを継承してしまう可能性があるためです。
ルーティングの記述
namespaceとしてpublic,adminを指定し、その中でルーティングを設定します。
public側のurlは/を起点にするため、pathオプションでnilを指定しています。
1 2 3 4 5 6 7 8 9 10 11 12 |
# config/routes.rb MyBlog::Application.routes.draw do namespace :public, path: nil do root to: 'entries#index' resources :entries end namespace :admin do root to: 'entries#index' resources :entries end end |
モデルの分割
モデルはmodelsディレクトリに基底モデルを作り、モジュール配下に継承したモデルを作るようにします。
基底モデルには共通で利用するバリデーションやメソッドなどを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# app/models/entry.rb class Entry < ActiveRecord::Base # 共通で利用したいものを書く validates :title, presence: true validates :content, presence: true end # app/models/public/entry.rb module Public class Entry < ::Entry # 公開機能のみで利用したいものを書く end end # app/models/admin/entry.rb module Admin class Entry < ::Entry # 管理機能のみで利用したいものを書く attr_accessible :title, :content end end |
ビューを作る
各アクションのビューについては特に意識する必要はありません。
レイアウトファイルは、各モジュール内のApplicationControllerに対して指定すると良いでしょう。
1 |
# app/views/layouts/public/application.html.erb |
公開機能
1 2 3 |
<%= yield %> # app/views/layouts/admin/application.html.erb |
管理機能
1 |
<%= yield %> |
モジュール分割による利点
モデルをモジュール配下に作ることにより、以下のような利点が生まれます。
機能単位で必要な機能を切り替えることができる
各モジュールごとにモデルクラスがわかれるため、コールバックやバリデーション、attr_accessibleなど、
共通で定義したいもの、機能ごとに切り替えたいものを素直に実装することができます。
インスタンス変数にモードを持たせてifで切り替える、などの強引なコードが不要になります。
form_forでurlを指定する必要がなくなる
form_forを利用してフォームを作った場合、formタグのaction属性に設定されるURLはモデルのクラス名を元に構築されます。
モデルをモジュール配下に定義し、コントローラと1対1で対応させることにより、このオプションの指定の必要がなくなります。
以下の3つのform_forのaction属性は全て同じになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<% @entry = ::Entry.new @admin_entry = Admin::Entry.new %> <!-- トップレベルにあるモデルの場合(urlオプションで指定) --> <%= form_for(@entry, url: admin_entries_path) do %> <% end %> <!-- トップレベルにあるモデルの場合(配列で指定) --> <%= form_for([:admin, @entry]) do %> <% end %> <!-- モデルがモジュール配下にある場合。インスタンスをそのまま利用できる --> <%= form_for(@admin_entry) do %> <% end %> |
active_decoratorと相性が良い!
モデルに対してデコレータを定義できる active_decorator というライブラリがあります。
このライブラリを使うと、モデルと1対1で対応するデコーレータ(モジュール)を定義することで、ビュー側のロジックを切り分けることができます。
モデルを機能単位でモジュール配下に分割することにより、管理機能と公開機能で別々のデコーレータが利用できるようになります。
active_decoratorは前回ちょっと触れたactiverecord-refinementsと同じ作者amatsudaさんのライブラリですね。
kaminariをはじめ、いつも使いやすいライブラリをありがとうございます。
モジュール分割による問題点
developmentモードでの問題
Railsをdevelopmentモードで起動している場合、このままでは上手く動かない場合があります。
Public::EntriesController#index アクションでは Entry.all で全エントリを取得しています。
コントローラはPublicモジュールの中に定義されているので、EntryはPublic::Entryクラスとして解決されることを期待しています。
developmentモード(config.cache_class=falseの場合)モデルクラスはconst_missingにより遅延読み込みされますが、Admin::Entryクラスが先に読み込まれていると継承元のEntryクラスが定義されるため、Publicモジュール内でのEntryがトップレベルのEntryとして解釈されてしまうのです。
この問題はコントローラ内でAdmin::Entryのようにモジュール名を省略せずに書けば解消されます。
あるいはinitializerに以下のファイルを追加することでも対応できます。
※このファイルは後々active_modularityの初期化に利用するので、ファイル名はactive_modularity.rbとしています。
1 2 3 4 |
# config/initializers/active_modularity.rb # load all files (Must be loaded the models and controllers.) MyBlog::Application.eager_load! unless Rails.configuration.cache_classes |
起動時に全てのモデルを読み込んでしまうことで、この問題を回避します。
しかし、起動中にモデルクラスを追加した場合は問題が発生する事があるので、その場合はRailsを再起動する必要があります。
関連(Association)先クラスの問題
先ほどのブログサイトにコメント機能を追加することにしました。
has_manyやbelongs_toの関連は機能間で異なることが無いため、基底モデルに記述するのが正しいでしょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# app/models/entry.rb class Entry < ActiveRecord::Base has_many :comments end # app/models/comment.rb class Comment < ActiveRecord::Base belongs_to :entry end # app/models/public/comment.rb module Public class Comment < ::Comment end end # app/models/admin/comment.rb module Admin class Comment < ::Comment end end |
しかし、関連先のモデルをbuildやcreateしようとした場合、トップレベルにあるモデルクラスのインスタンスが生成されてしまいます。
1 2 3 4 |
> admin_entry = Admin::Entry.first > comment = admin_entry.comments.build > comment.class => Comment(id: integer, ...) # トップレベルのCommentクラスになってしまう |
単一テーブル継承(Single Table Inheritance)クラスの問題
次はブログを編集するためのユーザを作ることにしました。
ユーザにはレビューワと著者という種類があり、単一テーブル継承を利用して実装することにします。
以下のモデルクラスを定義することにしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class User < ActiveRecord::Base end class Reviewer < User end class Author < User end module Public class User < ::User end class Reviewer < ::Reviewer end class Author < ::Author end end module Admin class User < ::User end class Reviewer < ::Reviewer end class Author < ::Author end end |
しかし、Admin::Authorクラスをcreateすると、typeカラムには「Admin::Author」のようにモジュール付きのクラス名が登録されてしまいます。
また、Public::Userクラスでのfindは全てのtypeを対象にしたいのですが、type属性がPublic::Userのレコードだけを検索してしまいます。
1 2 3 4 |
> Admin::Author.new.type => "Admin::Author" > Public::User.scoped.to_sql => SELECT "users".* FROM "users" WHERE "users"."type" IN ('Public::User') |
active_modularityの効果
active_modularityを利用することで、「関連(Association)先クラスの問題」「単一テーブル継承(Single Table Inheritance)クラスの問題」が解消されます。
active_modularityの導入
ではactive_modularityを導入してみます。
インストールはbundlerで行います
1 2 3 |
# Gemfileに以下の一行を追加 gem 'active_modularity' bundle install |
次に先ほど作ったinitializerのファイルを以下のように変更します。
1 2 3 4 5 6 7 |
# config/initializers/active_modularity.rb # enable active modurality ActiveRecord::Base.acts_as_modurality # load all files (Must be loaded the models and controllers.) MyBlog::Application.eager_load! unless Rails.configuration.cache_classes |
設定はこれで完了です。
Association先クラスがモジュール配下になる
もう一度関連モデルのbuildを試してみます。
1 2 3 4 |
> admin_entry = Admin::Entry.first > comment = admin_entry.comments.build > comment.class => Admin::Comment(id: integer, ...) # モジュール配下のクラスになった! |
期待通りの結果になりました。
単一テーブル継承のクラスが変わる
では、次に単一テーブル継承の例を試してみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# 作成したレコードのtypeにはモジュールが付かない > author = Admin::Author.create(name: "taro") > author.type => "Author" > author.class => Admin::Author # 検索後のクラスはモジュールは以下のインスタンスになる > Public::User.scoped.to_sql => SELECT "users".* FROM "users" WHERE "users"."type" > user = Public::User.last > user.type => "Author" > user.class => Public::Author > user.id = author.id => true # ベースのクラス名と同じ名前のクラスではtypeがnilとなる > Public::User.new.type => nil |
少し複雑ですが、単純に表現すると以下の挙動になります。
- 検索時や登録などtype属性のモジュール名が無視される
- インスタンス化時はモジュールが考慮される
まとめ
active_modularityはモデルクラスの分割をほんのちょっとだけ手助けしてくれるライブラリです。
モデルクラスを分割するパターンによって、実装を機能ごとに分離することができるので、ぜひ一度お試しください。