Rails: Active RecordモデルをDecoratorで気持ちよく整頓しよう(翻訳)
SingletonパターンやServiceパターン、Commandパターンなど、プログラミングでパターンとお付き合いしない日はありません。パターン同士が似すぎて結局何が違うのかわからなくなってくることすらあります。
そんなこんなで、私が自分用に構築したアプリや請負で構築したアプリで使っているのは、結局のところService(それともCommandだったかな?)パターンと、Decoratorパターンぐらいです。
ただし私の場合、app/ディレクトリ以下にservices/やdecorators/といったそれ専用のディレクトリは設けていません。少々風変わりだなと自分でも思います(私のアプリ編成の秘訣については、そのうち記事にするかもしれません: 何しろ複雑なことはやっていないので)。
今回はDecoratorパターンについて注目してみたいと思います。Decoratorを使うことで、(Active Record)オブジェクトの構造に手を加えることなく、代わりに「デコレーション」する、言い換えればオブジェクトをラッピングする🎁形で責務を追加できるようになります。
しかし私は、この手のパターンにはむやみに手を出しません。
多くのデータ処理は普通にActive Recordモデル内で行いますし、たいていはResource::Publishableなどのオブジェクトに関連付けられています。
ただし私は、Active Recordオブジェクトには画面表示用の「プレゼンテーションロジック」は置かないようにしています。私にはViewComponentという強い味方がいるので、プレゼンテーションロジックはそちらに配置しています。
それでは、最近手掛けたアプリを例として見てみることにしましょう。
class Resource < ApplicationRecord
include Event::History, Identifiable, Lockable, Sluggable
include Filterable, Renderable, Publishable
belongs_to :owner
delegated_type :resourceable, types: [Page, Chapter], dependent: :destroy
def primary_field
settings.find_by(key: "title").value.presence || "Untitled"
end
end
このResourceモデルは、RailsのDelegatedType APIで使われる標準的な「親モデル」です。
上のprimary_fieldメソッドの配置は、私にとってはアンチパターンになっているので、以下のようにコンポーネントで定義すべきです。
class ResourceComponent < ApplicationComponent
def initialize(resource)
@resource = resource
end
def primary_field
@resource.settings.find_by(key: "title").value.presence || "Untitled"
end
end
これで、Resourceモデルからprimary_fieldを問題なく削除できるようになりました。
class Resource < ApplicationRecord
include Event::History, Identifiable, Lockable, Sluggable
include Filterable, Renderable, Publishable
belongs_to :owner
delegated_type :resourceable, types: [Page, Chapter], dependent: :destroy
end
モデルが常にきちんと片付いている方がずっと気持ちがいいですね!
しかし今度は、このアプリでリソースを何らかの形でリストアップするためのAPIエンドポイントが必要になったとしたら、primary_fieldメソッドをResourceモデルに戻すべきでしょうか?
そうすることも一応可能ですし、フィールドがたった1個なら私も無理に止めません。しかしそうしたフィールドが1個で済むことはめったにありません。
そんなときはDecoratorパターンを使うようにしています。
早速Decoratorを追加してみましょう。
class Resource < ApplicationRecord
- include Event::History, Identifiable, Lockable, Sluggable
+ include Decoration, Event::History, Identifiable, Lockable, Sluggable
include Filterable, Renderable, Publishable
belongs_to :owner
delegated_type :resourceable, types: [Page, Chapter], dependent: :destroy
end
このDecorationというconcernは、Active Recordの任意のモデルに手軽に追加できる、実にシンプルなコードです。
# app/models/concerns/decoration.rb
module Decoration
def decorate(with: nil)
decorator_class = with || "#{self.class}::Decorator".constantize
decorator_class.new(self)
end
end
このメソッドは、そのコードが使われるクラス名(Resource::Decoratorなど)を利用しているクラスが存在するかどうかを探索します。
このクラスはresource/ディレクトリの下、つまりapp/models/resource/decorator.rbに配置することになります。それではこのクラスを作ってみましょう。
class Resource::Decorator < SimpleDelegator
def primary_field
settings.find_by(key: "title").value.presence || "Untitled"
end
end
これで、Resourceに関連するすべてのクラスがここにまとめられます。クリーンでメンテナンスもしやすいですね!
これはどのように使えばよいでしょうか?
Resourceモデルにdecorateをチェインできます。
例:Resource.find(1).decorate- コレクションの場合は以下のようにチェインできます。
例:Resource.all.map(&:decorate) - オプションの
with属性を使うと、必要に応じて別のDecoratorも設定できます。
例:Resource.find(1).decorate(with: AnotherDecorator)
🔗 SimpleDelegatorとは何か
上のコードにSimpleDelegatorというクラスがしれっと登場していることにお気づきでしょうか?これはRubyの標準ライブラリクラスであり、Decoratorパターンを実装するときのあらゆる「マジック」の源です。
SimpleDelegatorでオブジェクトをラップすると、特定の振る舞いだけを選択的にオーバーライド・拡張しながら、他のメソッド呼び出しを元のオブジェクトに委譲できるようになります。decoratorに存在しないメソッドは、それをラップしているオブジェクトに渡されるようになります。つまり、Resource.find(1).decorate.idと書いたとしても引き続き1が返されるようになります。
Ruby標準のツールを使った、ささやかで有用なテクニックを紹介いたしました。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。