看过DDD的一些书,这次将自己的理解转化为代码。论语里说“学而不思则罔,思而不学则殆”,学会某种能力需要了解到新的知识并思考这些知识,比较好的方式便是动手实践。
对DDD的资料,推荐如下:
- Eric Evans的《领域驱动设计——软件核心复杂性应对之道》或领域驱动设计精简版
- 沃恩·弗农的《实现领域驱动设计》
- DDD案例实战课
前两个都偏理论,后一个偏实战。如果大家没有足够的时间,可以看一下领域驱动设计读书笔记,里面包含了几乎所有的名词解释和作用说明。实战部分,可以看现在的这篇文章。
DDD以面向领域的之名实现面向对象的之实。
一、场景
1.1起因
做跨境业务的时候,负责过商家仓模块。这个模块功能相对简单,但后期发现代码开发、维护的越来越差,这也是为什么我想引入DDD的原因。DDD有一个重要作用,要求开发人员把业务对象想清楚再开发,并且设置了达成标准,即领域模型对象。
领域对象(Domain Object):包含领域模型对象(Domain Model Object)、资源库(Repository)、领域事件(Domain Event)以及应用服务所涉及到的命令(Command)和查询(Query)对象
领域模型对象:分为聚合、实体和值对象这三大类
1.2通用语言
商家仓是在真实的服务商仓上虚拟出的概念,一个服务商仓可以对应多个商家仓,目的是能够以更细的仓维度对商品进行管理。
商家仓场景:
- 创建商家仓:商家可以创建商家仓,创建的商家仓需要对应一个服务商仓,商家仓的唯一id(warehouseid)需要从仓管理服务申请
- 更新商家仓:商家仓创建后需运营审核通过才能使用
- 查询商家仓:商家、运营都能查看商家仓的信息,信息里需要包含服务商仓的内容
当然商家仓的场景还有很多,为了方便,我们只选择创建商家仓、更新商家仓状态、查询商家仓这几个场景。
DDD的具体实现没有标准的方案,本次实现只是其中一种,欢迎和大家一起讨论。
二、非DDD实现方案
虽然在Go中我们使用了对象,但本质上还是以面向过程的思维在进行开发。先演示一下常用的开发流程,以便与DDD方案比较。
2.1目录结构
一般而言,非DDD的目录结构如下:
.
├── dal
│ └── db //操作mysql
│ ├── init.go
│ ├── shopwarehouse.go
│ └── spwarehouse.go
├── handler //入口
│ ├── createwarehouse.go
│ └── getwarehouse.go
├── idl
│ └── idl.go
├── model //表结构
│ └── warehouse.go
└── service //通用业务逻辑
└── warehouse.go
比较重要的几个目录为:
handler:入口函数,一般一个场景对应其中的一个函数
service:公共的业务逻辑可以放到service层
dal:实现存储的初始化、具体操作等,如初始化mysql,对mysql进行增删改查
model:与存储相关的数据结构,如mysql的表结构等放在该层
2.2开发过程
2.2.1代码
代码:https://github.com/shidawuhen/asap/tree/master/controller/warehouse/normal
核心逻辑在service层,service层管理业务逻辑、调用第三方服务、和数据库交互、数据组装、处理返回信息等。
/**
* @Author: Jason Pang
* @Description: 创建商家仓
* @receiver s
*/func (s *shopWarehouseService) CreateShopWareHouse() bool { //1.从第三方获取id rpc.getwarehouseid
//2.从服务商仓获取信息 mysql
//3.组装信息
shopWarehouseInfo := model.ShopWareHouse{
Id: 2,
WarehouseId: 11,
Code: "商家仓2",
Name: "商家仓2",
SpWareHouseId: 2,
Status: 0, //init
} //4.插入数据库 mysql
shopWareHouseRepo := db.DefaultShopWareHouseRepo() //5.返回
return shopWareHouseRepo.CreateShopWareHouse(&shopWarehouseInfo)
}
2.2.2编写过程
虽然我们使用了类,但Service类其实是个贫血模型,而且我们没有办法控制开发人员使用面向对象方案进行开发。这个目录结构对面向过程是天然适应的,只需要按照流程编写即可,从handler->service->dal。这种结构有很大的市场,符合人类的分析模式,学习、上手成本低。
但随着业务变的更加复杂、项目存活时间越长,代码会越来越乱、越来越难以管理。
关键问题在于service层包含太多功能,没有进行更细维度的拆分。
三、DDD实现方案
DDD的方案,强制让开发人员在码代码前,对业务进行深入的思考。因为使用DDD,需分析出有哪些对象,这些对象有哪些特性、能力,这些对象之间是如何交互的。一旦把这些事情想明白,能更从容的面对未来业务的变化。
3.1目录结构
.
├── app //serveice层
│ ├── commandservice //命令类service层
│ │ └── shopwarehouse_commandservice.go
│ └── queryservice //查询类service层
│ └── shopwarehouse_queryservice.go
├── controller //入口层
│ ├── assembler //将请求dto转化为command
│ │ └── shopwarehousecommand_assembler.go
│ ├── dto //请求的结构体
│ │ └── shopwarehouse_dto.go
│ └── shopwarehouse_controller.go //入口controller
├── domain //领域层
│ ├── command //命令
│ │ ├── shopwarehousecreate_command.go
│ │ └── shopwarehouseupdatestatus_command.go
│ ├── model //领域模型对象
│ │ ├── aggregate //聚合根
│ │ │ └── shopwarehouse.go
│ │ ├── entity //实体
│ │ │ └── spwarehouse.go
│ │ └── valueobject //值对象
│ │ ├── shopwarehousestatus.go
│ │ └── warehouseid.go
│ └── repo //资源库接口
│ └── repo.go
├── infra
│ └── persistence
│ ├── convertor //po和领域模型对象转换
│ │ └── warehouse_convert.go
│ ├── dal //真正操作db
│ │ ├── shopwarehouse_dal.go
│ │ └── spwarehouse_dal.go
│ ├── po //数据库表结构定义
│ │ ├── shopwarehouse_po.go
│ │ └── spwarehouse_po.go
│ ├── shopwarehouse_repo_impl.go //资源库接口的位置
│ └── spwarehouse_reop_impl.go
└── integration
└── acl //防腐层,用于调用第三方,返回领域模型对象
└── warehouse_acl.go
通过该目录结构和说明,大家能够对DDD有个大概的认知,DDD中的限界上下文正好包含这几部分:
3.2对应关系
下方是非DDD实现方案和DDD实现方案的对比,能够很明显的表现出两者之间的区别。
我们可以发现service层的功能被拆分了:
- application层分为命令服务、查询服务,负责整个逻辑的编排,和service层的对应性最高
- 项目的核心业务逻辑(领域)从以前杂糅在service层中,拆分到domain层,这一层也是最关键、最重要的一层,包含了这个项目最核心的信息
- 数据组装、转换工作拆分到ACL、基础设施层
图片链接:https://www.processon.com/view/link/62dbdf8907912953fdda6179
3.3开发过程
3.3.1代码
代码:https://github.com/shidawuhen/asap/tree/master/controller/warehouse/ddd
我们看一下application层和domain层的代码样例:
Application: 更新商家仓状态。主要负责逻辑编排,调用聚合实现服务功能。
//update等func (s *ShopWarehouseApplicationService) UpdateStatus(command *command.ShopWarehouseUpdateStatusCommand) error { //1.从数据库获取商家仓信息
shopWareInfo, _ := s.ShopWarehouseRepo.Find(s.ctx, command.WarehouseId.Get()) //2.调用聚合更新状态
shopWarehouseAggregate := aggregate.ShopWarehouse{}
shopWarehouse := shopWarehouseAggregate.UpdateStatus(command, shopWareInfo) if shopWarehouse == nil { return errors.New("更新失败")
} //3.存储
s.ShopWarehouseRepo.Save(s.ctx, shopWarehouse) return nil}
Domain:更新商家仓状态。虽然样例写的比较简单,但状态机等核心逻辑,全部坐落在Domain了。
func (s *ShopWarehouse) UpdateStatus(command *command.ShopWarehouseUpdateStatusCommand, shopWare *ShopWarehouse) *ShopWarehouse { //此处是核心逻辑,判断更新的标准
if shopWare.Status != command.Status { return nil
}
shopWare.Status = command.Status return shopWare
}
3.3.2编写过程
写DDD可按如下步骤进行
- 分析通用语言,找出命令和对应的领域模型对象(实体、聚合、值对象)
- 分析聚合所包含的核心业务逻辑,以及实现这些逻辑需要有哪些数据
- application层准备这些数据,需要确定repo的接口、ACL里需要调用哪些服务
- 在基础设施层真正实现repo接口,在ACL里真正调用这些服务
- controller层将请求参数转换为命令(command),然后调用application层的接口,完成命令的执行
3.3.2.1分析通用语言,找出领域模型对象
在第一节中我们用通用语言描述了商家仓的场景,我们尝试分析对应的领域模型对象
角色 | 命令 | 领域模型对象 |
---|---|---|
商家 | 创建商家仓 | 商家仓、服务商仓 |
商家 | 查询商家仓 | 商家仓、服务商仓 |
运营 | 更新商家仓状态 | 商家仓 |
我们能够分析出三个命令,一个是查询类的,两个是执行类的,所以在domain/command下至少要建两个执行类的command。
我们分析出两个对象:服务商仓和商家仓,这两个都是实体(entity),但在当前场景下,商家仓也是聚合根,因为它本质上是包含服务商仓的。商家仓的warehouseid可以设置为值对象,因为这个数据不可变更。
3.3.2.2领域模型对象分析
领域模型对象包含聚合根、实体、值对象,这三者有如下区别:
- 实体:包含数据,可对数据进行赋值
- 值对象:包含数据,只能new,不可更改其值
- 聚合根:聚合根也是实体,但包含其它实体和值对象,并有对应的业务逻辑。在聚合根里,不能和其它系统(DB、RPC等)有交互。
商家仓聚合根包含两个业务逻辑-创建、更新状态,需要的数据如下:
创建:商家填写的商家仓部分信息、服务商仓信息
更新:商家仓warehouseid、要更新的状态、当前商家仓信息
通过分析,我们能初步写出domain下的创建和更新command、商家仓聚合根aggregate、服务商仓entity、warehouseid的valueobject。
3.3.2.3application层准备数据
application承担编排工作,调用聚合根完成核心功能,将结果进行存储。
我们知道命令有两类,分别是查询和执行类,所以在app层我们创建两个service。
对于创建和更新状态功能需要和DB、RPC交互,我们需要在
- repo层写接口,persistence层的impl写实现。persistence需要定义出表结构(po)、db的操作(dal),实现po与领域模型对象的转换(convertor)
- 在acl中实现与其它服务的交互,并返回指定领域模型对象
对于查询功能,一般只需要和DB交互,无需关注领域模型,直接调用repo即可。
通过分析,我们能初步写出app中的服务,domain的repo接口,infra下的impl、po、dal、convertor,integration下的acl。
3.3.2.4controller接收请求
无论是query还是command类的请求,都由controller承接,controller需要将请求参数转换为指定命令结构,然后调用相关的服务完成操作,并将操作结构返回。
通过分析,我们能初步写出controller下的入口函数、dto、assembler。
在controller/base/base.go中我们实现对controller的调用,大家可以执行一下,看一下返回结果。
四、总结
至此聊完了整个实战过程,大家一边看代码一边看编写过程能更容易理解。
DDD里有很多细节在本文中没有提及,如为了保证对领域模型对象的操作符合规范需要怎么设计、如果要产生事件需怎么做等。之所以不写这些还是希望大家能够先了解整体框架,然后在这个基础上不断补充细节,这样接受起来会更加简单。
完全按照DDD来实现,能够达成以领域模型对象限制整个代码的目标,使开发变得更加规范,代价是开发的难度、复杂度提升。具体怎么设计,丰俭由人,但找出领域模型对象是必须的,因为这部分能够表示开发人员真的深入思考过业务。
五、名词解释
DDD有一些常用名词,此处进行整理,方便大家查询。
- ACL:防腐层(Anticorruption Layer,ACL)
- UP:统一协议(Unified Protocol,UP)
- EDA:事件驱动架构(Event-Driven Architecture,EDA)
- CQRS:的全称是 Command Query Responsibility Segregation,也就是命令和查询职责分离
- CRUD:增加(Create)、检索(Retrieve)、更新(Update)和删除(Delete)
- DMO:领域模型对象(Domain Model Object)
- AOP:切面
- DMO:领域模型对象(Domain Model Object),聚合根、实体、值对象
- DO:领域对象(Domain Object),包含领域模型对象(Domain Model Object)、资源库(Repository)、领域事件(Domain Event)以及应用服务所涉及到的命令(Command)和查询(Query)对象
- PO:资源库实现部分所对应的数据对象称为是一种持久化对象(Persistence Object,PO)
- DM:数据映射器(Data Mapper) 的概念。映射(Mapping)思想在软件设计过程中非常常用,主要用于分离不同层次之间的数据耦合。对于数据访问而言,数据映射器的作用在于分离领域对象和持久化媒介。
- VO(View Object)视图对象:和视图打交道的,那么经历了视图的都归属于这个类,所以我们的输入输出类都是属于VO
- DTO(Data Transfer Object)**数据传输对象:**我们sql查询的时候是通过Id查询的,但是查询是可以查询出很多条信息的,但是我们给前端的数据只要某一部分,比如上例有4个属性,但是只要求输出3个。
- DAO:Data Access Object,数据访问对象1.用来封装对数据库的访问(CRUD)2.通过接收Business层的数据,将POJO持久化为PO
- BO( Business Object):业务对象。由Service层输出的封装业务逻辑的对象。
资料
- https://tech.bytedance.net/articles/7103447005514924045
- https://tech.bytedance.net/articles/6963521013434810404
- processon https://www.processon.com/view/link/62dbdf8907912953fdda6179
- 实战 https://juejin.cn/book/7056372655913435172/section/7062144186283196424
- 切面 https://zhuanlan.zhihu.com/p/421999882
- https://github.com/tianminzheng/customer-service
- https://github.com/tianminzheng/customer-service-axon
最后
大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)
我的个人博客为:https://shidawuhen.github.io/
往期文章回顾: