ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

屎山代码:每一行 if (version < 1.0) 都是无法触达的活化石

屎山代码:每一行 if (version < 1.0) 都是无法触达的活化石 屎山代码这四个字任何写代码超过三年的人听到都会会心一笑。我前几天在技术社区闲逛看到一个标题说屎山代码里每一行 if (version 1.0) 背后都是无法触达的活化石当时就愣住了。这个比喻太准确了。我干了十多年开发翻过无数历史系统太清楚这种代码是怎么来的又为什么没人敢动它们。今天这篇文章我就以自己的亲身经历出发聊聊屎山代码的本质、为什么每段老逻辑都无法触达以及我这些年积累下来的完整排查路径和改造思路。如果你正在跟一堆历史遗留代码死磕希望这些内容对你有用。1. 屎山代码的本质它从来不是技术债那么简单很多人一提到屎山代码第一反应就是技术债。这个词用得太多反而掩盖了很多真实情况。我更喜欢把这类代码称作活化石——它们是过去业务决策、人员变动和组织结构的直接投影。1.1 活化石的第一层含义代码里的时间胶囊在一个老系统里面你会发现每一段看似无意义的逻辑背后都有故事。比如这段代码# 某支付网关模块中的兼容逻辑 if version 1.0: # 老版本使用固定密钥进行传输 encrypt_key fixed-key-2016 else: encrypt_key get_rotated_key()表面上看这只是一个简单的版本判断。但如果你把它当成时间胶囊去拆解会发现这里记录了至少三件事2016年安全策略的变更、当年负责这个模块的开发者在面临密钥轮换问题时选择了就地打补丁、以及后续没有任何人清理过这段临时逻辑。我在实际改造类似系统时发现这种时间胶囊遍布各处。每一行 if (version 1.0) 的判断不仅仅是在区分新旧行为更像是系统自己在记录它经历过的每一个转折点。1.2 屎山不是一天堆积的但一定是从第一处临时方案开始的我这些年翻过很多历史系统的代码库注意到一个共同的规律几乎所有屎山都是从第一个临时方案开始的。这个临时方案可能是为了快速修复生产事故打的补丁也可能是为了赶上线时间留下的简化实现。它本身并不是错的问题在于这个临时从来就没有被结束过。举个例子我在改造一个CRM系统时发现其中有个客户状态字段为了处理一次数据迁移异常添加了一段特殊的转换逻辑。当时负责人标注了临时处理下个月移除结果那段代码在这里一待就是六年。六年里它经历了六任不同的维护者每一任都认为这不是我加的我也不敢乱动。这就是屎山的形成机制单次决策看起来都合理但累积起来就变成了无人敢动的大坑。2. 为什么每一行 if (version 1.0) 都是无法触达的如果仅仅说屎山是因为临时方案堆积那也太简单了。我更想强调的是无法触达这四个字。这背后其实涉及一个很残酷的现实我们经常无法去修改那些明显有问题的代码不是因为技术难度而是因为代码本身已经变成了一堵信息之墙。2.1 信息断层写代码的人已经不在了你打开一个老模块看到这样的逻辑// 注意此处的判断不能删除否则历史数据会异常 if (data.type legacy data.created_at 2020-01-01) { // do nothing特意留空 }任何一个正常的开发者看到这样的代码都会觉得莫名其妙。你想去问原作者但作者可能三年前就离职了。你想去看看需求文档但文档里只字未提这段逻辑。你想去看测试用例但这段代码压根就没有测试覆盖。这就是无法触达的第一层信息断层。代码还在跑但理解这份代码的上下文已经不存在了。你不是不想改你是不知道改了会发生什么。2.2 风险厌恶没有人愿意为别人的问题背锅第二层无法触达来自团队里的风险厌恶心理。我在一个金融系统里见过一次这样的真实场景有个老接口每次调用都会把数据写进一个奇怪的临时表所有人都知道这不合理但没人去动它。原因很简单——这个接口背后涉及资金流水出了问题要追责。改对了没人夸你改错了全是你背锅。注意在金融、医疗这类强合规系统里不改往往不是懒而是最理性的自我保护。不要单纯用技术眼光去评判这种选择。在这种情况下理性人的选择就是不做任何修改。于是这些代码从可以修改但没人改慢慢变成了没人敢提修改最后变成了任何新人都被警告不要乱碰的禁区。2.3 系统耦合动一行代码牵一发动全身第三层是纯技术层面的系统耦合。现代系统大多是微服务架构但微服务不意味着模块干净。很多时候服务之间的调用关系错综复杂一个老接口可能同时被十几个上游系统调用每个调用方依赖的行为细节都不一样。我做过一个实际统计某个核心订单服务的老版本字段表面上只有3个调用方但实际上通过层层透传最终影响到了27个下游系统。这种情况下如果你冒然把这个老字段清理掉后果就是整个链条上一堆系统同时出故障。所以每一句反正能跑就不动背后其实是开发者经过了无数次心理权衡之后做出的理性选择。3. 我在改造屎山代码时的完整排查路径前面聊了很多为什么接下来我讲讲怎么办。我自己在改造此类代码时有相对固定的排查路径在这里分享给大家参考。3.1 第一步建立代码的时间线激活活化石活化石之所以是活化石是因为我们只看到了它现在的样子却看不到它的演化过程。所以我的第一步永远不是修改代码而是重建代码的时间线。具体操作如下先翻版本管理工具的提交历史找出与这段代码相关的每一次 commit整理成时间线找到当时的需求记录、工单或邮件往来还原当时的业务背景如果有幸能找到当时的设计文档重点看它写的未来计划和已知限制部分在团队内部找到在这段时间工作过的老员工哪怕对方已经转岗也可以约个短会聊一聊。举个例子我曾经排查过一段非常奇怪的逻辑在某个导出功能中有一段判断如果消费金额大于10000必须使用老版打印模板无论怎么想都很不合理。后来翻提交历史才发现这是因为当年新版打印模板在处理大额消费时存在一个金额溢出的bug所以紧急在业务层加了这个判断。后来bug修复了这段逻辑却成了永久的业务规则。找到了这个源头我就可以去验证bug是否还存在从而安全地移除判断。这段经验总结下来就是不要试图读懂活化石而是努力恢复它的演化历史。你不需要完全理解为什么你只需要知道它是从哪个旧事件中遗留下来的就有方向去处理了。3.2 第二步画出真实依赖图而不是名义依赖图很多系统的架构文档和真实情况严重不符名义上的依赖图和真实运行状态完全是两回事。所以在做变更之前我不会轻信文档而是通过动态手段重新绘制依赖图。这里我的做法是在测试环境中对目标接口添加全量日志和链路追踪用事先准备的流量回放工具把生产环境的请求复制一份打过去收集日志分析所有真实调用方、字段使用情况和异常分支用代码静态分析工具扫描找出直接引用和反射调用的地方。这一步很关键因为如果你只按照文档上的依赖图来评估改动影响很可能会忽略那些隐藏的调用方。想想看一个字段在文档上显示仅内部使用但实际上它被某个报表系统通过数据库直连读取一旦你把这个字段从接口里去掉报表就会出空数据。3.3 第三步为每一行老逻辑编写行为契约测试在真正修改逻辑之前我会先为现有逻辑编写一套行为契约测试。所谓行为契约测试就是用测试用例把当前代码的每一项可观察行为都固定下来不去判断它对不对只判断它有没有被改变。具体可以这样操作def test_legacy_version_behavior(): # 固定旧版本行为 legacy_payload build_payload(version0.9, amount20000) response process_payment(legacy_payload) assert response.status legacy_success def test_new_version_behavior(): # 固定新版本行为 new_payload build_payload(version2.1, amount20000) response process_payment(new_payload) assert response.status normal_success这些测试不关心你的业务预期是什么它们只负责告诉你在你不小心把系统改挂了的时候第一时间能够定位是哪一行变化导致的。等到行为契约测试全部通过之后你才拥有了安全重构的底气。因为你手上有了一张行为底线的清单无论接下来怎么改只要这些测试还通过你的系统就没有发生不可控的变化。3.4 第四步灰度发布、监控与回归最后一步就是真正把改动发布出去。这里我有一个血的教训永远不要一步到位做全量发布即使你的行为契约测试全部通过了。我见过太多团队在重构老系统时因为测试通过了就大张旗鼓地全量发布结果一上线就炸了。为什么因为总有一些行为是测试覆盖不到的。所以我自己的习惯是准备两个版本通过流量染色把一部分真实请求引导到新版本上对比新旧版本的行为日志和核心指标看有没有差异观察至少一个完整的业务周期比如每天凌晨的批处理、月末的结算确认无误后再逐步放量先10%再30%再50%最后100%。这样做虽然会拉长发布周期但能让你在每一个步骤上都有充分的回退余地。处理屎山代码不是要快而是要稳。4. 实战案例一个支付模块的老代码改造全过程接下来我分享一个我亲身经历的比较典型的案例从中你可以看到上面这套方法到底怎么落地。这个案例在我给团队做内部分享的时候讲过很多次每次都有新感悟。4.1 背景与问题那是在我负责的一个交易中台项目里我们有一个支付模块。这个模块有一个非常诡异的方法专门负责处理版本兼容逻辑。方法本身并不复杂但里面充满了各种历史判断public PaymentResult processPayment(PaymentRequest request) { // 版本兼容逻辑 if (request.getVersion().compareTo(2.0) 0) { if (request.getChannel().equals(BANK)) { // 老渠道不支持营销字段忽略 request.setPromotionInfo(null); } } else { if (request.getChannel().equals(BANK) isInternalRequest(request)) { // 内部请求直接走快捷通道 return fastChannel.process(request); } } // 其他正常流程 return normalChannel.process(request); }这段代码让每一届维护者都很头痛。你想动它吧不知道里面藏着什么历史包袱你不动它吧新需求又总是要考虑老版本怎么处理。4.2 排查过程我接手这个模块后没有急着写代码而是先按照上面说的排查路径走了一遍。我翻了这个方法近三年来的所有提交记录找到了三个关键时刻第一次引入版本判断是因为营销系统上线为了不影响老渠道加了忽略营销字段的判断第二次引入内部请求快捷通道是因为内部调用量上涨为了降低响应时间走了一条特殊处理链路第三次修改是在一次大促前有人发现快捷通道在某些边界条件下会异常所以加了一个 isInternalRequest 的限制条件。你看每一段逻辑都不是凭空冒出来的都有明确的业务动机。但这三处逻辑叠加在一起就让代码变得极其难以阅读和理解。4.3 改造策略当我搞清楚了代码演变历史后改造策略就变得非常清晰了。第一个优化的点是老版本(BANK渠道忽略营销字段)这段逻辑本质上是一个已经过时的兼容策略。通过追查日志我发现事实上已经没有任何流量在使用低于 version 2.0 的请求了或者说即使有也都是测试脚本在跑。所以这段分支完全可以连同相关字段的透传一起清理干净。第二个优化点是内部请求快速通道的处理其实是一直在用的核心路径但它不应该以版本判断的形式散落在支付方法里。应该把它抽取成独立的策略类并放到请求路由层统一处理。改造之后的代码精炼了很多public PaymentResult processPayment(PaymentRequest request) { if (isInternalBankRequest(request)) { return fastChannel.process(request); } return normalChannel.process(request); } private boolean isInternalBankRequest(PaymentRequest request) { return request.getChannel().equals(BANK) isInternalRequest(request); }4.4 回归与结果改完之后我把行为契约测试跑了一遍又在灰度环境里观察了整整一周。结果非常理想核心支付成功率没有波动新旧版本的响应时间分布一致所有契约测试全部通过代码行数减少了大概三分之一后续团队接手的理解成本大幅降低。更重要的是我们把能力边界重新梳理清楚了。曾经没人敢动的老代码现在有了明确的测试保护、清晰的依赖图和干净的职责划分。改造一次屎山收获的不仅是代码变干净了更是一套能支撑后续迭代的基础设施。5. 如何跟屎山代码和平共处写给普通团队的建议上面聊了具体的排查路径和案例最后这部分我想聊聊更实际的问题如果你的团队没有条件做大规模重构你该怎么在一个充满屎山代码的环境里生存并持续产出价值5.1 先建立代码恐怖故事档案而不是急着清理很多团队一看到屎山代码就热血沸腾想要一口气铲平。但我见过太多这种激进清理的失败案例清理过程引入新bug、下游系统抗议、最后被迫回滚反而让管理层更加反对后续任何技术优化项目。我的建议是不要急着清理先给屎山建立档案。你可以建一个 wiki 页面叫做代码恐怖故事档案。每当你发现一段难以理解的代码就记录如下信息代码位置和当前行为肉眼可见的历史痕迹提交记录、注释等已知的依赖方和调用场景你猜测的原始业务动因。这个档案本身的价值在于它能把不可言说的恐惧转化为可讨论的事项。当你和团队成员一起给这些代码建立档案时信息断层就开始被重新连接风险盲区也开始逐渐显形。5.2 在改动任何老代码前先给自己写一张免责声明听起来有点好笑但这是一条极其实用的经验。这个免责声明不是推卸责任而是一种极端认真的态度。在动手改造一段屎山代码之前我会先写下这样的说明这段代码我理解的现状是什么我计划做什么变更我预期的风险有哪些我的回退方案是什么我需要在哪些情况下紧急求助。然后给自己设定一个最低目标无论如何不允许让系统出现未知的未知。也就是说我可以在改完之后引入新问题但新问题必须能被自己已有的监控或测试感知到而不是等到生产事故爆发了才发现。这个习惯帮我避免过很多次灾难性的改动。因为当你在纸上写出我预期风险时你的思考深度是完全不同的。你不再是一个凭直觉改代码的莽夫而是一个在做受控实验的工程师。5.3 给新人的屎山生存指南最后我要对新入行的开发者说几句。你们很可能是最讨厌屎山代码的人因为你们每天都要读这些老逻辑读得一头雾水。但请相信我这些代码也是你们成长最快的养料。在屎山代码里你可以学到至少三样在教科书上学不到的东西第一你会看到决策如何在真实世界中演化。每一层嵌套都对应着一次真实的业务妥协这是你在任何设计模式书里都读不到的第一手资料。第二你会被迫练习在信息不完整的情况下做判断。这恰恰是资深工程师最核心的能力。没有人给你完整的上下文但你照样得把事情推进下去。第三你会亲身体验沟通与文档的价值。经历过屎山之后你才会真正愿意为代码写注释、为架构画图、为逻辑写测试。所以我想说别急着恨它。屎山代码不是一个需要被消灭的敌人而是一个需要被理解的景观。它记录了一家公司技术演化的所有痕迹也记录了所有在这里工作过的人的故事。6. 最后聊聊我们面对屎山的心态与技术外功夫这个话题聊到这里我觉得有必要跳出技术本身谈谈心态。6.1 承认所有人都在屎山里做了这么多年开发我越来越明白一件事在这个行业里没有人真的生活在代码乐园里。那些看起来很美的开源项目、那些号称整洁架构的新系统只要你活得够久都会逐渐长出属于它们自己的屎山。区别仅仅在于有些团队意识到了这一点提前建立了应对机制有些团队还沉浸在我们技术很先进的幻觉里直到某一天被历史遗留问题绊倒。6.2 理解比清洗更重要我见过很多开发者在面对屎山代码时会产生一种道德洁癖认为这些代码不配存在。但在真实世界里每一段代码都是在当时的约束条件下做出的最优选择。也许是时间不足也许是信息缺失也许是管理层决策但很少有一个开发者是故意想写坏代码的。当我开始用这种视角去看待屎山代码时我对自己的工作也有了新的理解我不只是一个写代码的人更是一个在各种历史遗留问题和现实约束中寻找平衡的人。真正有价值的不是写出完美代码而是把系统安全地往前推动哪怕一小步。6.3 一个值得坚持的长期习惯最后分享一个我个人坚持了很久的习惯每当我负责一个模块时我都会在代码里留下一条时间戳注释记录下我当时的决策依据和已知的权衡。# 2024-05: 此处暂时保留老版本的兼容逻辑 # 原因仍有少量存量调用方未升级预计2024年Q3可全部下线 # 届时删除此判断并同步清理下游的legacy字段透传这个习惯看起来不起眼但它能让下一个接手的人少走很多弯路。你留给后人的不是一段莫名其妙的代码而是一扇可以推开的门。如果每代人都愿意多写这样一条注释我们其实可以共同把屎山时代慢慢终结掉。在我这十几年的职业生涯里我清理过的屎山代码可能比很多人写过的代码都多。每一次清理都没有让我变得对代码更苛责反而让我对软件开发和人性有了更深的敬意。毕竟每一行 if (version 1.0) 背后都曾经是一群人在真实世界的复杂约束下努力让系统正常运转的证明。我们读取它们理解它们然后在合适的时候体面地把它们送进历史。这就是作为工程师最大的浪漫。
返回列表