单一职责原则 vs 贫血领域模型反模式

63

我参与一个注重单一职责原则的项目。我们有许多小型类,情况非常简单。然而,我们的领域模型很贫弱 - 模型类中没有任何行为,它们只是属性袋。这不是对我们设计的抱怨 - 它实际上似乎运行得非常好。

在设计评审过程中,每当系统添加新行为时,SRP就会被提出来,因此新行为通常会出现在新类中。这使得事情易于单元测试,但有时我感到困惑,因为它感觉像是把行为从相关的地方分离出来。

我试图改进自己对如何正确应用SRP的理解。对我来说,SRP似乎与将共享相同上下文的业务建模行为添加到一个对象中相矛盾,因为该对象不可避免地要么做多个相关的事情,要么只做一件事情,但知道多个业务规则,这些规则会改变其输出的形状。

如果是这样的话,那么最终结果就会是一个贫乏的领域模型,在我们的项目中肯定是这种情况。然而,贫乏的领域模型是一种反模式。

这两个想法是否可以共存?

编辑:与上下文相关的一些链接:

SRP - http://www.objectmentor.com/resources/articles/srp.pdf
贫乏的领域模型- http://martinfowler.com/bliki/AnemicDomainModel.html

我不是那种只喜欢找到一个先知并遵循他们说的话作为信条的开发人员。因此,我提供这些链接不是为了说明“这些就是规则”,而只是为了定义这两个概念的来源。


你自从发帖以来学到了很多吗?我已经有几年的开发经验,我也遇到了你描述的类似情况,即项目显示出贫血领域模型的迹象,但我不确定如何平衡OOD和SRP。 - CamHart
哇,好久不见了。我认为这反映了面向对象编程已经走过的漫长道路。在大学时代(很久以前),我学到的面向对象编程是关于将数据与相关操作封装在类中,并通过继承共享行为。现在,它是轻量级数据类,其他类对其进行操作,通过设计模式和依赖注入粘合在一起,而继承则成了一个四个字母的词。单一职责原则在后一种面向对象编程风格中运作得更好。 - Niall Connaughton
我学到了什么?将实体建模为丰富的类的原始想法可能会导致层次结构的混乱和打洞封装,以允许模型以新的方式弯曲。这与TDD不兼容,这在一定程度上推动了SRP和DI。大多数情况下都很好。我们失去了代码中直观的位置,可以查找系统如何使某些功能工作。现在,您必须知道涉及的所有操作类或扫描代码库。我们依赖IDE来查找用法、接口实现等。这些部分是应对SRP和DI带给我们的挑战的方法。 - Niall Connaughton
从高层次上看,当我们从丰富的领域模型向SRP转移时,我们已经交换了演变成难以分割的怪物的清晰模型,而这些怪物是由一组干净的小类组成的,最终会演变成没有人能够完全跟踪的一团乱麻。丰富的领域模型很诱人,尤其是在早期,但最终你会发现你永远无法完美地建立问题模型,并且它可能很快变得糟糕。SRP方法在长期内可能更加灵活,但两种方法都不会给你带来终身的幸福。 - Niall Connaughton
谢谢您迅速回复。听起来并没有万能的解决方案。我赞赏您指出的权衡。 - CamHart
是的,永远没有银弹。不过我想补充一点:如果你选择SRP,努力保持设计模式易于理解,并在不再需要或者太难以理解它们如何交互时,折叠冗余层/类。如果你选择丰富的领域模型类,请确保对它们进行测试,以强制其内部行为由公共接口驱动。如果将其公开以进行测试效果不佳,那么现在可能是创建新类的好时机。 - Niall Connaughton
7个回答

14

丰富的领域模型(Rich Domain Model,RDM)和单一职责原则(Single Responsibility Principle,SRP)并不一定相互矛盾。RDM更多地与SRP的一个非常专业的子类相矛盾——提倡“数据bean+所有业务逻辑在控制器类中”(DBABLICC)的模型。

如果您阅读马丁的SRP章节,您会发现他的调制解调器示例完全在领域层中,但将DataChannel和Connection概念抽象为单独的类。他保留了调制解调器本身作为包装器,因为这是客户端代码的有用抽象。这更多关于正确的(重)构而不仅仅是分层。内聚性和耦合性仍然是设计的基本原则。

