开发Go服务有关DDD分层架构思考与实践

AI 摘要: 本文介绍了DDD(领域驱动设计)与中台、微服务架构的联系,以及如何通过Go语言实现DDD的分层设计。DDD是一种处理高度复杂领域的设计思想,通过领域驱动设计方法定义领域模型,界定业务和应用的领域边界。中台是各业务板块的共同需求,通过对通用需求的抽象提供给前台使用。微服务是一种追求对业务变化快速响应的架构设计实现。在DDD的分层设计中,需要通过合理的分层明确每个层的职责,同时注意依赖倒置的设计思想。通过Go语言实现DDD的分层设计,可以利用接口设计实现领域层的依赖倒置,使得业务领域聚焦解决业务问题。

1 序言

在软件架构设计这点上,中台、微服务、DDD 这些词汇,几乎成为了技术圈大网红词汇,那么 DDD 驱动设计、与微服务架构以及中台这三者有什么联系?每个微服务实现内部如何进行合理分层,才能让每个层职责明确?DDD 与架构设计的本质有什么呼应?DDD 与传统分层架构有什么差异?如何通过 Go 语言来实现 DDD 分层设计?不知道你有没有和我一样,有过这些疑问,希望通过读完本文可以给出这些问题的答案。

2 架构设计相关理论

2.1 架构设计的本质:识别并解决软件复杂度

为什么会谈架构设计的本质?因为 DDD 领域驱动设计也是一种架构设计方式,所以需要溯源清楚为什么要做架构设计!

在《从 0 开始学架构》中,李运华老师有提到软件架构设计的真正目的是解决软件复杂度带来的问题,软件复杂度由来主要由三方面:高并发场景下的对软件高性能要求、业务场景对软件高可用要求、持续变化的业务以及业务扩张和增加需求对软件扩展性的要求,除此外,对低成本、安全、软件规模也一定程度上增加了软件设计的复杂度

在解决每个复杂度维度上,分别有各自的应对解决方案:

  • 高性能方面,可以通过单机和集群两个维度提升系统性能:在单机方面通过多进程、多线程等技术解决单机高并发,在集群方面通过任务拆解、分解,将任务调度到每个 work 节点执行,从而进一步提升系统整体高并发与吞吐量
  • 高可用方面,可以通过冗余服务或机器节点方式来确保整体应用的高可用,常见的有计算和存储高可用(Mysql 主从、集群),而高可用的状态决策是非常关键的一点,即是否由一个状态转移到另一个状态,常见决策方式有独裁、协商(如 redis 哨兵、主备切换)、民主(如 Paxos、Raft 等分布式一致性算法)
  • 扩展性方面,由于软件唯一不变就是变化这个基本定理存在,正确的预测变化、完美封装变化,本身就是一件复杂事情;在这点上,才有把变化封装起来,与稳定的不变内容进行隔离(分层、提升软件内聚性,降低耦合度),不变层通过接口去适配多变的情况(想想文件系统的 VFS 适配多种文件格式,同时对用户提供标准化的操作接口场景),看到这,是否想起《Go 语言圣经》中提到的面向接口编程思想,是否如出一辙呢?
  • 低成本、安全、规模方面,技术革命创新、功能和架构安全、软件规模量变引发质变(微服务就是典型例子),这些因素也增加了软件的复杂度。

以上几点基本囊括了软件复杂度由来的本质,除以上因素外,信息沟通反馈也是软件开发过程中非常重要一环,这块不在本文讨论范畴内(有兴趣可以参考《10x 程序员工作法》中郑烨老师提及的沟通反馈章节会有所收获)。

2.2 架构的三原则:合适、简单、演化

  1. 合适优于业界领先:适合目前团队的技术才是关键(参考团队资源、技术储备,思考技术冰山模型)
  2. 简单优于复杂:KISS 原则
  3. 演化优于一步到位:在现有资源约束下,逐步做架构演化优于一步到位(技术过多占用了交付资源,耗死了业务发展)

知道了架构三原则,可以让我们在设计软件架构方案时候有的放矢,能够在一定程度上把控不确定性,从而达到更好地控制软件设计符合既定预期,同时满足业务后续的快速发展(与之相反的就是过度设计,喜欢贪大求全,盲目追踪技术创新而忽略业务发展)。

3 DDD 领域驱动设计理论

3.1 DDD 概念

