在 Rails 中构建领域模型

第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
2021-10-20
0