领域服务最主要的功能是对多个领域对象的逻辑进行封装。
有的时候,我们会面临这样的窘境,一段逻辑无论放到单独的一个实体中,还是值对象中,都有些不太合适。
比如给某个商品添加评价。
我们需要判断商品是否开启了评论功能,评论的用户是否有购买该商品、是否允许发布评价,评价的内容还要进行内容审查。
这个时候就需要在实体、值对象的基础之上引入领域服务。
什么时候需要一个领域服务
首要的一个原则是,如非必要,不要使用领域服务!!!
因为创建领域服务的成本并不高,所以很容易不管三七二十一就把业务逻辑都写到服务里面了。
这会造成实体、值对象的贫血。
要判断到底需不需要创建一个领域服务,其实就是确定哪些逻辑是不适合放到实体和值对象里的。
实体里需要用到资源库
定义在实体里的某个方法需要调用资源库的某个方法,那么推荐将这个逻辑提到领域服务里面。
比如在一个产品里,需要计算有多少有效的评价:
type Product struct {...}func (p *Product) EffectiveEvaluations() int {...}
在 EffectiveEvaluations 这个方法里,我们必须先通过 EvaluationRepository 获取到产品对应的所有评价,然后对这些评价进行遍历,从中筛选出有效评价,最后返回有效评价的数量。
这种情况为什么推荐使用领域服务来实现呢?
试想如果想在 EffectiveEvaluations 方法里能够调用 Repository 服务,要么是 Product 实体持有一个 Repository 属性,要么是在 EffectiveEvaluations 方法参数中传入一个 Repository 参数。
对于持有 Repository 属性的方式,其本身就比较奇怪。我们在领域建模的时候也绝不会讨论领域模型应该有一个 Repository 属性,而且我们每次创建 Product 实例时都要将 Repository 传进来也不太现实。
第二种方式也不是太好,因为实体在整个代码框架中处于最内层,所以如果外层代码想要调用这个方法,就必须一路传递 Repository 实例,这一路所有的方法都必须带一个 Repository 参数也比较恶心。
所以,在实体中一旦用到了 Repository,最好的实现就是将其放到领域服务中。
这里的资源库只是一种具体的表现,我们其实可以进而推广到更一般的情况,即当需要与外部资源进行交互时,都可以用领域服务来实现,比如某个逻辑里需要调用 RPC。
多个实体之间有交互
涉及多个实体之间有交互关系的逻辑,这个时候,将逻辑放到哪个实体中都不是特别合适,因此,也需要提到一个领域服务中。
还是上面发表评价的例子,要求产品必须是开启了允许评价功能,并且用户有过购买该产品,对评价的内容还要进行算法审核,保证没有不合规的文字。
无法放到某个实体上的逻辑
最后一种情况是某个方法没办法放于实体之上。
比如用户登录这个场景,用户在前台输入用户名和密码,如果我们将这个功能定义在实体上会怎么样呢?
type User struct {...}func (u *User) LogIn(username, password string) error {...}
从上面的代码可以看出,如果我们想调用 LogIn 方法,必须先获取一个 User 的实例,但是当前用户还没有登录,我们当然也不知道这个 User 应该是谁。
User 在等待 LogIn,LogIn 也在等待User,如此一来,锁死了。
因此,这种情况需要把逻辑提到一个领域服务中。
最后,还是那句话,如果没有非使用领域服务不可的理由,那就不要用。
实现领域服务
领域服务代码应该放在单独一个包中
有的同学在组织代码结构的时候喜欢按照业务分包,比如在一个商城系统中,会涉及产品、订单等,如果按照业务分包就会有如下的结构:
├── domain│ ├── product│ └── order
与产品相关的 Entity、ValueObject 等都放到 domain.product 这个包下,与订单相关的 Entity、ValueObject 等都放到 domain.order 这个包下。
同理,产品和订单相关的领域服务也应该分别放到 domain.product 、domain.order 包下。
这种方式看上去比较美好,但实际上是有很严重问题的。
在领域服务中可能会用到多个实体,这种情况下很难保证彼此之间的引用是单向的。而且,不仅仅是领域服务存在这个问题,实体之间如果有一些通用的结构也是会导致循环依赖的。
那么,一种解决方案,是将彼此依赖的内容下沉到一个独立的包中,但是需要注意的是,这种下沉可能会让你的代码看上去特别混乱。
推荐的做法是不要按业务分包,而是按照功能分包,将业务上不同功能的代码放到对应的包里,比如这样:
├── domain│ ├── entity # 业务所有的实体,不同的业务实体可以放到不同的go文件里│ ├── vo # 值对象│ ├── repository # 仓储│ └── service # 领域服务
因为我们现在很少会开发大的单体应用,在一个服务里基本上都是单一的业务或者相关性比较强几个功能,因此,将所有的业务实体放到一个包下是可行的。
不同的实体可以再按照划分放到不同的 go 文件中。
领域服务类似,所有的领域服务代码在开始的时候可以统一放到 domain.service 下面。
有的同学可能会担心随着业务变得复杂,service 包下的 go 文件会越来越多,从而变得难以管理。
其实这个问题基本上不会发生,在本文的后面会讲到 CQRS,而领域服务的代码基本上对应的都是 Command ,所以是不会变得特别多的。
保持领域服务的无状态性
确定了领域服务代码放在什么地方,接下来就是如何实现的问题了。
一个很重要的原则是:要保持领域服务的无状态性。
所以如果你定义了一个类似下面这样的 struct 来作为领域服务,一定是错误的:
type EvaluationService struct {ctx context.ContextuserId int64...}
无论是上面的 ctx 还是 userId,它们都是跟具体的某次会话或者某个用户绑定在一起的。
做到无状态最简单的方法就是使用函数,将相应的值对象、实体等领域元素以参数的形式传入,类似下面这样:
func AddEvaluation(ctx context.Context, product *Product, user *User, content string) (*Evaluation, error) {if product.EvaluationOff() {return nil, errors.ErrorOf("product '%d', no evaluations allowed", product.Id())}if user.IsBanned() {return nil, errors.ErrorOf("user '%d' has banned", user.Id())}return &Evaluation{...}, nil}
看起来还可以,除了稍微有那么点不太面向对象。
但是,有的时候,我们在领域服务中可能还会用到 Repository、Sal(service access layer)等服务。如果我们像下面这样定义函数,用起来就很别扭了:
func AddEvaluation(repo repository.EvaluationRepository, checker SpamChecker, product *Product, user *User, content string) {...}
每次调用都要传一大串参数也不是太方便。
所以就有了第二种写法:
type ProductEvaluationService struct {// repository.EvaluationRepository 和 ContentSal 在领域层都是以接口的形式存在// 因为在领域层不关心具体的实现evaluationRepo repository.EvaluationRepositoryspamChecker SpamChecker}func NewProductEvaluationService(repo repository.EvaluationRepository, checker SpamChecker) *ProductEvaluationService {return &ProductEvaluationService{evaluationRepo: repo,spamChecker: checker,}}func (s *ProductEvaluationService) AddEvaluation(ctx context.Context, product *Product, user *User, content string) (*Evaluation, error) {...}
这里有几点需要说明:
- 在 ProductEvaluationService 里持有的几个属性本身也要是无状态的;
- ProductEvaluationService 持有的属性是要定义在领域层的,因为领域层是最核心的层,所以只能是其它层引用领域层,而不能反过来;
- 我们同时提供了一个构造函数来生成一个 ProductEvaluationService 实例,构造函数的参数也是领域中定义的相关接口,利用多态,我们可以注入任意类型的实现,也方便了测试;
- ProductEvaluationService 全局只需要一个实例即可,没必要每次使用的时候就 New 一个,我们可以在程序启动的时候将相应服务注入,之后就是直接使用;
综合来看,第一种直接定义一个函数的方式虽然方便,但是适用的场景比较局限,一旦后面需要引入Repository、Sal(service access layer),代码就要大改。
通过依赖反转解耦对具体技术的直接引用
在领域服务中毕竟需要用到一些技术层面的能力,比如数据库访问,rpc调用等,这些具体的技术实现是不能放在领域层的。
这个时候,我们可以采用独立接口的形式来达到依赖反转的目的。
比如上面示例中的 SpamChecker 便是在领域层定义的一个接口,主要用来对用户发布的评价进行内容合规性检查。
package domain.service
// 在 domain.service 中定义领域服务的接口
type SpamChecker interface {
Check(content string) (Spam, error)
}
在一般的业务实现中,都会有专门的反作弊服务,大多数情况下都不需要自己去实现,只需要发起一个服务调用即可。这里的调用可以通过RPC、HTTP等形式来达成,甚至可能是引入一个反作弊的SDK,也就是说,调用过程其实是一个相对具体的技术实现。
基于此,对 SpanChecker 接口的实现是应当放到基础设施层的。
我们在 infra.sal 中定义具体的实现:
package infra.sal
type AiSpamChecker struct {
client rpc.AiClient
}
func (c *AiSpamChecker) Check(content string) (domain.Spam, error) {
...
}
在领域服务中,我们引用的还是 SpamChecker,只是在程序初始化的时候,会将 AiSpamChecker 注入进去。
这样做的一个好处也是显而易见的,就是当具体的技术细节发生变化后,并不会影响到领域层具体的逻辑。我们可以在 infra 中再创建一个 SpamChecker 的实现,并在程序初始化的时候注入新的实现即可。
总结
领域服务是对实体、值对象等领域模型的进一步扩展,也可以看做是对某些不便于在实体中实现的业务逻辑的一种妥协。
总之,对于领域服务的使用一定小心再小心,某些逻辑如果可以放到实体上,就一定不要使用领域服务。
至此,我们已经介绍了值对象、实体、仓储和领域服务,对于一些简单的应用,这基本上已经覆盖了大部分的开发场景。
我们前面也一直在强调,领域模型应该是具有丰富业务逻辑的充血模型,但是我们却忽略了,模型并不是凭空产生的,在DDD中要如何创建领域模型呢?
我们在下节就来说说DDD里的工厂。
