11天重写50万行代码:极限迁移实战与踩坑复盘
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 --vcs=git --by-file --report-file=analysis.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 的String,std::vector对应ArrayList,try { ... } 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, flags=re.S) # 行注释 content = re.sub(r'//[^\n]*', '', content) # 将 #include 替换为 import content = re.sub(r'#include\s*[<"]([^>"]+)[>"]', r'import \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 --since="24 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-resources |
| CPU 占用异常升高 | 老代码里的自旋等待被机械翻译成无限循环 | 搜索while(true)和空循环体,替换为事件驱动或sleep |
| 数据结果不一致 | 浮点运算精度或整数溢出行为不同 | 对照旧系统样本,单独测纯算法模块 |
| 前端页面加载慢 | 原查询逻辑一次性拉全量数据 | 加分页和缓存,必要时用新接口替代 |
| Git 仓库本地代码丢失 | 强制覆盖前未备份 | 立即从 reflog 恢复,git reflog和git reset --hard HEAD@{1} |
除了表格,我再提两个实战心得。第一,排查问题一定要看“第一个异常”,不要被后续的连锁错误带偏,转化后的代码往往一个根因引发几十个编译错误。第二,遇到性能问题不要凭感觉改代码,先跑 profiling 工具,我们当时发现慢的根源竟然是日志中打印了一个超大对象,而不是业务逻辑。
7. 事后复盘与我的真实体会
7.1 这次重写靠什么成功
回顾整个过程,核心不是“写代码的速度快”,而是以下几点:
一是范围控制。我们坚持不重开新功能,把业务变更冻结在启动之前,所有不必要的历史包袱直接放弃。二是自动化优先。转换脚本虽然在最后没有被保留,但它们在前期处理了海量机械劳动,让人力集中到了真正需要思考的地方。三是用旧系统当参照物。我们没有纠结“新版该怎么设计”,而是尽可能保持语义一致,大幅减少了新系统的不确定性。四是纪律。每日构建、站立会、代码仓库保护、强制同步,这些看似琐碎的流程保障了11天里不会出现不可控的混乱。
7.2 什么情况不要轻易尝试
这次成功不等于“重写50万行代码”是一件可以复制的壮举。如果你们的业务还处于快速变化期,需求每天都在变,那别做,因为重写只能照抄一个会动的靶子。如果团队里没有人同时懂旧技术栈和新技术栈,那也别做,因为自动转换产生的很多问题必须靠懂旧代码的人来判断。更重要的是,如果没有一个明确的“正确性帽子”——即旧系统的可测试输入输出样本,重写上线后你很难分清是业务差异还是实现缺陷。
其实软件工程里有一个老梗:重写是最后的手段,但有时候也是唯一的手段。如果走到了这一步,请务必按照本文的方式:先盘点、再自动化、保持纪律。在极限工期下,真正的竞争力不是加班,而是减少无效工作量。
7.3 最后分享一个“压箱底”的应急技巧
当转换脚本遇到嵌套语法而束手无策时,不要执着于写一个完美的 AST 转换器。可以像我们一样,把“解析不了的逻辑”从代码里抽出来,变成一个外部规则文件。开发一个小型表达式解释器,让新系统动态读取这些规则并执行。这个方法听起来有点像作弊,但在当时帮我们绕开了至少几千行难以转换的代码。当然,前提是这些逻辑更新频率低、执行性能要求不高。如果未来你也面临类似的大规模重写,试着跳出“代码必须编译进去”的固有思维,有时候“数据化”才是对付技术债的终极武器。