《遗留系统现代化实战》读书笔记

 


遗留系统的定义

请你先思考这样一个问题:假如一个系统七八年了,它是不是个遗留系统?系统的时间长等同于就是遗留系统,这是很多人的一个误区。虽然大多数遗留系统确实是存在的时间很长,但并不等于时间长的都是遗留系统。

一个有着12年历史的老系统,它的技术栈最初是.NET Framework,现在已经有部分迁移到了.NET Core;它最初是单体架构,现在是一个小单体加多个微服务;它从第一行代码开始就使用 TDD 的方式开发,至今已经有 30000 多个不同类型的测试;它一开始使用 SVN 来管理源代码,不过早在十年前就被迁移到了 Git;它从第一天就有 CI/CD 相伴,并且一直坚持基于主干开发的分支策略,每个月都有稳定的版本发布;它没有一行注释,但是任何开发人员都能通过阅读源代码快速了解系统的实现,也就是说代码质量相当高。

这个系统历史12年之久,比很多公司活的时间都长。那它是遗留系统吗?答案是否定的。因为它的代码质量高、架构合理、自动化测试丰富、DevOps 成熟度也高,各种技术、工具都是相对先进的,怎么能说是遗留系统呢?

那么,存在时间短的系统就不是遗留系统么?

拿我个人经历过的一个项目来举例。它是 07 年左右开发完成的,当我 10 年加入项目组的时候发现,它仍旧是基于 JDK 1.4 的(那个时候 Java 6 已经发布 4 年了),很多 Java 的新特性都无法使用。它是一个 C/S 结构的软件,前端基于 Java 富客户端,后端是一个大单体向前端提供 RPC 服务;它没有一行测试,每改一行代码都提心吊胆,有时为了不影响别的功能,只好把代码复制一份,加入自己的逻辑,这就导致了大量的重复代码;每次发布日期都是一拖再拖,而部署到生产环境上的 war 包,甚至在是开发机器上打包的

所以说,时间长短并不是衡量遗留系统的标准。代码质量差、架构混乱、没有测试、纯手工的 DevOps(或运维)、老旧的技术和工具,才是遗留系统的真正特点。

 

 

判断遗留系统的几个维度:代码、架构、测试、DevOps 以及技术和工具。

遗留系统的特点:旧,过时,重要,仍在使用。

 

 


 

遗留系统现代化

1.代码现代化

将“祖传”代码重构成职责清晰,结构良好的优质代码,重点是:可测试化重构

例如如下代码,我想测试if的逻辑,当Dao方法返回一个null时,这段代码会抛出一个异常。

复制代码
public class EmployeeService {
  public EmployeeDto getEmployeeDto(long employeeId) {
    EmployeeDao employeeDao = new EmployeeDao();
    // 访问数据库获取一个Employee
    Employee employee = employeeDao.getEmployeeById(employeeId);
    if (employee == null) {
      throw new EmployeeNotFoundException(employeeId);
    }
    return convertToEmployeeDto(employee);
  }
}
复制代码

这是典型的不可测代码,因为EmployeeDao内部会访问数据库,从中读取出一个 Employee 对象。而这个 EmployeeDao 是在方法内通过 new 的方式直接构造的,就意味着这个方法对 EmployeeDao 的依赖是固定的,无法解耦的。

在单元测试中,我们不可能直接访问真是的数据库,因此这样的方法需要对其进行可测试话重构。比如下面这样:

复制代码
public class EmployeeService {
  private EmployeeDao employeeDao;
  public EmployeeService(EmployeeDao employeeDao) {
    this.employeeDao = employeeDao;
  }

  public EmployeeDto getEmployeeDto(long employeeId) {
    Employee employee = employeeDao.getEmployeeById(employeeId);
    if (employee == null) {
      throw new EmployeeNotFoundException(employeeId);
    }
    return convertToEmployeeDto(employee);
  }
}
复制代码

通过这次重构,我们把会访问数据库的 EmployeeDao 提取成类的私有字段,通过构造函数传入到 EmployeeService 中来,在 getEmployeeDto 方法中,就可以直接使用这个 EmployeeDao 实例,不用再去构造了。由于传入的 EmployeeDao 并不是 EmployeeService 构造的,所以后者对前者的依赖就不是固定的,是可以解耦的。

如果我们传入 EmployeeService 的是一个 new 出来的 EmployeeDao,那和原来的方法一样,仍然会去访问数据库;如果传入的是一个 EmployeeDao 的子类,而这个子类不会去访问数据库,那么 getEmployeeDto 这个方法就不会直接访问数据库,它就变成可测试的了。比如我们传入这样的一个子类:

public class InMemoryEmployeeDao extends EmployeeDao {
  @Override
  public Employee getEmployeeById(long employeeId) {
    return null;
  }
}

