最近一年来,我所在的项目为一个传统行业客户的 IT 核心系统做遗留系统改造,我参与了该系统一个业务模块的拆分和服务化,在这过程中落地了一些有意思的实践,特此记录下来和大家分享。
项目背景
这是一个运行了至少 15 年的单体系统,采用的技术栈是 JDK8、Servlet、JSP、Oracle、JDBC、存储过程、Weblogic,从这些关键词就能感受到它的沧桑感。整个系统都在一个代码仓库中,或按业务或按功能划分成了 30 多个 maven 模块,模块间可以任意调用彼此的方法,也可以随意访问彼此业务的数据库表。最让人糟心的是,大部分的业务逻辑都写在复杂的 SQL 语句和存储过程里,几百行的 SQL、一堆表的 join、层层调用的存储过程比比皆是。当然,该系统也没有落下“没有自动化测试”这个遗留系统的典型标签。
图1 单个代码仓库里包含的各种业务模块和技术模块
客户PO是一个对技术有理想有抱负的人物,不希望这个系统再继续腐化下去,所以找到我司对该系统进行现代化改造,其中一个落地措施就是对这个单体系统进行拆分和服务化。经过挑选,A 业务成为了这一批改造的试点对象。
这次拆分的目标是:将 A 业务的代码和数据库表从原有代码和数据库中拆分出来,形成独立的 A 服务及其数据库,实现 A 业务的代码独立、数据独立、部署独立。
图2 拆分目标
总体策略
这次服务拆分的策略归纳起来有三条:
1. 先代码拆分、后数据拆分代码和数据是服务拆分的两个重要物理实体。先拆代码还是先拆数据,在《Monolith to Microservices》中介绍了这两种方式的优劣。我们考虑到在现有代码极其复杂的前提下,先拆数据会给代码带来更大的复杂性,并且在出现问题、需要回滚的情况下,拆分前后的数据一致性也十分困难,因此我们选择了先代码拆分的策略。
图3 先代码拆分、后数据拆分
2. 以单个页面请求为单位进行拆分拆分工作由 10 位开发人员承担,如何划分大家工作内容呢:按数据库表?按 Servlet?按页面?我们选择的是按请求来划分。A 业务的后端 Servlet 提供了近 300 个功能,每个功能对应一个前端请求URL。我们以单个请求为颗粒度划分开发任务,并按我们熟悉的敏捷开发方式创建 Jira 卡片、安排到每个迭代中。按这样的颗粒度划分后,大多数开发任务可以对应到1、2、3、5天的工作量,非常有利于安排每个迭代的内容、分批上线、形成紧凑的工作节奏、缩小每个开发任务的测试范围。
3. 代码先完整复制,再修改新服务的框架搭起来以后,是一开始就把 A 业务的代码复制到新创建的服务中,还是在做开发任务的时候才把涉及到的代码复制到新服务中再做修改?我们选择的是前者,因为后者在多人并行开发的时候会遇到复制冲突的问题,与其这样,不如一开始就整体复制好,在做开发任务的时候再修改涉及到的代码。当然,一开始还需要把一些公共代码或者依赖的其他业务代码也复制过来,以保证 A 服务的代码能编译通过。
使用 Feature Toggle 用于功能回滚
“
只要是对代码的改动,就有可能引入 bug。—— 我说的
”
虽然Dev、QA团队尽力最大努力,但依旧无法避免拆分出来的服务上线后出bug,一旦出现,需要尽快切换回原有系统以减少对业务的影响。结合我们以页面请求为单位进行拆分的方式,我们引入 feature toggle 作为切换新旧系统的开关,控制前端发来的请求是发送给原有系统还是发送给拆分出来的服务。
图4 使用 Feature Toggle 切换新旧系统
在实现的时候,所有请求默认还是先发给原有系统,我们在原有系统的后端新增了一个请求过滤器,在过滤器中提取请求的 URL,根据 URL 判断:如果是已经拆分出去的功能请求,并且数据库中记录的 toggle 是开启的,则将请求转发给新服务处理;反之依旧交由原有系统处理。
既然将回滚作为快速恢复功能的手段,那引入了一项开发约定:在拆分过程中,只允许修改新服务的代码,不允许修改原有系统的代码。
Feature Toggle 不仅在处理线上问题时可以用来及时止血,还给团队带来了额外的好处:
- 在上线前,Dev和QA可以切换开关,快速比对某个功能的改造前后的效果是否一致。
- 到了上线时间,但测试尚未充分,或者在年底大促的业务高峰期担心引入 bug 影响业务的时候,就出现了“开发完成但不能上线”的情况,这时关闭对应的 toggle 即可让拆分后的功能暂缓上线。
使用代码分析工具简化数据库表的使用分析
每一个拆分任务的重点工作之一,是识别该功能读写的表是否是 A 业务的表。因为只有 A 业务的表,最终才会拆分到 A 数据库中;反之如果不是 A 业务的表,被视为只有原有系统才能直接读写,在 A 服务中无法读写,需要改为调用原有系统新增加 API 的方式来取代原有的数据操作。
如果是一个简单的功能,尚可通过肉眼查看代码来识别都操作了哪些表。但凡功能稍微复杂,人工查看的效率和准确性就大打折扣甚至不可行。好在我司的一个大牛为该项目开发了代码分析工具,它可以通过分析编译后的 Java 字节码文件,得到方法调用链上所有方法的调用关系,以及 SQL 和存储过程里读写的表,并将分析结果形成一个树形结构并以 xmind 或者 svg 的格式保存下来。开发人员有了分析结果,不费吹灰之力就能知道当前拆分的功能涉及哪些表,以及在调用哪个方法的时候用到了这些表,从而对接下来要拆分的代码心中有数。
图5 一个稍微复杂一点的方法调用链分析结果
如果没有这个分析工具,Dev 可能要花好几天甚至好几周去分析一个复杂的待拆分功能,而现在只需要几秒钟,分析结果就呈现在眼前。这个工具被客户誉为“神器”,我们在用的时候也时常感叹:“自动化真香!”
使用 Code Owner 保持新旧代码的一致
在拆分过程中,如果有新需求涉及 A 业务的变更,则原有系统和新服务中的代码都需要同步修改,否则就会出现二者的功能不一致:
- 如果只修改了原有系统而未修改新服务,那么该功能在拆分改造后,功能就会和改造前存在差异。
- 如果只修改了新服务而未修改原有系统,那么一旦 toggle 关闭,则原有系统则无法提供新需求的功能。
客户使用的版本控制系统是 BitBucket,并且以提交 Pull Request(PR)的方式合并新代码。因此我们使用了 BitBucket 的 Code Owner 功能(Github、Gitlab 也有该功能)监控原有系统中 A 业务涉及的模块和文件夹,同时也监控了新服务所有代码,并将拆分团队的两位骨干Dev设置为 Code Owner。这样一旦在 PR 中包含被监控代码的改动,则会自动把 Code Owner 设置为 PR 的 Reviewer,Code Owner 收到系统通知后会检查代码是否做了同步修改。如果代码修改未同步,则不允许合并 Pull Request。
结语
“
让我们面对现实吧,我们今天所做的一切就是在编写明天的遗留系统 —— Martin Fowler
”
我们正在书写、即将面对、正在面对遗留系统。在与遗留系统的相爱相杀中,需要我们基于项目目标和现状、结合过往经验、经过剪裁和取舍,才能迎面不断出现的挑战。我以此文抛砖引玉,欢迎大家交流拍砖。
- 相关阅读 -
Thoughtworks 全球CTO:按需求构建架构,过度工程只会“劳民伤财”
故事点 vs. 人天
点击【阅读原文】可至洞见网站查看原文&加粗字体部分的相关链接。
本文版权属Thoughtworks公司所有,如需转载请在后台留言联系。