
有价值的软件往往比用来实现它的技术更长寿。捕获组织的独特规则、流程和特殊情况(例如库存、会计或销售管理)的业务应用程序可以保持数十年的相关性。他们解决的业务问题仍然存在,但是 30 年前选择的技术很少仍然是维护和扩展系统的最佳方式。想想看一部老电影:故事仍然有价值,但今天在阴极射线管电视上观看它会感到过时。我们希望保留业务解决方案的价值,同时通过现代技术使其易于访问。
为什么语言迁移很重要:遗留系统约束、技能短缺和现代化目标
当系统是使用诸如 RPG、COBOL、Visual Basic 6 或 SAS 等遗留技术实现时,组织将面临几个障碍:
具备所需技能的开发人员变得稀缺。可用的人越来越接近退休,而人才库非常有限
与现代语言相比,生态系统是有限的。忘记从 Maven Central、NPM 或 NuGet 获取数百万库和框架的访问权限。
与现代平台的集成变得越来越困难。
开发范例已经过时,并且不适合开发大型系统(例如,缺乏模块化抽象,使用 GOTO 语句)。
工具和环境也不如现代语言可用的生产力高。其结果是开发速度变慢,成本更高。
显然,继续使用这些技术将变得不可持续。
单体“大爆炸”迁移的常见陷阱
面对代码库老化的问题,一种常见但有风险的反应是放弃遗留系统,并尝试用一种现代语言进行完全重写。这些“大爆炸”式的重写有着悠久的失败历史,因为它们低估了现有代码中嵌入的复杂性和微妙的知识。例如,你的系统可能包含决定何时从供应商处订购零件的规则,这些逻辑考虑了无数的特殊情况,如最小数量、交付时间和节假日。虽然看起来简单,但细节决定成败,真正的复杂性往往被低估。
问题在于,绝大多数开发人员除了用某种现代语言从头开始重写之外,不会想到任何其他解决方案。
好吧,直到 ChatGPT 出现之前都是这样。现在,人们可能会倾向于直接用 LLMs 来解决这个问题。这不是一个好主意。LLMs 是关于创造力的,如果你正在创作一个故事或一首诗,创造力是很棒的,但当你想要迁移数百万行代码,并且你不能承受任何偏差时,它并不是一个好选择。
还有另一种不太为人所知的解决方案,那就是使用算法转译器:一种软件,它接受用一种语言编写的代码,并生成用另一种编程语言编写的高质量代码。例如,TypeScript 就是这样被转换成 JavaScript 的。
转译器在本质上与编译器相似:它们以可预测和透明的方式转换代码。它们工作可靠:每天,它们翻译数百万行代码,没有一个问题。通过使用它们,可以将迁移设计为系统的工程流程:分解为可验证的步骤,以保留功能,提高可维护性,并生成目标语言的惯用代码。
本文探讨了如何构建自动化语言迁移的管道结构,展示了中间模型、语义丰富和像LionWeb这样的开放标准如何支持实际的、逐步的转换。目标是揭开流程的神秘面纱,并强调组织如何在远离过时技术的同时保持数十年的业务价值。
代码迁移的挑战
将代码从一种编程语言自动迁移到另一种编程语言不仅仅是语法转换的问题。它涉及到一系列深层次的技术挑战,这些挑战决定了迁移是成功还是失败。其中最重要的三个是:
验证等价性
生成惯用代码
应对范式差异
验证等价性
从业务角度来看,迁移的基本目标是确保新系统产生与原始系统相同的结果。可见的等价性才是最重要的:即使内部机制不同,最终结果也必须匹配。例如,原始系统可能依赖于临时文件或临时缓存,而迁移后的版本可能在内部做不同的事情,这完全没问题——重要的是最终报告和输出保持一致。
等价性验证仍然是迁移中最难解决的开放问题之一。在实践中,可以使用两种互补策略:
系统观察和重放:在给定已知初始状态的情况下,记录遗留系统的行为,然后在迁移的系统上重放相同的交互并比较输出。虽然理论上很有吸引力,但这需要构建测试环境,准备受控数据集,并编排可重复的场景,这在实践中可能很复杂。
功能测试:通常来说,遗留系统没有现成的测试,因此必须编写新的测试。通常用Gherkin这样的语言表达的高级验收测试特别有价值。它们允许分析人员和领域专家(不仅仅是开发人员)验证业务级别的行为,同时记录系统未来的预期结果。LLMs 可能会帮助起草这样的高级测试,但它们仍然需要人工修改和批准。
第一种方法需要较少的努力,而第二种方法带来了更多的长期利益,因为它将隐藏在代码中的隐性知识呈现为涉众可以检查、学习和随着时间的推移重新检查的格式。
无论如何,一定程度的手动验证是不可避免的。虽然这可能不受欢迎,但在验证迁移结果时无法避免一些努力。手动验证是必要的。关键是通过基于上述两种策略的自动化测试来补充该活动,从而限制我们需要投入该活动的工作量。
最后需要考虑的一点是,验证不仅仅是确保新系统正确工作,还涉及到建立信任和获得支持。如果组织对新系统没有信心,风险是它将不会被采用,这将导致迁移项目的失败。
生成惯用代码
执行不佳的迁移通常会产生“音译”代码——在目标语言中语法是有效的,但在风格上是外来的。这就是 COBOL 到 Java 的努力产生了“JOBOL”的原因:看起来和行为都像 COBOL 的 Java 代码,包括无法维护的结构。
原因通常是在太低的抽象级别上执行的翻译,保留了原始实现的每个细节,而不是使其适应目标语言约定。解决方案是提高抽象级别:识别源代码中的代码习惯(例如,COBOL 中的特定循环结构),并将它们映射到目标语言中的习语结构(例如,Java 中的流和 for-each 循环)。这可以集成到迁移本身中,或者作为迭代重构步骤应用。
关键是迁移语义和意图,而不是语法。如果没有这一点,迁移后的系统虽然在技术上是正确的,但实际上是不可维护的。
应对语言范式差异(过程式→面向对象;静态→动态)
当迁移到具有根本不同范式的语言时,最大的挑战出现了。从过程式代码迁移到面向对象代码,或从静态类型迁移到动态类型,需要的不仅仅是结构重写。
有些范式转换根本无法自动处理——例如,将一般的 Python 逻辑直接转换为 SQL,而 SQL 的表达能力根本不匹配。即使是更容易处理的转换,比如从过程式到面向对象,也只能部分自动化。迁移工具可以检测到重复出现的模式——比如自然映射到实现公共接口的类中的方法的过程组——但是完全重构通常需要人工干预。
这里的实际策略通常是混合的:以一种用新语言生成正确的、可维护的代码的方式执行迁移,但接受更深层次的范式一致性将通过重构逐步演变。现代 IDE 和重构工具使这在几十年前不可行的方式变得可行。在语言迁移完成后,代码可以随着时间的推移变得更加可读和可维护。
典型的迁移需求
虽然 COBOL 到 Java 的迁移历来吸引了最多的关注,但它们并不是组织需要自动化解决方案的唯一场景。其他几种迁移在今天也越来越相关了:
从 RPG 到 Python
RPG曾经被广泛用于为中型企业构建 ERP 系统,是迁移的主要候选者。该语言仍然在许多IBM i (AS/400)环境中根深蒂固,但 RPG 开发人员的人才库已经变得非常小。迁移到 Python 有两个主要优势:访问一个更大的库和框架生态系统,以及一种更具表现力和更适合实现业务逻辑的语言。
从 4GL 到 Java 或 C#
许多第四代语言(4GLs)如 IBM EGL未能实现长期采用,使组织的关键系统处于过时的平台上。对于这些公司来说,迁移到像 Java 或 C#这样的现代生态系统是不可避免的,以确保可维护性。
其他常见场景
SQL 方言迁移:许多系统依赖于特定于供应商的 SQL 方言(例如,Teradata SQL,Oracle PL/SQL),组织希望用开源或更便携的替代品(如 PostgreSQL)替换它们。
Visual Basic 6 到 JavaScript 或 C#:用 VB6 编写的遗留桌面应用程序通常需要迁移到基于 Web 的解决方案,这使得使 JavaScript 或 C#成为常见的目标。
SAS 到 Python:长期以来依赖SAS进行统计分析和数据处理的组织越来越多地面临高昂的许可成本和有限的灵活性。Python,凭借其成熟的数据科学生态系统(pandas、NumPy、scikit-learn、TensorFlow),已成为迁移的首选目标。
典型迁移的规模
这些迁移通常涉及大型系统:数十万到数千万行代码。以下几个因素可以解释这种规模:
数十年的积累:系统已经发展了 20-40 年,不可避免地变得庞大。
复制和修改开发实践:在工具不足且没有自动化测试的环境中,开发人员经常通过复制现有代码并进行最小修改来扩展系统。
未使用但未修剪的代码:遗留系统很少包含可靠的机制来检测死代码。
这些迁移的规模和复杂性意味着手动重写是不切实际的。因此,自动化迁移成为唯一可行的前进道路。
构建迁移管道
迁移本质上是复杂的,任何尝试通过单一的、庞大的工具来处理它们很快就会导致过度的复杂性。一个更可持续和有效的办法是把这一进程设计成一个由连续的、可核查的阶段组成的管道,每个阶段都有明确的责任,并且可以独立理解。这遵循了经典的分而治之原则:将一个大的、复杂的问题分解成可以独立解决、测试和验证的较小子问题。
为什么采用管道方法?
隔离和测试:管道的每个阶段都可以单独验证,使调试和质量控制更容易。
可重用性:常见的管道组件(例如,解析器、分析器)可以跨不同的迁移进行重用,从而提高效率并降低成本。例如,RPG 解析器可以在 RPG 到 Python 和 RPG 到 Java 的迁移管道之间共享。
裁剪:与使用单一的、现成的转译器不同,该解决方案可以根据每个组织的需要进行调整。由于组件可以重用,这不会导致工作量或成本飙升。
并行开发:不同的团队可以并行地在不同的阶段上工作,从而加快进度。
渐进式交付:管道允许对部分迁移进行增量测试和验证,而不是等待“全有或全无”的结果。
与将系统视为一个黑匣子并一次性重写一切不同,管道提供了一个受控的流程:输入原始文件,逐步分析和转换,最终以目标语言中有效、符合习惯的文件形式出现。
通过将迁移结构化为管道,组织不仅降低了风险,还创建了一个可重复的工程过程。我们得到了一个系统化的转换机制,其中每个阶段都可以检查、测试和改进。
迭代验证和反馈循环
一个天真的方法是将所有源文件输入管道,等待一个完整的翻译系统从另一端输出。实际上,这种方法很少奏效。相反,迁移应该以迭代的方式进行,按照精心选择的顺序翻译文件子集,验证它们,并将学到的经验反馈到管道中。
这个迭代过程需要一个迁移计划,该计划定义了处理文件的顺序。规划的制定遵循两个原则:
依赖顺序:首先迁移没有依赖的文件(例如,定义一些数据结构的文件),然后是依赖它们的文件(例如,使用这些数据结构的程序)。在实际项目中,经常出现相互依赖的文件集,这些文件必须一起迁移。
复杂性顺序:从最简单的文件开始,逐渐转向最复杂的文件。这允许管道早期处理基本结构,然后随着更高级特性的出现逐步扩展支持。因此,可以快速产生第一批结果。一些迁移的文件可以交给能够验证它们的人,反馈可以开始流动。
其结果是一条增量迁移路径。在每个步骤中,部分结果可以编译、执行和测试。
LionWeb 的角色
在迁移管道中,每个步骤——解析、语义丰富、分析、代码生成——都会产生系统的新状态。这些状态被称为快照。使用与语言无关的开放格式来表达这些快照是很方便的,许多工具都可以生成和使用这种格式。
这就是LionWeb的用武之地。LionWeb 是一个开源倡议,由来自许多组织的知名语言工程专家(包括 JetBrains、Canon、F1re、Itemis 和 Strumenta)的贡献创建。它旨在定义抽象语法树(ASTs)的标准表示。
使用 LionWeb 作为交换格式带来以下几个优势:
语言中立的表示
每个处理阶段都可以以 LionWeb 格式输出其结果,后续阶段会使用这些结果。由于 LionWeb 在多个平台(Java、Python、TypeScript、C#)上都有实现,因此可以选用最适合的语言编写管道组件。例如,可以使用 Java 解析遗留代码,使用基于 Python 的工具进行分析,并在 TypeScript 仪表板中可视化结果,因为我们可以使用这些语言中的相同交换格式。
工具和作者之间的互操作性
通过采用共享的开放标准,迁移管道不再局限于自定义构建的组件。由不同作者编写的工具,甚至是为不同的移植项目开发的工具,都可以组合在一起。这种互操作性减少了重复工作,并使迁移管道更加模块化,可以在不同上下文中重用。
检查和调试中间快照
LionWeb 还支持检查中间快照,这对于调试和验证至关重要。快照包括:
通过专门的工具(如 LionWeb 服务器管理 UI)进行探索,其中可以交互式地上传、导航和检查中间 AST。
在 Python 笔记本或脚本中进行程序分析,工程师可以加载快照、提取统计数据或可视化代码的特定方面。
用于监控进度,例如通过创建仪表板来跟踪已解析、分析或成功迁移了多少代码库。
这种暂停、检查和验证中间状态的能力比不透明的“黑盒”迁移工具具有决定性的优势。有了 LionWeb,管道变得透明:每个阶段的输出都是可访问的、可测试的,并且可以进行独立分析。
核心管道阶段
管道的目的是从一个遗留系统中包含的原始信息开始,逐步提炼理解,并逐渐向一个足够完整的目标系统模型过渡,以生成高质量的代码。
解析
第一步是解析,即生成每个源文件的 AST。
遗留系统很少只包含一种语言或文件类型。例如,RPG 代码库可能包括:
用于业务逻辑的 RPG 源文件,
定义数据结构的 DDS 文件,
协调程序执行的 CL 脚本,
偶尔甚至包括 COBOL 片段或汇编例程。
每种都需要自己的解析器。解析器生成抽象语法树(AST),并将其导出为 LionWeb 格式。结果是一个 AST 森林,每个 AST 代表一个源文件。这些 AST 可能由不同的解析器生成,但如果所有解析器都支持 LionWeb,它们都可以从管道的后续阶段统一处理。在这个阶段,树纯粹是语法性的:它们捕获结构,但尚未捕获关系。这是下一步提供的。
语义丰富
下一步是语义丰富,它结合了符号解析和类型推断。这两个活动在实践中不能分开。
以 Java 为例:要解析 a.b.c,我们需要知道 a.b 的类型。要确定那个类型,我们首先必须解析 a 中的 b。c 的解析取决于由 a.b 的类型确定的类,这可能涉及继承和字段查找。符号解析和类型推断形成了一个紧密耦合的推理链,因此它们必须一起处理。
语义丰富还应该处理跨语言和跨文件的链接,这在遗留系统中很常见。例如:
调用 RPG 程序的 CL 脚本,
访问 DDS 定义的数据结构的 RPG 程序,
共享变量的不同 RPG 模块。
完成这一步后,之前孤立的 AST 通过引用边连接起来,将它们变成一个连贯的、交叉引用的系统模型。
分析
丰富之后,可以分析模型以检测特定的模式和结构。确切的分析取决于源语言。
RPG 中的 Goto 分析:仔细检查时,GOTO 可以代表结构化控制流。例如,一个 GOTO 跳转到没有其他引用的标签可能对应于一个 while 循环。其他可能翻译为 break 或 continue。我们可以对它们进行分类,并用特定注释标记相应的 AST 节点。在后续阶段,我们将查看这些注释以决定如何翻译每段代码。
RPG 数据结构中的覆盖分析:RPG 允许字段覆盖相同的内存区域。这使得直接翻译成高级语言变得困难。通过分析覆盖,我们可以对它们进行分类(安全/简单与复杂/不安全),并用注释标记节点以供后续处理。
模式识别:在 AST 中标记重复的习语。例如,在 Java 中,字段+getter+setter 可以被识别为概念上属于一个属性的一部分。因此,我们可以添加相应的注释。
每个分析步骤都使用 LionWeb 注释丰富 AST——这些自定义标记编码了关于该代码段的见解。模型逐渐变得更加丰富,不仅捕获语法和语义,还捕获更高级的模式。
转换
通过丰富和注释的模型,流程应用转换规则以生成目标语言的 AST。
转换可以是:
简单的一对一映射(例如,RPG 中的 if 语句转换为 Java 中的 if 语句)。
复杂的,基于习惯用法的重写,由注释驱动(例如,JavaBean 中的字段、getter 和 setter 的组合可以翻译成 Kotlin 属性)。
分析阶段产生的注释帮助我们在到达这一点时放置所需的所有信息,因此转换可以很简单。
细化
可选地,目标 AST 经过细化,使代码符合习惯用法:
根据 AST 中包含的代码添加必要的导入,
重新排序成员(字段在方法之前,私有的在公共的之前),
规范化命名约定,
执行自动增量重构(例如,删除没有人使用的生成方法)。
这个阶段是关于润色和提高可维护性的,确保输出看起来像是人编写的代码。
代码生成
最后,细化后的 AST 被传递给代码生成器,它将它们序列化为实际的源文件。此时我们获得了有效的 Java、Python 或 C#文件,可以像任何手动编写的代码一样进行编译、执行和测试。
最佳实践和最终注意事项
管道方法提供了几个实际的优势,使大规模迁移更加可靠且易于管理。通过将过程结构化为明确定义的阶段,团队获得了可测试性、可度量性和模块性——这三个属性对于通常持续数月甚至数年的项目至关重要。
独立测试每个阶段
管道的一个主要优势是每个阶段可以独立测试。例如:
解析步骤可以针对文件语料库进行测试,检查解析错误并验证生成的 AST。
语义丰富可以通过检查符号解析统计数据或验证引用和类型是否一致链接来测试。
分析过程,如 GOTO 分类,可以分别验证,确保它们在特定情况下添加了正确的注释。
这种隔离使得调试更容易,更有效地分配工作,并且随着管道的发展,增强了对管道正确性的信心。
监控进度
由于迁移需要长时间运行,因此进度跟踪至关重要。每个阶段都产生可度量的统计数据,可以在仪表板中显示:
解析:成功解析的文件百分比,无法识别的结构数量。
语义丰富:已解析的符号百分比,未解析的引用计数。
分析:GOTO 被分类为循环、中断或继续的比例;未分类的实例。
转换:支持的结构和代码习惯用法的数量,完全转换的原始文件数量。
跟踪这些指标将迁移转变为一个透明、可度量的过程,而不是一个黑盒工作。这与典型的场景相比是一个很大的变化,在典型的场景中,人们不得不依赖于参与开发人员的“直觉”。虽然 Jim 确信项目“正在以良好的速度进展”,但有数字支持它提供了不同级别的保证。
构建模块化管道
另一个优势是模块化。通过标准化中间表示(例如,LionWeb),不同的组件可以独立开发并组合:
基于 ANTLR、Tree-sitter 或其他框架的解析器可以适应以发出 LionWeb,而基于 Starlasu 的解析器原生支持 LionWeb。
分析模块或符号解析器可以跨项目共享。
不同目标语言的代码生成器可以互换或扩展。
这为可重用的迁移组件目录创造了潜力,使公司能够更快、更高质量地组装管道,而不是从头开始重新发明每个部件。
未来的方向和开放的挑战
虽然这种方法在实践中已经很好地发挥了作用,但仍有一些领域需要进行更多的研究来提高技术水平:
测试生成:特别是,使用高级规范(例如,Gherkin)来捕获业务行为,并使验证更加健壮。
自动习惯用法识别:提高在源代码中检测更高级语言习惯用法的能力,使迁移越来越多地从习惯用法到习惯用法(保留意图并适应风格)而不是从结构到结构的翻译。
细化和检查工具:扩展对快照可视化、调试和协作验证的支持。
最后,重要的是强调不要依赖什么。多年来,许多迁移项目因两种方法而失败:
手动重写,消耗巨大的预算,而且往往会因自身的复杂性而崩溃。
“一刀切”的工具,承诺神奇的一键迁移,但隐藏了无法验证的不透明过程。
一种新的诱惑是完全依赖 LLMs 进行代码迁移。虽然 LLMs 在创造性任务中非常强大,但它们并不是为确定性的、大规模的转换设计的,其中精确度是不容商量的。在实践中,要求一个 LLM 迁移数百万行代码,然后依赖开发人员修补不一致性,这是失败的秘诀。
管道方法提供了一些有价值的品质:它是系统的、可验证的和模块化的。从本质上讲,它把迁移变成了一个工程过程,而不是一场赌博。
原文链接:
https://www.infoq.com/articles/pipeline-language-migrations/
评论