ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

订单模块重构实战:从3000行到91行的解耦与状态机设计

订单模块重构实战:从3000行到91行的解耦与状态机设计 如果你维护过一段让自己头皮发麻的业务代码很可能体会过这种感觉一个模块逻辑看起来不复杂但代码量偏偏很吓人。我年中就接过这样一个订单模块将近三千行代码拆成两个文件十几个接口线上跑了两年多没人愿意动它。我用了两周时间把这堆代码重构到了不到九十行。今天就从这次“代码量骤降”的重构聊起说说这类重构到底在做什么哪些经验值得复制哪些坑我不能替你踩。这不是一篇教你怎么把代码“变短”的文章。行数从来不是重构的目标但它是一个很直观的信号——当一段代码膨胀到几千行往往意味着控制逻辑、业务规则和副作用被揉在了一起。这篇文章我会先拆解原本那三千行到底写了什么再讲重构后做对了哪几件事然后给出评估重构质量的指标、实操步骤和排错经验。适合正在啃历史遗留代码、想动手做系统重构但不知道从哪里下手的开发同学可能也适合刚接触代码解耦概念、想理解“数据驱动”到底有什么用的朋友。1. 重构前三千行代码到底写了什么1.1 把三千行拆开看无非三类东西我接到这个订单模块时第一件事不是改代码而是把代码从头到尾读了一遍随手做了个分类。翻完两三千行我发现真正“值钱”的逻辑其实只占一小部分其余都是在反复表达同样的规则。第一类是状态和权限判断。这个模块里有订单状态、用户角色、操作类型每个接口进来前面都有一大串 if/else 判断“这个状态下这个角色能不能做这个操作”。第二类是字段校验每个接口都有类似“金额不能为负”“商品不能为空”“备注不能超过200字”的校验写法大同小异。第三类是副作用处理也就是操作成功之后要做的那些事比如更新状态、发通知、写日志、记审计。这三类东西交错嵌套一个方法里先判断权限再校验字段然后执行主逻辑最后在成功分支里更新状态、发通知、写日志看起来每一步都在干活但整体非常臃肿。我摘一段典型代码出来你感受一下。这段只是“用户取消订单”这个接口里的一小段判断逻辑def cancel_order(self, user, order): if order.status pending and user.role in (admin, operator): if order.created_at now() - timedelta(days3): raise BusinessError(订单超过可取消时间) if order.pay_status paid: if not refund_service.check_refund_rule(user, order): raise BusinessError(退款规则校验失败) refund_service.apply_refund(order) order.status cancelled notify.send(user, order, cancelled, channelapp_push) audit.log(user, order, cancelled, detailformat_detail(order)) else: raise PermissionError(当前状态不允许取消)这段代码只有十来行但已经包含了权限判断、时间限制、退款规则、状态更新、通知、审计六件事。同一个项目里类似的逻辑还有几十处它们的判断条件只是稍有差异有的角色白名单不同有的通知渠道不同有的状态枚举不同。复制粘贴带来的代码膨胀就是这么来的。1.2 代码量膨胀不是一天造成的是三种习惯叠加的结果分析完内容我接着想了一个问题三千行代码是怎么被写出来的不是某个人偷懒而是好几种工程习惯叠加在一起慢慢滚出来的。第一种是历史叠加。这个模块经历过好几轮需求变更每次新增一个订单类型就在原方法里加一个 if 分支每次新增一个权限规则就在入口处加一段判断。新代码不敢动老代码只管往上叠叠着叠着方法就长了。第二种是命令式思路。写代码的人习惯把所有步骤一步步写出来每步都展开导致控制流特别长。第三种是不知道平台自带能力。比如很多语言都有内置的权限框架、校验机制、事件系统但代码没有用而是每个项目自己造了一套硬编码逻辑。所以重构的起点不是“把代码改短”而是先识别出这三类问题。我最后把所有代码画成了一张行为清单才看清这个模块本质上就是一个订单状态机外面套了一层权限判断和一堆副作用。有了这张图后面动手就顺了。1.3 代码行数是负债不是资产在动手前我给自己定了一个原则代码行数不是资产是负债。每多一行代码就多一行需要阅读、维护、测试的地方。系统重构的价值恰恰在于把负债高的代码压缩成简单清晰的结构。但这句话容易被误解成“代码越短越好”。不是的。短代码如果牺牲了可读性和可测试性那只是把问题藏起来了。更准确的说法是用更少的代码表达同样的行为同时让行为更可预测、更容易扩展。后面几节我会说说怎么判断重构是不是做对了这里先记住一个结论——重构的终点不是行数而是结构。2. 重构之后从三千行到九十行做对了什么2.1 用数据替代分支把规则变成表我这次重构里最核心的一个动作是把几十处 if/else 判断换成了一张状态转换表。你可以理解为原来代码是在一行行“讲述”规则而重构后规则变成了“可以被查询和执行的数据”。订单模块本质上是一个状态机每个订单有自己的状态待支付、待处理、已取消、已完成等每个操作提交、支付、取消、发货只有在特定状态下才合法操作成功后状态会切换到下一个值。这个逻辑用 if/else 写每个接口都要写一遍如果做成一张转换表核心执行器只需要十几行。下面是我重构后用到的核心执行器用 Python 写出来大概是这样ORDER_STATE_MACHINE { (pending, pay): paid, (pending, cancel): cancelled, (paid, deliver): shipped, (paid, refund): refunded, (shipped, complete): completed, } def transition(order, event, user): key (order.status, event) if key not in ORDER_STATE_MACHINE: raise InvalidTransition(f订单状态 {order.status} 不能执行 {event}) if not permission_rules.matches(key, user): raise PermissionDenied(当前角色无权执行该操作) order.status ORDER_STATE_MACHINE[key] event_bus.publish(order.transition, orderorder, eventevent, useruser)这个执行器只有十来行把原来散落各处的状态判断全部吸收了。我只需要补充权限规则和事件订阅业务扩展的时候多增一行转换表、多注册一个事件订阅者就行根本不用新增 if/else。你可能已经注意到这里的关键词是“数据化”分支判断不写在代码里而是写在数据表里代码只负责解释这张表。这样做的最大好处是业务规则集中在一处不用再到处找分支逻辑了。很多号称“代码解耦”的重构其实做的就是这件事把控制流从行为里剥离出来它在数据里你在表里。我们常说“配置化”“规则引擎”本质也是这个思路。2.2 用事件驱动替代硬编码副作用重构前每执行一个操作都要在代码里依次调用更新状态、发通知、写日志、记审计这四个动作顺序固定一旦要加一个新的通知渠道就得改主流程。重构后我把这些“副作用”全部改成了事件订阅主流程只负责状态流转和发布事件。还是用刚才那个执行器来举例。状态更新完后event_bus.publish(order.transition, ...)这一行就把所有后续动作都解耦出去了。通知逻辑变成事件订阅者event_bus.subscribe(order.transition) def send_notification(order, event, user): if event in (cancel, refund, complete): channel notification_policy.channel_for(event) notify.send(user, order, event, channelchannel)下次要加个“发送短信”或“推送小程序订阅消息”只需要新增一个订阅者函数主流程一行都不用改。这就是用事件机制替代硬编码调用的意义。可能有人会担心事件化之后代码变“绕”了不知道副作用在哪里发生。我承认这个担心有道理所以我在项目里只把“一定会发生但调用方不关心细节”的副作用事件化比如通知、日志、审计、统计至于“操作后必须同步拿到返回值”的核心逻辑比如退款、库存扣减依然用同步调用不走事件。把握这个边界代码就不会虚。2.3 用通用器和声明式校验替代手写校验重构前每个接口前面都有一串字段校验代码同一个校验逻辑在不同接口里重复出现。重构后我把校验改成声明式配置一张校验规则表描述每个接口的约束一处定义、到处复用。这段不需要展开代码你可以理解成把“金额必须大于零且小于100万”从每个接口的代码块里抽出来变成结构化的数据再交给框架统一执行。这样做的直接好处是校验规则不再是散落在代码里的“潜规则”而是集中的、可审计的配置。用上通用器之后很多重复代码自然就消失了。总结一下重构后的九十行代码实际上是三种力量的合力数据表替代了分支、事件机制替代了硬编码副作用、通用器替代了手写逻辑。不是某一行代码“魔法般”省略了什么而是整个结构从命令式思维方式变成了声明式思维方式。3. 重构不只看行数衡量重构效果要看四个硬指标3.1 圈复杂度才是“代码健康度”的真正标尺行数减少只是个结果。真正衡量重构效果的第一个指标是圈复杂度你可以粗暴地把它理解为“代码里独立路径的数量”。if/else 越多、分支嵌套越深圈复杂度就越高人脑就越难在给定输入时推算出程序的所有行为。有一段经典经验单个函数的圈复杂度超过10基本就很难快速看懂超过20基本建议拆分。原有代码里很多方法分支嵌套还带两层循环圈复杂度轻松超过30重构后绝大多数方法变成了纯函数数据查询圈复杂度降到了个位数。我拿改造前后的订单模块做过一次简单对比效果可以看这张表指标重构前重构后代码行数约2900行91行单个方法最高圈复杂度345复制粘贴重复代码片段47处0处修改一个状态流转需要改动的文件数4个1个当然这个表只针对我自己的项目不同场景会有差异但趋势是稳定的行数、复杂度、重复率三个指标一起下降才说明重构真的做对了。3.2 耦合度和可测试性比“短”更重要第二个指标是耦合度。重构前你要改一个状态流转逻辑得去翻订单状态常量、权限判断、通知逻辑、审计逻辑四个文件哪个没改到都会出问题——这是典型的隐性耦合。重构后状态转换集中在一张表里事件订阅者在各自文件里独立存在互不依赖改一个订阅者不影响主流程这就是低耦合。第三个指标是可测试性。重构前测一个取消操作要准备各种边界状态还要模拟通知、日志、审计三个模块重构后状态机、权限规则、事件订阅者都能被拆开单独测试。我随手写了个测试用例直观感受一下def test_cancel_from_pending(): order Order(statuspending) transition(order, cancel, operator_user) assert order.status cancelled assert event_bus.received(order.transition)一个断言就验证了核心行为这种测试写起来不痛苦才会有人愿意维护测试。如果重构后的代码让测试变得困难那重构就是白做了。3.3 性能不能为了行数而牺牲讲一个衡量的边界问题。重构后我用数据表替代了多重判断理论上多了一层字典查询但这层查找是 O(1) 的在业务接口层面几乎感知不到如果用字典查询去代替一段高频调用的复杂计算并且每次都要遍历一张大表那性能就要打个问号了。所以做这类重构时一定先搞清楚代码所在的热路径。如果是每秒几十万的调用点优化要以性能为主行数和结构让位于效率如果是普通业务接口结构清晰、容易测试的价值远大于那一点字典查询的开销。我在这次重构里就用 Python 的%timeit简单测过状态查找耗时在微秒级对接口整体耗时影响可以忽略才放心采用了数据表方案。4. 实操落地把一段真实服务重构成91行的完整过程4.1 第一步先画行为清单不急着改代码重构最有价值的工作往往发生在动手改代码之前。我先不碰任何文件而是把原有代码的所有入口、所有事件、所有状态列在一张表里画出了完整的订单生命周期。实际操作中我是先读代码、接着用思维导图梳理、最后形成一个 Excel 表。这个表的每一行是一个“操作事件”列分别是事件名、允许的起始状态、目标状态、触发权限、副作用列表。这个过程帮我发现了三个本来藏在代码里的隐藏分支比如“已取消的订单可以重新打开并重新支付”这个逻辑散落在两个方法里入口处有一次判断方法深处还有一次判断。我之前读代码时单独看任何一个方法都没意识到这是一个完整的状态转换把行为清单画完才看明白。所以第一步我建议你不要依赖任何重构工具先用纸、思维导图或是表格把行为列出来。写不出行为清单说明你还没完全理解代码这时候动手改后面大概率会翻车。4.2 第二步找出隐藏的状态机确认所有状态值有了行为清单下一步就是确认状态机边界。我翻遍了所有对订单状态赋值的代码逐个整理状态值结果发现一个很有意思的问题同一状态不同地方用了三种写法有字符串、有中文注释、还有数字常量。状态机的第一件事就是收口把所有状态统一定义为枚举值。确认状态值时我特别提醒自己不要只照抄代码里已有的状态还要检查“隐式状态”。举个例子订单没有数据库字段标记“退款中”但代码里有一处根据退款申请表是否存在来判断的隐式逻辑——这种隐式状态在代码层面是 if/else但如果要把状态机做准就必须把它显式化。我把这类“隐式状态”都补进了状态表虽然增加了几个枚举值但换来的是整个状态流转完全可见。4.3 第三步设计数据模型用映射表替换控制流状态确认之后我开始构建状态转换表和权限规则表。这里有一个设计上的取舍是把所有规则放在一张大表里还是拆成多张小表我选择拆成两张一张是状态转换表管“事件从哪个状态合法、落到哪个状态”另一张是权限规则表管“哪个角色在哪个事件上有权操作”。拆开的原因很简单这两类规则变化的频率不一样状态流转很少变权限规则经常变。放在一起权限一改就得动整张表拆开后各自独立甚至可以把权限表放到配置中心由运营人员维护。设计好表结构对应的执行器反而是最简单的部分。我直接用了前文那段十几行的transition函数把原来的多个接口入口全部改成调用它。这时候你会看到原代码里大量互相之间“差不多”的复制粘贴只在一个地方表达了因为核心机制是同一个。4.4 第四步用对比测试兜底保证行为不退化重构最怕的就是“行为变了”但没人发现。我的做法不是靠自信是搞了一套对比测出来兜底。操作过程是这样的重构前我先挑出十个覆盖了关键路径的真实历史订单把这些订单数据导入测试环境用原代码跑一遍接口把返回结果、数据库变更记录、通知发送记录全部打点保存下来。重构后再用同样数据跑一遍新代码两份结果做差异比对。任何一条结果不一致就说明某个隐式行为没有被新结构保留。这套方法听起来简单但我强烈建议你不要省。我这次比对就抓到过一个问题原代码在“订单取消成功”后通知内容是“订单已取消退款将在3-5个工作日到账”重构后因为事件处理顺序不同通知内容变成了“订单已取消退款将尽快到账”金额信息还丢了。原因是我把通知文本的模板放到了事件订阅者里但模板数据获取的位置和原来不一致。如果没有对比测试这个差异会直接漏到线上。4.5 第五步小步提交每步都可回滚重构过程中我严格控制了提交粒度。每完成一个状态的迁移就提交一次提交信息里写清楚“迁移 xxx 状态到新状态机原逻辑路径 xxx 已删除”。这样即使后期发现问题可以用git revert精准回退某一步而不是整盘重来。这也是我最后能把代码稳定在91行的原因每一步都有校验每一步都有回滚空间不用担心改崩了收不回来。5. 常见问题与排查技巧实录5.1 重构后行为不一致多半是“隐式分支”没被数据化如果你重构完对比测试发现行为变了不要怀疑是状态机“逻辑错了”先怀疑你自己是不是漏了某个隐式分支。隐式分支通常藏在三种地方第一某个字段值“恰好”不会被置为某状态于是代码里没单独处理第二某个异常会被最外层统一捕获看起来不需要处理但异常本身有业务含义第三某个通知文案里拼接了不同字段重构换了一种拼法文案内容就不一样了。排查这类问题我自己的经验是把原代码所有引用过相关字段的地方用 grep 搜一遍再对着行为清单逐项打勾确认每一个分支都在新结构里有映射。不要看“这段代码好像没走”只要它存在就说明设计者遇到过那个状态。5.2 配置化过度导致代码“只有表格没有逻辑”重构太猛也会带来新问题规则全部进了配置表代码变成“没有业务的代码”新人想看懂一个操作会经历什么反而要先去查数据表。我会在配置表和代码之间做一道区分稳定且不常变的规则进代码写死变化频繁的业务规则进配置表。具体到订单模块状态机本身就放代码里因为订单状态基本不会变权限和通知渠道这种经常调整的放配置里因为业务部门会时不时改。一句话数据驱动不意味着把所有东西都做成表而是把“会变的东西”做成表把“不变的东西”留在代码里。控制住这个度重构后的代码才不会从一个极端走到另一个极端。5.3 什么时候不要急着重构我不是所有代码都建议重构的。有些代码虽然长但它是历史边界异常的逻辑有些代码虽然重复但每次调用上下文完全不同抽公共函数反而会增加参数复杂度还有一种“三天就要上线”的功能这时候的重构成本会吃掉需求的交付时间我通常建议先把功能做完、测试补上再回头处理结构问题。做判断时可以参考这个标准没有自动化测试的代码先不重构先补测试改动会触碰核心资金流程且没有回滚预案的不重构先评估风险三次以上新增需求都叠加在同一个模块上的要主动提出重构请求因为这时候不改后面每加一次功能都是在给代码“加增高垫”。5.4 重构完成之后还要做的两件小事重构不是代码合并完就结束的。我做完之后第一件事是补测试把之前没有覆盖的边界情况全部补成单元测试现在核心状态机的测试覆盖率到了百分之九十以上。第二件事是写了一份简短的架构说明放在代码仓库里把状态转换表附上解释“为什么这里是状态机”“什么时候加一个事件订阅者”这份说明的意义是防止下一个接手的同事又把所有逻辑写成 if/else。在项目管理工具里我还留了一篇给测试同事看的变更文档标注了原来的通知文案变化和新增的状态枚举。不要让测试同事从 diff 里猜你改了哪些行为这是团队协作层面的基本尊重。落到个人体会我最大的感受是重构最难的从来不是写那九十行代码而是决定什么该留下、什么该删掉。删代码很爽但删之前你得像考古一样把原作者的每个分支都读明白确认它是不是真的没有存在的意义。这次重构给我带来的后续价值也不只是省了多少行而是以后每加一个订单新状态团队不再像以前那样如临大敌、四处找判断逻辑只要改一行表数据加一个订阅函数再补两个测试就结束了。这种“把改动局部化”的能力才是重构真正值得投入的原因。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进