DDD 是一种处理高度复杂领域的设计思想,试图分离技术实现的复杂性,同时围绕业务概念构建领域模型,提出的一种软件架构设计的方法论。

概念是否感觉很抽象?DDD 核心思想是通过领域驱动设计方法,定义领域模型,从而界定业务和应用的领域边界,保证业务模型与代码模型一致性。

DDD 领域驱动设计通常会包含战略设计和战术设计两部分:

  • 战略设计:重业务建模,以业务视角,拆分领域,通过事件风暴(从发散到收敛过程),梳理业务并构建领域模型,这块过程会涉及业务人员、产品人员、架构师等多方参与
  • 战术设计:重落地实现,以构建的领域模型,建立了领域模型的边界与上下文,也就确认了微服务的边界,这个过程会涉及架构师、技术人员参与

3.2 DDD 与微服务关系

DDD 是一种架构设计方法论,微服务是一种架构设计实现,两者本质都是追求对业务变化的快速响应,分别从业务视角去分离应用系统复杂度的手段。

  • DDD 主要关注做什么:DDD 从业务领域视角划分边界,构建通用语言进行高效沟通,抽象业务以建立领域模型,维持业务和代码的逻辑一致性;
  • 微服务主要关注怎么做:微服务从技术出发,关注服务运行时的进程间通信、容错、故障隔离、服务注册、发现、服务治理等内容,同时关注服务的开发、构建、部署、集成等 DevOps 方面的内容;

3.3 DDD 与中台关系

15 年阿里提出大中台、小前台战略,以更敏捷、快速的适应市场的变化,中台整合数据运营、产品技术能力,对前台业务进行强力支撑。

中台本质实际就是各业务板块的共同需求,通过对通用需求的抽象,实现可复用的业务模型,以组件化形式提供给到前台使用,而不需要每个业务板块自己独立自建一套。

中台、DDD、微服务,虽然属于不同层面的东西,但是还是可以将他们分解对照起来:

  • 中台:基于业务视角拆分业务,构建可复用的业务能力
  • DDD:按业务领域建模,划分领域边界,指导微服务的落地
  • 微服务:具体技术侧的落地实施

3.4 DDD 名词简述

在开始 DDD 之前,简要对 DDD 的概念进行简要概述:

  • 领域、子域、核心域、通用域、支撑域:
    • 领域与子域是划分业务域范围
    • 核心域是划分服务核心关注点
    • 通用域是多个领域通用范畴
    • 支持域是对领域起支撑范畴
  • 限界上下文:区分领域边界,因为在不同上下文环境会存在不同语义
  • 实体:领域内的主要成员,实体是领域驱动的核心成员
  • 值对象:属性集合,值对象能降低实体数量,值对象对实体状态和特征进行描述(比如可以把用户收货地址、电话、收货人等以值对象形式打包,放入一个物流单中)
  • 聚合:单个实体的聚合采用充血模型(业务逻辑都在一个类或者包内实现),将实体与实体相关业务操作包裹起来,实现业务逻辑的高内聚
  • 领域服务:跨多个实体的业务逻辑操作,通过领域服务实现。注意区别于应用服务:跨多个聚合的业务逻辑操作,通过应用服务实现(比如应用层的服务编排、组合)。领域服务是解决实体无法单独实现的业务逻辑,需要多个实体共同实现时候,领域服务就会出马(比如生单场景,涉及商品订单领域服务涉及商品、订单、用户等多个领域实体)
  • 聚合根:聚合内多个实体对外以统一 ID 对外标识

针对应用服务、领域服务、聚合、实体简单举例:

  • 某个简单业务场景,需要聚合实体 A(即有实体定义,还有实体方法)、B 共同实现,则可以提取一个领域服务来实现这段业务;
  • 某个复杂业务场景,需要多个领域服务,甚至涉及 RPC 调用其他服务,则可以提取一个应用服务来实现这段业务;

3.5 从架构设计扩展性思考 DDD 领域驱动设计:分层+接口设计

DDD 分层架构设计思想,实际还是分治思想体现,化繁为简,即复杂大问题拆分成简单小问题解决,一个复杂大领域可以简化成多个子域设计,再聚焦每个领域问题解决。

通过 DDD 分层,结合微服务架构实现,让 DDD 架构设计方法论得以落地。

