1

DDD的Go实战

 1 year ago
source link: https://shidawuhen.github.io/2022/07/24/DDD%E7%9A%84Go%E5%AE%9E%E6%88%98/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

DDD的Go实战

2022-07-24

看过DDD的一些书,这次将自己的理解转化为代码。论语里说“学而不思则罔,思而不学则殆”,学会某种能力需要了解到新的知识并思考这些知识,比较好的方式便是动手实践。

对DDD的资料,推荐如下:

  1. Eric Evans的《领域驱动设计——软件核心复杂性应对之道》或领域驱动设计精简版
  2. 沃恩·弗农的《实现领域驱动设计》
  3. 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中的限界上下文正好包含这几部分:

image-20220723191808179

3.2对应关系

下方是非DDD实现方案和DDD实现方案的对比,能够很明显的表现出两者之间的区别。

我们可以发现service层的功能被拆分了:

  • application层分为命令服务、查询服务,负责整个逻辑的编排,和service层的对应性最高
  • 项目的核心业务逻辑(领域)从以前杂糅在service层中,拆分到domain层,这一层也是最关键、最重要的一层,包含了这个项目最核心的信息
  • 数据组装、转换工作拆分到ACL、基础设施层
image-20220723115728653

图片链接: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可按如下步骤进行

  1. 分析通用语言,找出命令和对应的领域模型对象(实体、聚合、值对象)
  2. 分析聚合所包含的核心业务逻辑,以及实现这些逻辑需要有哪些数据
  3. application层准备这些数据,需要确定repo的接口、ACL里需要调用哪些服务
  4. 在基础设施层真正实现repo接口,在ACL里真正调用这些服务
  5. controller层将请求参数转换为命令(command),然后调用application层的接口,完成命令的执行
3.3.2.1分析通用语言,找出领域模型对象

在第一节中我们用通用语言描述了商家仓的场景,我们尝试分析对应的领域模型对象

角色 命令 领域模型对象
商家 创建商家仓 商家仓、服务商仓
商家 查询商家仓 商家仓、服务商仓
运营 更新商家仓状态 商家仓

我们能够分析出三个命令,一个是查询类的,两个是执行类的,所以在domain/command下至少要建两个执行类的command。

我们分析出两个对象:服务商仓和商家仓,这两个都是实体(entity),但在当前场景下,商家仓也是聚合根,因为它本质上是包含服务商仓的。商家仓的warehouseid可以设置为值对象,因为这个数据不可变更。

3.3.2.2领域模型对象分析

领域模型对象包含聚合根、实体、值对象,这三者有如下区别:

  1. 实体:包含数据,可对数据进行赋值
  2. 值对象:包含数据,只能new,不可更改其值
  3. 聚合根:聚合根也是实体,但包含其它实体和值对象,并有对应的业务逻辑。在聚合根里,不能和其它系统(DB、RPC等)有交互。

商家仓聚合根包含两个业务逻辑-创建、更新状态,需要的数据如下:

创建:商家填写的商家仓部分信息、服务商仓信息

更新:商家仓warehouseid、要更新的状态、当前商家仓信息

通过分析,我们能初步写出domain下的创建和更新command、商家仓聚合根aggregate、服务商仓entity、warehouseid的valueobject。

3.3.2.3application层准备数据

application承担编排工作,调用聚合根完成核心功能,将结果进行存储。

我们知道命令有两类,分别是查询和执行类,所以在app层我们创建两个service。

对于创建和更新状态功能需要和DB、RPC交互,我们需要在

  1. repo层写接口,persistence层的impl写实现。persistence需要定义出表结构(po)、db的操作(dal),实现po与领域模型对象的转换(convertor)
  2. 在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有一些常用名词,此处进行整理,方便大家查询。

  1. ACL:防腐层(Anticorruption Layer,ACL)
  2. UP:统一协议(Unified Protocol,UP)
  3. EDA:事件驱动架构(Event-Driven Architecture,EDA)
  4. CQRS: 的全称是 Command Query Responsibility Segregation,也就是命令和查询职责分离
  5. CRUD:增加(Create)、检索(Retrieve)、更新(Update)和删除(Delete)
  6. DMO:领域模型对象(Domain Model Object)
  7. AOP:切面
  8. DMO:领域模型对象(Domain Model Object),聚合根、实体、值对象
  9. DO:领域对象(Domain Object),包含领域模型对象(Domain Model Object)、资源库(Repository)、领域事件(Domain Event)以及应用服务所涉及到的命令(Command)和查询(Query)对象
  10. PO:资源库实现部分所对应的数据对象称为是一种持久化对象(Persistence Object,PO)
  11. DM:数据映射器(Data Mapper) 的概念。映射(Mapping)思想在软件设计过程中非常常用,主要用于分离不同层次之间的数据耦合。对于数据访问而言,数据映射器的作用在于分离领域对象和持久化媒介
  12. VO(View Object)视图对象:和视图打交道的,那么经历了视图的都归属于这个类,所以我们的输入输出类都是属于VO
  13. DTO(Data Transfer Object)数据传输对象:我们sql查询的时候是通过Id查询的,但是查询是可以查询出很多条信息的,但是我们给前端的数据只要某一部分,比如上例有4个属性,但是只要求输出3个。
  14. DAO:Data Access Object,数据访问对象1.用来封装对数据库的访问(CRUD)2.通过接收Business层的数据,将POJO持久化为PO
  15. BO( Business Object):业务对象。 由Service层输出的封装业务逻辑的对象。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK