This page looks best with JavaScript enabled
⚠️

『パーフェクトRuby on Rails』11章

 ·   ·  ☕ 6 分で読めます
✏️

11章

ドメイン
アプリケーションが対象とする問題領域のこと

モデリング
ドメインを分析して構成概念を抽出すること

ドメインモデル
モデリングによって得られた概念
ドメインモデルは、その概念に関連する属性と振る舞いをもったオブジェクトとして定義される。
振る舞いのことをドメインロジック・ビジネスロジックと呼ぶ(?)

Railsのモデルはドメインモデルとドメインロジックを実装するレイヤー。

ドメインモデルは単にドメインの概念を抽出しただけのものなので、リレーショナルデータベースなどのデータストアにドメインモデルの状態をデータとして保存する。
Railsではアクティブレコード(ActiveRecordに非ず)と呼ばれるアーキテクチャパターンを用いてデータベースのレコードとオブジェクトを対応付けている。

アクティブレコード

データの取得・保存処理とドメインロジックをあわせてカプセル化するアーキテクチャパターン
Railsでは、データベースのテーブルをクラス、レコードをクラスのインスタンス、カラムをインスタンスの属性に対応させる。
単純で理解しやすい一方、単体では複雑なドメインロジックを表現しきれないという欠点がある。

  • ドメイン固有の「値」に関するロジック
  • 複数のオブジェクトを組み合わせて表現するロジック

値オブジェクト

エンティティ
属性の値に関わらず一意に識別されるオブジェクト
「1月1日生まれの山田太郎」2人が同一人物ということにはならんやろ??
「山田花子」が結婚して「佐藤花子」になっても別人ということではないやろ??

識別子
エンティティが持つ同一性を識別するための情報

Railsのモデルのインスタンスは「id」を識別子とするエンティティです。

属性の型はデータベースのデータ型をRubyのデータ型へ変換したものということ(Date, Integer, Stringなど)
この中にはドメインの重要な概念が含まれていることがある。このとき、関連する振る舞いと属性を値オブジェクトとして定義することで、ドメインロジックをうまく表現できる。

例えば……
phone_numberという文字列属性を持つUserモデルのとき、phone_numberが携帯かどうか判別したいとする。
そのmobile_phone_number?メソッドをuserモデルで定義するのはなーんか違和感。そのロジックはphone_numberという値として切り出したいよね。

