开场白不用写。直接从##开始。
写正文。 ## 1. 项目概述:t3code从哪来,要往哪去
干这一行久了,你会发现一个特别扎心的事实:很多系统不是被需求压垮的,而是被自己写烂的。我手上有过一个老项目,业务逻辑全堆在Service层,一个方法三百行起步,改一个支付状态要先翻三层调用链。更要命的是,数据库表之间没有外键约束,全靠代码里那点脆弱的约定撑着。每次上线都像拆炸弹,谁也不敢保证下一个改动会引爆什么。这种状态持续了大半年之后,我实在忍无可忍,决定启动一次彻底的重构。这个重构项目,代号就叫t3code。
t3code的目标很明确:把一套历史包袱沉重的单体应用,重构成一个结构清晰、可测试、可演进的技术底座。这里面的“t3”我理解为third generation的缩写,也就是第三代。第一代代码是原型验证用的,怎么快怎么来;第二代是为了应付业务增长打的补丁,开始有了模块划分,但边界模糊;第三代则是我想要的形态——每个模块边界清晰,依赖方向明确,关键路径上没有任何模糊地带。
这篇博文会完整拆解t3code这个项目的设计思路、实操过程和踩坑记录。如果你手头也有那种“运行得好好的但没人敢动”的历史项目,或者你正在规划一次从乱到治的技术改造,这篇文章应该能给你提供一个可参考的路线图。我会尽量把每个决策背后的原因讲清楚,而不是只告诉你结论。毕竟重构这件事,真正的难点从来不是写代码,而是做取舍。
2. 整体设计思路:先定边界,再谈重构
2.1 为什么叫“第三代”:三个阶段的演进逻辑
复盘那个老项目的演进史,其实很有意思。第一阶段,业务刚起步,团队为了快速上线,所有代码都堆在一个地方。Controller里直接写SQL,前端页面要调接口看心情,一个接口返回的字段数量能差出两倍。这个阶段谈不上架构,就是纯粹的“能跑就行”。
第二阶段,业务量上来了,团队开始做模块拆分。但那个阶段的分层做得不够彻底,虽然有了Controller、Service、DAO这种经典三层,但代码的归属还是乱的。比如用户相关的逻辑,在订单模块里也有一份,在支付模块里也有一份。改一个用户状态的判断规则,得全局搜索替换,漏一个就是线上事故。
到了t3code,我给自己定的原则很简单:模块之间只能通过接口通信,不允许直接调用对方的内部实现。每个模块必须明确自己负责什么、不负责什么,模块内部可以自由调整,但对外暴露的接口一旦定下来,不能随便改。这个原则听起来像是教科书上的话,但真正落地的时候需要极大的克制力。
2.2 核心痛点和设计原则
老项目最让我头疼的问题有三个。第一,循环依赖。A模块调用B模块,B模块又调用A模块,编译期不会报错,但代码评审的时候谁也说不清楚调用链的起点在哪里。第二,隐式共享。多个模块直接操作同一个全局缓存或同一个数据库表,一旦数据格式变了,牵一发动全身。第三,测试困难。代码里到处是静态方法调用和环境变量判断,想写单元测试基本无从下手。
针对这些问题,t3code定下了四条设计原则:
- 依赖单向化:模块之间的依赖关系必须是一个有向无环图,如果发现循环依赖,就说明模块划分有问题,需要重新调整边界。
- 数据私有化:每个模块只能通过自己对外提供的接口来读取或修改数据,不允许直接操作其他模块的存储层。
- 接口稳定化:对外接口一旦定义并发布,必须保持兼容,内部实现怎么改都不影响调用方。
- 可测试优先:每个模块的核心逻辑都应该在没有外部依赖(数据库、缓存、外部服务)的情况下可以被测试。
这四条原则用大白话说就是:每个模块都是一个小作坊,你只需要知道自己能买到什么(接口)、卖出什么(接口),至于别人家里怎么生产的,跟你没关系。
2.3 技术选型背后的思考
做重构的时候,很多人第一反应是“换个新框架”。但在t3code这个项目里,我刻意没有引入任何炫酷的新技术,还是用原来的语言和框架。原因很简单:重构的目的是降低维护成本,不是增加学习成本。如果一个团队已经在Java Spring生态里泡了五年,你突然说要换Go,光是把全员的思想工作做通就需要半年,这个成本远远超过了技术本身带来的收益。
t3code在技术选型上做的事情,更多是“减法”和“整理”。把原来分散在多个工具类里的公共能力收敛成一个基础模块;把原来靠注释约定的接口规范变成显式的接口定义和数据模型;把原来靠人肉执行的代码检查变成自动化脚本。这些动作都不需要新技术,但需要很强的自律性。
当然,也不是完全没有新增的东西。我引入了一个轻量级的API文档工具,让每个模块的接口定义可以直接生成文档,省去了维护Word文档的痛苦。还引入了一套统一的日志追踪方案,给每个请求分配一个traceId,这样排查问题的时候可以通过日志直接串起整个调用链路。这两个工具都不复杂,但对工程效率的提升是立竿见影的。
3. 核心细节解析:关键机制怎么设计
3.1 包结构与模块边界
t3code的第一个落地动作,是重新梳理包结构。原来的包名是按技术分层来的,比如controller、service、dao,每个包下面塞了所有模块的类。这种方式的问题在于,你根本看不出来“订单模块”和“用户模块”的边界在哪里。
t3code改成了按业务模块划分,每个模块有自己的完整分层。效果如下:
com.company.t3code ├── module │ ├── order │ │ ├── api // 对外接口定义 │ │ ├── service // 业务实现 │ │ ├── repository // 数据访问 │ │ └── domain // 领域模型 │ ├── payment │ │ ├── api │ │ ├── service │ │ ├── repository │ │ └── domain │ └── user │ ├── api │ ├── service │ ├── repository │ └── domain └── common // 真正的基础设施 ├── utils ├── exception └── tracing这样调整之后,最直接的变化是:新同学接手项目的时候,看包路径就能知道这段代码属于哪个业务模块,而不需要从代码逻辑里去猜。另一个变化是,代码评审的时候可以快速判断依赖方向是否合理——如果order模块的service包引用了user模块的repository包,那可定是越界了。
注意:包结构不是一锤子买卖。重构过程中一定会有一些类暂时不知道该放在哪里,我的经验是宁可先放在一个临时的
shared包里,也不要硬塞进某个模块。等边界想清楚了再挪,比搬来搬去要节省时间。
3.2 依赖方向与数据流控制
包结构调整好之后,下一步是控制依赖方向。t3code用了一个非常简单的检查机制:在构建脚本里强制规定,module/order包下的代码不允许引用module/user包下的repository或service包(除api包外)。这个检查规则用脚本就能实现,不需要什么高级工具。一旦有人违反了规则,构建就会失败,并提示具体是哪一行代码越界了。
这个机制的灵感来自“绞杀者模式”——不指望一次把系统重写完,而是通过规则逐渐把不合理的依赖排除出去。最开始的时候,这个检查脚本可能会报出几百个错误,不用慌,把这些错误当成待办事项一个一个解决。每解决一个,模块的独立性就增强一分。
数据流方面,t3code强调模块之间的数据传递只能通过接口。比如订单模块需要知道用户当前是否处于活跃状态,那就调用user模块的api接口,而不是自己去查user表。这样做的好处有两个:第一,user模块的内部表结构怎么改都不会影响到订单模块;第二,订单模块写单元测试的时候,只需要mock掉user模块的api接口,不需要真的连数据库。
3.3 统一错误处理与日志追踪
老项目里每个模块都有自己的异常处理方式,有的返回null,有的抛RuntimeException,有的直接在Controller层try-catch然后返回空对象。这种混乱带来一个很现实的问题:一个接口报错了,前端根本不知道是参数问题、权限问题、还是服务器内部错误,因为返回的结构千奇百怪。
t3code规划了一套统一的错误码体系,分三层:第一层是通用错误码,比如参数校验失败、未授权、资源不存在;第二层是模块级错误码,由模块自己定义,不与其他模块冲突;第三层是附加信息,用来传递具体的业务提示。所有接口的返回结构统一为以下格式:
{ "code": 10001, "message": "用户不存在", "data": null, "traceId": "3f0a1b2c-8d4e-4f6a-9c2d-1e2f3a4b5c6d" }这个格式看起来很简单,但它解决了一个大问题:前后端联调的时候不用再问“这个接口失败到底返回啥”,所有的失败都是一个结构,只是code和message不一样。
日志追踪方面,t3code在入口处生成一个traceId,塞进日志的MDC(Mapped Diagnostic Context)里。这样每一行日志都会带上这个traceId,排查问题的时候只需要拿着traceId去日志系统里一搜,整个调用链路就一览无余。这个功能其实很多框架都有,但老项目里没有统一使用。t3code规定所有模块必须用统一的日志库,并且禁止在核心逻辑里打印敏感信息。
4. 实操过程与核心环节实现
4.1 从旧分支建立基线
动手改代码之前,必须先保证旧系统是可构建、可部署、可回滚的。t3code的第一步是从旧代码的主干分支拉出一个新的基线分支,并在新分支上做了三件事:
第一,跑通全量自动化测试。如果原来的自动化测试覆盖率不高,就优先补核心路径的测试用例,确保重构过程中有一个“安全网”。第二,冻结非必要的业务需求,明确声明重构期间不做新功能开发,只修线上紧急bug。第三,建立持续部署管道,保证合并到主干分支的代码随时可以部署到预发布环境。
做好这三件事之后,才开始动手改代码。这个过程务必保持耐心——一次重构动辄持续数月,如果连旧系统本身的稳定性都无法保障,后续的每一步都会举步维艰。
4.2 核心模块的重构步骤与代码示例
拿一个具体的模块来说,假设待重构的模块非常典型:原本的Service类有四五百行,同时处理了订单校验、库存扣减、优惠券核销、支付回调这四个职责。重构后的设计是把这四个职责拆成独立的组件,通过一个编排层来协调。
经纪模式的代码非常漫长,是一个典型的“上帝类”,所有事情都由一个类来实现。
重构时,我还顺带处理了事务边界的问题。原来的代码里,一个方法拖着一个大事务,期间还会调用远程服务。这种长事务非常危险,既浪费数据库连接,又容易产生死锁。t3code的做法是:只有真正需要原子性的本地数据库操作才开启事务;对远程调用,则通过消息队列或定时任务来保证最终一致性。事务拆分是重构中最容易被忽略但影响最大的优化之一。
4.3 数据层的迁移策略
模块代码重构完之后,数据层往往还留在原地。t3code的做法是:先把物理表重新命名,加上模块前缀,然后在代码里建立一个映射层,让旧代码和新代码都能访问同一张表。
以一个具体的例子说明。原有一张表叫order_info,重构后订单模块的表需要改成t3c_order_info。改动的时候,我先在数据库里复制一张新表,把旧表只保留读权限,然后让新代码读新表,旧代码一时改不完就走一个兼容视图。全部迁移完成后再彻底删除旧表。整个过程完全不阻塞业务。
这种做法也有一些需要注意的细节,比如数据一致性校验、索引优化兼容、跨模块的事务边界变化。t3code在迁移表结构的同时,还把原来散落在代码里的SQL语句全部收拢到repository层,禁止在Service层出现SQL字符串拼接。这样处理之后,数据库索引变更时只需要检查repository层,排查成本大幅降低。
4.4 扩展场景:批量数据导入与导出
t3code除了核心业务接口之外,还涉及很多后台批量操作的场景。比如运营需要批量导入商品价格、导出订单明细等等。这些场景在老代码里也是一锅粥,经常出现一个Excel导入接口处理了几万条数据,结果因为一条数据格式不对导致全部回滚,或者内存撑爆。
重构之后,t3code把批处理逻辑单独隔离出来,用了分页拉取和批量提交的策略。每一批数据处理完成之后,记录一个批次号。即使中途失败,下一次启动时可以从上一个成功批次继续,不需要从头开始。这个改进在线上省下了大量人力排查时间。
共享实现代码如下:
public void importPrices(List<PriceUpdateItem> items, int batchSize) { int total = items.size(); for (int from = 0; from < total; from += batchSize) { int to = Math.min(from + batchSize, total); List<PriceUpdateItem> batch = items.subList(from, to); priceUpdateHandler.processBatch(batch); batchProgressRecorder.record(from, to); } }这段逻辑核心在于:每次只处理一个批次,并将处理进度记录下来。这样即使后半段出错,也不需要重新处理前面已经完成的批次。
批量导出也是同样思路,核心原则是:不要让业务线程一次性把所有数据加载进内存,而是通过游标或者分页的方式逐批处理。如果数据量特别大,则可以考虑走异步任务,把导出结果生成到文件服务器,用户通过通知下载结果。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
重构踩坑太多,这里直接整理一个速查表,有相同症状的可以对号入座:
| 症状 | 根本原因 | 解决方案 |
|---|---|---|
| 模块间循环依赖,编译报错 | 边界划分不清晰 | 重新梳理模块职责,把公共部分下沉到common层 |
| 重构后接口返回结构不一致 | 错误处理未统一 | 全部走统一返回结构,禁止在Controller里直接new Map返回 |
| 数据库迁移后查询变慢 | 索引或SQL不在repository层 | 检查索引兼容性,收拢SQL到repository层 |
| 接口测试无法mock | 代码依赖全局静态方法 | 让依赖通过构造器注入,不要在方法内部new对象 |
| 重构后业务bug频发 | 自动化测试覆盖率不足 | 先补核心路径的测试,再动手重构 |
| 长事务导致锁等待 | 事务边界跨远程调用 | 拆分事务,远程调用移出事务范围 |
| 缓存数据漂移 | 不同模块同时写同一份缓存 | 缓存归模块私有,通过api访问数据 |
5.2 排查流程与日志串联
重构期间碰到那种“偶现的bug”最让人抓狂。t3code的排查思路固定为三步走:
第一步,先看日志。拿traceId去日志系统搜出整个调用链,确定是哪个模块哪一行代码出了异常。第二步,看数据。还原现场数据,确认是入参问题还是状态流转问题。比如一个订单状态跟预期不一致,就去看状态变更的历史记录。第三步,看代码。如果前两步都查不出问题,才去看代码逻辑。这个顺序能避免一开始就陷入代码细节而忽略了数据的可能性。
日志串联方面,有一个重要经验:核心业务变更必须打印日志。比如金额变更,我不建议只打印一个普通logger信息,而是打印一个结构化日志,包含操作人、变更前金额、变更后金额、订单号、操作时间。这些信息在排查问题的时候价值极高,远胜于通过查询数据库去逆向推断。
5.3 避坑经验:新手最容易犯的三个错误
第一个错误,一上来就重写。很多人觉得老代码太烂看不下去,想推倒重来。这个想法能理解,但风险极高。老代码里包含大量的业务规则,很多规则在文档里根本不存在,只存在于代码逻辑中。一推倒重来,这些隐性规则就丢了。t3code坚持增量重构,用绞杀者模式逐步替换,这种稳扎稳打的方式虽然心里痒,但绝不会出事。
第二个错误,过度设计。重构过程中容易产生“既然都改了,顺便把架构升级了吧”的想法。比如把单体应用直接微服务化,或者把所有模块都做成可插拔。t3code的原则是,只解决当前确定的问题,不为不确定的未来买单。就算要做扩展,也要保证在现有结构下顺手能做,而不是引入一套复杂的框架。
第三个错误,忽视自动化测试的重要性。很多人重构完代码,靠人工回归验证一遍就上线。这相当于拆完炸弹不穿防爆服。t3code对核心模块的要求是:每个模块的关键逻辑必须有自动化测试覆盖。哪怕测试写起来很费时间,也比线上出bug的代价小得多。
5.4 回滚策略与上线节奏
重构过程中,线上系统随时可能出现问题。t3code的部署策略是:每次合并到主干的功能,都必须单独发布、单独验证。如果验证过程中发现问题,可以快速回滚到上一个版本。这里的关键是,数据库迁移脚本必须能够回滚。比如新增了一张表,回滚的时候可以直接删除;但如果是修改了字段类型,回滚操作就会比较麻烦。
针对这个问题,t3code有一条铁律:不直接在线上库执行危险操作。所有表结构的修改,都通过兼容性迁移来完成——如果需要删除一个字段,先停止代码中对它的写入,再停止读取,最后才真正删除字段。整个流程可能要跨越多个版本,但胜在可回滚、可控风险。
上线节奏方面,我倾向于做“小步快跑”。每次重构都只改动一小块。比如一周内只做错误码统一,下周再改某个模块的SQL收拢。这种节奏的好处是,每次上线的风险范围都有限,出了问题能迅速定位。
6. 结果复盘与后续扩展
t3code这个项目持续了将近四个月。复盘下来,效果还是明显的:新增需求开发效率比重构前提升了大概三成,线上问题排查时间从平均两个小时缩短到了半小时以内。
数据层迁移和模块边界梳理是最花时间的两块,比预计的时间多花了大概两周。原因是老项目里有些历史遗留数据格式太乱,光清洗数据就花了不少功夫。如果再来一次,我会在项目一开始就成立一个小团队专门处理数据迁移,而不是让核心开发者在写业务代码和修数据之间来回切换。
另一个让我特别欣慰的变化是,团队新成员上手的速度明显变快了。以前新同学入职第一周基本都在看代码、猜逻辑,现在只需要看每个模块的api接口文档和架构图,就能对系统有个整体认识。这种“看得懂”的价值,在长期维护中会被放大得越来越明显。
往后t3code还有两个可以扩展的方向。第一个是引入更完善的契约测试,让模块之间的接口兼容性从“靠人注意”变为“靠流水线自动检查”。目前只是构建时做依赖方向检查,但接口的参数变化、返回结构变化还没有完全做到自动拦截,这是我认为接下来最值得马上补上的一块短板。第二个是把基础模块中相对稳定、通用性强的部分抽离成组织内部复用的技术组件。因为项目里沉淀了一些相当实用的工具:统一错误码、链路追踪、批量导入导出的框架代码,这几个东西在任何一个新项目里都用得上。
最后想分享一个我个人的体会。重构不是单纯的代码表演,而是对技术判断力和工程自律性的一场大考。真正的安全网来自自动化测试,而不是你对自己代码水平的盲目自信——这句话,是我做完t3code之后最大的收获。