0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パーフェクトRuby on Rails 12章 メモ・雑感(ActiveModel, フォームオブジェクト, プレゼンター)

Posted at

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::ModelActiveModelのモジュール群の一部がまとめられ、それらを組み合わせてコントローラ・ビューと連携するために必要なインターフェースを提供する。

  • 作成したオブジェクトをコントローラ・ビューのメソッドで利用可能
  • 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 モデルのバリデーション・コールバックを特定条件下で実行するというような形で各ユースケースのロジックを一つのモデルに実装できるが、モデルが肥大化する要因となる。
このような場合にフォームオブジェクトを導入することでフォーム入力を伴う各ユースケースごとのロジックをモデルから分離することができる。

フォームオブジェクトの作成

app/forms/user_registration_form.rb
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
app/controllers/user_registrations_controller.rb
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
app/views/user_registrations/new.html.erb
<%= form_with(model: @user_registration_form, url: user_registrations_path, local: true) do |form| %>
略
<% end %>
app/models/user.rb
class User < ApplicationRecord
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true

  # フォームオブジェクトを導入したことでユースケースのロジックをDBモデルに記述しなくても良い
end

フォームオブジェクトの設計ルール

  • ActiveModel::Modelを include する
  • クラス名は接尾辞を Form にする
  • savesearchなど、クラス名から推測可能な単一の処理用メソッドを定義する。失敗時に false を返す
  • バリデーションを実装する。ただし ActiveRecord モデルとの整合性に気をつける

ActiveModel::EachValidator で共通する属性にバリデーションを設定する

ActiveModel::EachValidatorはある一つの属性のバリデーションを定義する時に利用できる基底クラス。
上記の例においては共通の属性である email のバリデーションの記述がモデルとフォームオブジェクトで重複している。この共通ルールをActiveModel::EachValidatorを継承したクラスによって以下のように定義できる。

app/validators/email_validator.rb
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
EmailVallidatorを適用したapp/forms/user_registration_form.rb
class UserRegistrationForm
  # emailという属性に対して、上記のEmailVallidatorを有効化(true)
  validates :email, email: true
end
EmailVallidatorを適用したapp/models/user.rb
class User < ApplicationRecord
  validates :email, email: true, uniqueness: true
end

ActiveModel::Validator で複数の属性を組み合わせたバリデーションを設定する

ActiveModel::Validatorは複数の属性を組み合わせたより複雑なバリデーションを定義する場合に利用できる基底クラス。

app/validators/period_validator.rb
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

Gemfile
gem 'active_decorator'

プレゼンターを定義するモジュール名が#{モデル名}Presentorとなるように設定を変更する(デフォルトだと#{モデル名}Decoratorとなる)

config/initializers/active_decorator.rb
ActiveDecorator.configure do |config|
  config.decorator_suffix = "Presenter"
end

2. プレゼンターモジュールの実装

app/presenters配下にモジュールを実装する。

app/presenters/user_presenter.rb
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 モデルのインスタンスはメソッドを使用することができる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?