t3code实战:第三代代码重构的模块化架构设计 开场白不用写。直接从##开始。写正文。 ## 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塞进日志的MDCMapped 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(ListPriceUpdateItem items, int batchSize) { int total items.size(); for (int from 0; from total; from batchSize) { int to Math.min(from batchSize, total); ListPriceUpdateItem 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之后最大的收获。