关于后端:DDD系列-实战一-应用设计案例-golang

46次阅读

共计 10027 个字符,预计需要花费 26 分钟才能阅读完成。

DDD 系列 实战一 利用设计案例 (golang)

基于 ddd 的设计思维, 外围畛域须要由纯内存对象 + 基础设施的形象的接口组成

  • 独立于内部框架: 比方 web 框架能够是 gin, 也能够是 beego
  • 独立于客户端: 比方客户端能够是 web, 能够是挪动端, 也能够是其余服务 rpc 调用
  • 独立于根底组件: 比方数据库能够是 MySQL, 能够是 MongoDB, 甚至是本地文件
  • 独立于第三方库: 比方加密库能够是 bcrypt, 也能够是其余加密库, 不会应为第三方库的变更而大幅影响到外围畛域
  • 可测性: 外围畛域的 domain 是纯内存对象, 依赖的基础设施的接口是形象的, 能够 mock 掉, 便于测试

怎么实现呢? 上面我依据一个案例来一步步展现通过 DDD 重构三层架构的过程

案例形容

本案例将会创立一个利用, 提供如下 web 接口 (目前应用 golang 实现)

  • 注册 POST /auth/register
  • 登录 POST /auth/login
  • 获取用户信息 GET /user
  • 转账 POST /transfer

DDD 是为了简单零碎变动而设计的, 如果零碎简略, 需要变动不大. 那么脚本代码才是最好的抉择, 不仅能疾速开发, 而且了解.

所以这次案例里, 为了展现 DDD 设计的高拓展性, 需要次要强调 变动 的场景.

注册

  • 目前通过账号和明码注册, 当前可能减少依据手机号邮箱等注册
  • 用户的明码须要加密, 目前应用 hash 加密, 前面可能应用其余加密
  • 目前保留数据应用的 MySQL, 当前可能应用其余数据库

登录

同理注册

鉴权

除了注册登录, 其余接口都须要鉴权

  • 目前应用 redis 鉴权, 当前可能会更换为 jwt 鉴权, 须要有切换的能力

转账 (外围)

一个用户转账给另一个用户

  • 须要反对跨币种转账
  • 目前转账的汇率从第三方 (微软的 api) 获取, 当前可能会思考变更或者做缓存
  • 目前转账收取手续费 10%, 当前可能依据用户 vip 等级收取不同的手续费
  • 须要保留账单, 以便审计和对账用
  • 目前账单是保留在 MySQL 中, 当前可能会思考保留到其余数据库或者音讯队列生产

接口

  • 目前提供 web 接口, 当前可能会提供 rpc 或者其余接口

什么是 MVC 三层架构?

对于个别的后端开发来说, 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 层个别十分臃肿, 蕴含各种参数校验(这里省略了很多参数校验和错误处理), 逻辑计算, 数据操作, 甚至还蕴含了一些第三方服务的调用.

这样的代码, 也称之为 “ 事务脚本 ”, “ 胶水代码 ”, “ 面向过程代码 ” 等等. 长处是简略容易了解, 毛病是代码臃肿, 代码可维护性差, 代码可拓展性差, 代码可测试性差.

问题 1: 代码可维护性差

代码可维护性 = 当依赖变动的时候, 须要批改多少代码

参考下面的代码, 咱们发现胶水代码的可维护性比拟差次要因为以下起因

  • 数据结构不稳固: user 是一个存数据类, 通过 gorm 映射了 MySQL 中的 user 表. 这里的问题是 MySQL 属于内部依赖, 久远来看都可能扭转. 比方 ID 变成 int64 类型, 比方表中的字段名扭转; 比方数据量大了须要分库分表; 比方应用 Redis 或则 MongoDB 代替 MYSQL
  • 依赖库的不稳固: 比方 grom 降级导致 api 不统一; 比方咱们须要用 beego 代替 grom; 比方咱们须要用 goweb 框架代替 gin
  • 依赖服务的不稳固: 比方咱们依赖的 MicroServer 降级导致 api 不统一; 比方咱们须要用 GoogleService 代替 MicroService

问题 2: 代码可拓展性差

可拓展性 =减少或者批改需要的时候, 须要批改多少代码

参考下面的代码, 如果咱们明天要减少一个充值的性能, 咱们能够发现下面的代码根本没有能够复用的逻辑

充值性能须要将银行卡的钱充值到余额, 银行卡可能是其余银行的银行卡, 其余银行卡的用户的数据结构可能不统一

  • 数据格式不兼容: 其余银行卡的用户的数据结构可能不统一, 导致数据校验, 数据读写, 错误处理, 金额计算, 手续费计算 等等逻辑都须要从新写
  • 业务逻辑无奈复用: 因为数据结构的不统一, 所以业务逻辑根本须要将原来的复制过来, 而后从新改
  • 业务逻辑和数据贮存耦合重大: 当业务逻辑变得越来越简单的时候, 新增的业务逻辑可能须要新的数据结构, 转账性能和充值性能都须要改代码