之前说到,软件对扩展性的要求,是软件复杂度来源之一。在解决架构扩展性方面,内化成一个字就是:“拆”,这也是李运华老师提到的可扩展架构的本质思想,即拆流程(呼应分层架构)、拆服务(呼应 SOA、微服务)、拆功能(微内核)

实际设计中会将这几类技术综合运用,比如一个大的系统,会拆分成不同子系统(如传统 SOA 通过 ESB 总线将各系统连接起来)或者多个微服务,每个子系统或微服务按职责分层设计(比如 MVC、DDD 分层架构),针对一些独立子系统,可以直接采用微内核架构(避免分层架构带来的复杂性),软件的复杂性就是这样增加起来的。

3.5.1 分层架构设计本质是在隔离关注点(Separation of Concerns)

简单点说就是区分每个层之间的差异,让边界足够明显。比如 CS、BS、TCP/IP 分层设计、OS 内核架构、MVC、MVP、逻辑分层设计等都是采用了类似思想。

分层有两点需要注意:

  • 层之间的逻辑边界与职责需要划分清楚:分层架构如果没有考虑清晰,时间一久,层之间的逻辑边界就逐渐模糊,导致职责不清晰,维护成本增加,因此分层需要考虑层之间的逻辑边界与职责划分清晰
  • 层之间的依赖应该是稳定的:如果下层接口依赖总是变动,那对应的依赖方也需要变动,因此层之间的依赖应该是尽可能的保证稳定和扩展,这也对应服务开发过程中接口清晰的重要性,与依赖接口编程的思想是一脉相承的

3.6 传统四层 VS DDD 分层架构设计思考:依赖倒置设计

在了解完 DDD 分层的本质是隔离关注点后,我们来看下传统四层与 DDD 分层之间的差异:

3.6.1 传统分层与 DDD 分层差异

以上图来自《DDD 实战课》,描述了传统四层架构是由上到下,对其下方各层依赖,即最底层的基础层最终被其各层依赖,在以前的设计中,DB 设计就是核心;

但实际上,领域层才是我们需要聚焦解决的业务问题,至于数据是如何存储的,是基于哪种驱动的基础层组件实现的,领域层应该不因过度关心,业务也应该去考虑;

通过依赖倒置的设计方式,我们可以把业务领域聚焦起来,通过接口暴露领域的能力,让其上的各层按接口适配这些能力构建业务,应用层与接口层也是类似设计思想,即让用户接口层对应用层依赖、应用层对领域层依赖、基础设施层对其他各层依赖。

3.6.2 DDD 各层的主要内容

  • 接口层(Interfaces):该层包含与其他系统交互的所有内容,如 Web 服务器、RESTful 接口。接口层处理传入数据的解释、校验、编解码、序列化操作,同时可以考虑引入专门的 DTO(数据转换对象)来协助数据转换;
  • 应用层(Application):该层负责驱动应用程序完成工作流程。很薄一层,协调多个领域对象(实体、聚合根、领域服务)实现服务编排和组合完成工作流,该层通常不应该包含具体业务逻辑。
    • 应用业务流中,涉及其他微服务 RPC 调用,微服务编排、组合,也在该层调用
    • 分布式事务通常也是在该层实现
    • 消息驱动的事件也是在该层驱动
    • 日志记录通常也是在该层记录
  • 领域层(Domain):该层是软件的核心,包含业务逻辑具体实现,包含实体、值对象、聚合、领域服务、仓储接口等领域对象内容,通常该层应该配备图示告知软件是如何工作的;
  • 基础层(Infrastructure):包含网关、缓存、数据库存储、消息中间件、监控、应用程序服务等通用的技术和基础服务
    • 基础层以不同方式支持到其他三层,促进各层间通信
    • 配置文件,数据库 Schema 模式定义以及仓储接口实现都是基础结构的一部分

3.6.3 传统 MVC 分层演进到 DDD 分层参考

从传统 MVC 演进到 DDD,在业务逻辑层与数据访问层,有两个核心点值得关注:

  1. 业务逻辑层演进:DDD 领域驱动设计,把业务逻辑层内聚到领域层,同时将跨领域的服务抽离到应用层。相对于传统的三层架构,领域业务更简单内聚,层之间的职责也更加清晰,应用层可以更加灵活组装领域服务,适配业务变化;
  2. 数据访问层演进:仓储(Repository)包含仓储接口(放在 Domain 领域层)仓储实现(放在 Infrastructure 基础层),DDD 分层架构将原来 DAO 数据访问方式,通过依赖倒置实现各层对基础资源的解耦,让基础层的组件来适配其他层接口。

通过领域层的仓储接口设计,在对领域层进行 UnitTest,就非常容易 Mock 数据实现了(仅需要 Mock 一个仓储层的实现,就可以完成领域模型的测试)。

4 Go 语言 DDD 分层实践:纸上得来终觉浅

4.1 图片智能识别检索应用

我们下面计划以一个简单的上传图片服务,实现图片缩放、短链、图片 AI 打标以及图片检索 4 个微服务,此次我们仅以图片上传服务来实践 DDD 分层设计,Github 地址:https://github.com/lupguo/go-ddd-sample

4.2 图片上传服务以 DDD 分层方式实现

回顾下我们 DDD 分层的架构图:

按功能模块图,我们可以把各功能模块与 DDD 分层架构进行对照起来,初步设想了各层的能力:

  • 领域层:领域层包含上传图片、图片短链、图片标签、检索图片四个实体对象,实体需要通过聚合,提供能力给到应用层使用;同时,需要通过仓储接口抽象好实体持久化存储能力;
  • 基础层:包含日志功能、Mysql 数据库存储功能、Redis 缓存等基础层能力;
  • 接口层:图片 Post 接收,图片参数识别,调用应用层图片上传应用(采用严格分层,否则接口层可以直接调用领域层);
  • 应用层:图片上传应用,调用图片领域层;其他类似的保护图片缩放、短链生成、图片检索等应用功能;

Tips

上传图片的领域模型非常简单,在这里主要是为了展示分层在 Go 中的实现(后面也会介绍 DDD 在使用过程中的注意事项)。

在面对真实复杂业务进行领域建模过程中,可能需要拉齐业务专家、产品、研发相关人员进行事件风暴,梳理领域对象(实体、聚合等),由发散到收敛的方式构建出业务领域模型。

4.3 实现步骤

  1. 项目仓库初始化,基于Go Module管理 Go 模块依赖;
  2. 对项目进行目录划分;
  3. 领域设计优先,设计领域层实体、仓储接口,并做 UT(单元测试)
  4. 基础层实现领域层的仓储接口,完成数据入库、读取,并做 UT
  5. 应用层对领域层调用(遵循严格分层)
  6. 接口层对应用层调用,实现图片上传请求、响应(基于 Echo 框架),并以 HTTP 服务形式提供出去

4.4 仓库初始化

考虑我们要对图片进行存储,设计 DB 配置、图片存储路径等信息,结合《Factory12 因子》 配置因子: https://12factor.net/zh_cn/config ,把服务配置相关参数通过环境变量读取,并基于 Lib github.com/joho/godotenv 库从.env文件内的读取;

考虑到我们快速编译和清理编译环境,我们创建了 Makefile 文件,方便通过 make 相关工具套件构建应用;

考虑到后续我们会基于 Docker 部署,所以我们把Dockerfiledocker-compose.yml也创建好;

4.5 目录划分

我们借鉴 https://github.com/golang-standards/project-layout 中的一些布局思想,结合 DDD 分层命名,构建我们的 Go 服务参考目录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ tree -d -NL 2
.
├── application     // [必须]DDD - 应用层
├── cmd             // [必须]参考project-layout,存放CMD
│   ├── imgupload           // 命令行上传图片
│   └── imgupload_server    // 命令行启动Httpd服务
├── deployments     // 参考project-layout,服务部署相关
├── docs            // 参考project-layout,文档相关
├── domain          // [必须]DDD - 领域层
│   ├── entity      //  - 领域实体
│   ├── repository  //  - 领域仓储接口
│   ├── service     //  - 领域服务,多个实体的能力聚合
│   └── valobj      //  - 领域值对象
├── infrastructure  // [必须]DDD - 基础层
│   └── persistence //  - 数据库持久层
├── interfaces      // [必须]DDD - 接口层
│   └── api         //  - RESTful API接口对外暴露
├── pkg             // [可选]参考project-layout,项目包,还有internel等目录结构,依据服务实际情况考虑
└── tests           // [可选]参考project-layout,测试相关
    └── mock

4.6 领域层 - 定义上传图片域的实体与仓储接口

4.6.1 domain/entity/uploadimg.go - 领域实体

实体是领域中非常核心的组成,在我们的应用中,直接定义成entity.UploadImg

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package entity

import (
	"os"
	"time"
)

// UploadImg 上传图片实体
type UploadImg struct {
	ID        uint64     `gorm:"primary_key;auto_increment" json:"id"`
	Name      string     `gorm:"size:100;not null;" json:"name"`
	Path      string     `gorm:"size:100;" json:"path"`
	Url       string     `gorm:"-" json:"url"`
	Content   os.File    `gorm:"-" json:"-"`
	CreatedAt time.Time  `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
	UpdatedAt time.Time  `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
	DeletedAt *time.Time `json:"-"`
}

4.6.2 domain/repository/uploadimg_repo.go - 领域仓储接口

仓储接口定义了一组方法,用于定义领域实体的与持久化存储相关的操作,实现该接口的持久化存储,都可以操作该领域实体(entity.UploadImg);

Tips:仓储接口也可以用于定义与第三方 RPC、HTTP 服务交互的方法,而不仅仅局限于数据库存储;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package repository

import "github.com/lupguo/go-ddd-sample/domain/entity"

// UploadImgRepo 图片上传相关仓储接口,只要实现了该接口,则可以操作Domain领域实体
type UploadImgRepo interface {
	Save(*entity.UploadImg) (*entity.UploadImg, error)
	Get(uint64) (*entity.UploadImg, error)
	GetAll() ([]entity.UploadImg, error)
	Delete(uint64) error
}

4.7 基础层 - 实现领域层的仓储接口

4.7.1 infrastructure/persistence/db.go - 仓储结构体

这里定义了总的仓储结构体:type Repositories struct{},其内包含领域层的仓储接口和 DB 实例,可以方便持久层;

同时通过gorm.AutoMigrate()来实现 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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package persistence

import (
	"github.com/go-sql-driver/mysql"
	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
	"github.com/lupguo/go-ddd-sample/domain/entity"
	"github.com/lupguo/go-ddd-sample/domain/repository"
	"time"
)

// Repositories 总仓储机构提,包含多个领域仓储接口,以及一个DB实例
type Repositories struct {
	UploadImg repository.UploadImgRepo
	db        *gorm.DB
}

// NewRepositories 初始化所有域的总仓储实例,将实例通过依赖注入方式,将DB实例注入到领域层
func NewRepositories(DbDriver, DbUser, DbPassword, DbPort, DbHost, DbName string) (*Repositories, error) {
	cfg := &mysql.Config{
		User:                 DbUser,
		Passwd:               DbPassword,
		Net:                  "tcp",
		Addr:                 DbHost + ":" + DbPort,
		DBName:               DbName,
		Collation:            "utf8mb4_general_ci",
		Loc:                  time.FixedZone("Asia/Shanghai", 8*60*60),
		Timeout:              time.Second,
		ReadTimeout:          30 * time.Second,
		WriteTimeout:         30 * time.Second,
		AllowNativePasswords: true,
		ParseTime:            true,
	}
	// DBSource := fmt.Sprintf("%s:%s@%s(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", DbUser, DbPassword, "tcp", DbHost, DbPort, DbName)
	db, err := gorm.Open(DbDriver, cfg.FormatDSN())
	if err != nil {
		return nil, err
	}
	db.LogMode(true)

	// 初始化总仓储实例
	return &Repositories{
		UploadImg: NewUploadImgPersis(db),
		db:        db,
	}, nil
}

// closes the database connection
func (s *Repositories) Close() error {
	return s.db.Close()
}

// This migrate all tables
func (s *Repositories) AutoMigrate() error {
	return s.db.AutoMigrate(&entity.UploadImg{}).Error
}

4.7.2 infrastructure/persistence/uploadimg_persis.go - 上传图片领域仓储接口的实现

persistence.UploadImgPersis结构体实现了领域层的仓储接口,后续只要匹配领域层仓储接口即可以匹配操作领域中的能力

 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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// persistence 通过依赖注入方式,实现领域对持久化存储的控制反转(IOC)
package persistence

import (
	"errors"
	"github.com/jinzhu/gorm"
	"github.com/lupguo/go-ddd-sample/domain/entity"
)

// UploadImgPersis 上传图片的持久化结构体
type UploadImgPersis struct {
	db *gorm.DB
}

