跳至内容 跳至搜索

活动记录聚合

活动记录通过一个名为 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

值对象也可以由多个属性组成,例如地址的情况。映射的顺序将决定参数的顺序。

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 美元的资金对象。两个都表示 5 美元的资金对象应该相等(通过 Comparable 中的 ==<=> 等方法,如果排名有意义)。这与实体对象不同,实体对象的相等性由标识确定。诸如 Customer 的实体类可以很容易地有两个不同的对象,这两个对象都在 Hyancintvej 上有地址。实体标识由对象或关系唯一标识符(例如主键)确定。普通的 ActiveRecord::Base 类是实体对象。

将值对象视为不可变对象也很重要。不允许资金对象在创建后更改其金额。相反,创建一个具有新值的资金对象。Money#exchange_to 方法就是这种情况的一个示例。它返回一个新值对象,而不是更改其自身的值。活动记录不会保留通过编写器方法以外的方式更改的值对象。

不可变的要求由 Active Record 强制执行,方法是冻结任何指定为值对象的任何对象。之后尝试更改它将导致 RuntimeError

c2.com/cgi/wiki?ValueObject 上阅读有关值对象的更多信息,并在 c2.com/cgi/wiki?ValueObjectsShouldBeImmutable 上了解不保持值对象不可变的危险

自定义构造函数和转换器

默认情况下,值对象通过调用值类的 new 构造函数来初始化,该构造函数将每个映射属性(按 :mapping 选项指定的顺序)作为参数传递。如果值类不支持此约定,则 composed_of 允许指定自定义构造函数。

当新值分配给值对象时,默认假设新值是值类的实例。指定自定义转换器允许在必要时将新值自动转换为值类的实例。

例如,NetworkResource 模型具有应该使用 NetAddr::CIDR 值类(www.rubydoc.info/gems/netaddr/1.5.0/NetAddr/CIDR)聚合的 network_addresscidr_range 属性。值类的构造函数称为 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 = {})

添加用于操作值对象的读取器和写入器方法:composed_of :address 添加 addressaddress=(new_address) 方法。

选项是

  • :class_name - 指定关联的类名。仅在无法从部分 ID 推断出该名称时才使用它。因此,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