最后,三个问题:

  • 正如马丁自己所指出的,很难看到不同的'变化原因'。YAGNI、敏捷等概念都反对预见未来的变化原因,因此我们不应该在没有明显变化原因的情况下发明铺张浪费的理由。我认为“过早地预期变化原因”是应用SRP时的真正风险,需要开发人员加以管理。

  • 进一步说,在先前的问题上,即使是正确的(但不必要的分析)应用SRP也可能导致不希望的复杂性。始终考虑到下一个要维护您的类的可怜家伙:将微不足道的行为精心抽象成自己的接口、基类和一行实现是否真正有助于他理解仅仅应该是一个单一类的东西?

  • 软件设计通常涉及在竞争的因素之间取得最佳平衡。例如,分层架构大多是SRP的一个很好的应用,但是,如果业务类的某个属性从boolean更改为enum,这将对所有层面(从数据库到领域、门面、Web服务再到GUI)产生连锁反应,这是否表示设计不良呢?不一定:它表明您的设计偏重于改变的某些方面而非其他方面。


2
"DBABLICC"并不是SRP的一种形式,而更多地是过程式程序员使用面向对象编程语言来合理化他们的过程式编程的方式。 - Sled

9
我必须说“是的”,但您必须正确执行SRP。如果相同的操作仅适用于一个类,则应将其放在该类中,不是吗?如果相同的操作适用于多个类,那么,如果您想遵循将数据和行为组合的OO模型,您会将操作放入基类中,对吧?
我怀疑从您的描述中,您最终得到的类基本上是操作的集合,因此您实际上重新创建了C风格的编码:结构体和模块。
从链接的SRP论文中可以看出:“SRP是最简单的原则之一,也是最难正确执行的原则之一。”

3
嗯...我希望你能说服我,但我还没有被说服。 优先使用组合而非继承 与你的论点相矛盾... - Mark Seemann
2
好的,我们可以交换经文引用,或者争论“恩惠”是否需要绝对主义。或者我们可以尝试解决原帖作者的问题。你对如何解决原帖作者的问题有什么想法? - CPerkins
1
谢谢你的回答。我发现这些天将数据和行为组合在一起的面向对象模型已经过时了。虽然我非常清楚复杂继承层次结构可能带来的问题,但现在似乎继承几乎是一个禁忌词。SRP原则和更青睐于组合而非继承,似乎对丰富领域模型产生了冲击。我认为问题在于,有些原则被跟随只是因为它们是原则,而不是因为它们最适合你的项目。就像你说的,经文引用。 - Niall Connaughton
2
你的倒数第二句话非常明智,但它会让你与很多人产生分歧。意识形态比分析更容易。 - CPerkins
1
@CPerkins:我一直在关注这个问题,因为我真的很想得到一个答案——我有着与组合优于继承相似的经验,它往往会使领域模型更加贫血。只是组合有很多其他的优点,所以我不愿意通过继承来建模逻辑,除非它有意义。如果我自己有更好的答案,我会发帖的。我很想得到一个令人信服的答案,但我想我需要自己在专业领域里继续探索。这与“经文”无关。 - Mark Seemann
@Mark - 很好。我们面临的挑战之一是最佳实践不一致。就像我们都在教自己开车,学习和教授诸如“不要开得太快”和“不要开得太慢”这样的做法……而对于什么速度适合该情况,缺乏普遍知识。对于我认为你提到Bloch技巧是意识形态的简单假设,我表示歉意-事实上,我经常遇到这种情况,但这并不是认为你会这样想的借口。 - CPerkins

7
SRP论文中的引用非常正确; SRP很难做到完美。SRP和OCP是SOLID原则中必须至少在某种程度上放松的两个元素,以便实际完成项目。过度应用其中任何一个都会很快产生混乱的代码。
如果“更改原因”太具体,SRP确实可以被推向荒谬的极端。即使是POCO/POJO“数据包”也可能被认为违反了SRP,如果你认为字段类型的更改是一种“更改”。你会认为常识会告诉你,字段类型的更改是“更改”的必要允许,但我见过领域层包装内置值类型的包装器;这让ADM看起来像乌托邦。
通常最好根据可读性或所需的内聚水平确定一些现实目标。当你说,“我想让这个类做一件事情”时,它应该不多不少地只有必要的内容。你可以通过这个基本哲学保持至少过程上的内聚。例如,“我想让这个类维护发票的所有数据”通常会允许一些业务逻辑,甚至是计算小计或计算销售税,基于对象对知道如何为其包含的任何字段提供准确、内部一致的值的责任。
我个人对“轻量级”领域没有大问题。拥有成为“数据专家”的一个角色,使领域对象成为与类相关的每个字段/属性的保管者,以及所有计算字段逻辑、任何显式/隐式数据类型转换和可能的简单验证规则(即必填字段、值限制、如果允许将打破实例内部)。如果计算算法,例如加权或滚动平均数,可能会发生变化,请封装该算法并在计算字段中引用它(这只是良好的OCP/PV)。
我不认为这样的领域对象是“贫血”的。我对这个术语的理解是“数据包”,一个字段集合,除了包含它们之外,完全没有关于外部世界甚至其字段之间关系的概念。我也见过这种情况,跟踪对象状态的不一致性是很困难的,因为对象从未意识到这是一个问题。过度应用SRP会导致这种情况,因为它声明数据对象不负责任何业务逻辑,但通常情况下,常识会首先介入,并说作为数据专家的对象必须负责维护一致的内部状态。
再次强调,个人意见,我更喜欢存储库模式而不是Active Record。一个对象,一个职责,系统中上面的很少甚至不需要知道它如何工作。Active Record要求领域层至少了解一些关于持久性方法或框架的具体细节(无论是用于读/写每个类的存储过程的名称、框架特定的对象引用还是用ORM信息装饰字段的属性),因此默认情况下向每个领域类注入第二个更改原因。
我的看法仅供参考。