这样,想测试原方法中 if 的代码逻辑就非常方便了。

2.架构现代化

1)改造老城区:指对遗留系统内部的模块进行治理、让模块内部结构合理、模块之间职责清晰的一系列模式。前端方面包括单页应用注入、微前端等,后端包括抽象分支、扩张与收缩等,数据库端包括变更数据所有权、将数据库作为契约等。

2)建立新城区:指将遗留系统内部的某个模块拆分到外面,或将新需求实现在遗留系统外部的一系列模式。包括绞杀植物、冒泡上下文等。为了对新建立的新城区予以各种支持,老城区还可以通过提供 API、变动数据捕获、事件拦截等各种模式,与新城区进行集成。

3.DevOps现代化

要从头开始搭建一个 DevOps 平台,包括代码、构建、测试、打包、发布、配置、监控等多个方面。

4.团队结构现代化

四种团队拓扑包括业务流团队、复杂子系统团队、平台团队和赋能团队。

三种团队交互模式包括协作、服务和促进。我们在进行开发团队的组织结构规划时,应该参考这四种团队拓扑。

遗留系统现代化的三个原则

1. 以降低认知负载为前提

认知负载主要分为三大类,分别是:内在认知负载,外在认知负载和相关认知负载。我们需要做的是尽量降低外在认知负载。

 

 

如何降低外在认知负载?使用活文档。将代码转换为脑图的形式可以大大降低外在认知负载。例如下面的代码转变成脑图之后可以大大降低其他工作人员理解代码的时间,甚至业务分析师、开发人员、测试人员都可以围绕这样一份文档来讨论需求、设计测试用例。

实例化需求最好的工件就是活文档。所谓实例化需求,实际上指的是以现实中的例子来描述需求,而不是抽象的描述。同理将需求文档的描述转换成了测试的方法名,这样的测试也可以看作是一种活文档,此时测试与代码共同演进,通过了测试也就代表需求通过了。

复制代码
public class EmployeeService {
  public void createEmployee(long employeeId) { /*...*/ }
  public void updateEmployee(long employeeId) { /*...*/ }
  public void deleteEmployee(long employeeId) { /*...*/ }
  public EmployeeDto queryEmployee(long employeeId) { /*...*/ }
  public void assignWork(long employeeId, long ticketId) {
    // 获取员工
    EmployeeDao employeeDao = new EmployeeDao();
    EmployeeModel employee = employeeDao.getEmployeeById(employeeId);
    if (employee == null) {
      throw new RuntimeException("员工不存在");
    }
    // 获取工单
    WorkTicketDao workTicketDao = new EmployeeDao();
    WorkTicketModel workTicket = workTicketDao.getWorkTicketById(ticketId);
    if (workTicket == null) {
      throw new RuntimeException("工单不存在");
    }

    // 校验是否可以将员工分配到工单上
    if ((employee.getEmployeeType() != 6 && employee.getEmployeeStatus() == 3)
          || (employee.getEmployeeType() == 5 && workTicket.getTicketType() == "2")) {
      throw new RuntimeException("员工类型与工单不匹配,不能将员工分配到工单上");
    }

    if (!isWorkTicketLocked(workTicket)) {
      if (!isWorkTicketInitialized(workTicket)) {
        throw new RuntimeException("工单尚未初始化");
      }
    }
    
    // ...
  }