(´-`).。oO(phone_numberはUserに紐づくわけではなく、例えばCompanyモデルでも扱うかもしれないよなあ)

実装例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class PhoneNumber
  attr_reader :value
  delegate :hash, to: :value

  def initialize(value)
    raise "Phone number is invalid" unless value.match?(/|A0\d{9,10}|z/)
    @value = value.frozen? ? value : value.dup.freeze
  end

  def ==(other)
    # PhoneNumberインスタンスが渡されることを想定
    self.class == other.class && value == other.value
  end
  alias eql? ==

  def mobile?
    value.match?(/\A0[7-9]0\d{8}\z/)
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class User < ApplicationRecord
  def phone_number
    @phone_number ||= PhoneNumber.new(self[:phone_number])
  end

  def phone_number=(new_phone_number)
    self[:phone_number] = new_phone_number.value
    @phone_number = new_phone_number
  end
end

phone_numberメソッドを上書きしたので、userインスタンスのphone_numberはself[:phone_number]で取得する。
[:phone_number]で取得できるのはActiveRecordによるところっぽい。いまいちわからなかった)
rails/read.rb at master · rails/rails

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
irb(main):006:0> user = User.last
   (0.1ms)  SELECT sqlite_version(*)
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 4, name: "aaa", created_at: "2020-09-15 09:26:10", updated_at: "2020-09-15 09:26:10", phone_number: "000-1111-1111">
irb(main):007:0> user.phone_number
=> #<PhoneNumber:0x00007fde1491d238 @value="000-1111-1111">
irb(main):008:0> user[:phone_number]
=> "000-1111-1111"
irb(main):009:0> user.phone_number = PhoneNumber.new('000-0000-0000')
=> #<PhoneNumber:0x00007fde14044fa8 @value="000-0000-0000">
irb(main):010:0> user.name
=> "aaa"
irb(main):011:0> user[:name]
=> "aaa"

user.phone_number=の引数にはPhoneNumberのインスタンスを渡す。文字列が変わるたびに新しいPhoneNumberを作成する。なので、インスタンス作成時にvalue.dup.freezeをしている。

composed_ofによる値オブジェクトの利用

上記のコードは以下のように書き換えられる。

1
2
3
class User < ApplicationRecord
  compose_of :phone_number, mapping: %w[phone_number value]
end

第一引数は値オブジェクトを用いる属性名。
mappingオプションで、["モデルの属性名", "値オブジェクトの属性名"]の対応を配列で指定する。複数の場合は二要素配列[["", ""], ["", ""]]こんな感じ。

1
2
3
4
5
composed_of :ip_address,
            class_name: "IPAddr",
            mapping: %w[ip to_i],
            constructor: Proc.new { |ip| IPAddr.new(ip, Socket::AF_INET) },
            converter: Proc.new { |ip| ip.is_a?(Integer) ? IPAddr.new(ip, Socket::AF_INET) : IPAddr.new(ip.to_s) }
オプション名 内容
:class_name 値オブジェクトのクラス名を文字列で指定する。指定しない場合、属性名をclassyしたクラス名が用いられる。
:mapping モデルと値オブジェクトの属性の対応関係を指定する。
:allow_nil nilを許可するかどうかtrueかfalseで指定する。trueの場合、対応するモデルの属性の値がすべてnilの場合、値オブジェクトの参照もnilとなる。デフォルトはfalseで、この場合、nilが値オブジェクトのコンストラクタに渡される。
:constructor 値オブジェクトのコンストラクタを指定する。値オブジェクトのクラスメソッド名のシンボルか、モデルの属性の値を受け取って値オブジェクトを初期化するProcオブジェクトを指定する。デフォルトは:new
:converter 値オブジェクトのクラスとは異なるクラスのインスタンスが代入された場合の変換方法を指定する。値オブジェクトのクラスメソッド名のシンボルか、代入された値を受け取って値オブジェクトに変換するProcオブジェクトを指定する。デフォルトはnilで、この場合変換は行われない。

composed_of | Railsドキュメント

サービスオブジェクト

複数のオブジェクトを組み合わせて表現するロジックなどを独立したオブジェクトとして定義する。
「ユーザー認証サービス」「合計金額計算サービス」のように、ドメインロジックそのものを指す名前がつけられる。

サービスオブジェクトは自身の動作を変更するような状態を持たない。=入力が同じなら常に同じ結果を返す。インスタンス変数とか変更しない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

name で指定したクラスメソッド (クラスの特異メソッド) の可視性を private に変更します。
Module#private_class_method (Ruby 2.7.0 リファレンスマニュアル)

注意点

モデルに実装すべきロジックまで実装しない

引数のオブジェクトの属性を直接参照・更新しているような箇所は、その操作を対応するモデルのインスタンスメソッドとして定義する。
withdrawとか。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class BankAccount < ApplicationRecord
  composed_of :balance, class_name: "Money", mapping: [%w[balance amount], %w[currency currency]]

  def deposit(money)
    with_lock { update!(balance: balance + money) }
  end

  def withdraw
    with_lock do
      raise "Withdrawal amount must not be greater than balance" if money > balance
    end
  end
end

イベントの見落としがないか確認する

送金・受け取りの記録を行う場合、データベースの中間テーブルが存在することになる。
送金イベントに注目するとテーブルが必要になるため、ActiveRecordに送金ロジックを書けばいいので、サービスクラスは不要になる。

欠点:テストがしづらい

サービスオブジェクトでは、ロジックの組み立てに必要なオブジェクトを外から渡すため、テストダブルに置き換えれば簡単にテストできる。
イベントに着目すると、モデルのコールバックにロジックが実装されるので、本物のオブジェクトを使うことになる。

実装ルールについて

1つのドメインロジックを指すものにする。
クラスメソッドは1つにする。名前はcall, run, execute, performなど……。
ここではProc.callに合わせてcallにしている。
(自分が見たのもcallが多い印象)

Share on

END
END
@aiandrox

 
目次