// NewUploadImgPersis 创建上传图片DB存储实例
func NewUploadImgPersis(db *gorm.DB) *UploadImgPersis {
	return &UploadImgPersis{db}
}

// Save 保存一张上传图片
func (p *UploadImgPersis) Save(img *entity.UploadImg) (*entity.UploadImg, error) {
	err := p.db.Create(img).Error
	if err != nil {
		return nil, err
	}
	return img, nil
}

// Get 获取一张上传图片
func (p *UploadImgPersis) Get(id uint64) (*entity.UploadImg, error) {
	var img entity.UploadImg
	err := p.db.Where("id = ?", id).Take(&img).Error
	if gorm.IsRecordNotFoundError(err) {
		return nil, errors.New("upload image not found")
	}
	if err != nil {
		return nil, err
	}
	return &img, nil
}

// GetAll 获取一组上传图片
func (p *UploadImgPersis) GetAll() ([]entity.UploadImg, error) {
	var imgs []entity.UploadImg
	err := p.db.Limit(50).Order("created_at desc").Find(&imgs).Error
	if gorm.IsRecordNotFoundError(err) {
		return nil, errors.New("upload images not found")
	}
	if err != nil {
		return nil, err
	}
	return imgs, nil
}

// Delete 删除一张图片
func (p *UploadImgPersis) Delete(id uint64) error {
	var img entity.UploadImg
	err := p.db.Where("id = ?", id).Delete(&img).Error
	if err != nil {
		return err
	}
	return nil
}

4.8 应用层 - 实现具体的业务流程(可能涉及自身或远程领域服务调用)

4.8.1 application/uploadimg_app.go - 上传图片应用

之前说过,应用层比较薄,主要做业务流程实现,需要对服务进行组合与编排,另外应用层做到承上启下,即对上接口层暴露实例化应用的方法,方便把仓储实现给接管过来,并通过调用具体的仓储实现完成业务;

上传图片应用,这块统一采用_app结尾,这可可以统一标识应用层文件;

UploadImgApp.db实际是一个仓储层接口(在实际执行时候会传入具体的仓储接口实现,即基础层具体实现),在编写应用层时候,看不到任何 DB 具体实现,同时也看不到任何接口层的入参信息,这就是接口抽象的优势,层之间隔离的比较彻底;

另外,在应用层还会做一些额外的处理,比如这里的rawUrl()函数组合,非常通用的功能可以考虑放入pkg包内。

 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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package application

import (
	"github.com/lupguo/go-ddd-sample/domain/entity"
	"github.com/lupguo/go-ddd-sample/domain/repository"
	"os"
)

type UploadImgAppIer interface {
	Save(*entity.UploadImg) (*entity.UploadImg, error)
	Get(uint64) (*entity.UploadImg, error)
	GetAll() ([]entity.UploadImg, error)
	Delete(uint64) error
}

type UploadImgApp struct {
	db repository.UploadImgRepo
}

// NewUploadImgApp 初始化上传图片应用
func NewUploadImgApp(db repository.UploadImgRepo) *UploadImgApp {
	return &UploadImgApp{db: db}
}

func (app *UploadImgApp) Save(img *entity.UploadImg) (*entity.UploadImg, error) {
	img, err := app.db.Save(img)
	if err != nil {
		return nil, err
	}
	img.Url = rawUrl(img.Path)
	return img, nil
}

func (app *UploadImgApp) Get(id uint64) (*entity.UploadImg, error) {
	img, err := app.db.Get(id)
	if err != nil {
		return nil, err
	}
	img.Url = rawUrl(img.Path)
	return img, nil
}

func (app *UploadImgApp) GetAll() ([]entity.UploadImg, error) {
	imgs, err := app.db.GetAll()
	if err != nil {
		return nil, err
	}
	for i, img := range imgs {
		imgs[i].Url = rawUrl(img.Path)
	}
	return imgs, nil
}

func (app *UploadImgApp) Delete(id uint64) error {
	return app.db.Delete(id)
}

func rawUrl(path string) string {
	return os.Getenv("IMAGE_DOMAIN") + os.Getenv("LISTEN_PORT") + path
}

4.9 接口层 - 处理输入和输入信息

4.9.1 interfaces/api/handler/uploadimg_handler.go - 上层处理

