数字化转型 频道

于振:实体表达力不够?那你应该试试领域服务


作者 | 于振

责编 | 韩楠



关于DDD在Go中的落地,过往我分享了几篇,我向你介绍了如何通过值对象和实体来表达业务概念,以及在落地的过程中应该注意哪些细节。

感兴趣的朋友,可以回过头去看一看这两篇,《基础问题不简单|怎么合理使用值对象,让你的代码更清晰、更安全?》和《不想只做Cruder?实体、聚合根,那还不赶紧了解下》。但在有的时候,我们会面临这样的窘境,一段逻辑无论放到单独的一个实体中,还是值对象中,都有些不太合适。

比如给某个商品添加评价,我们假设这个功能需要按照如下的逻辑进行处理:

如果我们将整段逻辑放在 Product 这个实体上,那么 Product 就不得不需要了解 Order 的一些细节,比如是否有某个用户的订单,订单完成了吗?

更重要的是,Order 与 Product 会处于不同的限界上下文之中,这个时候, Product 就必须通过某种方式对 Order 发起请求,而请求本身又是纯技术性操作。通过我们前面的讲解,在实体上不应该依赖任何的外部资源,所以上面的逻辑如果放在 Product 实体上来实现,就不太合理了。

反之,如果将逻辑放在 Order 实体上,也存在同样的问题。

Eric Evans 在他那本经典的《领域驱动设计》一书中提到:

在某些情况下,最清楚、最实用的设计会包含一些特殊的操作,这些操作从概念上讲不属于任何对象。与其把它们强制地归于哪一类,不如顺其自然地在模型中引入一种新的元素,这就是Service(服务)。

因此,本着单一职责的原则,这个时候就需要在实体、值对象的基础之上,引入领域服务。

领域服务最主要的功能是对多个领域对象的逻辑进行封装,通过这样一个概念,为某些重要的、但又无法放到实体 或值对象的领域操作,找到了一个合适的去处,从而避免了过程化的编程。

但就像世间万物都有两面性一样,使用领域服务也不是没有副作用的。如果过度替代了实体和值对象上的行为,最终会导致两者退化为失血模型,这并不是我们所希望的。

所以,在决定使用领域服务时,你首先要判断是否真的需要它?



01 什么时候需要一个领域服务

首要的一个原则是,如非必要,不要使用领域服务!!!

因为创建领域服务的成本并不高,所以很容易不管三七二十一就把业务逻辑都写到服务里面了。这会造成实体、值对象的贫血。

要判断到底需不需要创建一个领域服务,其实就是确定哪些逻辑,是不适合放到实体和值对象里的。

▶︎  实体里需要用到资源库

定义在实体里的某个方法,需要调用资源库的某个方法,那么推荐将这个逻辑提到领域服务里面。

比如说在一个产品里,需要计算有多少有效的评价:

在 EffectiveEvaluations 这个方法里,我们必须先通过 EvaluationRepository 获取到产品对应的所有评价,然后对这些评价进行遍历,从中筛选出有效评价,最后返回有效评价的数量。

这种情况,为什么推荐使用领域服务来实现呢?

试想,如果想在 EffectiveEvaluations 方法里,能够调用 Repository 服务,要么是 Product 实体持有一个 Repository 属性,要么是在 EffectiveEvaluations 方法参数中传入一个 Repository 参数。

对于持有 Repository 属性的方式,其本身就比较奇怪。我们在领域建模的时候,也绝不会讨论领域模型应该有一个 Repository 属性,而且我们每次创建 Product 实例时 都要将 Repository 传进来,也不太现实。

第二种方式也不是太好,因为实体在整个代码框架中处于最内层,所以如果外层代码想要调用这个方法,就必须一路传递 Repository 实例,这一路所有的方法都必须带一个 Repository 参数也比较恶心。

所以,在实体中一旦用到了 Repository,最好的实现就是将其放到领域服务中。