  public void cancelWork(long employeeId, long ticketId) { /*...*/ }

复制代码

 

 

 

2. 以假设驱动为指引

以假设驱动的方式进行开发,我可以在某个方向上快速验证,如果假设不成立,就立即止损,不再追加投资。这样整个过程就显得十分精益了。

比如对于一个电子商城的 App,我们假设如果在商品的展示页面中加入视频功能,商品的销量就会增加 10%。之后我们就开始开发视频功能,上线之后通过 A/B 测试做实验对比,看看是否加入了视频功能的商品,销量真的会增加 10%。拿商品页的视频功能为例,我可以先开发一个极其简单的版本快速上线,在对比发现真的对销量有提升效果后,再来逐步优化整个方案,比如延长视频时间、提高视频清晰度,甚至把直播带货时该商品的介绍剪辑下来,放到商品页等等。这样不断迭代,每一步都通过假设驱动,并不断验证假设,得到能带来最多客户价值的方案。

假设驱动开发与传统的需求式开发不同,它先对要达成的目标做一个假设,这个目标其实才是我们真正要解决的问题。然后根据假设制定解决方案,也就是我们平时开发时所面对的需求。不同于传统需求式开发,并不是功能验收上线之后就算完成了,而是还要验证假设,看看所收集到的数据是否支持我们的假设,从而帮助我们更好地演进产品。不以假设驱动,遗留系统现代化的很多技术改进就会盲目开展,最后忘了初心,走错了方向。

在应用假设驱动开发时,你首先要根据自己的项目制定一些目标,然后再根据目标建立度量体系。这样,所有的技术改进都可以围绕这些指标展开了。

在建立度量指标时要尽量避免绝对的数值,而要尽量用数据的比值。比值更能体现数据的相对性,比绝对的数值更能减少误差。

最后,要记得把数据可视化出来,可以打印出来贴在墙上,也可以用一个大显示器立在团队旁边。它们一方面可以激励团队成员,另一方面也是向业务方展示工作的成果,让他们相信,一个看上去很技术向的改进任务,也能给业务带来巨大的价值。

3. 以增量演进为手段

增量演进是指,以增量的方式,不断向明确的目标前进的过程。

代码的增量演进

举个例子,如下代码来自《代码整洁之道》第2章“有意义的命名”。这段代码来自一个扫雷游戏,想实现获取所有被标记过的单元格的目的。

public List<int[]> getThem() {
 List<int[]> list1 = new ArrayList<int[]>();
 for (int[] x : theList)
   if (x[0] == 4)
    list1.add(x);
 return list1;
}

然而你不难发现,这段代码的坏味道远不止 getThem、theList 这种晦涩的命名,还包括魔法数字、基本类型偏执等。

面对如此多的坏味道,我相信对代码有洁癖的你,已经摩拳擦掌准备重构了吧?但是请别急,如果你直接改代码,在没有测试的情况下,有信心保证正百分之百正确吗?

在遗留系统中,到处充斥着这样的糟糕代码,而且没有测试覆盖。我们可以选择先补测试,然后再开始重构。这也是我强烈推荐的方式,因为这样的步子迈得更稳、更扎实。

但有时代码本身并不可测,还要先完成可测试化改造。我的初衷就是单纯地重构这段代码,现在又要可测试化,又要加测试,似乎外延越来越广了,工作量也随之越来越大。有没有办法不用加测试,也能安全地重构呢,并且完成增量式交付呢?答案是肯定的。

这种方法其实很简单,就是先把代码复制出来一份,在复制的代码处进行重构。等重构完毕,再通过某种开关,来控制新旧代码的切换。在测试时,可以通过开关来做 A/B 测试,从而确保重构的正确性。

重构完的代码可以像下面这样,只有一行,十分精练:

public List<Cell> getFlaggedCells()  {
  return gameBoard.stream().filter(c -> c.isFlagged()).collect(toList());
}

在原方法的调用端,我们可以像这样引入开关,来实现这个增量:

复制代码
List<int[]> cells;
List<Cell> cellsRefactored;
if (toggleOff) {
  cells = getThem();
  // 其他代码
}
else {
  cellsRefactored = getFlaggedCells();
  // 其他代码
}
复制代码

开关的值通常都写到配置文件,或存储在数据库里。我们可以通过修改这个配置,不断验证新代码的行为是否和旧代码完全一致。直到经过了充分的测试,我们有了十足的信心,再来删掉开关,将旧代码完全删除。

总结:旧的不变,新的创建。一步切换,旧的再见。

架构的增量演进

对于架构或系统的替换,Martin Fowler 提出了绞杀植物模式。这源于他一次在澳大利亚旅行时发现的奇观,一棵巨大的古树被榕树的藤蔓缠绕,许多年以后最终被榕树所取代。

使用绞杀植物模式最主要的好处,就是降低风险。作为绞杀植物的新系统可以稳定提供价值,并且频繁发布。你还可以很好地监控它的状态和进度。这种新旧系统或架构同时存在、同时运行、逐渐替换的方式,就是我们的增量演进所追求的目标。

假设我们有这样一个单体系统,包含员工、财务和薪酬三个模块,其中员工和薪酬模块都会调用通知模块来发送邮件或短信。上游服务或前端页面通过 HTTP API 来访问不同的模块。

 

 第一步,建立开关。要实现增量演进,开关是必不可少的。一方面可以通过开关来控制 A/B 测试,以验证功能不被破坏,另一方面一旦新实现有问题,也能迅速回退到旧实现。

 

 第二部,增量迁移。按迭代逐步将薪酬模块的功能迁移到薪酬服务中。假设我们需要 4 个迭代来完成全部的迁移工作,迭代 0 的工作主要是为开发开关和搭建新服务的脚手架,其余迭代就可以按计划来迁移不同的功能了。

 

第三步,并行运行。对于有一定规模的架构演进,我强烈建议你将开关和旧代码保留一段时间,让新旧代码并行运行几个迭代。

第四步,代码清理。删除旧代码和开关,切记不要忘了这一步。很多遗留系统的架构演进都没有完成这一步,导致很多无用的代码留在系统中。它们除了给人带来迷惑之外没有任何用处。

遗留系统现代化的五种策略

 

posted @   李琦贝尔蒙特  阅读(419)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
点击右上角即可分享
微信分享提示