引言: 遗留系统

遗留系统的一些明显特征

  • 代码质量一言难尽
  • 架构混乱,模块之间职责不明,改一个简单的功能也会改到几个服务
  • 缺乏单元测试,集成测试
  • 老旧的技术和工具
  • 上手难度高,业务知识难以获取
  • 缺乏CI/CD

为什么对遗留系统现代化?

业务价值层面

  • 遗留系统蕴含了大量的数据资产
  • 遗留系统中还藏匿着丰富的业务知识
  • 仍然有大量用户在使用,为公司带来收益

业务交付层面

  • 缩短需求交付周期
  • 增加需求交付质量
  • 减少系统故障的概率
  • 减少线上工单数量

遗留系统现代化的技术建设

  • 代码现代化,对遗留系统的代码进行安全的可测试化重构
  • 架构现代化,微服务架构或云原生架构,通常和代码现代化并行进行。对遗留系统内部改造的同时,将内部模块拆分到外面,或者将新需求实现到遗留系统外部
  • DevOps现代化以应对频繁的迭代/hotfix(我们系统里面argodeploy已解决)
  • 监控告警现代化,增加系统的可观测性
  • 对老旧的技术/工具进行升级

DDD

为什么选择DDD重构老架构

方法优势劣势适用场景
传统分层架构简单直观容易产生大泥球简单业务
DDD业务驱动学习成本高复杂业务领域

什么是DDD(领域驱动设计)

DDD 是一种架构设计方法论,的核心思想是通过领域驱动设计方法定义领域模型,从而确定业务和应用边界,保证业务模型与代码模型的一致性。

DDD为什么适合微服务/复杂业务

它可以帮我我们设计出清晰的领域和应用边界, 更容易实现业务和架构上的演进, 当然DDD实际上也被广泛适用于单体应用

DDD的战术设计和战略设计

DDD的战略设计

战略设计主要从业务视角出发,建立业务领域模型,划分领域边界。使用限界上下文确定业务边界

DDD的战术设计

战术设计则从技术视角出发,侧重于领域模型的技术实现,完成软件开发和落地,包括:聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的设计和实现。

DDD核心概念

  • 领域:限定业务范围,比如观麦业务领域有毛菜,净菜,央厨等子领域,这些子领域还可以更近一步细化
  • 子领域:领域可以划分成为多个子领域,每个子域对应一个更小的问题域或更小的业务范围
  • 核心域:公司核心竞争力的子域,对于观麦来说,就是进销存,订单,采购等领域
  • 通用域:多个子域使用的通用功能子域是通用域,比如商品服务,账户服务,短信服务等
  • 支撑域:为核心业务提供辅助功能的子域,比如openAPI,openMsg,营销交互层等
  • 事件风暴:事件风暴是一项团队活动,领域专家与项目团队通过头脑风暴的形式,罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合,然后对每一个事件,标注出导致该事件的命令,再为每一个事件标注出命令发起方的角色。命令可以是用户发起,也可以是第三方系统调用或者定时器触发等,最后对事件进行分类,整理出实体、聚合、聚合根以及限界上下文。
  • 限界上下文:保证在领域之内的一些术语、业务相关对象有一个确切的含义,没有二义性。确保通用语言语义的唯一性。
  • 实体:拥有唯一标识符,且标识符在历经各种状态变更后仍能保持一致, 比如订单,商品,实体上通常挂有很多的业务逻辑
  • 值对象:值对象是描述事物的状态或属性的对象,它没有唯一标识,并且通常是不可变的,值对象上不会挂复杂业务逻辑
  • 聚合:而能让实体和值对象协同工作的组织就是聚合
  • 聚合根:聚合不仅是实体,也是聚合的入口点和管理者
  • 仓储:又称repo, 一个聚合对应一个repo, 你可以理解为实现了领域对象到数据库对象转换的dao层。
  • 领域服务:当业务逻辑在单一聚合无法实现的时候需要,组合多个聚合实现业务逻辑。
  • 领域事件:领域模型中发生的事件,用于减少领域之间的耦合,通过发布订阅模式发布领域事件,让订阅者自行订阅
  • 防腐层(ACL): 使用防腐层来隔离新旧系统,做一些协议/命名的转换,防止遗留系统的接口schema变更影响到新的领域服务

毛菜DDD战略设计

按战略设计思想基本划分毛菜的核心,通用,支撑域

ddd domain

老架构毛菜的核心域是订单,进销存,配送,采购。所以需要重构重点聚焦于红色的核心领域代码质量很差的部分,比如进销存,分拣, 采购,订单等,而其他的领域优先级可以往后。

DDD战术设计

DDD分层架构

以下的代码分层为一个限界上下文(微服务)的分层

ddd layer

  • 用户接口层: webApi, 前端模版的渲染,用户界面,异步任务,定时任务
  • 应用层: 微服务,领域服务的编排,协调,认证,权限校验,事务控制,领域事件发布订阅,这一层越薄越好。
  • 领域层: 包含包含聚合根、实体、值对象、领域服务
  • 基础层: 数据库,事件总线,缓存,等第三方工具的具体实现

领域服务只能被应用服务调用,而应用服务只能被用户接口层调用,服务是逐层对外封装或组合的

老架构DDD工程分包结构

project/
├── src/
│   ├── facade/
│   │   ├── web 
│   │   │   ├── controller/
│   │   │   ├── dto/
│   │   │   ├── converter/
│   │   │   └── adapter/
│   │   ├── tools/
│   │   ├── asynctask/
│   │   └── crontask/
│   │
│   ├── application/
│   │   ├── event/  
│   │   │   ├── publish/
│   │   │   └── subscribe/
│   │   ├── service/   
│   │   └── client/
│   │       ├── acl
│   │       └── dto
│   │
│   ├──domain/
│   │   ├── aggregate01/
│   │   │   ├── event/
│   │   │   ├── entity/
│   │   │   ├── repository/
│   │   │   ├── exception/
│   │   │   └── service/
│   │   │       
│   │   └── aggregate02/
│   │
│   ├── infrastructure/
│   │    ├── repo/
│   │    │   ├── aggregate01/
│   │    │   │   └── po.py
│   │    │   └── aggregate02/
│   │    ├── cache/
│   │    ├── persistent/
│   │    ├── eventbus/
│   │    ├── log/
│   │    ├── web/
│   │    ├── client/
│   │    │   ├── grpc
│   │    │   └── http
│   │    ├── utils/
│   │    └── config/
│   │
│   └── main/
├── test/
│   │
│   ├── unit/
│   │   ├── application/
│   │   │   └── service/
│   │   │       └── test_xxxx.py
│   │   │   
│   │   │
│   │   └── domain/
│   │       ├── aggregate01/
│   │       │   └── test_aggregate01_xxxx.py
│   │       └── aggregate02/
│   │
│   └── intergration
│       ├── infrastructure/
│       ├── repo/
│       │   └── aggregate01/
│       │       └── test_aggregate01.py
│       └── eventbus/
│           └── test_pub_sub.py

DDD设计下微服务的代码分层,调用关系

service call

老架构重构策略

增量演进

一步到位全部推翻重构的风险

参考新架构毛菜

增量演进的好处

通过增量演进,新旧系统并存的方式逐渐改掉遗留服务。让新老系统并行运行,新系统出现故障快速切换老系统。我们做新的需求的同时,顺带以可测试化的方式重构一部分相关的逻辑,使用DDD的模式把需求用微服务的方式实现在遗留系统外,针对重构的接口配置灰度路由规则和开关。

todo:

  • 因为实现在遗留系统外面的新服务不可能再使用Django session用户认证机制,所以需要一个统一认证鉴权网关来传递租户上下文至新的领域服务。

如何拆分数据?数据归属问题?

暂时不要拆分,先共享数据库。

我们的系统里面到处都是所有模块都可以随意访问任意的表,操作这些数据的业务逻辑散落在各个服务中,在重构出清晰的领域模型之前,很难找出它真正属于谁。我们需要先以增量改进,共存的模式重构代码,再去拆分逻辑/物理数据库,这个是最后一步的事情。目前代码质量,架构现代化的优先级会高于数据归属,拆分。

万幸的是,我们系统里面没有横跨了多个领域数据的复杂联表查询。

有以下几种方式去在新拆分的领域服务里面去读取遗留系统的数据库:

  • 通过ACL防腐层实现repo直接读取

acl

  • 通过订阅数据库CDC(目前mongodb低版本不支持),事件总线的方式去把遗留系统的数据持久化在自己的领域, 当然也需要ACL来实现(老架构推荐)
  • 通过事件总线的方式订阅遗留系统数据

acl

  • 通过调用遗留系统API,ACL转换协议和schema(适用order, merchandise)

acl

实战:出库单,入库单相关需求

我们可以在写某个领域的需求的时候,对那个领域进行事件风暴以梳理出领域模型,比如我这次写出库损耗,入库损耗的时候,我可以用这种方式去顺带重构部分出库单,入库单的代码

一. 事件风暴

标准参与者:研发,测试,产品, 业务人员

我们需要重点关注这类业务的语言和行为。比如某些业务动作或行为(事件)是否会触发下一个业务动作,这个动作(事件)的输入和输出是什么?是谁(实体)发出的什么动作(命令),触发了这个动作(事件)

事件风暴的要素:

ddd_event_storming

进销存简化版事件风暴(只列举了一小部分):

ddd_event_storming(聚合和聚合之间不需要强一致,可以通过跨聚合的事务, 消息队列同步的保证数据的最终一致性)

要重构哪个模块,就用事件风暴把哪个模块的事件,实体,命令理清楚。

二. 技术设计,建立领域模型

进销存领域库存管理上下文入库单聚合

其他出库单/出库单聚合

classDiagram
     class OtherOutStockSheet {
        + String ID
        + ValueObject Type
        + String BatchNumber
        + List[OtherOutStockSheetDetail] Details # 商品项实体 
     }
     class OtherOutStockSheetDetail {

     }

     OtherOutStockSheetDetail "n" -- "1" OtherOutStockSheet

