12 複雑なユースケースを実現する
雑感
この章ではモデル・コントローラからユースケースに関するビジネスロジックを分離してカプセル化するデザインパターンについての内容でした。この辺りは前章のサービスオブジェクトと同様にモデル・コントローラをスリムにできる反面、アプリケーションコードの複雑性につながるため、導入についてはチーム内での議論が必要な印象です。以下、忘備録となります。
ユースケースとは
なんらかの目的を達成するために行われるユーザとアプリケーションの一連のやり取り。(「GitHub アカウントでログインする」「イベントを登録する」など)
Rails は URL で表されるリソースを DB のテーブルと一対一に対応させており、これらの CRUD 操作を通じてユーザとやり取りする。これらの一つ、または複数の CRUD 操作によって一つのユースケースが構成される。
複雑なユースケース
例えば、Cookie ベースのセッションの作成・削除操作のようなユースケースは、対応するテーブルが存在せず上述した URL と DB テーブルの一対一の関係は成り立たない。このようなロジックは DB と紐づいているモデルに実装するのは不適切と言える。また、コントローラにユースケースのロジックを実装することもできるが、コントローラの肥大化につながるため避けた方が良い。
Active Model で DB と紐づかないモデルを作成する
複雑なユースケースに対応するため、ユースケースのロジックを新しいレイヤーとしてモデルから分離して実装する。
単純に、サービスオブジェクトのように PORO(Rails の基底クラスを継承しない素の Ruby クラス)として実装する方法もあるが、Active Model モジュール郡を利用することで、通常のモデルと同様にコントローラ、ビューと連携できる。いわば、DB と紐づかないモデルを作成することができる。
ActiveModel::Attributes による型を持つ属性の定義
ActiveModel::Attributes
は型を持つ属性の定義を容易にできる。ActiveRecord の属性のように、型に合わない値を設定すると自動で型変換が行われる。
class Person
include ActiveModel::Attributes
# 第一引数で属性名、第二引数で型名
attribute :name, :string
attribute :age, :integer
# 値または値を返却するブロックでデフォルト値も設定できる
attribute :point :integer, default: 0
end
person.age = "40"
=> "40"
person.age
=> 40
ActiveModel::Callbacks によるコールバック機能
ActiveModel::Callbacks
はコールバック機能の実装を容易にできる。
class Person
include ActiveModel::Callbacks
attr_accessor :created_at, :updated_at
# saveアクションのコールバックを定義(デフォルトではbefore, around, afterが利用可能)
# onlyオプションで特定のメソッドのみ利用可能にする
define_model_callbacks :save, only: %i[before]
before_save :record_timestamps # ActiveRecordModelと同様の定義
def save
# run_callbacksでコールバックを実行
run_callbacks :save do
true
end
end
private
def record_timestamps
current_time = Time.current
self.created_at ||= current_time
self.updated_at = current_time
end
end
ActiveModel::Serialization によるオブジェクトのシリアライズ
ActiveModel::Serialization
はオブジェクトのシリアライズ機能の実装を容易にする。このモジュールにおいてはserializable_hash
メソッドのみが提供され、このメソッドの返却するハッシュを元に各形式でのシリアライズを実装する。
class Person
include ActiveModel::Serialization
attr_accessor :name, :age
# このメソッドが実装されることでserializable_hashメソッドが利用可能になる
# モジュールを利用したいだけならばハッシュの値は実はなんでも良い
def attributes
{ "name" => name, "age" => age }
end
def to_xml(option = nil)
# このモジュールで利用可能になるメソッド
# camelize: :lowerでXMLのタグをローワーキャメルケースに変換
# rootパラメータにクラス名を指定してルート要素として使用
serializable_hash(options).to_xml(camelize: :lower, root: self.class.name)
end
end
使用例:
person = Person.new
person.name = "山田太郎"
person.age = 30
puts person.to_xml
<?xml version="1.0" encoding="UTF-8"?>
<person>
<name>山田太郎</name>
<age>30</age>
</person>
ActiveModel::Serialization::JSON による JSON 形式でのシリアライズ
ActiveModel::Serialization::JSON
によって JSON 形式のシリアライズをActiveModel::Serialization
のよる他の形式へのシリアライズよりも簡単に実装できる。
class Person
include ActiveModel::Serialization::JSON
attr_accessor :name, :age
def attributes
{ "name" => name, "age" => age }
end
# serializable_hashメソッドを使用する必要使用する必要がない
# 特定の属性を除外したい場合など複雑なケースでは上記メソッドが必要
end
{
"person": {
"name": "山田太郎",
"age": 30
}
}
ActiveModel::Validations によるバリデーション
ActiveModel::Validations
は属性のバリデーション機能を容易実装できる。
-
validates_uniqness_of
のような DB のレコードを参照するヘルパーを除く、ActiveRecord と同じバリデーション機能が利用可能 -
before_validation
,after_validation
などのコールバックを利用するにはさらにActiveModel::Validations::Callbacks
を include する必要がある
class Person
include ActiveModel::Validations
include ActiveModel::Validations::Callbacks
attr_accessor :name
validates :name, presence: true, length: { maximum: 100 }
before_validation :normalize_name, if: -> { name.present? }
private
def normalize_name
self.name = name.downcase.titleize
end
ActiveModel::Model でコントローラ・ビューと連携する
ActiveModel::Model
はActiveModel
のモジュール群の一部がまとめられ、それらを組み合わせてコントローラ・ビューと連携するために必要なインターフェースを提供する。
- 作成したオブジェクトをコントローラ・ビューのメソッドで利用可能
- ActiveRecord のように属性のハッシュで初期化できる
- バリデーションを設定できる
class Person
include ActiveModel::Model
attr_accessor :name, :age
validates :name, presence: true, length: { maximum: 100 }
validates_numericality_of :age, greater_than_or_equal_to: 0
end
person = Person.new(name: "David")
app.url_for(person)
=> "http://www.example.com/people"
フォームオブジェクト
フォームオブジェクトとはユースケースのロジックを実装するオブジェクト(インタラクター)にform_with
との連携に必要なインターフェースを持たせたもの。
いつ導入するか
例えば、「ユーザを登録する」というユースケースがあった場合、通常は「利用規約の同意」や「登録完了メール送信」といった副次的な処理も含めて Rails way に沿って無理なく実装可能だが「ユーザの登録」という処理がこのユースケース以外でも行われる場合(csv ファイルからのユーザ一括登録など)は前述の副次的な処理・バリデーションは必要ない場合がある。
上記の例では User モデルのバリデーション・コールバックを特定条件下で実行するというような形で各ユースケースのロジックを一つのモデルに実装できるが、モデルが肥大化する要因となる。
このような場合にフォームオブジェクトを導入することでフォーム入力を伴う各ユースケースごとのロジックをモデルから分離することができる。
フォームオブジェクトの作成
class UserRegistrationForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
# URI::MailTo::EMAIL_REGEXPは、Rubyの標準ライブラリに含まれるメールアドレスの正規表現パターン
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP}
# このユースケース特有のバリデーション
validate :email_is_not_taken_by_another
# このフォーム特有のバリデーション(フォーム画面での利用規約の同意)
validates :terms_of_service, acceptance: { allow_nil: false }
def save
return false if invalid?
user.save!
# このユースケース特有のメール送信処理
UserMailer.with(user: user).welcome.deliver_later
true
end
def user
@user ||= User.new(email: email)
end
private
def email_is_not_taken_by_another
errors.add(:email, :taken, value: email) if User.exist?(email: email)
end
end
class UserRegistrationsController < ApplicationController
def new
@user_registration_form = UserRegistrationForm.new
end
def create
@user_registration_form = UserRegistrationForm.new(user_registration_form_params)
if @user_registration_form.save
sign_in(@user_registration_form.user)
redirected_to action: :completed
else
render :new
end
def completed; end
private
def user_resisatration_form_params
params.require(:user_registration_form).permit(
:email
:terms_of_service
)
end
end
end
<%= form_with(model: @user_registration_form, url: user_registrations_path, local: true) do |form| %>
略
<% end %>
class User < ApplicationRecord
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true
# フォームオブジェクトを導入したことでユースケースのロジックをDBモデルに記述しなくても良い
end
フォームオブジェクトの設計ルール
-
ActiveModel::Model
を include する - クラス名は接尾辞を Form にする
-
save
やsearch
など、クラス名から推測可能な単一の処理用メソッドを定義する。失敗時に false を返す - バリデーションを実装する。ただし ActiveRecord モデルとの整合性に気をつける
ActiveModel::EachValidator で共通する属性にバリデーションを設定する
ActiveModel::EachValidator
はある一つの属性のバリデーションを定義する時に利用できる基底クラス。
上記の例においては共通の属性である email のバリデーションの記述がモデルとフォームオブジェクトで重複している。この共通ルールをActiveModel::EachValidator
を継承したクラスによって以下のように定義できる。
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
# =~演算子で、入力値がこのパターンにマッチするかチェック
unless value =~ URI::MailTo::EMAIL_REGEXP
# パターンにマッチしない場合、モデルのエラーコレクションにエラーを追加
record.errors.add(attribute, :invalid, options.merge(value: value))
end
end
end
class UserRegistrationForm
# emailという属性に対して、上記のEmailVallidatorを有効化(true)
validates :email, email: true
end
class User < ApplicationRecord
validates :email, email: true, uniqueness: true
end
ActiveModel::Validator で複数の属性を組み合わせたバリデーションを設定する
ActiveModel::Validator
は複数の属性を組み合わせたより複雑なバリデーションを定義する場合に利用できる基底クラス。
class PeriodValidator < ActiveModel::Validator
def initialize(option = {})
super({ from: :from, to: :to }.merge!(options))
end
# validateメソッドにバリデーションルールを記述する
def validate(record)
from = record.read_attribute_for_validation(option[:from])
to = record.read_attribute_for_validation(option[:to])
if to < from
record.errors.add(:base, "Period from #{from} to #{to} is invalid")
end
end
end
class EventRegistrationForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :start_at, :datetime
attribute :end_at, :datetime
# validates_withメソッドで定義したバリデーションを呼び出す
validates_with PeriodValidator, from: :start_at, to: :end_at
end
プレゼンター(デコレーター)
プレゼンターとは
ビューヘルパーは通常、対応するコントローラのビューで利用するヘルパーを実装することが慣例となっている。(例えば、UsersHelper
モジュールではUsersController
のビューで利用するヘルパーを実装する)
しかし、実際には設定を変更しない限りあるモジュールで実装したヘルパーは全てのコントローラで利用できてしまう。そのため、ヘルパーメソッド名が重複しないように注意する必要があり、また設定を変更してその問題を解決しても今度は複数のコントローラ間で共用したいビューヘルパーをどのように実装するかという問題が生じる。
このような問題を解決する方法の一つがプレゼンターと呼ばれるレイヤーを導入すること。
ビューヘルパーをモデルと一対一に対応するクラスやモジュールのインスタンスメソッドとして実装する。これにより、他のモデルのオブジェクトからは呼び出せないので、上記の名前の重複の問題を解消できる。
表示に関するロジックをモデルのインスタンスメソッドとして実装しても実現できるがこれはモデルの肥大化へつながるため避けるべき。
ActiveDecorator で プレゼンターを実装する
ActiveModel を利用して簡単に実装できるフォームオブジェクトとは異なり、Rails にはプレゼンターの実装に利用できる仕組みが用意されていない。そのため、外部 gem のActiveDecorator
を利用する。
1. セットアップ
gem を追加してbundle install
gem 'active_decorator'
プレゼンターを定義するモジュール名が#{モデル名}Presentor
となるように設定を変更する(デフォルトだと#{モデル名}Decorator
となる)
ActiveDecorator.configure do |config|
config.decorator_suffix = "Presenter"
end
2. プレゼンターモジュールの実装
app/presenters
配下にモジュールを実装する。
module UserPresenter
# メールアドレスを伏せ字にするヘルパーメソッド
def masked_email
email.sub(/\A(?<init>.).+(?<tld>\.[^.]+)\z/, '\k<init>****@****\k<tld>')
end
end
3. ビューでの利用の仕方
class UserController < ApplicationController
def show
@user = User.find(params[:id])
end
end
<div class="user-profile">
<h1><%= @user.name %></h1>
<%# メールアドレスを伏せ字で表示 %>
<p>メールアドレス: <%= @user.masked_email %></p>
</div>
注意点
プレゼンターで実装したメソッドは基本的にはビューの中でしか利用できない。
ビューに渡したActiveRecoed::Relation
の実行結果の User モデルのインスタンスはメソッドを使用することができる。