第1部分 解耦持久性
抽象
域模型是用于软件开发的有效工具。它可以用来表达真正复杂的业务逻辑,并验证和验证涉众之间对领域的理解。在Rails中构建富域模型很困难。主要是由于Active Record不能与域模型方法配合使用。
解决此问题的一种方法是使用实现数据映射器模式的ORM。不幸的是,没有针对Ruby的生产就绪的ORM。DataMapper 2将是第一个。
另一种方法是将Active Record用作持久性机制,并在其之上构建丰富的域模型。这就是我将在本文中讨论的内容。
活动记录问题
首先,让我们看一下使用扩展Active Record的类来表达域概念所引起的一些问题:
- 该类知道Active Record。因此,您需要加载Active Record来运行测试。
- 该类的实例负责保存和更新自身。它使嘲笑和存根变得更加困难。
- 每个实例都公开诸如“ update_attribute!”之类的低级方法。它们给您太多改变对象内部状态的能力。电源损坏。这就是为什么您会在许多地方看到“ update_attributes”的原因。
- “具有许多”关联允许绕过聚合根。功率太多,众所周知,它会损坏。
- 每个实例负责验证自己。很难测试。最重要的是,它使验证更难以编写。
解
按照Rich Hickey的分裂原则,我看到的最好的解决方案是将每个Active Record类都分为三个不同的类:
- 实体
- 数据对象
- 资料库
这里的核心思想是,每个实体在实例化时都被赋予一个数据对象。实体委派其字段对数据对象的访问。数据对象不必是Active Record对象。您始终可以提供存根或OpenStruct。由于该实体是一个普通的旧红宝石对象,因此它不知道如何保存/验证/更新自身。它还不知道如何从数据库获取自身。
存储库负责从数据库中获取数据对象并构建实体。它还负责创建和更新实体。为了应付其职责,存储库必须知道如何将数据对象映射到实体。为此,将创建所有数据对象及其对应实体的注册表。
例
让我们看一下这种方法的实际应用。订单和商品是形成一个集合的两个实体。这是我们可以用来将它们存储在数据库中的模式:
create_table "orders", :force => true do |t|
t.decimal "amount", :null => false
t.date "deliver_at"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end
create_table "items", :force => true do |t|
t.string "name", :null => false
t.decimal "amount", :null => false
t.integer "order_id", :null => false
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end如您所见,我们不必为我们的方法调整架构。
所有实体都是普通的Ruby对象,包括模型模块:
class Order
include Model
# Delegates id, id=, amount, amount=, deliver_at, deliver_at to the data object
fields :id, :amount, :deviver_at
# ...
end
class Item
include Model
fields :id, :amount, :name
end其中“模型”模块定义为:
module Model
def self.included(base)
base.extend ClassMethods
end
attr_accessor :_data
def initialize _data = _new_instance
if _data.kind_of?(Hash)
@_data = _new_instance _data
else
@_data = _data
end
end
protected
#...
def _new_instance hash = {}
# Using the registry to get the correspondent data class
Registry.data_class_for(self.class).new hash
end
module ClassMethods
def fields *field_names
field_names.each do |field_name|
self.delegate field_name, to: :_data
self.delegate "#{field_name}=", to: :_data
end
end
end
end由于Order和Item类形成一个集合,因此我们只能通过其订单获得对项目的引用。因此,我们只需要实现一个存储库:
module OrderRepository
extend Repository
# All ActiveRecord classes are defined in the repository.
class OrderData < ActiveRecord::Base
self.table_name = "orders"
attr_accessible :amount, :deliver_at
validates :amount, numericality: true
has_many :items, class_name: 'OrderRepository::ItemData', foreign_key: 'order_id'
end
class ItemData < ActiveRecord::Base
self.table_name = "items"
attr_accessible :amount, :name
validates :amount, numericality: true
validates :name, presence: true
end
# Mappings between models and data objects are defined here.
# "root:true" means that the OrderData class will be used
# when working with this repository.
set_model_class Order, for: OrderData, root: true
set_model_class Item, for: ItemData
def self.find_by_amount amount
where(amount: amount)
end
end存储库模块定义为:
module Repository
def persist model
data(model).save!
end
def find id
model_class.new(data_class.find id)
end
protected
def where attrs
# We search the database using the root data class and wrap
# the results into the instances of the model class.
data_class.where(attrs).map do |data|
model_class.new data
end
end
def data model
model._data
end
def set_model_class model_class, options
raise "Data class is not provided" unless options[:for]
Registry.associate(model_class, options[:for])
if options[:root]
singleton_class.send :define_method, :data_class do
options[:for]
end
singleton_class.send :define_method, :model_class do
model_class
end
end
end
end现在,让我们看看如何在应用程序中使用所有这些类。
test "using a data object directly (maybe used for reporting purposes)" do
order = OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today
order.items.create! amount: 6, name: 'Item 1'
order.items.create! amount: 4, name: 'Item 2'
assert_equal 2, order.reload.items.size
assert_equal 6, order.items.first.amount
end
test "using a saved model" do
order_data = OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today
order = Order.new(order_data)
order.amount = 15
assert_equal 15, order.amount
end
test "creating a new model" do
order = Order.new
order.amount = 15
assert_equal 15, order.amount
end
test "using hash to initialize a model" do
order = Order.new amount: 15
assert_equal 15, order.amount
end
test "using a repository to fetch models from the database" do
OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today
orders = OrderRepository.find_by_amount 10
assert_equal 10, orders.first.amount
end
test "persisting models" do
order = Order.new amount: 10
OrderRepository.persist order
assert order.id.present?
assert_equal 10, order.amount
end
test "using a struct instead of a data object (can be used for testing)" do
order = Order.new OpenStruct.new
order.amount = 99
assert_equal 99, order.amount
end社团协会
构建富域模型的一个重要方面尚未涉及。聚合根及其子代之间的关联如何管理?我们如何访问物品?
最简单的方法是使用活动记录关联自己构建Item数组。
class Order
include Model
fields :id, :amount, :deliver_at
def items
_data.items.map{|i| Item.new i}
end
def add_item attrs
Item.new(_data.items.new attrs))
end
end这里的问题是每个人都被迫使用_data变量,这确实是不可取的。通过添加Model模块的collection和wrap方法,我们可以提供对数据对象的受控访问器。
module Model
# Returns a rails has_many.
def collection name
_data.send(name)
end
# Wraps a collection of items into instances of the model class.
def wrap collection
return [] if collection.empty?
model_class = Registry.model_class_for(collection.first.class)
collection.map{|c| model_class.new c}
end
end使用收集和包装的订单:
class Order
include Model
def items
wrap(collection :items)
end
def add_item attrs
wrap(collection(:items).new attrs)
end
end尽管这些变化乍看起来似乎并不重要,但它们至关重要。不再需要访问_data变量。最重要的是,我们不必自己创建Item的实例。
但是收集和包装方法只是最低限度的要求。可以轻松想象我们可以在它们之上添加的语法糖。
module Model
module ClassMethods
def collections *collection_names
collection_names.each do |collection_name|
define_method collection_name do
wrap(collection collection_name)
end
end
end
end
end
class Order
include Model
fields :id, :amount, :deliver_at
collections :items
def add_item attrs
wrap(collection(:items).new attrs)
end
end现在,让我们看看如何在应用程序中使用它:
test "using a saved aggregate with children" do
order_data = OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today
order_data.items.create! amount: 6, name: 'Item 1'
order = Order.new order_data
assert_equal 6, order.items.first.amount
end
test "persisting an aggregate with children" do
order = Order.new amount: 10
order.add_item name: 'item1', amount: 5
OrderRepository.persist order
from_db = OrderRepository.find(order.id)
assert_equal 5, from_db.items.first.amount
end验证方式
由于数据对象是隐藏的,并且不应由客户端代码直接访问,因此我们需要更改运行验证的方式。有很多可用的选项,其中之一是:
module DataValidator
def self.validate model
data = model._data
data.valid?
data.errors.full_messages
end
end这就是您在代码中使用它的方式:
test "using data validation for a saved model" do
order_data = OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today
order = Order.new(order_data)
assert_equal [], DataValidator.validate(order)
end
test "using data validation for a new model" do
order = Order.new amount: 10
assert_equal [], DataValidator.validate(order)
end
您不必返回字符串数组。它可以是哈希,甚至可以是特殊对象。这里的想法是将实体与它们的验证分开。再次,通过将事物分开,我们得到了更好的设计。为什么?一方面,我们可以根据用户设置在运行时进行验证。另一方面,我们可以一起验证一组对象,因此无需将错误从一个对象复制到另一个对象。
架构
将持久性与域模型分开会对应用程序的体系结构产生巨大影响。以下是传统的Rails应用程序体系结构。

如果我们将持久性分开,这就是我们得到的。

您不必成为“三个朋友”之一就可以看到传统的Rails应用程序体系结构的缺陷:域类取决于数据库和Rails。而第二张图所示的体系结构没有这些缺陷,这使我们能够保持域逻辑抽象和框架不可知。
我们得到了什么
- 持久性逻辑已被提取到OrderRepository中。有一个单独的对象在许多方面都是有益的。例如,它可以简化或伪造,从而简化了测试。
- Order和Item实例不再负责保存或更新自己。唯一的方法是使用特定于域的方法。
- 低级方法(例如update_attributes!)不再公开。
- 没有ItemRepository,也没有has_many关联。结果是强制了聚合边界。
- 将验证分隔开可以实现更好的可组合性并简化测试。
参考来源:https://gist.github.com/vsavkin/3577292