前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >于振:如何使用工厂,进一步解耦领域对象的职责

于振:如何使用工厂,进一步解耦领域对象的职责

作者头像
用户6543014
发布2023-03-02 14:26:46
3510
发布2023-03-02 14:26:46
举报
文章被收录于专栏:CU技术社区CU技术社区

在DDD中,为了构建领域对象,同样提出了工厂的概念,工厂的引入将领域对象自身的职责与创建的具体逻辑,进行了分离,并且更好地表达了业务。

作者 | 于振

责编 | 韩楠

关于《DDD在Go中的落地》这一系列内容,在过往分享的几篇文章中,我与你交流了对值对象、实体、领域服务等的理解。作为领域设计中最为核心的几个领域元素,它们承载了几乎全部的领域逻辑与概念。

如果你对这些内容还不是太熟悉,可以抽时间回过头去再看一看。这里,我将几篇文章的链接贴在下面,方便你的跳转浏览:

我们言归正传。

虽然这些元素具有丰富的领域行为,但是,这些行为是建立在领域对象被正确实例化的基础之上的。就像我们平时使用手机可以打电话、发短信、看视频,做各种各样的事情,是因为手机在工厂里被正确地组装,电池也尚有电量。一旦这些条件得不到满足,手机的功能也就无法实现了。

在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 模式实现

这一篇,到这里我们就要结束了,非常感谢你耐心的阅读,后面的分享再见。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-12-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 SACC开源架构 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
短信
腾讯云短信(Short Message Service,SMS)可为广大企业级用户提供稳定可靠,安全合规的短信触达服务。用户可快速接入,调用 API / SDK 或者通过控制台即可发送,支持发送验证码、通知类短信和营销短信。国内验证短信秒级触达,99%到达率;国际/港澳台短信覆盖全球200+国家/地区,全球多服务站点,稳定可靠。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档