于振:如何使用工厂,进一步解耦领域对象的职责
在DDD中,为了构建领域对象,同样提出了工厂的概念,工厂的引入将领域对象自身的职责与创建的具体逻辑,进行了分离,并且更好地表达了业务。
作者 | 于振
责编 | 韩楠
关于《DDD在Go中的落地》这一系列内容,在过往分享的几篇文章中,我与你交流了对值对象、实体、领域服务等的理解。作为领域设计中最为核心的几个领域元素,它们承载了几乎全部的领域逻辑与概念。
如果你对这些内容还不是太熟悉,可以抽时间回过头去再看一看。这里,我将几篇文章的链接贴在下面,方便你的跳转浏览:
- 《基础问题不简单|怎么合理使用值对象,让你的代码更清晰、更安全?》
- 《不想只做Cruder?实体、聚合根,还不快去了解下》
- 《如何通过仓储,对实体进行持久化处理?》
- 《实体表达力不够?那你应该试试领域服务》
我们言归正传。
虽然这些元素具有丰富的领域行为,但是,这些行为是建立在领域对象被正确实例化的基础之上的。就像我们平时使用手机可以打电话、发短信、看视频,做各种各样的事情,是因为手机在工厂里被正确地组装,电池也尚有电量。一旦这些条件得不到满足,手机的功能也就无法实现了。
在DDD中,为了构建领域对象,同样提出了工厂的概念,工厂的引入将领域对象自身的职责与创建的具体逻辑,进行了分离,并且更好地表达了业务。
提到工厂,很容易让人联想到设计模式里的工厂模式。事实上,DDD 里的工厂跟设计模式里的创建型模式,有很大的关系。在一些复杂的构建逻辑中,我们会借鉴相应的设计模式,来优化代码的编写。
对工厂模式不太熟悉的同学,可以看这里,这里总结了一些常用的设计模式。
秉着知其然知其所以的态度,在讲解如何实现工厂之前,让我们先来看一下工厂到底给我们带来了哪些好处。
01⎪ 为什么我们需要工厂
我们先思考现实中的一个场景。
比如我们去驾校学习如何开车,教练会告诉你如何发动汽车、哪个是油门、哪个是刹车。作为汽车的使用者,我们仅仅知道如何使用就好了,我想大部分人都不会去关心如何生产一辆汽车吧。
就像汽车的生产是在工厂,而普通消费者只需要知道具体如何使用一样,在领域中,工厂同样是为了将创建复杂对象的职责和复杂对象本身的职责,进行分离。
需要注意的是,工厂在这里并没有承担领域模型中的职责,它只是领域设计的一部分。
职责分离是引入工厂的首要原因,但使用工厂还会带来另外的几个好处。
▶︎ 隐藏部分创建细节,避免领域知识的泄露
在一些比较复杂的场景下,聚合根的创建通常都要一个复杂的构造流程,比如要调用其他的服务来获取某几项数据,根据一定的条件自动生成某几个属性等。
这些细节被封装到工厂方法里后,一方面减轻了客户负担,另一方面客户也不再需要了解模型具体的逻辑。
▶︎ 确保不变条件得到满足
复杂的领域对象通常会有一些内部约束,这些约束我们称为不变条件。
比如一个人的年龄,不可能小于0也不可能无限大,这种约束条件在工厂内部会进行检查,保证所有创建出来的对象都是能够满足业务的。
▶︎ 帮助我们更好地表达通用语言,也即表述业务
在Go中,创建一个对象比较简单,比如我们有一个命名为 Product 的结构体,Product{} 即生成了这个结构体的一个实例。
但这种方式本身是一种非常技术性的东西,而这里使用工厂就将业务上的创建与技术上的创建区别开来了。
02⎪ 实现工厂
工厂这个词,很容易让人误解为必须通过设计模式来实现。但其实,DDD中的工厂并不限于使用具体哪一种方式,哪怕只是一个函数,只要在其中实现了对领域对象的创建逻辑,都可以看做是工厂。
▶︎ 使用单独的 New 方法创建简单对象
在前面介绍值对象的时候,举了一个 MonentaryValue 的例子,这是一个典型的简单对象,这里我们再回顾一下 :
这里的 NewMonetaryValue 就是一个简单的工厂方法,虽然没有什么太多的逻辑,但是通过这种写法,我们将对象的创建逻辑成功凸显了出来,使得代码可以很好地表达业务概念。
在具体实现上,要遵循下面几点:
1、方法的返回值,是要创建的对象和一个error。
在对象内部可能会有一些状态约束,而我们是没法保证传入的参数一定是满足这种约束的,如果不满足,需要返回具体的错误。
无论创建简单对象还是复杂对象,我们都可以将这种写法作为一个标准写法来实现。
2、创建的过程应该是原子的,要保证生成的对象处于一个一致的状态,也即不能创建一个半成品出来。
同样是上面 NewMonetaryValue 这个例子,我们再来看一看下面的写法:
在上面代码中,我们先创建了一个部分正确的 MonetaryValue,在后续校验失败后,直接返回了这个半成品。
这样,用户侧在拿到这个实例后,就需要判断里面哪些数据是可用的,哪些是不可以的,如此一来,一方面增加了使用这个 MonetaryValue 的复杂性,另一方面,对领域模型的细节也是一种泄露。
因此,最好的办法就是,当返回的 error 不为 nil 时,另一个返回值可以是 nil 或零值。
3、必须对参数进行业务合规性校验,否则,所创建的对象可能处于不正确的状态。
一个不符合业务约束的对象,即使被创建出来,也是不可用的,同时也是不符合业务概念的。作为使用者,还要担负起验证模型合法性的职责。
比如现在有一个 Person 对象,对象里包含一个 Age 属性:
业务上对 Age 的约束是 18 ~ 60 岁,假如我们采用直接实例化的方式:
上面的代码虽然创建的对象是完整的,但是 Age 并不符合业务的要求。那么,使用方就需要像下面这样先校验再使用:
这个校验逻辑,在所有需要访问 Age 的地方都是需要的,我们似乎闻到了代码中的一丝坏味道。
有的同学可能在想,将判断 Age 在区间 [18, 60] 中的逻辑封装到一个方法里不就行了:
问题解决了吗?并没有,因为每次使用前还是要对 Age 进行校验:
因此,返回的对象不仅要保证是完备的,还要保证是符合领域约束的。
当上面两点得到满足后,我就可以大胆地去掉 if person.IsAgeValid()逻辑了,因为我们知道,person 里的 Age 一定是在 [18, 60] 区间的。
▶︎ 使用独立的工厂创建复杂对象
类似上面的 MonetaryValue ,参数不多,虽然有一些校验逻辑,但是对外部资源没有依赖,可以自我满足,除此以外的情况,就需要一个独立的 Factory 类(struct),或者是服务了。
还是以在介绍领域服务时,提到的添加评价的场景来看。
评价的内容需要通过反作弊系统的检查,避免出现一些不合规的字眼。反作弊系统因为属于外部服务,领域模型无法直接应用,在这个例子中,我们使用了领域服务,但同时,这个领域服务也承担了工厂的职责:
这里唯一需要说明的是,我们已经声明了 ProductEvaluationCreator ,为什么在最后还是需要调用 entity.NewEvaluation(...)呢?
这有两方面的原因:
• 领域模型的校验本身可能比较复杂,一些单属性校验、或者一些可以自我满足的校验,还是要放到对象内部来实现;
• 在仓储层,利用持久化的数据重建领域对象时,是不需要校验的。我们可以认为被持久化了的数据,一定是满足模型约束的,那么这个时候就需要一个轻量级的构造方法。
03⎪ 对模型进行校验
校验主要的目的,是为了保证模型的正确性。
一般校验的方法是逐级校验,即,首先保证领域模型的各个属性是合法的,再保证这些属性组合起来是合法的,最后在单个领域对象合法的基础上,再保证对象之间的组合是合法的。
▶︎ 单个属性的校验
对于简单对象,其内部属性比较少时,可以将对属性的校验置于工厂方法里,比如 MonetaryValue 就采用的这种方式。
对象内部属性比较多时,可以采用自封装的方式,简单来说就是为属性提供一个 setter 方法,,对属性的赋值都通过 setter 方法。
我们也可以将 MonetaryValue 的校验改成自封装形式:
需要注意的是,所有的 Setter 方法都需要返回一个 error,用于接收校验的错误信息。同时,本着最小化原则,Setter 方法最好是未导出的。
▶︎ 对象整体的校验
对领域对象的验证,推荐放到一个单独的组件或者方法里,这样就将校验逻辑从领域对象中剥离出来。
另外,校验逻辑的变化速度和领域对象的变化速度是不一样的,领域对象相对更稳定,因此将校验逻辑剥离,使其单独演化,更符合 SOLID 原则。
如下所示,我们定义了一个 ProductValidator 结构体,该结构体持有一个需要被校验的 Product 实例:
单独使用一个结构体的好处是:在结构体里还可以持有其他的引用,在一些复杂的校验场景下会变得非常有用。
当对象的校验比较简单时,我们甚至可以将 ProductValidator 简化成一个函数:
具体使用哪种方式,需要具体问题具体分析。
如果直接使用函数就可以完成校验,那么就定义一个方法就可以了,单独的方法完不成可以再使用 Validator 的形式。
另外,在校验的过程中可能发现多处错误,我们这里推荐的做法是,当发现非法状态时,直接中断后续校验,立即返回当前校验错误即可。
有些校验框架会收集所有验证的结果,私以为是没必要的,因为最终返给用户的是具体的某一条错误。
最后,对 Validator/校验方法的调用职责,就落到工厂的头上了,工厂在生成对象后,需要手动执行下校验方法。
在 Java 里,可以通过构造方法和继承来帮助我们自动执行校验,但是Go没法做到这一点。
▶︎ 对象组合的校验
因为涉及到多个对象,所以这种逻辑是不会放到具体某个领域下的,那最好的地方是哪里呢?领域服务。
我们考虑在购买商品的时候,需要判断库存这个场景。
在稍微大一些的商城系统中,库存的管理基本上都是单独的一个服务,因此,在用户下单这个动作中,就需要同时考虑 Product 和 Inventory 两个实体。
根据 Product 可以判断商品是否是售卖状态,根据 Inventory 判断商品的库存是否充裕:
04⎪ 结语
今天分享的这一篇,到这里就接近尾声了,一起来回顾下。前面我与你介绍了DDD里的工厂,要正确理解工厂,你需要从下面几个方面入手:
工厂的引入,将领域对象的创建逻辑和其自身职责进行了分离,这不但保证了领域模型的干净整洁,也方便了后续的维护。
无论是值对象还是实体,几乎都需要利用工厂来进行构建,区别只是实现的方式不同而已。对象比较简单的情况下,可以直接定义一个 New 函数,对象复杂时,就要借鉴设计模式里的一些思想,使用独立的结构体来承载构建的职责。
无论使用什么样的方式,都要保证构建出的对象是完备的,并且是满足业务约束的,也即对象在业务域中是能够真真正正合理存在的。
至此,领域层中除了领域事件,其余所有的领域元素就都介绍完了。
但是,领域层并不是孤立存在的,如果希望在模型上执行各种操作,就需要有一个可以直接访问领域层的入口,这个入口,在DDD中称为应用服务。
我们在下一章节就来说说应用服务的实现。
▶︎ 延伸思考
这里,我们先来回顾一下设计模式中的几种创建型模式,然后详细说下我个人比较青睐的其中两种模式,它们在实际中是如何实现的。
在Go里,有下面几种创建型模式:
• 简单工厂模式:通过接收一个类型参数,来返回不同的实例;
• 工厂方法模式:通过将factory接口化,解决了简单工厂不方便扩展的问题;
• 抽象工厂模式:用于创建一系列相关的对象, 而无需指定其具体类;
• 建造者模式:用于复杂对象的构建;
• 原型模式:能够复制已有对象, 而又无需使代码依赖它们所属的类;
• 单例模式:保证一个类只有一个实例;
• options模式:非常适合在一些参数较多、同时这些参数可以选填的场景下使用;
结合我们的使用场景,主要是对复杂的业务流程进行封装,因此,建造者模式和options模式就具有更多的优势。
当实体的属性比较多时,如果将所有的属性都放在一个方法中,以参数的形式传入,那对于使用者来说将是噩梦一样。这个时候就可以考虑使用建造者模式,或者options模式。
1、建造者模式实现
2、options 模式实现
这一篇,到这里我们就要结束了,非常感谢你耐心的阅读,后面的分享再见。