こんにちは!!
「光のエンジニア」ってプロフィールでブランディングしようとしてるけど、検索順位で太陽光発電関連のエンジニアにどうしても勝てないことを悔しがってる板倉(itkrt2y)です。
セルフブランディング難しい!
さて、早速ですが本日はactive_decoratorを機能拡張したGemを作った話をしたいと思います。
active_decorator
active_decoratorとは、Ruby/Railsコミッターとして有名なamatsudaさんが作った、RailsにDecorator層を追加するためのGemです。
Decorator層の説明については本記事のスコープを外れるので、active_decoratorないし同目的のGemであるdraperのREADMEをご覧ください。
拡張Gemを作った動機
active_decoratorは非常に便利なGemですが、関連先のオブジェクトまではdecorateしないという仕様がしばしば問題になります(公式README)。
※ decorateする == オブジェクトにDecoratorをextendする(例:UserモデルのオブジェクトにUserDecoratorをextendする)
「関連先をdecorateしない」というのがどういうことかを公式READMEをお借りして説明すると、以下のようなことを言います。
# 以下のようなコードがあったとする # app/models/blog_post.rb class BlogPost < ActiveRecord::Base # published_at:datetime end # app/models/user.rb class User < ActiveRecord::Base has_many :blog_posts end # app/decorators/blog_post_decorator.rb module BlogPostDecorator def published_date published_at.strftime("%Y.%m.%d") end end # app/controllers/users_controller.rb class UsersController < ApplicationController def index # @userをViewに渡す @user = User.first end end
この@user
の渡し方だと@user
自体はdecorate対象になるのですが@user.blog_posts
のblog_posts
(関連先)はdecorateされないので、以下のようにはpublished_date
を呼べません。
# app/views/users/index.html.erb @user.blog_posts.first.published_date #=> NoMethodError
もし関連先もdecorateしたい場合は、以下のようにrender partial
して関連先を渡してやる必要があります。
# app/views/users/index.html.erb <%= render partial: "blog_post", locals: { blog_posts: @user.blog_posts } %> # app/views/users/_blog_post.html.erb <% blog_posts.each do |blog_post| %> <%= blog_post.published_date %> <% end %>
このようにすればblog_posts
にBlogPostDecorator
がdecorateされます!
弊社の課題
関連先もdecorateしたいならrender partial
で関連先オブジェクトを渡すという上記の方法、View内で使われるオブジェクトが明確になりコードの見通しが良くなるため、active_decoratorに関係なくそのようにすべきでしょう。
そのため、できる限りこの方法に則っていきたいところですが、そうもいかない事情がありました。
というのも、新規のコードはいいのですが、既存のメソッドをDecorator層に移した際にそのメソッドを関連越しに呼んでいたためエラーが発生する、直そうにも影響範囲が大きすぎる、といった問題が起こったからです。
そのため、苦肉の策としてactive_decoratorに関連先までdecorateさせる機能拡張を行いました。
余談
機能拡張については別Gemにせずactive_decoratorをforkして書いても良かったのですが、
保守運用がGemとして切り離されてた方が楽だと思ったのと、自分のOSS増やしたかったので切り出すことになりました(公私混同ではないですよ?)。
active_decorator_with_decorate_associations
拡張Gemの名前は捻りゼロでactive_decorator_with_decorate_associations。
英文法間違ってないか心配です。
どうでもいいですが、rubygems.orgで見ると明らかに想定してない文字数ではみ出し気味なのが個人的にじわじわきています。
使い方
使い方は簡単でGemfileに以下を定義すればOKです!
本家active_decoratorをいれている人は本Gemの依存関係で入るので削除して大丈夫です。
gem 'active_decorator_with_decorate_associations'
実装
実装方針
このGemの実装方針はざっくり言うと、以下の2段階になります。
- 関連元が本家active_decoratorのdecorate処理を通った後だったら
- 関連先の呼び出し時にdecorateする
active_decoratorでのdecorate方法
まず、そもそも本家active_decoratorはどのようにdecorate処理を行っているのでしょうか?
active_decoratorのdecorate処理を明示的に呼び出すには、以下のように書きます。
ActiveDecorator::Decorator.instance.decorate(obj)
active_decoratordeではControllerからViewに変数を渡す際にこれを実行するようモンキーパッチを当てることで、暗黙のdecorate処理を実現しています。
詳しい実装は以下のrailtie(Railsのinitialize時に呼ばれ、RailsとGemを結びつける処理をするところ)とモンキーパッチの実コードを参照ください。
つまり、関連先をdecorateするのも、何らかのタイミングで関連先を引数にActiveDecorator::Decorator.instance.decorate
を呼んでやればいいわけです。
関連先のdecorateタイミング
没案:active_decoratorと同じタイミングに全ての関連先をdecorateする
最初に考えた方法は、active_decoratorがdecorateする時に一緒に関連先まで見るようにする方法でした。
ただ、この方法だと再帰的に関連を潜って全てのオブジェクトをdecorateするため、非常に非効率ということで没になりました。
採用案:関連先呼び出し時にdecorateする
「関連元はレンダリング時に必ずActiveDecorator::Decorator.instance.decorate
を通る」というactive_decoratorの仕様を生かし、関連先の呼び出し時に関連元がactive_decoratorを通った後ならば関連先もdecorateする、という変更をいれることにしました。*1
この方法ならdecorate対象は実際に使うものに絞れるので、没案よりもはるかにエコに済みます。
関連先をdecorateする
関連元にマーカーを仕込む
さて、「関連先呼び出し時に関連元を見てdecorateする」と言いましたが、そのままでは関連元を見てもactive_decoratorを通った後かどうかを判断する手段はありません。
そのため、マーカーとして空のmoduleを用意しておき、ActiveDecorator::Decorator.instance.decorate
時にそのmoduleをextendするようにしました。
こうすることで、obj.is_a? Marker
をすればdecorate処理を通った後か判別できるようになります。
実際のコードでは以下のようになりました。
# lib/active_decorator_with_decorate_associations/marker.rb module ActiveDecoratorWithDecorateAssociations module Marker end end # lib/active_decorator_with_decorate_associations/active_decorator_extension.rb require "active_decorator_with_decorate_associations/marker" module ActiveDecoratorExtension def decorate(obj) # 中略 super # マーカーを差し込む obj.extend ActiveDecoratorWithDecorateAssociations::Marker if obj.is_a?(ActiveRecord::Base) obj end end
上記を用いてrailtieにてactive_decoratorを拡張します。
# lib/active_decorator_with_decorate_associations/railtie.rb require "rails" require "active_decorator" module ActiveDecoratorWithDecorateAssociations class Railtie < ::Rails::Railtie initializer 'active_decorator_with_decorate_associations' do ActiveDecorator::Decorator.send :prepend, ActiveDecoratorExtension end end end
これで関連元がdecorate処理を通ったかどうかを判別できるようになりました。
関連先呼び出し処理にモンキーパッチを当てる
上記処理で実装方針1の「関連元が本家active_decoratorのdecorate処理を通った後だったら」を判別できるようになりました。
次は実装方針2の「関連先の呼び出し時にdecorateする」です。
これを実現するにはActiveRecord::Associationsを理解しなくてはならないので、まずはこれを見ていきましょう。
ActiveRecord::Associations
Associationsという名前通り、関連を定義するためのクラスです。
ActiveRecord::Associationsモジュール内のクラス階層は以下のようになっています。
- Association
- SingularAssociation
- HasOneAssociation + ForeignAssociation
- HasOneThroughAssociation + ThroughAssociation
- BelongsToAssociation
- BelongsToPolymorphicAssociation
- HasOneAssociation + ForeignAssociation
- CollectionAssociation
- HasManyAssociation + ForeignAssociation
- HasManyThroughAssociation + ThroughAssociation
- HasManyAssociation + ForeignAssociation
- SingularAssociation
クラス名を見れば大体どんな役目を持ったものかわかるのではないでしょうか?
今回重要なのは ActiveRecord::Associations::Association
に定義されたattr_reader :owner, :target
です(Github)。
owner
, target
はそれぞれ「owner
== 関連元」「target
== 関連先」を表します。
ActiveRecord::Associationsにモンキーパッチを当てる
今回やりたいことは以下になります。
- 関連先を呼び出すときに
- 関連元にマーカーがextendされてるか見て
- extendされていれば関連先をdecorateする
そのため、以下の処理をすればいいことになります。
target
を呼び出すときにowner
にMarker
がextendされているか見て- extendされていれば
target
をdecorateする
コードに落とし込んだものがこちら。
target
をオーバーライドしてdecorateして返すようにしました。
def target if owner.is_a?(ActiveDecoratorWithDecorateAssociations::Marker) ActiveDecorator::Decorator.instance.decorate(super) else super end end
これをActiveRecord::Asscoations::Association
にモンキーパッチすることで、
実装方針2の「関連先の呼び出し時にdecorateする」も出来ました!
CollectionAssociation
上記で基本的な対応は完了ですが、CollectionAssociationではもう少し対応しなければならないことがあります。
CollectionAssociationは上記クラス階層を見て貰えばわかる通り、has_many
を行うクラスの親になります。
このクラスでは#first
, #last
などのメソッドにて、「SQLで取得できるならSQLで取得し、できない場合はtargetの配列から取得する」という処理を行います
(実態は#first_nth_or_last
, Github)。
この#first_nth_or_last
の結果をまた上記と同じくowner
を見てMarker
がいればdecorateする、とすれば、実装完了です!
想定問答
実際に運用してみてどう?
実はまだ実戦投入されてません・・・
これからリリースする予定です。バグがあったら泣きます。
ActiveRecordにモンキーパッチ当てるの怖くない?
めっちゃ怖いです
テスト頑張ります
最後に
今回拡張Gemを書くにあたって、active_decoratorとActiveRecord::Associationsのコードを読み込みました。
はじめはGemのコードリーディングに慣れてなかったのですが、一度本気で読んでしまうとなんてことはなく、最近はRailsのコードもカジュアルに読むくらいには成長しました。
「Gemのドキュメントは読んでも、コードはそこまで読まないな〜」という人も多いかと思いますが、active_decoratorはコード量もそんなに多くなく読みやすいし勉強にもなるので、一度読んでみてはいかがでしょうか?
宣伝
Gemをガンガン作ってOSSに発信していきたいみなさん!!
一緒に クラウドソーシングのクラウドワークス で働きませんか?
お待ちしております!
*1:Decoratorが定義されていないModelのオブジェクトはdecorateされないので、「関連元が”decorateされていれば”」では判別できない。そのため、「関連元が”active_decoratorを通った後ならば”」で判別する。