个别的胶水代码做需要都十分快, 然而可复用的逻辑很少, 一旦波及到新增有雷同然而又不同的逻辑或者批改需要, 须要批改的代码很多. 如果有中央的代码忘了改就是一个 bug. 在重复变动的需要中, 代码的可拓展性显得很差

问题 3: 代码可测试性差

可测试性 = 运行每个测试用例所破费的工夫 * 每个需要所须要减少的测试用例数量

除了局部工具类、框架类和中间件类的代码有比拟高的测试笼罩之外,咱们在日常工作中很难看到业务代码有比拟好的测试笼罩,而绝大部分的上线前的测试属于人肉的“集成测试”。低测试率导致咱们对代码品质很难有把控,容易错过边界条件,异样 case 只有线上暴发了才被动发现。而低测试覆盖率的次要起因是业务代码的可测试性比拟差。

参考下面的代码, 这种代码可测试性比拟差的起因是:

  • 基础设施搭建艰难: 比方代码中依赖了数据库, 第三方服务 等内部依赖, 想要跑测试用例须要将所有的依赖都跑起来. 本案例作为 demo 依赖还是比拟少的. 个别我的项目有 Reids MySQL ES 音讯队列 再加 10 个三方微服务, 请问怎么写单元测试?
  • 耗时长: 如果跑测试用例的工夫超过 1 分钟, 大部分程序员就不会去跑测试用例了, 这种测试即便所有的依赖都搭建起来的了, 各种 IO 网络 都十分耗时, 测试工夫比拟长. 面对这种窘境, 程序员个别抉择 “ 颅内测试 ”
  • 测试用例简单: 如果一个脚本中有 3 个步骤, 每个步骤对应了 N 个状态, 那么测试用例的数量就是 N*N*N当胶水代码中的步骤越来越多, 测试用例将会出现指数级增长

个别的这样的胶水代码, 当测试比较复杂的时候, 开发人员无奈写这个办法的单元测试, 依赖测试人员的 “ 人肉测试 ” 或者开发人员的 “ 颅内测试 ”.

每次代码变动, 之前的测试就可能变得不牢靠, 有须要从新 “ 人肉测试 ” 或者 “ 颅内测试 ”, 如果每天变动 2 次代码, 就陷入了有限的测试和代码 review 的风暴中

问题总结

咱们从新来剖析一下为什么以上的问题会呈现?因为以上的代码违反了至多以下几个软件设计的准则:

  • 单一性准则(Single Responsibility Principle):单一性准则要求一个对象 / 类应该只有一个变更的起因。然而在这个案例里,代码可能会因为任意一个内部依赖或计算逻辑的扭转而扭转。
  • 依赖反转准则(Dependency Inversion Principle):下层模块不要依赖底层, 应该依赖底层的形象, 面向接口编程。在这个案例里内部依赖都是具体的实现, 比方 MicroService 尽管是一个接口类,然而它对应的是依赖了 MicroSoft 提供的具体服务,所以也算是依赖了实现。同样的 grom.DB 实现也属于具体实现。
  • 开闭准则(Open Closed Principle):对拓展凋谢, 对批改敞开。在这个案例里的金额计算属于可能会被批改的代码,这个时候该逻辑应该须要被包装成为不可批改的计算类,新性能通过计算类的拓展实现。

咱们须要对代码重构能力解决这些问题。上面咱们就来看下如何应用 DDD 重构咱们下面的胶水代码

如何应用 DDD 重构?

怎么重构呢? 次要分为 2 方面:

  • 抽离逻辑: 贫血的类数据没有逻辑, 充血模型的类既有数据又有逻辑. DDD 的思维是将数据校验和逻辑抽离到存内存的类中, 这品种不能有任何的依赖
  • 形象接口: 将依赖形象一个接口, 不依赖具体的实例, 依赖接口

参考下面的代码, 咱们画一张流程图形容一下次要的步骤:

  • TransferController 依赖 TransferService, TransferService 依赖 MicroService 和 GROM
  • 业务层中彩色字体代表业务逻辑, 红色字体代表须要依赖基础设施的输入输出
  • 读数据, 更新金额, 保留账单依赖于 GROM
  • 获取汇率 依赖 MicroService

第一步 抽离逻辑

业务层中彩色字体代表业务逻辑, 蕴含 参数查看, 金额计算和转账

MVC 三层架构是一种贫血模型, 贫血模型的类数据没有逻辑, 充血模型的类既有数据又有逻辑

咱们要做的是: 将 Service 中的逻辑抽离到类中