供应商上下文供应商聚合

classDiagram
    class Supplier {
        +String ID # 聚合根id
        +String No
        +String Address
        +ValueObject Station # station值对象
        +Object BizInfo #业务信息实体
        +Bool IsAutoSyncPuchaseSheet
        +List[SupplyCategory] SupplyCategorys # 可供分类实体
        +List[String] # 可供分类Sku
        +List[Contract] # 合同实体


        +Create(CreateSupplierDo) SupplierCreatedEvent
        +AddContract(AddContractDo) ContractAddedEvent
        +AddSupplySku(String) SupplySkuAddedEvent
    }
    

批次聚合

……

商品库存聚合

……

三. 要重构的需求到单元测试

任务的分解法

  • 从原始需求需求出发:首先分解出可验证的用户故事或功能点
  • 逐步细化:将大功能分解为可测试的小单元
  • 可测试性:确保每个分解后的任务都能用测试验证
用户故事的作用
  • 用户故事从用户(或角色)的角度出发,描述了他们希望从产品中获得的价值。这种描述通常以“作为…,我想要…,以便…”的格式,清晰地表达了用户的需求和背后的原因。
  • 通过用户故事,开发团队可以清楚地了解用户是谁,他们想要做什么,以及为什么要做。这种清晰的需求定义有助于避免开发过程中的歧义和误解。
  • 用户故事能够将需求分解为更小的,可实现的目标,使团队更容易理解和管理。

标准用户故事(站在用户角度,按照用户与系统的单次交互拆分)

标准的用户故事通常遵循以下格式:

作为一个[角色],我想要[功能],以便于[价值/收益]

本次要重构的业务场景/需求

  1. 生鲜erp出库单出库,需要扣减库存,批次库存,生成台账,然后生成出库明细,出库汇总

本次需求的用户故事

作为仓库管理员,我想要通过系统生成出库单,以便于准确记录商品流向(销售/调拨/报损),并自动扣减库存,批次库存。

从用户故事到验收条件(分解成可测试单元)

  • 系统能根据订单类型(销售/调拨/报损)自动生成唯一编号的出库单,包含必填字段:出库单号、出库日期、客户、商品、数量、批次号
  • 系统在出库单审核通过后,自动扣减对应批次库存,商品库存,确保扣减数量与出库单一致
  • 出库单出库的时候,实时校验即时库存是否满足出库数量。若库存不足且租户设置为“不允许负库存”,系统应明确报错并阻止操作,提示具体商品、短缺数量及当前可用库存
  • 出库单出库异步同步至库存台账,出库明细,出库汇总。
  • ………….

根据上一步的验收条件, 先写单元测试,在写业务代码

单元测试是极其极其重要的,不仅仅是质量门禁的重要一部分,也是代码活文档,依靠产品设计,技术设计的文档是不可靠的。我们可以从单元测试(验收条件)中获取到业务知识,然而单元测试依赖于良好的代码的低耦合高内聚,业务逻辑和基础设施的分离,在目前的老架构想写出优质的单元测试是不可能的,而ddd战术设计可以帮助我们写出低耦合高内聚的代码。

例子:单元测试和业务代码:

选择性写集成测试

推荐集成测试工具:test-container,它提供可以在Docker容器中运行的任何东西的轻量级,一次性的实例。我们可以用它启动数据库,中间件去测试,重要的地方可以加下,比如事件总线,普通的数据库操作必要不大

https://testcontainers.com/

四. 不依赖单元测试的纯代码重构手段

  • 重命名(变量、方法、类)
  • 提取方法/函数
  • 内联方法/函数
  • 移动方法/类到合适的位置
  • 改变变量作用域
  • dict改为schema类
  • 为mongodb增加model类
  • …………

这样做明显成本更低,但是老架构依然是大泥球单体服务,各个模块之间的边界依旧模糊,业务知识难以获取的状态,只是略微提高了代码可读性,其实并没有实现真正的代码现代化,架构现代化和可测试化,再过几年,随着人员流动,老架构可能又会回到和现在一样。

该方案其他待办

必要项

  • 统一认证网关,以便向新系统传播租户上下文
  • 发布工具改造,支持接口级别的灰度
  • 现代化的事件总线组件/数据库CDC机制
  • 代码review机制
  • 分库分表中间件

非必要项

  • 现代化的业务监控系统,链路追踪
  • 服务的熔断,降级机制

风险

  • 业务风险,重构的新系统出现bug会对业务造成影响,需要快速通过接口级别的灰度机制快速切换回老系统
  • 可能比上面的纯代码重构手段更花时间
  • 团队ddd的学习成本

结论

个人感觉没有必要再去用老架构python那一套去重构,老架构的基础设施除了异步任务框架和mqlib等于没有,之前的各种基类就是垃圾。新架构的基础设施更好的话,有限时间使用新架构的底子实施老架构的增量重构可能会更好?老架构想摆脱现状,必须开始对代码做可测试化的重构。ddd这种业务逻辑和基础设施解藕的思想,可以帮我们更好实施该重构方案。