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_address
和 cidr_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"))
執行個體公用方法
composed_of(part_id, options = {}) 連結
加入用於處理值物件的 getter 和 setter 方法:composed_of :address
會加入 address
和 address=(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) }
資料來源:顯示 | 在 GitHub 上
# 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