接口层是整体架构的最上层,用于处理信息的输入和输出,这里我们通过_handler来作为统一后缀标识接口层处理文件,相关的结构体也是采用Handler作为后缀区分,另外我们通过 Go 的echo框架来提供 HTTP 的服务以及路由处理。

接口层主要的处理内容就是从echo.Context HTTP 请求上下文中获取信息,解析后传给应用层处理,最终再响应具体的 HTTP 回包给到客户端。通过接口层的这层去壳和加壳操作,让应用层就可以聚焦应用流程的实现,解耦得比较干净。

  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
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
package handler

import (
	"errors"
	"fmt"
	"github.com/labstack/echo"
	"github.com/lupguo/go-ddd-sample/application"
	"github.com/lupguo/go-ddd-sample/domain/entity"
	"io"
	"io/ioutil"
	"math/rand"
	"net/http"
	"os"
	"path"
	"strconv"
	"time"
)

// UploadImgHandle 上传处理
func UploadImgHandle(c echo.Context) error {
	callback := c.QueryParam("callback")
	var content struct {
		Response  string    `json:"response"`
		Timestamp time.Time `json:"timestamp"`
		Random    int       `json:"random"`
	}
	content.Response = "Sent via JSONP"
	content.Timestamp = time.Now().UTC()
	content.Random = rand.Intn(1000)
	return c.JSONP(http.StatusOK, callback, &content)
}

// UploadImgHandler 图片上传接口层处理
type UploadImgHandler struct {
	uploadImgApp application.UploadImgAppIer
}

// NewUploadImgHandler 初始化一个图片上传接口
func NewUploadImgHandler(app application.UploadImgAppIer) *UploadImgHandler {
	return &UploadImgHandler{uploadImgApp: app}
}

func (h *UploadImgHandler) Save(c echo.Context) error {
	forms, err := c.MultipartForm()
	if err != nil {
		return err
	}
	var imgs []*entity.UploadImg
	for _, file := range forms.File["upload"] {
		fo, err := file.Open()
		if err != nil {
			continue
		}
		// file storage path
		_, err = os.Stat(os.Getenv("IMAGE_STORAGE"))
		if err != nil {
			if os.IsNotExist(err) {
				if err := os.MkdirAll(os.Getenv("IMAGE_STORAGE"), 0755); err != nil {
					return err
				}
			} else {
				return err
			}
		}
		// file save
		ext := path.Ext(file.Filename)
		tempFile, err := ioutil.TempFile(os.Getenv("IMAGE_STORAGE"), "img_*"+ext)
		if err != nil {
			return err
		}
		_, err = io.Copy(tempFile, fo)
		if err != nil {
			return err
		}
		// upload
		uploadImg := entity.UploadImg{
			Name:      file.Filename,
			Path:      tempFile.Name(),
			CreatedAt: time.Time{},
			UpdatedAt: time.Time{},
		}
		img, err := h.uploadImgApp.Save(&uploadImg)
		if err != nil {
			return err
		}
		imgs = append(imgs, img)
	}
	return c.JSON(http.StatusOK, imgs)
}

func (h *UploadImgHandler) Get(c echo.Context) error {
	strID := c.Param("id")
	if strID == "" {
		return errors.New("the input image ID is empty")
	}
	id, err := strconv.ParseUint(strID, 10, 0)
	if err != nil {
		return err
	}
	img, err := h.uploadImgApp.Get(id)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, img)
}

func (h *UploadImgHandler) GetAll(c echo.Context) error {
	imgs, err := h.uploadImgApp.GetAll()
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, imgs)
}

func (h *UploadImgHandler) Delete(c echo.Context) error {
	strID := c.Param("id")
	if strID == "" {
		return errors.New("the deleted image ID is empty")
	}
	id, err := strconv.ParseUint(strID, 10, 0)
	if err != nil {
		return err
	}
	err = h.uploadImgApp.Delete(id)
	if err != nil {
		return err
	}
	msg := fmt.Sprintf(`{"msg": "delete Imgage ID:%s success"`, strID)
	return c.JSON(http.StatusOK, msg)
}

4.10 服务入口

4.10.1 cmd/imgupload_server/main.go

main.go中,我们遵循 Factory12 因子,将我们的 DB 相关参数信息都提炼到环境变量,然后实例化我们 DB 存储,用于创建我们的上传图片应用,然后通过上传应用实现我们的接口对象,这样基础层的 DB 通过接口实现,悄悄的注入到接口层、应用层、领域层,与之前的 DDD 分层架构图也呼应上了。

 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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package main