(1) 应用 Domain Object 代替根底数据类型
// 根底数据类型
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 是纯内存的, 没有任何依赖, 非常不便测试

(2) 应用 Domain Service 封装业务逻辑

之前咱们的计算金额和转账逻辑都是由 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 实现也属于具体实现。

咱们要做的就是形象一层接口进去, 面向接口编程

(1) 形象汇率获取服务
type RateService interface {GetRate(from *model.Currency, to *model.Currency) (*model.Rate, error)
}

而后 MicroRateService 是该接口的一种实现, 这种设计叫也做 防腐层

防腐层不仅仅是多了一层调用, 它还能够提供如下性能

  • 适配器:很多时候内部依赖的数据、接口和协定并不合乎外部标准,通过适配器模式,能够将数据转化逻辑封装到防腐层外部,升高对业务代码的侵入。在这个案例里,咱们通过封装了 Rate 和 Currency 对象,转化了对方的入参和出参,让入参出参更合乎咱们的规范。
  • 缓存:对于频繁调用且数据变更不频繁的内部依赖,通过在防腐层里嵌入缓存逻辑,可能无效的升高对于内部依赖的申请压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入防腐层,可能升高业务代码的复杂度。
  • 兜底:如果内部依赖的稳定性较差,一个可能无效晋升咱们零碎稳定性的策略是通过防腐层起到兜底的作用,比方当内部依赖出问题后,返回最近一次胜利的缓存或业务兜底数据。这种兜底逻辑个别都比较复杂,如果散落在外围业务代码中会很难保护,通过集中在防腐层中,更加容易被测试和批改。
  • 易于测试:相似于之前的接口,防腐层的接口类可能很容易的实现 Mock 或 Stub,以便于单元测试。
  • 性能开关:有些时候咱们心愿能在某些场景下凋谢或敞开某个接口的性能,或者让某个接口返回一个特定的值,咱们能够在防腐层配置性能开关来实现,而不会对实在业务代码造成影响。同时,应用性能开关也能让咱们容易的实现 Monkey 测试,而不须要真正物理性的敞开内部依赖。
(2) 形象 gorm
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 个局部

  • 抽离逻辑 (彩色字体): 有数据又有逻辑的 Domain Object 对象 和没有任何依赖的 Domain Service 对象, 纯内存, 不便测试
  • 形象接口 (红色字体): 形象的基础设施层的接口, 不便 mock 测试

如何组织代码构造?

参考下面的代码, 你或者曾经蠢蠢欲动的尝试本人通过 DDD 的思维实现一下这个案例了
然而当你拿到需要时候, 关上 IDE 第一个问题就是: 如何组织代码构造?
要解决这个问题的前提我想是: 明确架构

MVC 三层架构

比方下面代码的例子

DDD 四层架构

比方下面代码的重构后的例子

对应的四层架构模型如下

DDD 六边形架构 / 洋葱架构 / 洁净架构

在下面重构的代码里,如果摈弃掉所有 Repository、ACL、Producer 等的具体实现细节,咱们会发现每一个对外部的抽象类其实就是输出或输入,相似于计算机系统中的 I / O 节点。这个观点在 CQRS 架构中也同样实用,将所有接口分为 Command(输出)和 Query(输入)两种。除了 I / O 之外其余的外部逻辑,就是利用业务的外围逻辑。基于这个根底,Alistair Cockburn 在 2005 年提出了 Hexagonal Architecture(六边形架构),又被称之为 Ports and Adapters(端口和适配器架构)。

在这张图中:

  • I/ O 的具体实现在模型的最外层
  • 每个 I / O 的适配器在灰色地带
  • 每个 Hex 的边是一个端口
  • Hex 的地方是利用的外围畛域模型

在 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

因为在编码实际中,咱们总是基于一个业务用例来实现代码,在 “ 按层分包 ” 场景下,咱们须要在扩散的各包中来回切换,减少了代码导航的老本;另外,代码提交的变更内容也是散落的,在查看代码提交历史时,无奈直观的看出该次提交是对于什么业务性能的。在业务分包下,咱们只须要在单个对立的包下批改代码,缩小了代码导航老本;另外一个益处是,如果哪天咱们须要将某个业务迁徙到另外的我的项目(比方辨认出了独立的微服务),那么间接整体挪动业务包即可。

总结

  • 抽离逻辑: 将 service 逻辑抽到 domain 类中, domain 是纯内存对象, 独立于任何内部依赖
  • 形象接口: 依据依赖反转的设计思维, 不要依赖实现, 依赖接口; 如果是三方服务, 形象一个防腐层进去爱护本人的业务

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 是领导微服务拆分的重要思维

通过下面的代码, 如果你想要拆分这个案例为微服务, 你会怎么拆分呢?

reference

  • https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/
  • https://zhuanlan.zhihu.com/p/84223605

正文完
 0