基于DDD落地Go微服务开发总结

AI 摘要: 本文是对DDD(领域驱动设计)落地实践经验的系统总结。文章介绍了DDD的理解、具体步骤、Go微服务中的DDD代码布局以及反模式行为。通过分治思想将大型复杂业务完成业务域划分、领域建模确定,指导微服务落地,以便更好地管理业务复杂性并提高代码可维护性。

1. 前言

最近一直忙着总结复习,在重阅王启军老师的《持续演进的云原生架构》,看到微服务拆分时候提到了 DDD,想起前不久同事也问到 DDD 相关问题,加上自己在写一些小项目时候,也是复用的 DDD 布局架构,想着还是再系统总结下 DDD 落地实践经验。

1.1. DDD 代码布局的几个小项目

1.2. 过往有关 DDD 文章

以前陆续写过几篇 DDD 的文章,有一些零散,这篇文章算是对之前的 Blog 内容,加上过往项目经验的综合总结,可以看下之前的文章:

  1. 开发 Go 服务有关 DDD 分层架构思考与实践: https://ln/ddd/go-ddd-sample-2020
  2. 项目实践 DDD 后的一些思考: https://tkstorm.com/ln/ddd/ddd-think-2021
  3. DDD 脚手架工具: https://tkstorm.com/ln/ddd/ddd-layout-tools-2023

1.3. Go 微服务 DDD 代码布局供参考

花了一些时间把之前的go-ddd-layout github 代码仓库完善了下,最新布局参考: https://github.com/lupguo/go-ddd-layout

2. DDD 理解

DDD 是一种复杂业务(战略设计、战术实施)软件架构方法,通过分治思想将大型复杂业务完成业务域划分、领域建模确定,指导微服务落地,以便更好地管理业务复杂性并提高代码可维护性

3. DDD 具体步骤

DDD 具体步骤包含: DDD-战略设计DDD-战术实施两部份,战略设计聚焦在业务分治,通过梳理业务,领域划分,完成领域建模,战略实施聚焦在领域概念代码映射,完成微服务的编码落地

3.1. DDD - 战略设计(梳理业务,领域划分,完成领域建模)

  1. 业务梳理(事件风暴、领域故事分析):搞清楚业务流、用例,深入理解业务需求流程和规则
  2. 领域划分(业务域、子域划分):基于业务流、用例划分业务域、子域,区分哪些是核心域、支持域
  3. 领域建模(领域对象梳理):明确领域内核心要素,提炼业务域内的实体、值对象、聚合、聚合根
  4. 服务提炼(划定限定文边界):基于业务域能力内聚原则,根据不同子领域之间的关系和依赖性,明确领域边界,提炼出领域下的服务;基于聚合根定义服务接口(RPC),确保单一职责,清晰且有意义,如果存在模糊,重复步骤 2、3

DDD-战略设计

3.2. DDD - 战术实施(将领域概念映射成具体的服务代码)

战术实施即服务开发,旨在将领域核心映射成服务代码

Go 微服务 DDD 代码布局参考: https://github.com/lupguo/go-ddd-layout

服务代码 DDD 目录分层推荐(接口层、应用层、领域层、基础设施层),每层职责和边界清晰,可以具体团队规模、业务复杂实际情况,控制服务的内聚程度,避免一开始服务拆分粒度过细导致服务数量增长过快,增大服务维护和治理成本。

  • 接口层:协议转换(DTO)、参数校验、错误处理
  • 应用层:业务流程编排(不含实际处理逻辑),针对业务缓存、分布式锁、事件通知、任务调度非业务流操作也放应用层(不含实现内容)
  • 领域层:领域服务核心能力,提供业务逻辑实现(比如赤字数据生成读取、报表下载、GMV 等),核心要素包括领域服务 service、领域实体 entity、仓储接口 repository
  • 基础设施层:实现仓储接口,RPC 具体实现、DB 存储、缓存、消息中间件等,都会去实现领域层定义好的仓储接口

4. Go 微服务 DDD 代码布局

4.1. Go 微服务 DDD 布局实践

以下是在之前腾讯课堂研发团队 Go 微服务项目开发过程中使用的 DDD 布局参考,相关布局规范也通过标准化专项推广到了全组中。

我将下述布局抽象到 Go 微服务 DDD 代码布局供参考: https://github.com/lupguo/go-ddd-layout,其他团队在实践落地过程可以适当灵活调整,形成自己的统一标准即可!