我认为你在结尾附近的评论,即对象必须负责维护一致的内部状态,才是真正的关键。我见过的贫血模型将此留给通过公共设置器操作模型的类,基于这样一个想法:计算值是与提供值不同的责任,无论计算的复杂性如何。 - Niall Connaughton

5
我发现遵循SOLID原则实际上使我远离DDD的丰富领域模型,最终,我发现我并不在意。更重要的是,我发现一个域模型的逻辑概念和任何语言中的类没有1:1的映射,除非我们谈论某种外观。
我不会说这完全是C风格的编程,其中你有结构体和模块,而是你可能最终得到的是更加功能性的东西,我意识到这些风格是相似的,但细节会有很大的区别。我发现我的类实例最终表现得像高阶函数、部分函数应用、惰性求值函数或者以上几种的组合。对我来说,这有点难以言喻,但这是我从遵循TDD+SOLID编写代码中得到的感觉,它最终表现出一种混合的面向对象/函数式风格。
至于继承成为一个坏词,我认为这更多是因为Java/C#等语言中的继承不够精细。在其他语言中,这不是那么重要,而且更有用。

1

在我开始发牢骚之前,这是我的观点概括:所有的东西都必须合在一起......然后一条河流贯穿其中。

编程让我感到困扰。

=======

贫血数据模型和我... 嗯,我们很亲近。也许这只是小型到中型应用程序的本质,几乎没有业务逻辑内置其中。也许我有点“智障”。
然而,这是我的两分钱:
你难道不能将实体中的代码因素分解出来,并将其绑定到接口上吗?
public class Object1
{
    public string Property1 { get; set; }
    public string Property2 { get; set; }

    private IAction1 action1;

    public Object1(IAction1 action1)
    {
        this.action1 = action1;
    }

    public void DoAction1()
    {
        action1.Do(Property1);
    }
}

public interface IAction1
{
    void Do(string input1);
}

这是否会违反SRP的原则?

此外,拥有一堆仅被消费代码联系的类实际上是对SRP更大的违反,只是把它们推到了另一个层次。

想象一下编写客户端代码的人坐在那里试图弄清楚与 Object1 相关的事情。如果他必须使用你的模型,他将与 Object1、数据包以及许多具有单个职责的“服务”一起工作。他的任务就是确保所有这些东西正确地交互。所以现在他的代码变成了一个事务脚本,而该脚本本身将包含完成特定事务(或工作单位)所需的所有职责。

此外,你可能会说,“不用担心,他只需要访问服务层。就像 Object1Service.DoActionX(Object1)。小菜一碟。”好吧,那么逻辑在哪里呢?全部都在那个方法中?你仍然只是在推动代码,并且无论如何,你最终都会将数据和逻辑分离。

因此,在这种情况下,为什么不向客户端代码公开特定的 Object1Service,并使其 DoActionX() 基本上成为领域模型的另一个钩子呢?我的意思是:

public class Object1Service
{
    private Object1Repository repository;

    public  Object1Service(Object1Repository repository)
    {
        this.repository = repository;
    }

    // Tie in your Unit of Work Aspect'ing stuff or whatever if need be
    public void DoAction1(Object1DTO object1DTO)
    {
        Object1 object1 = repository.GetById(object1DTO.Id);
        object1.DoAction1();
        repository.Save(object1);
    }
}

你已经将Action1的实际代码从Object1中分离出来,但在所有情况下,你拥有了一个非贫血的Object1。

假设你需要Action1来表示2个(或更多)不同的操作,你希望将它们原子化并分别放入自己的类中。只需为每个原子操作创建一个接口,并在DoAction1内部连接即可。

这就是我处理这种情况的方式。但话说回来,我真的不知道SRP是什么。


1
我喜欢SRP的定义是:
“一个类只有一个业务原因需要改变”
只要行为可以分组到单个“业务原因”中,那么它们就没有理由不能在同一个类中共存。当然,“业务原因”的定义是可以讨论的(并且应该由所有利益相关者进行讨论)。

0

使用通用基类将您的普通域对象转换为ActiveRecord模式。将通用行为放在基类中,并在派生类中覆盖必要的行为或在需要时定义新行为。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接