这里的资源库只是一种具体的表现,我们其实可以进而推广到更一般的情况,即当需要与外部资源进行交互时,都可以用领域服务来实现,比如某个逻辑里需要调用 RPC。

▶︎  多个实体之间有交互

涉及多个实体之间有交互关系的逻辑,这个时候,将逻辑放到哪个实体中都不是特别合适,因此,也需要提到一个领域服务中。

还是上面发表评价的例子,要求产品必须是开启了允许评价功能,并且用户有过购买该产品,对评价的内容还要进行算法审核,保证没有不合规的文字。

这样的一段逻辑,已经不是单个某个实体的职责就可以完成了的。

▶︎  无法放到某个实体上的逻辑

最后一种情况是某个方法没办法放于实体之上。

比如用户登录这个场景,用户在前台输入用户名和密码,如果我们将这个功能定义在实体上会怎么样呢?

从上面的代码可以看出,如果我们想调用 LogIn 方法,必须先获取一个 User 的实例,但是当前用户还没有登录,我们当然也不知道这个 User 应该是谁。

User 在等待 LogIn,LogIn 也在等待User,如此一来,锁死了。

因此,这种情况需要把逻辑提到一个领域服务中。

最后,还是那句话,如果没有非使用领域服务不可的理由,那就不要用。


02 实现领域服务

▶︎  领域服务代码应该放在单独一个包中

有的朋友在组织代码结构的时候,喜欢按照业务分包,比如在一个商城系统中,会涉及产品、订单等,如果按照业务分包就会有如下的结构:

与产品相关的 Entity、ValueObject 等,都放到 domain.product 这个包下,与订单相关的 Entity、ValueObject 等都,放到 domain.order 这个包下。

同理,产品和订单相关的领域服务,也应该分别放到 domain.product 、domain.order 包下。

这种方式看上去比较美好,但实际上是有很严重问题的。

在领域服务中可能会用到多个实体,这种情况下很难保证彼此之间的引用是单向的。而且,不仅仅是领域服务存在这个问题,实体之间如果有一些通用的结构,也是会导致循环依赖的。

那么,一种解决方案,是将彼此依赖的内容下沉到一个独立的包中,但是需要注意的是,这种下沉可能会让你的代码看上去特别混乱。

推荐的做法是不要按业务分包,而是按照功能分包,将业务上不同功能的代码放到对应的包里,比如这样:

因为我们现在很少会开发大的单体应用,在一个服务里基本上都是单一的业务,或者相关性比较强几个功能,因此,将所有的业务实体放到一个包下是可行的。

不同的实体,可以再按照划分放到不同的 go 文件中。

领域服务类似,所有的领域服务代码在开始的时候可以统一放到 domain.service 下面。

有的同学可能会担心随着业务变得复杂,service 包下的 go 文件会越来越多,从而变得难以管理。

其实这个问题基本上不会发生,在本文的后面会讲到 CQRS,而领域服务的代码基本上对应的都是 Command ,所以是不会变得特别多的。

▶︎  保持领域服务的无状态性

确定了领域服务代码放在什么地方,接下来就是如何实现的问题了。

一个很重要的原则是:要保持领域服务的无状态性。

所以如果你定义了一个类似下面这样的结构体来作为领域服务,一定是错误的:

无论是上面的 ctx 还是 userId,它们都是跟具体的某次会话 或者某个用户绑定在一起的。

做到无状态,最简单的方法就是使用函数,将相应的值对象、实体等领域元素以参数的形式传入,类似下面这样:

看起来还可以,除了稍微有那么点不太面向对象。

但是,有的时候,我们在领域服务中可能还会用到 Repository、Sal(service access layer)等外部服务。如果我们像下面这样定义函数,用起来就很别扭了:

况且,每次调用都要传一大串参数,也不是太方便。

所以就有了第二种写法:

这里有几点需要说明:

• 在 ProductEvaluationService 里持有的几个属性本身,也要是无状态的;

• ProductEvaluationService 持有的属性是要定义在领域层的,因为领域层是最核心的层,所以只能是其它层引用领域层,而不能反过来;

