通过值对象,可以让你的模型和控制器不那么臃肿。
什么是 Value Objects
Value Object 是 Domain Driven Design (DDD) 的一个重要概念。
Entities vs Value Objects
For example, a Person could be an Object within our application. A person will have a name, email address and password as well as many other attributes. Within our database this person is represented by an id. This means that the person could change their name, email and password but it would still be the same person. When an object can change it’s attributes but remain the same object we call it an Entity. An Entity is mutable because it can change it’s attributes without changing the identity of the object. The Entity object will maintain the identity because it has an id in the database.
假设我们的应用程序需要跟踪用户当前的位置,每当用户登录的时候我们就创建一个新的 Location 对象。这个 Location 对象只储存经纬度,Location 对象就是 Value Object,因为我们不关注它究竟是哪一个实例,只关注经纬度属性。
当用户再次登录的时候,我们不需要改变原来的 Location 对象属性,而是简单的重新创建一个就行。Location 对象从创建到销毁的过程中都不会改变自己的属性,它是 immutable
的,这是 Value Object 的特征之一。
另一个重要的区别是,判断两个 Value Object 是否相等并不是基于 identity。以上面的例子来说,判断两个 Person 是否是同一个对象,是判断数据库 id,而判断两个 Location 是否相等只需要判断经纬度是否一致。
How to identify Value Objects
最简单区分 Entities 和 Value Object 的方法是看这个对象是否需要一个 id。而一个对象是否需要 id 主要取决于程序的上下文或者要看它是否需要被持久化。
A small simple object, like money or a date range, whose equality isn’t based on identity.
- 值对象是依赖于它们的值而不是身份(identity)的一种简单对象。
- 它们通常是不可变的。例如:Ruby 标准库中的 Date, URI, Pathname 等。
- Rails 应用程序中定义的特定域的值对象也是如此(下面的代码中的
ATTRS
)。从 ActiveRecords 中提取它们是常见的重构方法。
class Favorite < ActiveRecord::Base
ATTRS = [:name, :age]
end
下面是一个正确的例子:
class Money < Struct.new(:amount, :currency)
def amount=(other_amount)
Money.new(other_amount, currency)
end
end
usd = Money.new(10, 'USD')
usd2 = Money.new(10, 'USD')
usd.hash == usd2.hash # => true
usd == usd2 # => true
下面就是一个错误的例子:
Point = Value.new(:x, :y)
p = Point.new(1, 2)
p.x = 1
# => NoMethodError: undefined method x= for #<Point:0x00000100943788 @x=0, @y=1>
如何定义值对象
我们需要在值对象的类中实现 ==
或 <=>
方法,之后就可以根据他们的值来比较两个值对象是否相等。
- 值对象应该包含多个属性
- 属性在其生命中期中应该是不可见的
- 两个值对象是否相等,取决于它们的 value (以及其 type、unit)
好处
主要是为了简化系统,重构代码而使用值对象。
- 封装相关的逻辑放到一个类,遵从单一职责原则
- 通过分割出与特定变量相关联的逻辑,减少大类的体积
- 防止代码重复
- 它允许我们将行为与数据结合起来,并在不污染模型的情况下为数据添加功能
- 隔离之后,代码更容易测试
何时提取值对象
在 Rails 中,当你有一个属性或逻辑上互相关联的一小组属性,使用 Value Objects 特别方便。通常来说复杂的文本和数值都可被抽取成 Value Object。
例如:一个短消息系统,我需要处理电话号码的对象。在线商城系统需要一个 Money 类。Code Climate 有一个叫做 Rating
的 Value Object 用来表示 A - F 等级供给每个类和模块使用。最初我们使用 Ruby 的 String 实例来完成该功能,但是创建一个 Rating 的 Value Object 允许我把行为和数据结合起来,参考例1。
下面几种情况是提取值对象的明显特征:
- 总是需要携带一堆参数 Arguments
- 一个属性 attribute 跟着一个行为 behaviour
- 两个不可分割的属性值 attributes value 和单位 unit,比如:金额
- 可枚举的类
下面依次展示这 4 种情况。
1. Arguments together all the time
当你的代码中出现 “数据泥团” 的时候,就可以使用值对象了。比如我们很多方法都需要同时传递 start_date
和 end_date
。
class DateRange
attr_reader :start_date, :end_date
def initialize(start_date, end_date)
@start_date, @end_date = start_date, end_date
end
def include_date?(date)
date >= start_date && date <= end_date
end
def include_date_range?(date_range)
start_date <= date_range.start_date && end_date >= date_range.end_date
end
def overlap_date_range?(date_range)
start_date <= date_range.end_date && end_date >= date_range.start_date
end
def to_s
"from #{start_date.strftime('%d-%B-%Y')} to #{end_date.strftime('%d-%B-%Y')}"
end
end
class Event < ActiveRecord::Base
def date_range
DateRange.new(start_date, end_date)
end
def date_range=(date_range)
self.start_date = date_range.start_date
self.end_date = date_range.end_date
end
end
$ event = Event.create(name: 'Ruby conf', start_date: Date.today, end_date: Date.today + 1.days)
$ event.date_range #=> #<DateRange:0x007fd8760c2690 @start_date=Tue, 06 Jun 2017, @end_date=Fri, 16 Jun 2017>
$ event.date_range.include_date?(Date.today) #=> true
$ event.date_range.include_date_range?(DateRange.new(Date.today, Date.today + 2.days)) #=> false
$ event.date_range.include_date_range?(DateRange.new(Date.today, Date.today + 1.days)) #=> true
另一个简单的例子。
class Person < ActiveRecord::Base
def address
Address.new(address_city, address_state)
end
def address=(address)
self.address_city = address.city
self.address_state = address.state
end
end
class Address
attr_reader :city, :state
def initialize(city, state)
@city, @state = city, state
end
def ==(other_address)
city == other_address.city && state == other_address.state
end
end
$ gary = Person.create(name: "Gary")
$ gary.address_city = "Brooklyn"
$ gary.address_state = "NY"
$ gary.address #=> #<Address:0x007fcbfcce0188 @city="Brooklyn", @state="NY">
$ gary.address = Address.new("Brooklyn", "NY")
$ gary.address #=> #<Address:0x007fcbfa3b2e78 @city="Brooklyn", @state="NY">
2. One attribute with behaviour
当你的模型中有一个属性,该属性需要关联一些行为,而这些行为跟模型毫无关系。假设你有一些房间,你需要按温度给这些房间排序。或者找出比较冷的房间。
我们当然可以认为 Room 应该知道自己冷不冷,但是还是建议抽出一个值对象,并把 code?
之类的方法放在 Temperature 中。这样的好处是,我将来也可以问椅子和书桌冷不冷。
class Temperature
include Comparable
attr_reader :degrees
COLD = 20
HOT = 25
def initialize(degrees)
@degrees = degrees
end
def cold?
self < COLD
end
def hot?
self > HOT
end
def <=>(other)
degrees <=> other.degrees
end
def hash
degrees.hash
end
def to_s
"#{degrees} °C"
end
end
$ room_1 = Room.create(degrees: 10)
$ room_2 = Room.create(degrees: 20)
$ room_3 = Room.create(degrees: 30)
$ room_1.temperature.cold? #=> true
$ room_1.temperature.hot? #=> false
$ [room_1.temperature, Temperature.new(20), room_3.temperature, room_2.temperature].sort #=> [#<Temperature:0x007fe194378840 @degrees=10>, #<Temperature:0x007fe194378818 @degrees=20>, #<Temperature:0x007fe1943787c8 @degrees=20>, #<Temperature:0x007fe1943787f0 @degrees=30>]
$ [room_1.temperature, Temperature.new(20), room_3.temperature, room_2.temperature].uniq #=> [#<Temperature:0x007fe194361e88 @degrees=10>, #<Temperature:0x007fe194361e60 @degrees=20>, #<Temperature:0x007fe194361e38 @degrees=30>]
3. Two inseparable attributes value and unit
class Product < ActiveRecord::Base
def cost
Money.new(cents, currency)
end
def cost=(cost)
self.cents = cost.cents
self.currency = cost.currency.to_s
end
end
$ product = Product.create(cost: Money.new(500, "EUR"))
$ product.cost #=> #<Money fractional:500 currency:EUR>
$ product.cost.cents #=> 500
$ product.currency #=> "EUR"
4. Class enumerable
我们都写过下面这样的代码。
class Event < ActiveRecord::Base
SIZE = %w(
small
medium
big
)
end
这个属性和你模型的业务逻辑无关,而且当你有很多模型都有 size 属性的时候也很麻烦,所以可以用值对象。
class Size
SIZES = %w(small medium big)
attr_reader :size
def initialize(size)
@size = size
end
def self.to_select
SIZES.map{|c| [c.capitalize, c]}
end
def valid?
SIZES.include?(size)
end
def to_s
size.capitalize
end
end
重构
有一些方便我们使用的 gem,比如 Values 和 money。
我们是 Values gem 来重构一波。
class DateRange < Value.new(:start_date, :end_date)
def include_date?(date)
date >= start_date && date <= end_date
end
def include_date_range?(date_range)
start_date <= date_range.start_date && end_date >= date_range.end_date
end
def overlap_date_range?(date_range)
start_date <= date_range.end_date && end_date >= date_range.start_date
end
def to_s
"from #{start_date.strftime('%d-%B-%Y')} to #{end_date.strftime('%d-%B-%Y')}"
end
end
例子
例1
class Rating
include Comparable
def self.from_cost(cost)
if cost <= 2
new("A")
elsif cost <= 4
new("B")
elsif cost <= 8
new("C")
elsif cost <= 16
new("D")
else
new("F")
end
end
def initialize(letter)
@letter = letter
end
def better_than?(other)
self > other
end
def <=>(other)
other.to_s <=> to_s
end
def hash
@letter.hash
end
def eql?(other)
to_s == other.to_s
end
def to_s
@letter.to_s
end
end
class ConstantSnapshot < ActiveRecord::Base
# …
def rating
@rating ||= Rating.from_cost(cost)
end
end
例2
Grade Score 是储存了某些值的东西。这些值可能不会保留在数据库中,我们只在业务逻辑中使用到它。值对象可能是:日期、金额、字符串。
# app/lib/report_card.rb
class ReportCard
attr_accessor :grades
def initialize(attributes = {})
@scores = attributes[:scores]
@grades ||= grade_scores
end
private
def grade_scores
@scores.map do |score|
grade_score(score)
end
end
def grade_score(score)
if score < 60
'F'
elsif score < 70
'D'
elsif score < 80
'C'
elsif score < 90
'B'
else
'A'
end
end
end
ReportCard
的 grade_scores
这部分很逻辑很多,我们可以把这些逻辑抽取到一个单独的 Grade
对象:
# app/lib/grade.rb
class Grade
attr_reader :score
def initialize(score)
@score = score
end
def letter
grade_score(score)
end
private
def grade_score(score)
if score < 60
'F'
elsif score < 70
'D'
elsif score < 80
'C'
elsif score < 90
'B'
else
'A'
end
end
end
# app/lib/report_card.rb
class ReportCard
attr_accessor :grades
def initialize(attributes = {})
@scores = attributes[:scores]
@grades ||= grade_scores
end
private
def grade_scores
@scores.map do |score|
Grade.new(score).letter
end
end
end
相关文章