首页 / 资讯中心 / 文章详情

11天重写50万行代码:极限迁移实战与踩坑复盘

11天重写50万行代码:极限迁移实战与踩坑复盘 ★ FEATURED ARTICLE
11天重写50万行代码这句话在立项会上一出来会议室瞬间安静了几秒。我当时也在场第一反应是“疯了”。但后面接到的任务是实打实的一套运行了十几年的工业设备监控客户端底层依赖的编译器即将停止安全支持新操作系统上老界面频频崩溃业务要求年底前支持浏览器远程访问而我们只有11天窗口期。代码量不是传说是仓库里真实存在的50万行C、C和一些历史遗留脚本。这篇文章不是要教你如何“人肉重写”而是把这次极限迁移中真正有效的方法、踩过的坑、以及那些常规文档不会写的细节统统记录下来给同样被技术债逼到墙角的团队一个参考。1. 项目背景与风险判断1.1 任务到底是什么原系统是一套传统的C/S架构软件前端是MFC界面后端是C写的采集与调度服务数据库用的是SQL Server。界面运行在工控机上操作员通过本机客户端连接服务器。问题在于客户新的办公环境全面转向Web端管理层希望随时在浏览器里查看实时数据老客户端根本无法满足再加上编译器的支持终止连安全补丁都没有了。于是“重写”成了唯一能被业务接受的答案。50万行并不是一夜之间冒出来的。我们用统计工具扫描后看到整个仓库包含C源文件、C文件、头文件、一些VB脚本和配置模板真实的代码行数约为49.8万。其中一部分是历史备份文件、被注释掉的旧逻辑、演示用的demo代码真正需要在这次迁移中保留的核心业务大约只有20万行左右。这个数字让团队稍微松了一口但仍然是个硬仗。1.2 为什么不能渐进式重构很多工程师的第一反应是为什么不采用“绞杀者模式”在原系统旁边逐步建新系统慢慢替换。这个思路本身没问题但它有两个前提一是新老系统能长期并行运行二是团队有足够的资源同时维护两套系统。在我们的场景里老系统连编译器都在倒计时留给并行维护的时间盒只有11天远远不够。第二老代码质量很差模块之间通过全局变量和文件共享耦合在一起想“渐进式地把它拆干净”相当于一边启动飞机一边换引擎。所以这次只能选择“重写”这条路。重写不是指推翻所有业务规则而是用一个新的技术栈重新表达同样的逻辑。关键思路是把旧系统当作“参考实现”——我们对业务逻辑不做重新设计只做“翻译”。这样可以尽量减少新的认知负担让团队把精力集中在工程量上而不是业务再思考上。1.3 启动前的风险预估任何明眼人都能看出这个计划的风险。我们启动时就列了一张风险清单业务范围变化业务方可能在过程中追加新需求导致范围蔓延。数据迁移问题历史数据格式与老系统强耦合迁移后字段语义可能不一致。团队能力断层有人熟悉新栈但不熟悉旧代码有人熟悉旧代码又抽不开身。硬件适配问题现场设备依赖老驱动新平台无法直接兼容。应对措施也很直接业务方必须派一位熟悉全流程的专家驻场所有新增需求一律走“变更冻结流程”不接受任何非必要的改动。团队分成四组转换脚本组、后端服务组、前端重建组、测试验证组。代码仓库在第一天冻结主干只有负责人在特殊情况下才能改主分支。这条纪律在后面几天的混乱中救了所有人。2. 前期准备给50万行代码做“人口普查”2.1 用工具盘点代码资产重写的第一步不是写代码而是弄清楚“我们到底在重写什么”。这里推荐用 cloc 做行数统计和语言分布分析用法很简单cloc --vcsgit --by-file --report-fileanalysis.txt .它会输出每个文件的代码行数、注释行数、空白行数并按语言分类。再配合find命令查看大文件find . -name *.cpp -o -name *.c | xargs wc -l | sort -rn | head -30我们很快发现最大的五个文件合计占到了总代码量的接近四分之一几乎是一个“上帝文件”状态机、网络通信、数据库访问全部揉在一起。这类文件是重写的主战场必须单独拆解。统计完后我们建立了一份“代码资产清单”标注每个模块的作用、依赖关系、迁移优先级。清单是后续排期的基础没有这份清单直接开干大概率会在第7天发现还有一大块核心逻辑没找到。2.2 哪些代码是“黄金逻辑”哪些是“垃圾”50万行里并非所有代码都值得迁移。我们按照三个原则进行分类被注释掉的代码块超过一定年限且无版本管理原因的直接忽略不进迁移清单。只运行一次的历史迁移脚本例如数据补偿、临时报表不迁移。明确还在业务链路中的逻辑例如设备数据采集协议解析、报警判断、权限校验、数据处理算法这些必须保留。识别方式不能只靠人肉看代码。我们通过 git log 和运行时日志交叉验证如果一个文件在过去两年没有任何提交记录线上日志也没有出现过它的关键路径基本可以判定为死代码。我们还在代码里搜过像“TODO”“临时”“old”等关键词但更可靠的办法是查看调用关系图。这里用了 Doxygen 配合 Graphviz 生成简单的调用图迅速定位到没有被任何模块引用的“孤岛函数”。最终真正需要迁移的核心模块被收敛到大约20万行压缩比超过了一半。2.3 划定重写边界翻译、重写还是封装不是所有代码都该用同一种处理方式。我们划分了三类机械翻译业务逻辑与语言无关比如数据计算、状态判断、字符串处理这类可以通过脚本批量转换然后人工复查。界面与交互MFC界面无法迁移到Web直接丢弃按新的UI规范重写。硬件与驱动层有些设备驱动只有老版DLL转成64位新系统非常麻烦。我们选择保留一个独立的32位代理进程用HTTP或命名管道与主系统通信。后来证明这个决定避免了在11天里陷入无底洞。这条边界划定后借助“翻译层”的概念我们把精力集中在20万行核心代码的迁移上而不是跟操作系统驱动死磕。3. 自动化转换把“翻译”交给脚本而不是人肉3.1 先建语法映射表如果全靠手工一行行重写30个人一天1000行已经是极限20万行根本不可能。所以必须写自动转换脚本。但在动脚本之前我们先做了一份“语法映射表”。例如C 中的std::string对应 Java 的Stringstd::vector对应ArrayListtry { ... } catch(...)对应try { ... } catch (Exception e)NULL对于null等等。这让后续的人工审查有据可循也让团队确认哪些结构是转换器无法处理的。这张表不仅是技术文档它本身是团队之间沟通的“契约”。前端的同学看到ShowMessage(hello)应该对应alert(hello)后端的同学看到GetItemByID()应该对应getItemById()命名习惯都被统一起来。3.2 用AST还是正则这是个问题早期很多教程教你用正则表达式做批量替换但实际处理50万行代码时会崩溃嵌套括号、注释干扰、字符串中的关键字都会让正则失控。更稳妥的方案是先做词法分析把老代码解析成一棵语法树然后基于语法树做改写。不过写一个完整的C到Java的AST转换器不是11天内能完成的事。我们的实际策略是“正则先行、AST补充”。先解决大面上的替换问题import re def convert_source(content: str) - str: # 去掉块注释保留注释内容用于人工审计 content re.sub(r/\*.*?\*/, , content, flagsre.S) # 行注释 content re.sub(r//[^\n]*, , content) # 将 #include 替换为 import content re.sub(r#include\s*[]([^])[], rimport \1;, content) # 简单替换指针星号旁的空白 content re.sub(r\b(\w)\s*\*\s*(\w), r\1 \2, content) # ... 更多规则 return content这个脚本不是万能的但可以在几分钟内把40%的熟悉语法清理完产出“能看但还不能编译”的中间代码。真正难处理的地方我们写了一批小工具用 tree-sitter 针对旧语言生成具体语法树提取函数签名和控制流结构再映射到新语言。虽然只覆盖了有限的语法节点但对关键函数来说已经够用。3.3 无法自动化的部分手工重写清单自动转换总会遇到三类“硬骨头”全局状态与静态变量转换后难以保证初始化顺序必须手工改造成依赖注入或配置上下文。复杂的指针运算和内存管理例如链表、自绘控件、结构体字节序列化这部分无法机械映射需要重新设计。平台相关API像 Windows API 中的CreateFile、DeviceIoControl直接调用在新架构下可能完全不存在需要替换为网络服务或第三方库的等价方案。处理方式是将每块硬骨头单独列出分配给最熟悉该业务的工程师手工重写。同时在“翻译层”里做了个巧妙的兜底利用公式引擎。老代码里大量判断条件和数值计算转换器处理不了就提取成表达式字符串例如把if (value 100 flag ! 0)转化为可配置的表达式保存在数据库新系统运行时用脚本引擎解释。这样即使转换不彻底至少新系统不会因无法编译而卡死。这个技巧后来被证明极其有效把很多逻辑直接从“待重写”降级为“可配置”。3.4 代码解耦从毛线团到接口老代码最大的问题是耦合。比如一个业务模块直接修改全局变量另一个模块再读取。如果自动翻译后依然保持这种全局耦合新系统上线后第一个晚上就会因为线程安全问题崩溃。所以我们在重写过程中强制做一件事代码解耦。具体步骤是先识别出“哪些模块负责什么”然后用接口把跨模块访问全部变成调用。例如设备采集模块不再直接写全局数据缓冲区而是通过一个DataBus接口发布事件报警模块订阅DataBus检测阈值。这样做的效果是当自动转换产生了一些错误代码时错误被隔离在单一模块内不会像旧代码那样牵一发动全身。解耦的另一个惯例是数据对象的拆分。旧代码一个结构体几百个字段既有设备信息又有配置参数还有中间计算结果我们把这些字段按语义拆成多个DTO虽然初期增加工作量但后期联调的时间反而缩短了。不要试图在11天里把每根耦合都解干净至少要解开“关键路径”上的耦合。4. 11天实战排期分阶段、倒计时、双班倒4.1 详细排期表再完美的计划也要落到日期。我们当时按“双班倒”设计了排期日期核心任务交付物第1天代码盘点、环境准备、组队分工代码资产清单、环境文档第2天开发转换脚本、建立仓库纪律转换脚本V0.1、自动构建流水线第3-4天批量执行自动转换、手工修复编译错误主服务代码可编译通过第5-6天前端界面重建、数据库访问层替换可运行的最小系统第7-8天模块联调、黄金测试集对照测试报告、缺陷列表第9-10天性能优化、全量回归、问题修复完整的测试通过记录第11天灰度发布、监控、回滚演练上线完成排期表定下来后所有人严格按照时间盒工作。每天上午9点和晚上7点各开一次站立会只汇报三件事当前阻塞是什么、今天预计完成什么、是否影响里程碑。会议被严格限制在15分钟以内不许讨论技术方案技术问题拉小群解决。4.2 Git仓库纪律强制覆盖本地代码的正确姿势11天的冲刺最怕出现“本地代码覆盖了新代码”的灾难。我们采用了一条铁律主干分支只允许合并最终可编译的代码任何人在本地的修改必须先提交到个人分支再由代码负责人合并到主干。但实际执行中还是有人因为拉了旧分支导致本地代码混乱。这里分享一个“强制覆盖本地代码”的正确命令姿势# 先将要保留的本地修改放到暂存区 git stash -u # 拉取远端最新主干 git fetch --all git reset --hard origin/master # 再恢复自己的修改 git stash pop注意绝对不要在没有任何备份的情况下直接 reset --hard。哪怕当时觉得本地代码不重要事后可能发现那是一个同事刚给你的补丁。最安全的方式是先git stash或git diff save.patch再强制同步。每天结束时我们还会用git log --oneline --since24 hours ago快速审查提交记录确保不会出现“怪提交”悄悄混进主干。4.3 每日构建与“编译红灯”管理自动化构建用的是 Jenkins一旦构建失败会立刻把红灯亮到所有成员的桌面上。我们派了一个专门的“构建守护人”他的职责只有一件事保证主干随时可以编译。上午的红灯优先于所有新功能开发谁造成的编译错误谁去修修好之后再干自己的活。这看起来很简单但在高强度冲刺中非常有效——它防止了“问题滚雪球”。我们还给每个模块建了独立的编译任务避免一个模块的错误阻塞其他模块的联调。比如前端单独构建后端单独构建代理服务单独构建。交叉验证放在测试环境统一做。4.4 黄金测试集用旧系统做验证基准重写最大的隐藏风险是“翻译得不对”。我们用了Golden Master测试法在冻结旧系统之前录制了一批标准输入和对应的标准输出。比如配置一台设备、采集100条数据、触发各种报警阈值记录旧系统在这些场景下的结果。然后在新系统同样输入这些数据比对输出是否完全一致。有些输出在旧系统里表现为GUI弹窗没人能自动比对我们就让测试人员人工点击配合屏幕录制。随后的自动比对比对的是后端日志或数据库状态。这个过程在第7-8天成为最大瓶颈但也是把错误暴露得最充分的手段。两个系统只要输出不一致立刻定位到转换代码避免上线后在真实环境里出问题。5. 踩坑实录那些文档里不告诉你的细节5.1 隐式全局状态与初始化顺序老代码里到处是全局变量和静态变量比如一个全局的“当前设备ID”“运行模式标志”。转换后的新代码把所有全局变量集中到一个对象里但对象初始化顺序不对时会出现一个模块读取到默认值而不是实际值的诡异bug。我们花了整整一天排查为什么报警模块偶尔不报警原因在于转换脚本把全局变量变成了新系统中的StaticConfigHolder的静态字段而StaticConfigHolder在被访问之前还没有被配置加载代码赋初值。新系统启动时模块A先调用了报警模块触发它读取尚未初始化的全局值而该值默认为0正好对应“不报警”。最后解决办法是引入PostConstruct显式初始化并对所有全局变量进行“第一次读取时间点”的代码审查。5.2 异常体和资源释放的坑C 老代码里有大量这样写法try { // do something } catch (...) { // ignore }捕获所有异常后吞掉在老系统里还能勉强运行但转换到 Java 后这个模式被直译为catch (Exception e) { }一旦出现异常新系统会静默失败数据丢失还找不出原因。我们做了一项全局替换所有空的 catch 块至少加入log.error和默认返回错误码。虽然这改变了原逻辑但保护了可观测性。另一个经典问题是资源释放。C 中new和delete是成对出现的但转换后delete全部消失了Java 垃圾回收虽然接管了内存但文件句柄、数据库连接、Socket 连接依然需要显式关闭。我们用try-with-resources重写所有 IO 操作否则会出现几百个“打开的文件过多”错误。5.3 代码格式化失效与编辑器提示丧失自动转换后的代码缩进混乱IDE 里甚至出现波浪线。我们一开始想立刻用格式化工具统一格式结果发现格式化工具在某些语法错误上会直接罢工。经验是先保证能编译再统一格式化。正确的顺序是用编译器错误驱动修改等到编译通过后再执行mvn spotless:apply或google-java-format整体格式化。编辑器代码提示丧失通常不是因为代码本身而是项目配置问题Maven/Gradle依赖没有下载成功、Java SDK 版本不对、或.idea目录混乱。修复命令很简单mvn clean install mvn idea:idea如果 IDE 是 IntelliJ IDEA可以执行File - Invalidate Caches / Restart。我们遇到过一个反复出现的问题某个模块代码没问题但 VSCode 不识别代码块跳转最后发现是项目里有多个源目录VSCode 默认只扫描第一个。解决办法是手工添加源目录到 settings.json。5.4 第三方组件和驱动代码适配这套系统依赖不少第三方库包括一个工业数据采集卡厂商提供的驱动DLL。这个DLL只有32位版本已经被厂商停止更新。新系统又是64位Java进程直接调用会抛UnsatisfiedLinkError。当时有三种选择说服厂商提供64位版本不现实、绕过DLL自己写协议解析风险极大、保留32位代理进程我们最终选择。做法是写一个极小的C转发程序运行在32位模式下调用原始DLL把数据通过本地HTTP接口暴露给新系统。新系统通过 HttpClient 定时拉取设备数据。这个代理进程只做数据转发不做任何业务判断所以逻辑非常简单两天内就能完成。它让整个重写避免了最复杂的部分可以说是整个项目里性价比最高的决定。6. 常见问题与排查技巧速查表问题现象可能原因解决思路大批量编译错误提示找不到符号自动转换漏掉了类型声明或头文件未转换优先处理头文件转换使用IDE快速定位缺失类启动时出现NoSuchMethodError依赖版本冲突或转换时方法签名不一致用mvn dependency:tree检查版本核对映射表运行时崩溃日志显示 NPE老代码中解引用空指针转换后没有兜底在关键入口增加Objects.requireNonNull和默认值数据库连接耗尽转换后的连接没有及时关闭全库搜索Connection改为try-with-resourcesCPU 占用异常升高老代码里的自旋等待被机械翻译成无限循环搜索while(true)和空循环体替换为事件驱动或sleep数据结果不一致浮点运算精度或整数溢出行为不同对照旧系统样本单独测纯算法模块前端页面加载慢原查询逻辑一次性拉全量数据加分页和缓存必要时用新接口替代Git 仓库本地代码丢失强制覆盖前未备份立即从 reflog 恢复git reflog和git reset --hard HEAD{1}除了表格我再提两个实战心得。第一排查问题一定要看“第一个异常”不要被后续的连锁错误带偏转化后的代码往往一个根因引发几十个编译错误。第二遇到性能问题不要凭感觉改代码先跑 profiling 工具我们当时发现慢的根源竟然是日志中打印了一个超大对象而不是业务逻辑。7. 事后复盘与我的真实体会7.1 这次重写靠什么成功回顾整个过程核心不是“写代码的速度快”而是以下几点一是范围控制。我们坚持不重开新功能把业务变更冻结在启动之前所有不必要的历史包袱直接放弃。二是自动化优先。转换脚本虽然在最后没有被保留但它们在前期处理了海量机械劳动让人力集中到了真正需要思考的地方。三是用旧系统当参照物。我们没有纠结“新版该怎么设计”而是尽可能保持语义一致大幅减少了新系统的不确定性。四是纪律。每日构建、站立会、代码仓库保护、强制同步这些看似琐碎的流程保障了11天里不会出现不可控的混乱。7.2 什么情况不要轻易尝试这次成功不等于“重写50万行代码”是一件可以复制的壮举。如果你们的业务还处于快速变化期需求每天都在变那别做因为重写只能照抄一个会动的靶子。如果团队里没有人同时懂旧技术栈和新技术栈那也别做因为自动转换产生的很多问题必须靠懂旧代码的人来判断。更重要的是如果没有一个明确的“正确性帽子”——即旧系统的可测试输入输出样本重写上线后你很难分清是业务差异还是实现缺陷。其实软件工程里有一个老梗重写是最后的手段但有时候也是唯一的手段。如果走到了这一步请务必按照本文的方式先盘点、再自动化、保持纪律。在极限工期下真正的竞争力不是加班而是减少无效工作量。7.3 最后分享一个“压箱底”的应急技巧当转换脚本遇到嵌套语法而束手无策时不要执着于写一个完美的 AST 转换器。可以像我们一样把“解析不了的逻辑”从代码里抽出来变成一个外部规则文件。开发一个小型表达式解释器让新系统动态读取这些规则并执行。这个方法听起来有点像作弊但在当时帮我们绕开了至少几千行难以转换的代码。当然前提是这些逻辑更新频率低、执行性能要求不高。如果未来你也面临类似的大规模重写试着跳出“代码必须编译进去”的固有思维有时候“数据化”才是对付技术债的终极武器。
阅读完成 · 觉得有帮助?
咨询建站