• 我们同时提供了一个构造函数,来生成一个 ProductEvaluationService 实例,构造函数的参数也是领域中定义的相关接口,利用多态,我们可以注入任意类型的实现,也方便了测试;

• ProductEvaluationService 全局,只需要一个实例即可,没必要每次使用的时候就 New 一个,我们可以在程序启动的时候 将相应服务注入,之后就是直接使用。

综合来看,第一种直接定义一个函数的方式虽然方便,但是适用的场景比较局限,一旦后面需要引入Repository、Sal(service access layer),代码就要大改。

因此,我们总是可以使用第二种方式,来定义一个领域服务。

▶︎  通过依赖反转解耦对具体技术的直接引用

在领域服务中毕竟需要用到一些技术层面的能力,比如数据库访问、rpc调用等,这些具体的技术实现是不能放在领域层的。

这个时候,我们可以采用独立接口的形式 来达到依赖反转的目的。

比如上面示例中的 SpamChecker 便是在领域层定义的一个接口,主要用来对用户发布的评价进行内容合规性检查。

在一般的业务实现中,都会有专门的反作弊服务,大多数情况下都不需要自己去实现,只需要发起一个服务调用即可。这里的调用可以通过RPC、HTTP等形式来达成,甚至可能是引入一个反作弊的SDK,也就是说,调用过程其实是一个相对具体的技术实现。

基于此,对 SpanChecker 接口的实现是应当放到基础设施层的。

我们在 infra.sal 中定义具体的实现:

在领域服务中,我们引用的还是 SpamChecker,只是在程序初始化的时候,会将 AiSpamChecker 注入进去。

这样做的一个好处也是显而易见的,就是当具体的技术细节发生变化后,并不会影响到领域层具体的逻辑。我们可以在 infra 中再创建一个 SpamChecker 的实现,并在程序初始化的时候注入新的实现即可。


03 结语

在这篇文章中,我为你梳理了在什么情况下需要用到领域服务,以及领域服务在代码层面的实现方式:

领域服务,是对实体、值对象等领域模型的进一步扩展,也可以看作是对某些不便于在实体中实现的业务逻辑的一种妥协。

总之,对于领域服务的使用一定小心再小心,某些逻辑如果可以放到实体上,就一定不要使用领域服务。

至此,我们已经介绍了值对象、实体、仓储和领域服务,对于一些简单的应用,这基本上已经覆盖了大部分的开发场景。

我们前面也一直在强调,领域模型应该是具有丰富业务逻辑的充血模型,但是我们却忽略了,模型并不是凭空产生的,在DDD中要如何创建领域模型呢?

我们在下节就来说说DDD里的工厂。

▶︎  延伸思考

我们在前面的讨论中一直在强调,如非必要,不要使用领域服务。说到这,倒是可以问问自己,有想过为什么吗?

为了回答这个问题,让我们先来回顾一下在传统的MVC架构中,代码是怎样一步步腐化的。通常在一个项目刚刚开始的时候,所有的事物看起来都是美好的。但随着不停地堆需求、赶进度,代码中开始逐渐散发出一股坏味道:

• 职责不明模块

• 依赖关系混乱

 模块划分不清

• ……

无论是在现实世界中,还是在代码实现中,秩序都是非常重要的。领域服务在领域模型中,扮演了比值对象、实体更高一层次的角色,它可以协同不同实体之间的逻辑、连接与技术层面的协作。

可以想象一下,你如果是一个团队的负责人,会大包大揽、什么都自己去做吗?显然不会。一个优秀的团队,其内部各成员之间一定是分工明确,权利与职责划分清晰的。

因此,不过分使用领域服务,就是为了保证实体、值对象等领域模型的充血,保证各个领域对象各司其职,在统一的秩序下,有序运行。


【技术专家】

于振

现于某大型互联网公司,负责架构工作

曾就职于美团、快手等一线互联网公司


0
相关文章