4.2. 项目布局代码示例参考

5. 服务分层代码实施细节示例说明

5.1. 接口层(协议转换、参数检测):

接口层职责:做参数的基本处理,比如入参校验,回参 DTO 转换,主要职责包括拆包、组包RPC、HTTP 协议转换成内部应用层可以处理的对象

接口层简单将req参数转换成entity.UploadInfo值对象,处理完后将结果回填到rsp响应中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (i *UploadImageIntf) UploadImage(req interface{}) (rsp interface{}, err error) {
    ctx := context.Background()

    // fill data from info
    images, err := i.app.UploadImage(ctx, &entity.UploadInfo{
        FileName:   "",
        WebUrl:     "",
        UploadFrom: 0,
        Method:     0,
        Style:      nil,
        Tag:        nil,
    })
    if err != nil {
        return nil, err
    }

    // json rsp
    data, _ := json.Marshal(images)
    _ = data

    // rsp.data = data
    return rsp, nil
}

5.2. 应用层(流程编排、缓存、事件通知)

应用层职责:做领域下一个或多个领域服务的业务流程编排,对外提供聚合能力,说明清楚业务 1234 步骤

例如下单应用会调用订单服务的订单创建,同时应用层会做密等性设计、库存检测、订单创建数据获取、订单创建、库存扣减、支付通知等

补充说明

  • 应用层负责业务流编排不写业务逻辑业务逻辑应该内聚在领域内实现
  • 应该编排不同领域服务的逻辑,不要下沉到领域层,下沉后**容易导致领域层的服务耦合变高,领域服务职责不单一,变得复杂(**参考 应用层的UploadImage 实现)
  • 事件通知、缓存处理,应该在应用层实现
  • 如果业务很简单,应用层可做简单透传
  • 参考图片上传包含两个步骤,上传、存储两者实际是解耦开来的,后续扩展仅需要扩展一种上传类型from,并在 Service 内针对性的扩展一个方法,其他服务无需变动
  • 图片上传处理,基于类型完成图片上传、水印、存储
  • 将处理后的图片存储到 DB
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// UploadImage 图片上传
func (app *ImageUploadApp) UploadImage(ctx context.Context, updInfo *entity.UploadInfo) ([]*entity.Image, error) {
    // 1. 图片解析&上传&水印
    var uploadImgFiles []*entity.UploadFile
    var err error
    from := updInfo.UploadFrom
    switch from {
    case entity.UploadFromAI:
        // 获取prompt 配置
        uploadImgFiles, err = app.srv.UploadAIImage(ctx, "key", "macbook in the sky")
        if err != nil {
            return nil, errors.Wrap(err, "upload ai image got err")
        }
    case entity.UploadFromLocalFile:
        // 获取上传图片
        var uploads []*os.File
        uploadImgFiles, err = app.srv.UploadLocalImage(ctx, uploads)
        if err != nil {
            return nil, errors.Wrap(err, "upload local image got err")
        }
    case entity.UploadFromWebUrl:
        // 从data 获取weburl
        var webUrls []string
        uploadImgFiles, err = app.srv.UploadWebImage(ctx, webUrls)
        if err != nil {
            return nil, errors.Wrap(err, "upload web url image got err")
        }
    default:
        return nil, errors.New("error upload image type")
    }

    // 2. 存储到DB
    imgs, err := app.srv.StorageImage(ctx, 0, uploadImgFiles)
    if err != nil {
        return nil, errors.Wrap(err, "storage image got err")
    }

    return imgs, nil
}

5.3. 领域层(子域下服务核心)

领域层职责: 领域服务核心能力,提供业务逻辑实现(比如赤字数据生成读取、报表下载、GMV 等),核心要素包括领域服务 service、领域实体 entity、仓储接口 repository

