跳至內容 跳至搜尋

ActiveRecord 聚合

ActiveRecord 透過稱為 composed_of 的巨集類別方法來實作聚合,以代表屬性為值物件。它表達關係,例如「帳戶 [包含] 金錢 [以及其他項目]」或「人員 [包含] 地址」。每次呼叫巨集都會新增說明,說明如何從實體物件的屬性產生值物件(當實體初始化為新物件或從尋找現有物件時),以及如何將其轉換回屬性(當實體儲存到資料庫時)。

class Customer < ActiveRecord::Base
  composed_of :balance, class_name: "Money", mapping: { balance: :amount }
  composed_of :address, mapping: { address_street: :street, address_city: :city }
end

現在,客戶類別有下列方法來處理值物件

  • Customer#balance、Customer#balance=(money)

  • Customer#address、Customer#address=(address)

這些方法會操作類似以下所述的值物件

class Money
  include Comparable
  attr_reader :amount, :currency
  EXCHANGE_RATES = { "USD_TO_DKK" => 6 }

  def initialize(amount, currency = "USD")
    @amount, @currency = amount, currency
  end

  def exchange_to(other_currency)
    exchanged_amount = (amount * EXCHANGE_RATES["#{currency}_TO_#{other_currency}"]).floor
    Money.new(exchanged_amount, other_currency)
  end

  def ==(other_money)
    amount == other_money.amount && currency == other_money.currency
  end

  def <=>(other_money)
    if currency == other_money.currency
      amount <=> other_money.amount
    else
      amount <=> other_money.exchange_to(currency).amount
    end
  end
end

class Address
  attr_reader :street, :city
  def initialize(street, city)
    @street, @city = street, city
  end

  def close_to?(other_address)
    city == other_address.city
  end

  def ==(other_address)
    city == other_address.city && street == other_address.street
  end
end

現在可以透過值物件來存取資料庫中的屬性了。如果你選擇將組成命名與屬性名稱相同,那這將會是你存取該屬性的唯一方法。balance 屬性就是如此。你可以就像對待任何其他屬性一樣,和值物件互動

customer.balance = Money.new(20)     # sets the Money value object and the attribute
customer.balance                     # => Money value object
customer.balance.exchange_to("DKK")  # => Money.new(120, "DKK")
customer.balance > Money.new(10)     # => true
customer.balance == Money.new(20)    # => true
customer.balance < Money.new(5)      # => false

值物件也可以由多個屬性組成,例如 Address。映射的順序將決定參數的順序。

customer.address_street = "Hyancintvej"
customer.address_city   = "Copenhagen"
customer.address        # => Address.new("Hyancintvej", "Copenhagen")

customer.address = Address.new("May Street", "Chicago")
customer.address_street # => "May Street"
customer.address_city   # => "Chicago"

撰寫值物件

值物件是不可變且可互換的物件,代表指定的值,例如代表 5 美元的 Money 物件。代表 5 美元的 Money 物件應該相等(如果排名有意義,則透過 Comparable 中的 ==<=> 等方法)。這與等號由身分決定的實體物件不同。例如 Customer 等實體類別可能會很容易地有兩個不同的物件,且兩者在 Hyancintvej 上都有地址。實體身分由物件或關係式唯一識別碼(例如主索引鍵)決定。一般的 ActiveRecord::Base 類別就是實體物件。

將值物件當作不可變物件也非常重要。不要允許 Money 物件在產生後變更金額。相反地,產生新的 Money 物件,並具有新值。Money#exchange_to 方法就是此範例。它會傳回新的值物件,而不是變更自己的值。值物件已透過寫入器方法以外的方式變更,ActiveRecord 就不會保留這些值物件。

不可變條件會由 ActiveRecord 執行,方式為凍結任何指定為值物件的物件。之後嘗試變更該物件會產生 RuntimeError

深入了解 c2.com/cgi/wiki?ValueObject 上的值物件,以及 c2.com/cgi/wiki?ValueObjectsShouldBeImmutable 上有關不保留值物件為不可變的危險性

自訂建構函式和轉換器

預設上,會透過呼叫值類別的 new 建構函式來初始化值物件,傳遞每個已映射屬性作為引數,順序依照 :mapping 選項所指定。如果值類別不支援此慣例,則 composed_of 會允許指定自訂建構函式。

當新的值指定給值物件時,預設假設新的值是值類別的執行個體。指定自訂轉換器會允許必要時自動將新值轉換成值類別的執行個體。

例如,NetworkResource 模型擁有 network_addresscidr_range 屬性,應使用 NetAddr::CIDR 值類別 (www.rubydoc.info/gems/netaddr/1.5.0/NetAddr/CIDR) 彙總。值類別的建構函數稱為 create,並預期一個 CIDR 址字串作為參數。新的值可以使用另一個 NetAddr::CIDR 物件、字串或陣列指派給值物件。可以使用 :constructor:converter 選項來滿足這些需求

class NetworkResource < ActiveRecord::Base
  composed_of :cidr,
              class_name: 'NetAddr::CIDR',
              mapping: { network_address: :network, cidr_range: :bits },
              allow_nil: true,
              constructor: Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") },
              converter: Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) }
end

# This calls the :constructor
network_resource = NetworkResource.new(network_address: '192.168.0.1', cidr_range: 24)

# These assignments will both use the :converter
network_resource.cidr = [ '192.168.2.1', 8 ]
network_resource.cidr = '192.168.0.1/24'

# This assignment won't use the :converter as the value is already an instance of the value class
network_resource.cidr = NetAddr::CIDR.create('192.168.2.1/8')

# Saving and then reloading will use the :constructor on reload
network_resource.save
network_resource.reload

透過值物件來尋找記錄

針對模型指定 composed_of 關係後,可以透過在條件雜湊中指定值物件的執行個體,從資料庫載入記錄。以下範例尋找 address_street 等於「May Street」且 address_city 等於「Chicago」的所有客戶:

Customer.where(address: Address.new("May Street", "Chicago"))
方法
C
包含的模組

執行個體公用方法

composed_of(part_id, options = {})

加入用於處理值物件的 getter 和 setter 方法:composed_of :address 會加入 addressaddress=(new_address) 方法。

選項包含:

  • :class_name - 指定關聯的類別名稱。僅在無法從部分識別碼推斷出該名稱時使用。因此,composed_of :address 預設會連結到 Address 類別,但如果實際類別名稱是 CompanyAddress,則必須使用此選項來指定它。

  • :mapping - 指定實體屬性與值物件屬性的對應關係。每個對應關係都表示為一個鍵值對,其中鍵是實體屬性的名稱,而值是值物件中屬性的名稱。定義對應關係的順序會決定送往值類別建構函數的屬性順序。對應關係可以寫成雜湊或配對陣列。

  • :allow_nil - 指定當所有對應屬性都為 nil 時,不會實例化值物件。將值物件設為 nil 會將 nil 寫入所有對應的屬性。此選項預設為 false

  • :constructor - 指定建構函數方法名稱或 Proc,用於初始化值物件的符號。建構函數會傳遞所有對應的屬性(順序為在 :mapping 選項 中定義的順序)作為引數,並使用它們來實例化 :class_name 物件。預設值為 :new

  • :converter - 指定 :class_name 的類別方法或 Proc 的名稱,用於在將新值指派給值物件時呼叫。轉換器會傳遞在指派中使用的單一值,並且只有當新值不是 :class_name 的執行個體時才會呼叫轉換器。如果將 :allow_nil 設為 true,轉換器可以傳回 nil 來略過指派。

選項範例

composed_of :temperature, mapping: { reading: :celsius }
composed_of :balance, class_name: "Money", mapping: { balance: :amount }
composed_of :address, mapping: { address_street: :street, address_city: :city }
composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ]
composed_of :gps_location
composed_of :gps_location, allow_nil: true
composed_of :ip_address,
            class_name: 'IPAddr',
            mapping: { 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) }
# File activerecord/lib/active_record/aggregations.rb, line 225
def composed_of(part_id, options = {})
  options.assert_valid_keys(:class_name, :mapping, :allow_nil, :constructor, :converter)

  unless self < Aggregations
    include Aggregations
  end

  name        = part_id.id2name
  class_name  = options[:class_name]  || name.camelize
  mapping     = options[:mapping]     || [ name, name ]
  mapping     = [ mapping ] unless mapping.first.is_a?(Array)
  allow_nil   = options[:allow_nil]   || false
  constructor = options[:constructor] || :new
  converter   = options[:converter]

  reader_method(name, class_name, mapping, allow_nil, constructor)
  writer_method(name, class_name, mapping, allow_nil, converter)

  reflection = ActiveRecord::Reflection.create(:composed_of, part_id, nil, options, self)
  Reflection.add_aggregate_reflection self, part_id, reflection
end