import (
	"github.com/joho/godotenv"
	"github.com/labstack/echo"
	"github.com/labstack/echo/middleware"
	"github.com/lupguo/go-ddd-sample/application"
	"github.com/lupguo/go-ddd-sample/infrastructure/persistence"
	"github.com/lupguo/go-ddd-sample/interfaces/api/handler"
	"log"
	"os"
)

func init() {
	// To load our environmental variables.
	if err := godotenv.Load(); err != nil {
		log.Println("no env gotten")
	}
}

func main() {
	// db detail
	dbDriver := os.Getenv("DB_DRIVER")
	host := os.Getenv("DB_HOST")
	password := os.Getenv("DB_PASSWORD")
	user := os.Getenv("DB_USER")
	dbname := os.Getenv("DB_NAME")
	port := os.Getenv("DB_PORT")

	// 初始化基础层实例 - DB实例
	persisDB, err := persistence.NewRepositories(dbDriver, user, password, port, host, dbname)
	if err != nil {
		log.Fatal(err)
	}
	defer persisDB.Close()
	// db做Migrate
	if err := persisDB.AutoMigrate(); err != nil {
		log.Fatal(err)
	}

	// 初始化应用层实例 - 上传图片应用
	uploadImgApp := application.NewUploadImgApp(persisDB.UploadImg)
	// 初始化接口层实例 - HTTP处理
	uploadImgHandler := handler.NewUploadImgHandler(uploadImgApp)

	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	// 静态主页
	e.Static("/", "public")

	// 图片上传
	e.POST("/upload", uploadImgHandler.Save)
	e.GET("/delete/:id", uploadImgHandler.Delete)
	e.GET("/img/:id", uploadImgHandler.Get)
	e.GET("/img-list", uploadImgHandler.GetAll)

	// Start server
	e.Logger.Fatal(e.Start(os.Getenv("LISTEN_PORT")))
}

4.11 运行服务

  1. 创建名为img-upload的 Mysql DB 数据库
  2. 拉取代码并构建
1
2
3
4
5
6
7
// 拉代码
git clone github.com/lupguo/go-ddd-sample
// 编译
cd go-ddd-sample
go build ./cmd/imgupload_server
// 运行
./impgupload_server
  1. 访问http://localhost:1818/并上传图片,查看图片信息
  2. 依次通过main.go中的路由,进行查看单张、多张、删除图片操作

5 总结

本文从架构设计的本质以解决软件复杂度出发,逐步引出通过分层与接口设计解决软件扩展复杂性问题,进一步引出文章主题 DDD 领域驱动的分层架构设计,最后通过一个实际 Go 语言的示例展示了服务分层的落地。

通过 DDD 可以对中台和微服务起到承上启下作用,中台是更偏业务角度考虑,DDD 通过领域建模拆解、划分业务领域边界,指导微服务的落地。在使用 DDD 过程中有一些注意点也需要关注下:

  1. DDD 适合偏复杂业务,DDD 不是万能的。简单业务使用 DDD 会有些杀鸡用牛刀感觉(思考架构三原则:简单、合适、演进),不要拿着 DDD 这个锤子到处找钉子;
  2. DDD 分层建议采用严格分层,不跨层调用,而是采用依赖注入方式把相关实例传入下层(例如不要从接口层直接调用存储层方法,因为跨层调用会导致整个调用链变复杂);
  3. DDD 目录结构命名,这块也是比较关键一点。目前 Go 是倾向简洁,不希望向 Java 那么冗余,所以这块命名还可以在 DEMO 基础上进一步优化;
  4. DDD 分层会接口一多,代码可读性不好的问题。可以通过好的命名来规避(比如统一后缀、选取合适简短的接口名),同时用依赖倒置思维逐层看接口,以及其依赖;
  5. DDD 设计步骤,可以按领域层 -> 基础层 -> 应用层 -> 接口层,一般是按这个步骤开发;
  6. DDD 分层后,每层隔离得比较干净,非常适合单元测试和 Mock 测试(可以参考文末food-app-server这个仓库)

希望通过这篇文章介绍,能让大家对 DDD 领域驱动设计的分层架构设计有一个真实的感知,以便后续在服务设计过程中有更好的落地。

6 参考