5.3.1. 领域服务(优先设计)服务领域接口设计和实现,参考 IServiceImageUpload 接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// IServiceImageUpload 图片上传服务接口
type IServiceImageUpload interface {
    // UploadLocalImage 本地图片上传&保存
    UploadLocalImage(ctx context.Context, uploads []*os.File) ([]*entity.UploadFile, error)

    // UploadAIImage 按域定义的prompt key生成AI图片上传&保存
    UploadAIImage(ctx context.Context, promptKey string, keywords string) ([]*entity.UploadFile, error)

    // UploadWebImage 上传了一张web url地址图片,需要下载webUrl图片,保存图片
    UploadWebImage(ctx context.Context, webImgUrl []string) ([]*entity.UploadFile, error)

    // StorageImage 将上传处理后的文件,附加标签等信息后,存储到DB中
    StorageImage(ctx context.Context, method entity.StorageMethod, imgs []*entity.UploadFile) ([]*entity.Image, error)

    // GetImages 通过图片的uuid获取指定上传的文件信息
    GetImages(uuids []uint64) ([]*entity.Image, error)
}

5.3.2. 实体(OOP 实体)、值对象

  • 实体可以简单理解为领域的核心对象,比如登录领域(账号)、商品领域(商品、商品价格、商品库存、商品类目、商品规格等)、订单领域(订单、订单用户、订单购物车、订单商品)
  • 实体采用充血模型实现所有与之相关的业务,若单一实体或值对象无法实现领域中的功能,可以借助领域服务组合多个实体(或值对象)实现复杂的业务逻辑(比如文件上传聚合信息、订单收货地址等),例如在下单上下文中,领域内商品订单(实体),收货地址(可以是值对象)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// UploadInfo 上传信息 - 值对象
type UploadInfo struct {
	FileName   string         // 本地文件名称
	WebUrl     string         // 网络图片URL
	UploadFrom UploadFromType // 图片上传来源
	Method     StorageMethod
	Style      *Style
	Tag        *Tag
}

// UploadFile 上传文件 - 实体
type UploadFile struct {
	UploadInfo *UploadInfo // 图片上传信息

	SaveMethod      StorageMethod // 保存方式
	OriginImagePath string        // 保存路径
	WaterImagePath  string        // 水印图
}

5.3.3. 仓储接口(基于依赖设计)

  • **依赖接口编程,而非实现编程,这样可以解耦对基础设施的实现依赖。**业务领域代码的存储、RPC、消息队列、外部 HTTP 请求等,都可以通过依赖注入方式,解耦对基础设施层的依赖
1
2
3
4
5
6
7
type IReposStorage interface {
	// SaveImage 图片存储到指定服务(可能是本地文件存储服务、COS等)
	SaveImage(imgs []*entity.Image) error

	// FindImages 基于图片ID从存储中找到文件
	FindImages(ids []uint64) ([]*entity.Image, error)
}

5.4. 基础设施层(实现仓储接口)

  • 基础层是基于依赖倒置设计的,封装基础服务能力,常见有三方工具、MQ、文件、缓存、数据库、搜索等,通过依赖注入方式解耦
  • 例如MysqlInfra 实现了IReposStorage 仓储接口,负责图片对存储、查询功能:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (infra *MysqlInfra) SaveImage(imgs []*entity.Image) error {
	err := infra.db.Debug().CreateInBatches(imgs, 100).Error
	if err != nil {
		return errors.Wrap(err, "db sql[SaveImage] got err")
	}

	return nil
}

func (infra *MysqlInfra) FindImages(ids []uint64) ([]*entity.Image, error) {
	var records []*entity.Image
	err := infra.db.Debug().
		Find(&records, "uuid in (?)", ids).Error
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}
		return nil, errors.Wrap(err, "db sql[FindImages] got err")
	}

	return records, nil
}

6. DDD 反模式

6.1. 战略设计过程中反模式行为

  1. 业务梳理投入不足,缺乏对业务的深入和系统的理解,导致后续领域建模、限定上下文划分不合理
  2. 业务过度设计,还没开始就把业务按百万日活访问,过度拆分导致服务数量增大,服务治理维护难度增大
  3. 限定上下文划分不合理,导致后续服务的职责边界模糊,带来设计不合理,引入服务间 RPC 通信的开销

6.2. 战术实施过程中反模式行为

  1. 没有严格遵循各层职责,常见在应用层写业务逻辑代码,各层职责不清导致业务逻辑割裂代码难维护

  2. 过度抽象导致代码变得复杂且难以理解,同时还增加了维护成本

  3. 过度依赖 ORM 框架可能会破坏领域对象之间的聚合关系,并导致性能问题

  4. 过度关注技术细节,DDD 鼓励开发人员专注于业务需求,而不是过度关注技术实现细节,过度关注技术细节,可能会导致失去对业务需求的理解,并且无法满足真正的业务价值

  5. DDD 资源参考