贫血 vs 充血
在领域开发中,还存在着贫血模型和充血模型之争:
- 贫血模型:把数据和行为分离到不同的对象之中,数据对象即DTO或者ViewObject只表示数据,不包括业务逻辑,而将业务逻辑转移到 Service中。
- 充血模型:将数据和行为都合并到一起,采用标准的OO模式。
在大部分的DDD著作中,都倾向于支持充血模式,而不是贫血模式,也有将贫血模式等同于“事务脚本”,而“充血模式”等同于“领域模型”的说法。当然,最有代表性的说法是:https://martinfowler.com/bliki/AnemicDomainModel.html Martin将 贫血模型直接列为了anti-pattern(反模式)。
之前读过一本关于“贫血模型”和“充血模型”的图书(具体书名现在不记得了),其中有一个案列给我很多的启发:一个订单有一个发送邮件的功能,于是我们在订单的对象中添加了“发送邮件”的功能。当时的案列是作为“充血”模型的案列来编写的,可是我阅读着就感觉到这才是真正的反模式了。一个数据,可能会有无限想象的潜在操作,如果将这些操作都强行附载在“对象”里,那么,这个对象就不是“充血”模型,而是“肥胖”模型了。
但是连Martin Flower这样的大神都认为贫血模型都是反模式,而我的经历项目中基本上都是以“贫血模型”为主的,这个问题也折磨了我很久。直至后来,学习函数式编程,了解了Clojure的以数据为中心,数据不变,”行为“可持续的扩展。再到Scala中,也提供了为对象进行扩展的机制,以及采用bound context进行更为复杂的特性扩展,我才对我们使用的“贫血”模型有一些释怀。
在本书中,我们会推荐一个混合的”贫血模型“,即建立一个完全由不可变数据组成的,不携带任何操作和行为逻辑的 ”ViewObject" + 按领域划分的,无状态的“Service" + 基于RDMS的数据存储构成的一个领域实现模型,对外提供一个RPC的接口模型:
- VO。将实体、聚合表述为immutable的VO,只包括数据,不携带任何行为和操作。自然也不携带任何的业务逻辑。这个可以映射到 clojure 的数据,或者 erlang 的tuple。虽然不携带行为,但仍然建模了数据的逻辑概念。
- Service。服务作为无状态的业务逻辑,接收VO,返回VO,并且内部可能产生一些副作用操作,比如更新数据库,发送MQ,调用外部接口通知等等。所有的Service自身是无状态的。可以建模为一些 有副作用(IO)或无副作用的(Pure Function)的方法。
- Service对外提供的是RPC风格的API,我们使用Thrift来建模RPC,并使用基于Thrift的协议来进行RPC通讯。所有的服务模型,站在客户端来看,就是一系列的无状态的方法调用。但是Service实际上可以理解为Entity(表述为VO)的充血模型。这个在本质上与充血模型是无差异的。实际上,在RPC模式下,网络上传输的只是Data,没有行为是可以传递的。
在这种模式下,我们认为,无需纠结于是采用贫血模型,还是充血模型,系统按照领域模型划分为Entity,每一个实体的操作和逻辑被封装到实体、package相关的对象之中,避免“事务脚本”中的直接访问Entity的属性,从而实现Entity的良好的封装、内聚,降低跨Entity间的依赖和耦合度。
而且,在这种模式下,一个Entity,并不意味着必须将所有的业务逻辑都固定在一个Service实现中,这样很容易产生“肥胖”的服务接口,实际上,从实际业务角度来看,一个Entity,针对不同的Client(例如对内、对外是不一样的),可能会提供不通的服务操作,将其分离在多个不同的Service中,可以实现更好的封装和内聚。