基于 ddd 的设计思想, 核心领域需要由纯内存对象+基础设施的抽象的接口组成
怎么实现呢? 下面我根据一个案例来一步步展示通过 DDD 重构三层架构的过程
本案例将会创建一个应用, 提供如下 web 接口 (目前使用 golang 实现)
/auth/register
/auth/login
/user
/transfer
DDD 是为了复杂系统变化而设计的, 如果系统简单, 需求变动不大. 那么脚本代码才是最好的选择, 不仅能快速开发, 而且理解.
所以这次案例里, 为了展示 DDD 设计的高拓展性, 需求主要强调 变化 的场景.
同理注册
除了注册登录, 其他接口都需要鉴权
一个用户转账给另一个用户
对于一般的后端开发来说, MVC 架构都不会陌生. 现在几乎所有的 Web 项目, 都是基于这种三层架构开发模式. 甚至连 Java Spring 框架的官方 demo, 都是按照这种开发模式来编写的.
后端的三层架构就是: Controller, Service, Repository. Controller 负责暴力接口, Service 负责业务逻辑, Repository 负责数据操作.
下面是转账服务的核心 Service (忽略所有错误和参数验证和事务)
// TransferService 转账服务
// @param fromUserID 转出用户ID
// @param toUserID 转入用户ID
// @param amount 转账金额
// @param currency 转账币种
func (t *TransferService) Transfer(fromUserID string, toUserID string, amount decimal.Decimal, currency string ) {
// 读数据
var fromUser User
var toUser User
t.Db.Where("id = ?", fromUserID).First(&fromUser)
t.Db.Where("id = ?", toUserID).First(&toUser)
// 币种验证
if fromUser.Currency != currency || toUser.Currency != currency {
return errors.New("currency not match")
}
// 获取汇率
var rate decimal.Decimal
if fromUser.Currency == toUser.Currency {
rate = decimal.NewFromFloat(1)
} else {
// 通过微软的 api 获取汇率
rate = t.MicroService.GetRate(fromUser.Currency, toUser.Currency)
}
// 计算需要的金额
fromAmount = amount.Mul(rate)
// 计算手续费
fee = fromAmount.Mul(decimal.NewFromFloat(0.1))
// 计算总金额
fromTotalAmount = fromAmount.Add(fee)
// 余额验证
if fromUser.Balance.LessThan(fromTotalAmount) {
return errors.New("balance not enough")
}
// 转账
fromUser.Balance = fromUser.Balance.Sub(fromTotalAmount)
toUser.Balance = toUser.Balance.Add(amount)
// 保存数据
t.Db.Save(&fromUser)
t.Db.Save(&toUser)
// 保存账单
t.Db.Create(&Bill{
FromUserID: fromUserID,
ToUserID: toUserID,
Amount: amount,
Currency: currency,
Rate: rate,
Fee: fee,
BillType, "zhuanzhang",
})
return nil
}
我们可以看到, MVC 的 Service 层一般非常臃肿, 包含各种参数校验(这里省略了很多参数校验和错误处理), 逻辑计算, 数据操作, 甚至还包含了一些第三方服务的调用.
这样的代码, 也称之为 "事务脚本", "胶水代码", "面向过程代码" 等等. 优点是简单容易理解, 缺点是代码臃肿, 代码可维护性差, 代码可拓展性差, 代码可测试性差.
代码可维护性 = 当依赖变化的时候, 需要修改多少代码
参考上面的代码, 我们发现胶水代码的可维护性比较差主要因为以下原因
可拓展性=增加或者修改需求的时候, 需要修改多少代码
参考上面的代码, 如果我们今天要增加一个充值的功能, 我们可以发现上面的代码基本没有可以复用的逻辑
充值功能需要将银行卡的钱充值到余额, 银行卡可能是其他银行的银行卡, 其他银行卡的用户的数据结构可能不一致
一般的胶水代码做需求都非常快, 但是可复用的逻辑很少, 一旦涉及到新增有相同但是又不同的逻辑或者修改需求, 需要修改的代码很多. 如果有地方的代码忘了改就是一个 bug. 在反复变化的需求中, 代码的可拓展性显得很差
可测试性 = 运行每个测试用例所花费的时间 * 每个需求所需要增加的测试用例数量
除了部分工具类、框架类和中间件类的代码有比较高的测试覆盖之外,我们在日常工作中很难看到业务代码有比较好的测试覆盖,而绝大部分的上线前的测试属于人肉的“集成测试”。低测试率导致我们对代码质量很难有把控,容易错过边界条件,异常case只有线上爆发了才被动发现。而低测试覆盖率的主要原因是业务代码的可测试性比较差。
参考上面的代码, 这种代码可测试性比较差的原因是:
N*N*N
当胶水代码中的步骤越来越多, 测试用例将会呈现指数级增长
一般的这样的胶水代码, 当测试比较复杂的时候, 开发人员无法写这个方法的单元测试, 依赖测试人员的 "人肉测试" 或者开发人员的 "颅内测试".
每次代码变动, 之前的测试就可能变得不可靠, 有需要重新 "人肉测试" 或者 "颅内测试", 如果每天变动 2 次代码, 就陷入了无限的测试和代码 review 的风暴中
我们重新来分析一下为什么以上的问题会出现?因为以上的代码违背了至少以下几个软件设计的原则:
我们需要对代码重构才能解决这些问题。下面我们就来看下如何使用 DDD 重构我们上面的胶水代码
怎么重构呢? 主要分为 2 方面:
参考上面的代码, 我们画一张流程图描述一下主要的步骤:
业务层中黑色字体代表业务逻辑, 包含 参数检查, 金额计算和转账
MVC 三层架构是一种贫血模型, 贫血模型的类数据没有逻辑, 充血模型的类既有数据又有逻辑
我们要做的是: 将 Service 中的逻辑抽离到类中
// 基础数据类型
Transfer(fromUserID string, toUserID string, amount decimal.Decimal, currency string ) error
// 领域类
Transfer(fromUserID, toUserID *model.UserID, amount *model.Amount, currencyStr string) error
Domain Object 就是一种充血模型, 既有数据又有逻辑, 比如上面的 model.UserID
这个对象的构造方法如下
func NewUserID(userID string) (*UserID, error) {
// 参数检查
return &UserID{
value: userID,
}, nil
}
我们可以发现参数检查在构造方法里面, 这样就能保证传入的参数一定是经过参数检查的, 如果所有的 Service 方法都用 Domain Object 替代基础数据类型, 这样就不用担心参数校验的问题了
而且 Domain Object 是纯内存的, 没有任何依赖, 十分方便测试
之前我们的计算金额和转账逻辑都是由 Controller 下面的 Service 层实现的 因为 Service 需要太多依赖, 比如 MicroService 和 GROM, 所以我们很难对这一块逻辑进行测试 如果我们把这这块逻辑抽离到没有任何依赖的纯内存的 Domain Service 中, 就能很方便的测试
func (*TransferService) Transfer(fromUser *User, toUser *User, amount *Amount, rate *Rate) {
// 通过汇率转换金额
fromAmount := rate.Exchange(amount)
// 根据用户不同的 vip 等级, 计算手续费
fee := fromUser.CalcFee(fromAmount)
// 计算总金额
fromTotalAmount := fromAmount.Add(fee)
// 转账
fromUser.Pay(fromTotalAmount)
toUser.Receive(amount)
}
我再贴一下我们之前分析的三层架构流程图, 业务层中红色字体代表需要依赖基础设施
依赖反转原则(Dependency Inversion Principle):上层模块不要依赖底层,应该依赖底层的抽象, 面向接口编程。在这个案例里外部依赖都是具体的实现,比如 MicroService 虽然是一个接口类,但是它对应的是依赖了MicroSoft提供的具体服务,所以也算是依赖了实现。同样的 grom.DB 实现也属于具体实现。
我们要做的就是抽象一层接口出来, 面向接口编程
type RateService interface {
GetRate(from *model.Currency, to *model.Currency) (*model.Rate, error)
}
然后 MicroRateService 是该接口的一种实现, 这种设计叫也做防腐层
防腐层不仅仅是多了一层调用, 它还可以提供如下功能
type UserRepo interface {
Get(*model.UserID) (*model.User, error)
Save(*model.User) (*model.User, error)
}
在接口层面做统一,不关注底层实现。比如,通过 Save 保存一个 Domain 对象,但至于具体是 insert 还是 update 并不关心. 是使用 MYSQL 还是使用 MongoDB 甚至是本地文件储存都不关心.
重构之后的 Service 层如下, 为了区别我们抽离出来的纯内存的 TransferService, 我们将这原来的Service层命名为 TransferApp 代表 Application 层
重构之后的 UserApp.Transfer() 代码如下 (忽略所有的错误处理和事务):
func (u *UserApp) Transfer(fromUserID, toUserID *model.UserID, amount *model.Amount, currencyStr string) error {
// 读数据
fromUser := u.userRepo.Get(fromUserID)
toUser := u.userRepo.Get(toUserID)
toCurrency := model.NewCurrency(currencyStr)
// 获取汇率
rate := u.rateService.GetRate(fromUser.Currency, toCurrency)
// 转账
u.transferService.Transfer(fromUser, toUser, amount, rate)
// 保存数据
u.userRepo.Save(fromUser)
u.userRepo.Save(toUser)
// 保存账单
bill := &bill_model.Bill{
FromUserID: fromUser.ID,
ToUserID: toUser.ID,
Amount: amount,
Currency: toCurrency,
}
u.billApp.CreateBill(bill)
return nil
}
重构之后的架构流程图如下, 我们新增了粉色部分的领域层:
新增的领域层分为 2 个部分
参考上面的代码, 你或许已经跃跃欲试的尝试自己通过 DDD 的思想实现一下这个案例了 但是当你拿到需求时候, 打开 IDE 第一个问题就是: 如何组织代码结构? 要解决这个问题的前提我想是: 明确架构
比如上面代码的例子
比如上面代码的重构后的例子
对应的四层架构模型如下
在上面重构的代码里,如果抛弃掉所有Repository、ACL、Producer等的具体实现细节,我们会发现每一个对外部的抽象类其实就是输入或输出,类似于计算机系统中的I/O节点。这个观点在CQRS架构中也同样适用,将所有接口分为Command(输入)和Query(输出)两种。除了I/O之外其他的内部逻辑,就是应用业务的核心逻辑。基于这个基础,Alistair Cockburn在2005年提出了Hexagonal Architecture(六边形架构),又被称之为Ports and Adapters(端口和适配器架构)。
在这张图中:
在Hex中,架构的组织关系第一次变成了一个二维的内外关系,而不是传统一维的上下关系。同时在Hex架构中我们第一次发现UI层、DB层、和各种中间件层实际上是没有本质上区别的,都只是数据的输入和输出,而不是在传统架构中的最上层和最下层。
除了2005年的Hex架构,2008年 Jeffery Palermo的Onion Architecture(洋葱架构)和2017年 Robert Martin的Clean Architecture(干净架构),都是极为类似的思想。除了命名不一样、切入点不一样之外,其他的整体架构都是基于一个二维的内外关系。这也说明了基于DDD的架构最终的形态都是类似的。Herberto Graca有一个很全面的图包含了绝大部分现实中的端口类,值得借鉴。
这里不赘述该架构图描述, 可以参考原文: https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/
到目前为止, 大部分的 DDD 应用使用类似这样的架构, 比如著名的案例: https://github.com/victorsteven/food-app-server
├── application
│ ├── food_app.go
│ ├── food_app_test.go
│ ├── user_app.go
│ └── user_app_test.go
├── domain
│ ├── entity
│ └── repository
├── infrastructure
│ ├── auth
│ ├── persistence
│ └── security
├── interfaces
│ ├── fileupload
│ ├── food_handler.go
这是一种 "按层分包" 的架构, 对于从 MVC 三层架构重构到 DDD 来说只是分解了 Service 层, 比较容易理解
但这是细粒度的代码隔离。粗粒度的代码隔离至少是同样重要的,它是根据子域和有界上下文来隔离代码的。我称之为 "基于业务分包".
我之前一直使用的是 "按层分包" 的结构, 现在我是 "基于业务分包" 的忠实拥护者. 在我的案例中, 我无耻的将上面的按层打包改成下面的内容
├── bill // 账单组件
│ ├── app.go
│ ├── model
│ └── repo.go
├── common // 通用工具
│ ├── logs
│ └── signals
├── servers // 通用 servers
│ ├── apps.go
│ ├── repos.go
│ ├── rpc
│ ├── servers.go
│ └── web
└── user // 用户组件
├── app.go
├── auth_repo.go
├── model
├── rate_service.go
├── repo.go
├── rpc_server.go
├── transfer_service.go
├── web_auth_middleware.go
└── web_handler.go
因为在编码实践中,我们总是基于一个业务用例来实现代码,在 "按层分包" 场景下,我们需要在分散的各包中来回切换,增加了代码导航的成本;另外,代码提交的变更内容也是散落的,在查看代码提交历史时,无法直观的看出该次提交是关于什么业务功能的。在业务分包下,我们只需要在单个统一的包下修改代码,减少了代码导航成本;另外一个好处是,如果哪天我们需要将某个业务迁移到另外的项目(比如识别出了独立的微服务),那么直接整体移动业务包即可。
DDD 不是银弹, 它是 复杂业务 的一种设计思想. DDD 的核心在于对业务的理解, 而不是对领域模型的熟悉程度, 不要花太多时间去研究理论
如果今天的内容你只能记住一件事情, 那我希望是: 抽离逻辑, 抽象接口
案例中的代码我已经提交到了 GitHub: https://github.com/dengjiawen8955/ddd_demo
下面的代码可以快速运行案例中的项目:
# 下载项目
git clone git@github.com:dengjiawen8955/ddd_demo.git && cd ddd_demo
# 准备环境 (启动mysql, redis)
docker-compose up -d
# 准备数据库 (创建数据库, 创建表)
make exec.sql
# 启动项目
make
也许你听说过 "要做好微服务先做好 DDD" 这样类似的话, 因为 DDD 是指导微服务拆分的重要思想
通过上面的代码, 如果你想要拆分这个案例为微服务, 你会怎样拆分呢?
本文由 mdnice 多平台发布