在 Rails 中为域逻辑使用回调的优缺点
Posted
技术标签:
【中文标题】在 Rails 中为域逻辑使用回调的优缺点【英文标题】:Pros and cons of using callbacks for domain logic in Rails 【发布时间】:2012-06-17 20:03:45 【问题描述】:您认为将回调用于域逻辑的优缺点是什么? (我说的是 Rails 和/或 Ruby 项目的上下文。)
为了开始讨论,我想提一下Mongoid page on callbacks中的这句话:
对域逻辑使用回调是一种糟糕的设计实践,并可能导致 当链中的回调停止时难以调试的意外错误 执行。我们建议仅将它们用于横切 问题,例如排队后台作业。
我很想听听这一主张背后的论据或辩护。它是否仅适用于 Mongo 支持的应用程序?还是打算跨数据库技术应用?
看起来The Ruby on Rails Guide to ActiveRecord Validations and Callbacks 可能不同意,至少在关系数据库方面是这样。举个例子:
class Order < ActiveRecord::Base
before_save :normalize_card_number, :if => :paid_with_card?
end
在我看来,这是一个实现领域逻辑的简单回调的完美示例。它似乎又快又有效。如果我接受 Mongoid 的建议,这个逻辑会去哪里?
【问题讨论】:
关于这些主题的一些讨论可能会非常两极分化。当然,我不认为好的软件设计应该退化为相对主义(例如“你认为好的就足够了”。)我在建议中寻找的一个关键模式是:“如果你想实现 [插入目标这里]——这就是为什么你应该关心[插入令人信服的理由]——一个值得考虑的好策略是:_____。” 【参考方案1】:我真的很喜欢对小班使用回调。我发现它使一个类非常易读,例如像
before_save :ensure_values_are_calculated_correctly
before_save :down_case_titles
before_save :update_cache
现在发生的事情一目了然。
我什至觉得这可以测试;我可以测试方法本身是否有效,并且可以分别测试每个回调。
我坚信类中的回调应该只用于属于该类的方面。如果您想在保存时触发事件,例如如果对象处于某种状态或记录,则发送邮件,我会使用Observer。这尊重单一责任原则。
回调
回调的优势:
一切都集中在一个地方,让一切变得简单 非常易读的代码回调的缺点:
由于一切都在一个地方,因此很容易打破单一责任原则 可能适合重课 如果一个回调失败会发生什么?它仍然遵循链条吗?提示:确保你的回调永远不会失败,否则将模型的状态设置为无效。观察者
观察者的优势
非常简洁的代码,您可以为同一个类创建多个观察者,每个观察者做不同的事情 观察者的执行不耦合观察者的劣势
起初,行为的触发方式可能很奇怪(看看观察者!)结论
简而言之:
对简单的模型相关内容(计算值、默认值、验证)使用回调 使用观察者进行更多的跨领域行为(例如发送邮件、传播状态……)和往常一样:所有建议都必须持保留态度。但根据我的经验,观察者的扩展性非常好(而且鲜为人知)。
希望这会有所帮助。
【讨论】:
优秀的答案。很多关于优缺点和用例的详细信息,非常有帮助!【参考方案2】:我不认为答案太复杂。
如果您打算构建一个具有确定性行为的系统,那么处理规范化等数据相关事物的回调是可以的,处理诸如发送确认电子邮件等业务逻辑的回调就不行了.
OOP 以紧急行为作为最佳实践而得到普及1,根据我的经验,Rails 似乎同意这一点。很多人 including the guy who introduced MVC 认为这会给运行时行为是确定性且提前众所周知的应用程序带来不必要的痛苦。
如果您同意 OO 紧急行为的做法,那么将行为耦合到数据对象图的活动记录模式就没什么大不了的。如果(像我一样)您看到/感受到理解、调试和修改此类紧急系统的痛苦,您会想尽一切努力使行为更具确定性。
现在,如何在松散耦合和确定性行为之间取得适当的平衡来设计 OO 系统?如果你知道答案,写一本书,我会买的! DCI、Domain-driven design 和更普遍的 GoF patterns 是一个开始:-)
-
http://www.artima.com/articles/dci_vision.html,“我们哪里出错了?”。不是主要来源,但与我对野外假设的一般理解和主观经验一致。
【讨论】:
您能否详细说明“OOP 在设计时将紧急行为作为最佳实践”?那句话是你的在它上面旋转——还是它实际上是由面向对象编程的创始人明确表达的?你有参考分享吗? 我在这里依靠 Trygve Reenskaug,但他足够可信。从答案中的参考资料:“我们可以将我们未能捕捉到最终用户的行为心理模型的大部分原因追溯到一种在 1980 年代和 1990 年代上半叶蓬勃发展的对象神话。......这个词那天是:本地思考,全球行为会自行解决。”我已经隐含地考虑到了这一点,其他系统也是如此(尤其是 Rails)。【参考方案3】:编辑:我在这里结合了一些人的建议的答案。
总结
基于一些阅读和思考,我得出了一些我认为的(暂定的)陈述:
“对域逻辑使用回调是一种糟糕的设计实践”的说法是错误的,正如所写的那样。它夸大了这一点。回调可以是域逻辑的好地方,使用得当。问题不应该是如果领域模型逻辑应该进入回调,而是什么样的领域逻辑才有意义。
“对域逻辑使用回调...可能会导致在链中的回调暂停执行时难以调试的意外错误”语句为真。
是的,回调可能会导致影响其他对象的连锁反应。就无法测试的程度而言,这是一个问题。
是的,您应该能够测试您的业务逻辑,而无需将对象保存到数据库中。
如果某个对象的回调过于臃肿而超出您的感知能力,则可以考虑其他设计,包括 (a) 观察者或 (b) 辅助类。这些可以干净地处理多对象操作。
“仅将 [回调] 用于横切关注点,例如排队后台作业”的建议很有趣,但被夸大了。 (我查看了cross-cutting concerns,看看我是否可能忽略了什么。)
我还想分享我对我读过的关于这个问题的博客文章的一些反应:
对“ActiveRecord 的回调毁了我的生活”的反应
Mathias Meyer 2010 年的帖子ActiveRecord's Callbacks Ruined My Life 提供了一种观点。他写道:
每当我开始在 Rails 应用程序中向模型添加验证和回调时 [...] 就感觉不对劲。感觉就像我添加了不应该存在的代码,这使一切变得更加复杂,并将显式代码转换为隐式代码。
我发现最后一个声明“将显式代码转换为隐式代码”是一个不公平的期望。我们在这里谈论的是Rails,对吧?!如此多的附加值是关于 Rails “神奇”地做事,例如无需开发人员明确执行。享受 Rails 的成果却又批评隐式代码,这不是很奇怪吗?
仅根据对象的持久状态运行的代码。
我同意这听起来令人讨厌。
难以测试的代码,因为您需要保存一个对象来测试部分业务逻辑。
是的,这会使测试变得缓慢而困难。
所以,总而言之,我认为 Mathias 给火上加了一些有趣的燃料,尽管我并不觉得所有这些都引人注目。
对“疯狂、异端和令人敬畏:我编写 Rails 应用程序的方式”的反应
在 James Golick 2010 年的帖子Crazy, Heretical, and Awesome: The Way I Write Rails Apps 中,他写道:
此外,将所有业务逻辑与持久性对象耦合可能会产生奇怪的副作用。在我们的应用程序中,当创建某些内容时,after_create 回调会在日志中生成一个条目,用于生成活动提要。如果我想在不记录的情况下创建一个对象——比如说,在控制台中?我不能。保存和记录是永远的结合。
后来,他找到了它的根源:
解决方案实际上非常简单。对这个问题的简单解释是我们违反了单一职责原则。因此,我们将使用标准的面向对象技术来分离模型逻辑的关注点。
我非常感谢他通过告诉你什么时候适用什么时候不适用来缓和他的建议:
事实是,在一个简单的应用程序中,肥胖的持久性对象可能永远不会受到伤害。当事情变得比 CRUD 操作更复杂一点时,这些事情开始堆积起来并成为痛点。
【讨论】:
这是我从多个角度综合得出的答案。【参考方案4】:Avdi Grimm 在他的书中Object On Rails 中有一些很好的例子。
您会发现here 和here 为什么他不选择回调选项,以及如何通过覆盖相应的ActiveRecord 方法来摆脱这种情况。
在你的情况下,你最终会得到类似的东西:
class Order < ActiveRecord::Base
def save(*)
normalize_card_number if paid_with_card?
super
end
private
def normalize_card_number
#do something and assign self.card_number = "XXX"
end
end
[更新您的评论“这仍然是回调”]
当我们谈到域逻辑的回调时,我理解 ActiveRecord
回调,如果您认为 Mongoid 引用者的引用指向其他内容,请纠正我,如果在某处我没有找到“回调设计”。
我认为ActiveRecord
回调在大多数(整个?)部分中只不过是我之前的示例可以摆脱的语法糖。
首先,我同意这个回调方法隐藏了它们背后的逻辑:对于不熟悉ActiveRecord
的人来说,他必须学习它才能理解代码,上面的版本很容易理解和测试.
ActiveRecord
回调他的“常见用法”或它们可以产生的“解耦感觉”可能是最糟糕的。回调版本一开始可能看起来不错,但随着您将添加更多回调,将更难以理解您的代码(它们以什么顺序加载,哪个可能会停止执行流程等)并对其进行测试(您的域逻辑与ActiveRecord
持久性逻辑相结合)。
当我阅读下面的示例时,我觉得这段代码很糟糕,它很臭。我相信如果你在做 TDD/BDD,你可能不会得到这个代码,如果你忘记了 ActiveRecord
,我想你会简单地编写 card_number=
方法。我希望这个例子足够好,不要直接选择回调选项,先考虑设计。
关于 MongoId 的引用,我想知道为什么他们建议不要将回调用于域逻辑,而是将其用于排队后台作业。我认为排队后台作业可能是域逻辑的一部分,有时可能会更好地设计与回调以外的其他东西(比方说观察者)。
最后,从面向对象编程设计的角度来看,关于如何在 Rail 中使用/实现 ActiveRecord 存在一些批评,answer 包含有关它的良好信息,您会更容易找到。您可能还想检查 datamapper design pattern / ruby implementation project 这可能是 ActiveRecord 的替代品(但要好多少)并且没有他的弱点。
【讨论】:
特定的代码示例只是将代码从“before_save”回调中移出到保存方法中。好的,你“抓住了我”......从技术上讲,你没有使用回调,但实际上你仍然是。明白我的意思了吗? 阿德里安,谢谢!你提到的问题,Does the ActiveRecord pattern follow/encourage the SOLID design principles? 有一个很好的引用:“这导致了一个两难境地。Active Record 真正落在哪一边?它是一个对象吗?还是一个数据结构?” Jim Weirich 在他的SOLID Ruby Talk 在 2009 年 Ruby 大会的结尾向观众提问:“ActiveRecord 对象实现了域概念和持久性概念。这是否违反了 SRP(单一责任原则) )?”观众同意它确实违反了 SRP。吉姆问这是否让他们感到困扰。许多观众说是的。为什么?它使测试变得更加困难。它使持久性对象变得更重。【参考方案5】:在我看来,使用回调的最佳场景是触发它的方法与回调本身执行的内容无关。例如,一个好的before_save :do_something
不应该执行与保存相关的代码。这更像是一个观察者应该如何工作。
人们倾向于只使用回调来干燥他们的代码。这还不错,但会导致代码复杂且难以维护,因为如果您没有注意到调用了回调,阅读save
方法并不能告诉您它所做的一切。我认为显式代码很重要(尤其是在 Ruby 和 Rails 中,发生了如此多的魔法)。
与保存相关的所有内容都应该在save
方法中。例如,如果回调是为了确保用户经过身份验证,与保存无关,那么这是一个很好的回调场景。
【讨论】:
【参考方案6】:这里的这个问题 (Ignore the validation failures in rspec) 是为什么不在回调中加入逻辑的一个很好的理由:可测试性。
随着时间的推移,您的代码可能倾向于产生许多依赖项,此时您开始将unless Rails.test?
添加到您的方法中。
我建议只在您的 before_validation
回调中保留格式化逻辑,并将涉及多个类的内容移出到 Service 对象中。
因此,在您的情况下,我会将 normalize_card_number 移至 before_validation,然后您可以验证卡号是否已标准化。
但如果您需要在某处创建 PaymentProfile,我会在另一个服务工作流对象中执行此操作:
class CreatesCustomer
def create(new_customer_object)
return new_customer_object unless new_customer_object.valid?
ActiveRecord::Base.transaction do
new_customer_object.save!
PaymentProfile.create!(new_customer_object)
end
new_customer_object
end
end
然后您可以轻松地测试某些条件,例如它是否无效、保存是否未发生或支付网关是否引发异常。
【讨论】:
以上是关于在 Rails 中为域逻辑使用回调的优缺点的主要内容,如果未能解决你的问题,请参考以下文章
在 NetBeans 中为 Android 开发有啥缺点吗?