#はじめに
Railsで開発をしていると頭を悩ます3つのしばしばがあります。
-
1つのモデルで複数のモデルを操作してしまう
-
冗長なコードが複数のモデルまたはコントローラに出てくる
-
表示のためだけに、モデルにメソッドを追加する
これらを解決するためには、オブジェクト指向とデザインパターンによる考え方ができるとよいです。
この記事では、Railsでよりオブジェクト指向的に、より構造が単純で、より DRYに書くためのパターンを学びます。
環境は Rails 4 です。
キーワードは3つ。
- 複雑なビジネスロジックを分離する Non associated Model
- 関心の分離を保ちつつ処理をまとめる Model/Controller Concerns
- 表示ロジックをモデルから分離する Decorator
Non associated Model
解決する問題
- 1つのモデルで複数のモデルを操作してしまう
##導入する理由
ActiveRecordは、1つのモデルが1つのテーブルにひもづくので、あるモデルに複数のモデルを操作するような処理は書くべきでない。
さらに言うと、複数のモデルを操作するクラスは、テストしづらい。
##回答
ActiveRecordにひもづかないクラスを作り、複雑なロジックは分離する。
##例
車モデルに対して、複数の部品を組み立てる build_parts というアクションを考える。
#cars_controller.rb
class CarsController < ApplicationController
before_action :set_car, only:[:show, :update, :edit, :delete]
:
def build_parts
@car.build_parts
respond_to do |format|
:
end
end
private
def set_car
@car = Car.find(params[:id])
end
end
#car.rb
class Car < ActiveRecord::Base
def build_parts
self.class.transaction do
self.suspension.create
self.tire.create
self.body.create
:
複雑なロジック
end
end
end
↓
#cars_controller.rb
class CarsController < ApplicationController
:
def build_parts
car_builder = CarBuilder.new(@car)
car_builder.build_parts
respond_to do |format|
:
end
end
private
def set_car
@car = Car.find(params[:id])
end
end
#car.rb
class Car < ActiveRecord::Base
end
#car_builder.rb
class CarBuilder
attr_reader :car
def initialize(car)
@car = car
end
def build_parts
car.class.transaction do
car.suspension.create
car.tire.create
car.body.create
:
複雑なロジック
end
end
テーブルに関連づいているCarモデルは、自分のことしか見ないクラスになりました。
例における実装は、builderパターンが元になっています。
参考 Builderパターン
Model/Controller Concerns
解決する問題
- 冗長なコードが複数のモデルに出てくる
##導入する理由
ActiveRecordは、1つのモデル/コントローラが1つのテーブルにひもづくので、モデル単位でロジックを意識する。
これが行き過ぎると、モデル/コントローラごとに同じようなメソッドが出てきて、DRYではなくなってくる。
##回答
app/controllers/Concerns、または、 app/models/concernsに共通メソッドをまとめる。
##例1(models/concerns)
写真と動画にコメントしたいケースを考える。
#comment.rb
class Comment < ActiveRecord::Base
belongs_to :commentable, polymorphic: true
:
end
#picture.rb
class Picture < ActiveRecord::Base
has_many :comments, as: :commentable
#duplicate code
def comments_by_user(id)
comments.where(user_id: id)
end
end
#movie.rb
class Movie < ActiveRecord::Base
has_many :comments, as: :commentable
#duplicate code
def comments_by_user(id)
comments.where(user_id: id)
end
end
↓
#app/models/concerns/commentable.rb
module Commentable
extend ActiveSupport::Concern
included do
has_many :comments, as: :commentable
end
def comments_by_user(id)
comments.where(user_id: id)
end
end
または
module Commentable
def self.included(base)
base.class_eval do
has_many :comments, as: :commentable
end
end
def comments_by_user(id)
comments.where(user_id: id)
end
end
----
#picture.rb
class Picture < ActiveRecord::Base
include Commentable
:
end
----
#movie.rb
class Movie < ActiveRecord::Base
include Commentable
:
end
polymorphic 関連がわからない、という人は、以下の記事がわかりやすいです。
Rails4でポリモフィックのリレーションを実装する
ActiveSupport::Concernを使う例のほうが、すっきり見えるので、私は好んで使っています。
##例2(controllers/concerns)
先ほどの例で、写真と動画のサムネイルをそれようのパスに保存したいケースを考える。
#pictures_controller.rb
class PicturesController < ApplicationController
def create
:
#duplicate code
thumb_path = "/thumb/#{@picture.file_name}"
thumb = @picture.make_thumbnail thumb_path
thumb.save
end
end
#movies_controller.rb
class MoviesController < ApplicationController
def create
:
#duplicate code
thumb_path = "/thumb/#{@movie.file_name}"
thumb = @movie.make_thumbnail thumb_path
thumb.save
end
end
↓
#app/controllers/concerns/previewable.rb
module Previewable
def thumbnail(content)
thumb_path = "/thumb/#{content.file_name}"
thumb = content.make_thumbnail thumb_path
thumb.save
end
end
#pictures_controller.rb
class PicturesController < ApplicationController
include Previewable
def create
:
thumbnail(@picture)
end
end
#movies_controller.rb
class MoviesController < ApplicationController
include Previewable
def create
:
thumbnail(@movie)
end
end
Decorator
解決する問題
- 表示のためだけに、モデルにメソッドを追加する
##導入する理由
表示のためのロジックがモデルに増えてくると、肝心のビジネスロジックが見づらくなってしまう。
##回答
app/decorators/にモデル単位で表示するための固有のロジックをまとめる。
##例
日付をフォーマットして表示したいケースを考える。
#sample.html.haml
:
= "created_at #{@sample.formatted_date}"
:
#samples_controller.rb
class SamplesController < ApplicationController
before_action :set_sample, only: [:show, :update, :edit, :delete]
:
def show
end
private
def set_sample
@sample = Sample.find(params[:id])
end
end
#sample.rb
class Sample < ActiveRecord::Base
def formatted_date
self.created_at.strftime("%Y/%m/%d %H:%M:%S")
end
end
↓
#sample.html.haml
:
= "created_at #{@sample_decorator.formatted_date}"
:
# app/decorators/sample_decorator.rb
class SampleDecorator
attr_reader :sample
def initialze(sample)
@sample = sample
end
def method_missing(method_name, *args, &block)
sample.send(method_name, *args, &block)
end
def respond_to_missing?(method_name, invalid_private = false)
sample.respond_to(method_name, invalid_private)
end
def formatted_date
sample.created_at.strftime("%Y/%m/%d %H:%M:%S")
end
end
#samples_controller.rb
class SampleController < ApplicationController
before_action :set_sample, only: [:show, :update, :edit, :delete]
:
def show
end
private
def set_sample
@sample_decolator = SampleDecorator.new(Sample.find(params[:id]))
end
end
#sample.rb
class Sample < ActiveRecord::Base
end
ポイントは、method_missingとrespond_to_missingをDecoratorに実装すること。
これにより、sample_decoratorから、sampleのメソッドを呼び出すことができます。
:
@sample_decorator.find(params[:id])
:
参考:デザインパターン
Decorator
補足:ヘルパーについて
この議論をするとき、よくヘルパーに切り出す、という記事を見つけます。
この方法でも、モデルを汚さずに、かつ、手軽に表示ロジックを切り出せますが、ネームスペースを汚してしまう。というデメリットもあります。
Decoratorとして、明示的にclassに切り出せるほうが、より局所化できるので、(OOP的には)ベターな方法だと思います。
DecoratorをパッケージングしてくれているDraperというGemがあります。
scaffoldするたびに、Decoratorを自力定義する必要がないので、効率化できます。
#最後に
よりDRYに、またオブジェクト指向でいうところの関心の分離ができたのではないかと思います。
Railsは本当によくできたフレームワーク(お前が言うな!)なので、逆にOOPを意識しなくてもある程度形になってくれるのですが、OOPをある程度知ってなかったら、このような気づきはなかったと思います。
あ、例に出したコードや記事の内容について、もちろん異論はありです。
いろんな意見を聞かせていただければと。