迁移不等于结束:我们是如何验证生成代码的“正确性“的

📅 2026/7/12 20:07:04
迁移不等于结束:我们是如何验证生成代码的“正确性“的
系列文章第 6 篇上一篇《SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join》下一篇《给迁移平台做一个“驾驶舱”Vue3 ECharts 实战》一、引子编译通过 ≠ 逻辑正确在代码迁移的世界里有一个巨大的认知陷阱“只要编译通过迁移就成功了。”这是一个危险的错觉。我们曾经也这么天真过。直到有一天一个“完美编译”的 Repository 方法上线后直接把数据库 CPU 打满。原因很简单RuleEngine 正确地生成了leftJoinLLM 正确地填充了方法体代码编译毫无报错但是JOIN 条件写反了t.role_id t.id写成了t.id t1.role_id。在 MySQL 优化器看来这依然是一条合法的 SQL。但在业务语义上这是一场灾难。这次事故给我们上了一课代码迁移的终点不是生成代码而是验证正确性。今天我想揭开我们迁移系统中最“无聊”、但也最关键的组件——MigrationValidator迁移验证器的面纱。二、验证的四层金字塔我们把验证分为四层像金字塔一样层层递进。每一层都试图拦截不同类型的错误。┌─────────────────────┐ │ 规范校验 (Layer 4) │ ← 最高层符合架构规范 └─────────────────────┘ ┌─────────────────────┐ │ SQL Diff (Layer 3) │ ← 核心层语义等价性 └─────────────────────┘ ┌─────────────────────┐ │ AST Diff (Layer 2) │ ← 结构层语法一致性 └─────────────────────┘ ┌─────────────────────┐ │ 编译检查 (Layer 1) │ ← 地基层语法正确性 └─────────────────────┘原则下层是基础上层是核心。任何一层失败整个迁移都被标记为失败。三、第一层编译检查地基这是最基础也是最重要的一关。为什么必须做LLM 最喜欢犯的错误引用不存在的类import com.baomidou.unknown.MPJWrapper;方法签名不匹配返回void实际需要Boolean类型转换错误String赋值给Integer这些问题编译器一眼就能看出来。我们怎么做我们使用 Java 原生的javax.tools.JavaCompilerAPI在内存中进行编译不触碰磁盘文件。// CompilationChecker.java核心逻辑 public class CompilationChecker { private final JavaCompiler compiler ToolProvider.getSystemJavaCompiler(); public CompilationResult check(String javaCode, Path classpath) { // 1. 将生成的代码包装成 JavaFileObject JavaFileObject fileObject new MemoryJavaFileObject(Temp.java, javaCode); // 2. 设置编译参数指定 classpath包含 Crystal Framework 依赖 ListString options List.of( -classpath, buildClasspath(classpath), -source, 17, -target, 17 ); // 3. 执行编译 DiagnosticCollectorJavaFileObject diagnostics new DiagnosticCollector(); JavaCompiler.CompilationTask task compiler.getTask( null, null, diagnostics, options, null, List.of(fileObject) ); boolean success task.call(); if (!success) { // 收集错误信息 ListValidationError errors diagnostics.getDiagnostics().stream() .filter(d - d.getKind() Diagnostic.Kind.ERROR) .map(d - new ValidationError( COMPILE_ERROR, d.getMessage(null), d.getLineNumber() )) .toList(); return new CompilationResult(false, errors); } return new CompilationResult(true, List.of()); } }关键点内存编译速度快不产生垃圾文件。完整 Classpath必须包含 Crystal Framework 的所有 Jar 包否则会报“找不到符号”的错误。快速失败编译不过直接终止绝不进入下一层。四、第二层AST Diff结构编译通过说明代码“长得像”Java。但我们需要确认它“长得像”我们期望的 Java。为什么需要 AST Diff考虑这个场景RuleEngine 生成了一个方法Override public UserVO getById(Long id) { return baseMapper.selectById(id); }LLM 在填充逻辑时不小心改成了Override public UserVO getById(Long id) { // 错误的逻辑 return new UserVO(); }编译依然通过但逻辑丢了。我们需要一种机制来对比“结构”。我们怎么做我们使用JavaParser​ 来解析生成的 Java 代码然后与 IR 进行比对// AstDiffValidator.java核心逻辑 public class AstDiffValidator { public AstDiffResult diff(IrTypeDecl ir, String javaCode) { // 1. 解析 Java 代码 CompilationUnit cu JavaParser.parse(javaCode); // 2. 找到对应的类 OptionalClassOrInterfaceDeclaration clazzOpt cu.getClassByName(ir.name()); if (clazzOpt.isEmpty()) { return AstDiffResult.failed(Class name mismatch); } ClassOrInterfaceDeclaration clazz clazzOpt.get(); // 3. 遍历 IR 中的方法 for (IrMethod irMethod : ir.methods()) { // 在 Java AST 中寻找同名方法 OptionalMethodDeclaration javaMethodOpt clazz.getMethodsByName(irMethod.name()).stream().findFirst(); if (javaMethodOpt.isEmpty()) { return AstDiffResult.failed(Method missing: irMethod.name()); } MethodDeclaration javaMethod javaMethodOpt.get(); // 4. 对比方法签名 if (!matchSignature(irMethod, javaMethod)) { return AstDiffResult.failed(Method signature mismatch: irMethod.name()); } // 5. 对比注解非常重要 if (!matchAnnotations(irMethod, javaMethod)) { return AstDiffResult.failed(Annotation mismatch: irMethod.name()); } } return AstDiffResult.success(); } private boolean matchSignature(IrMethod irMethod, MethodDeclaration javaMethod) { // 对比返回类型、参数数量、参数类型 // 忽略泛型细节只对比简单名称 return irMethod.returnType().simpleName().equals(javaMethod.getType().asString()) irMethod.parameters().size() javaMethod.getParameters().size(); } }我们能发现什么方法丢失LLM 删除了一个方法。签名篡改LLM 修改了返回类型或参数。注解丢失Override、Transactional、CacheEvict不见了。类结构破坏把class改成了interface。AST Diff 确保了“形”的正确。五、第三层SQL Diff灵魂这是整个验证体系的核心也是技术难度最高的一层。对于 Repository 层的迁移仅仅保证 Java 代码正确是不够的。我们必须保证生成的 SQL 语义等价。为什么这是核心因为leftJoin和innerJoin在语法上都是合法的但语义天差地别。eq(u.id, r.id)和eq(r.id, u.id)在 SQL 中是一样的但在某些数据库的优化器中可能有性能差异。like(% keyword %)和like(keyword)在功能上相似但前者可能导致索引失效。核心思想Logical Plan逻辑计划我们不直接比较 SQL 字符串因为格式千变万化而是比较它们背后的逻辑计划。Logical Plan 是什么它是一种抽象的树状结构描述了查询的意图而不关心具体的语法。例如对于查询SELECT u.name, r.name FROM user u LEFT JOIN role r ON u.role_id r.id WHERE u.status 1;它的 Logical Plan 是Project [u.name, r.name] Filter [u.status 1] Join [LEFT] Scan [user as u] Scan [role as r] Condition [u.role_id r.id]我们怎么做1. 从 IR 生成 Sugar Plan从 C# 的 SqlSugar 调用链中提取 Logical Plan。// SqlSugarLogicalPlanner.java public SqlLogicalPlan plan(IrTypeDecl ir) { // 识别 Queryable - LeftJoin - Where - Select - ToList // 构建 Logical Plan 树 // 例如 // IrMethodCall(Queryable) - TableScan // IrMethodCall(LeftJoin) - Join Node // IrMethodCall(Where) - Filter Node // IrMethodCall(Select) - Project Node }2. 从 Java 代码生成 MPJ Plan使用 JavaParser 解析生成的 Java 代码提取 MPJ 的链式调用构建 Logical Plan。// MyBatisPlusLogicalPlanner.java public SqlLogicalPlan plan(String javaCode) { // 解析 selectJoinList(...) // 解析 wrapper - leftJoin(...) // 解析 wrapper - eq(...) // 解析 wrapper - selectAs(...) // 构建 Logical Plan 树 }3. 比较 Plan这是最关键的一步。我们递归地比较两个 Logical Plan 树。// SqlPlanDiffer.java public class SqlPlanDiffer { public boolean equivalent(SqlLogicalPlan planA, SqlLogicalPlan planB) { // 1. 节点类型必须相同 if (planA.getClass() ! planB.getClass()) return false; // 2. 根据节点类型进行比较 if (planA instanceof SqlLogicalPlan.Join joinA planB instanceof SqlLogicalPlan.Join joinB) { // JOIN 类型必须一致 (LEFT vs INNER) if (joinA.type() ! joinB.type()) return false; // JOIN 条件必须一致 if (!equivalent(joinA.condition(), joinB.condition())) return false; // 左右子树必须一致 return equivalent(joinA.left(), joinB.left()) equivalent(joinA.right(), joinB.right()); } if (planA instanceof SqlLogicalPlan.Filter filterA planB instanceof SqlLogicalPlan.Filter filterB) { // WHERE 条件必须一致 return equivalent(filterA.condition(), filterB.condition()); } // ... 其他节点类型的比较 return true; } private boolean equivalent(SqlPredicate predA, SqlPredicate predB) { // 比较谓词逻辑 // eq(a, b) vs eq(a, b) - true // eq(a, b) vs ne(a, b) - false // and(a, b) vs and(a, b) - 递归比较 return false; // 简化逻辑 } }我们能发现什么JOIN 类型错误LEFT JOIN 变成了 INNER JOIN。JOIN 条件错误u.role_id r.id变成了u.id r.id。WHERE 条件丢失漏掉了deleted 0。SELECT 字段映射错误u.name映射到了dto.age。SQL Diff 确保了“神”的正确。六、第四层规范校验品味最后一层也是最“人文”的一层。它不关心对错只关心是否符合 Crystal Framework 的规范。为什么需要规范校验因为 RuleEngine 和 LLM 都可能“偷懒”或“创新”RuleEngine 可能忘记加Schema注解。LLM 可能觉得Transactional太麻烦就不加了。两者都可能写出new Date()而不是LocalDateTime.now()。这些代码能跑但不符合我们的架构标准会增加后期的维护成本。我们怎么做我们定义了一系列基于正则表达式和 AST 扫描的规则。// CrystalSpecValidator.java public class CrystalSpecValidator { public SpecValidationResult validate(IrTypeDecl ir, String javaCode) { ListValidationError errors new ArrayList(); // 1. Entity 规范 if (ir.name().endsWith(Entity)) { if (!javaCode.contains(TableName)) { errors.add(new ValidationError(SPEC_001, Entity 缺少 TableName)); } if (!javaCode.contains(Data)) { errors.add(new ValidationError(SPEC_002, Entity 缺少 Data)); } if (!javaCode.contains(private Long deleted)) { errors.add(new ValidationError(SPEC_003, Entity 缺少逻辑删除字段 deleted)); } } // 2. Controller 规范 if (ir.name().endsWith(Controller)) { if (!javaCode.contains(R)) { errors.add(new ValidationError(SPEC_004, Controller 返回值必须使用 RT 包装)); } if (!javaCode.contains(HasPermission)) { errors.add(new ValidationError(SPEC_005, Controller 方法必须添加 HasPermission)); } } // 3. Service 规范 if (ir.name().endsWith(ServiceImpl)) { if (javaCode.contains(update) !javaCode.contains(Transactional)) { errors.add(new ValidationError(SPEC_006, 更新方法必须添加 Transactional)); } } // 4. 通用规范 if (javaCode.contains(new Date())) { errors.add(new ValidationError(SPEC_007, 禁止使用 new Date()请使用 LocalDateTime.now())); } if (javaCode.contains(String.valueOf)) { errors.add(new ValidationError(SPEC_008, 请使用 Objects.toString() 替代 String.valueOf())); } return new SpecValidationResult(errors); } }我们能发现什么架构腐化不符合分层架构的调用。安全隐患硬编码密码、SQL 注入风险。代码异味使用过时的 API、不规范的命名。规范校验确保了“美”的统一。七、验证失败的降级策略当任何一层验证失败时我们的系统不会崩溃而是执行优雅降级。// MigrationOrchestrator.java private FileMigrationResult migrateSingle(IrTypeDecl ir) { try { // ... 生成代码 ... // 执行验证 ValidationResult result validator.validate(ir, javaCode, outputPath); if (!result.passed()) { // 1. 记录详细的错误信息 log.error(Validation failed for {}: {}, ir.name(), result.errors()); // 2. 生成占位符代码 String placeholder generatePlaceholder(ir, result.errors()); // 3. 写入文件但标记为 TODO writeToFile(placeholder); // 4. 返回失败结果但迁移流程继续 return FileMigrationResult.failed(ir.name(), result.errors()); } // 5. 验证通过写入最终代码 writeToFile(javaCode); return FileMigrationResult.success(ir.name(), javaCode); } catch (Exception e) { // 6. 捕获所有未预料的异常 return handleException(ir, e); } }生成的占位符代码示例/** * TODO: 自动迁移失败 * 原因SQL Diff 验证失败 (JOIN 条件不匹配) * 原始 C# 方法GetUserOrderDetails * IR Hasha1b2c3d4e5f6 * * 请参考 original_code.cs 手动实现此方法。 */ Override public ListUserOrderDetailDTO getUserOrderDetails(String keyword) { throw new UnsupportedOperationException(Manual migration required. See TODO comment.); }这种策略确保了流程不中断一个文件的失败不影响其他文件。问题可追溯错误信息被记录在代码中。人工介入点明确开发人员清楚地知道哪里需要手动处理。八、总结迁移不等于结束。生成代码只是第一步验证代码才是真正的挑战。我们的四层验证体系像一道严密的过滤网编译检查拦住了语法错误AST Diff拦住了结构破坏SQL Diff拦住了逻辑偏差规范校验拦住了架构腐化。正是这套体系让我们敢说“我们的迁移平台不是为了生成代码而是为了生成可信赖的代码。”在下一篇也是本系列的收官之作中我们将把视角从后端转向前端。我将展示如何为这个复杂的迁移平台打造一个直观、高效的可视化驾驶舱让枯燥的迁移过程变得“看得见、摸得着、管得住”。系列目录从 C# 到 Java我们是如何把老系统无痛迁到 Crystal Framework 的代码迁移的中间语言为什么我们要设计一套 IRRuleEngine让 90% 的代码迁移不再依赖大模型LLM 兜底如何安全地使用大模型处理复杂逻辑SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join迁移不等于结束我们是如何验证生成代码的正确性的本文[给迁移平台做一个驾驶舱Vue3 ECharts 实战待发布]关于代码验证你有什么独特的见解或工具推荐吗欢迎在评论区一起探讨。