7AA7 博客

代码生成器实践:模板、AST 与批量改造

许既白 UID:107
2026-10-02 08:24
51 1 1

一个有六七十张表的服务,手写数据访问层要花几天,而且每张表的写法会逐渐产生偏差。一个 API 废弃需要在三百个调用点上加参数,手工改要一天,还容易漏。这两类问题的共同点是:工作量大、模式重复、出错点分散。代码生成是处理它们的常规手段,但生成方式的选择会直接影响后续维护成本。

模板生成与 AST 改写的分工

两条路线的适用边界很清楚,判断依据是”目标文件是否已存在、是否需要保留人工修改”。

维度 模板生成 AST 改写
适用场景 新建文件(脚手架、DTO、CRUD、客户端 SDK) 修改存量代码(API 迁移、批量加参数、换库)
输入 数据模型(schema、接口定义) 已有源码
对原文件的处理 整体覆盖 局部修改,保留未匹配部分
能否保留注释与格式 不能(原文件是生成物) 能(好的工具会保留)
失败影响 单个文件重建 影响范围内所有匹配点

简单说:新建用模板,修改用 AST。用正则去改存量代码是第三种选择,也是最危险的一种——它不理解语法,遇到换行、注释、嵌套、字符串里恰好有相同文本的情况就会误伤。

模板方案的关键设计

数据模型

生成器的核心不是模板,是数据模型。模板只是把模型渲染成文本,模型决定了能表达什么。

数据模型通常来自:数据库 schema、接口描述文件(OpenAPI、protobuf)、或者手写的领域描述文件。选择依据是”哪个是权威来源”——如果数据库 schema 是权威的,就从它读;如果接口定义是权威的,就不要再从代码反推,反推出来的模型会丢失可选性、注释这类信息。

# schema.yaml —— 生成器的输入
entities:
  - name: Order
    table: orders
    fields:
      - name: id
        type: int64
        primary: true
      - name: user_id
        type: int64
        index: true
      - name: status
        type: string
        enum: [CREATED, PAID, CANCELLED]
      - name: created_at
        type: time.Time

模型里要显式携带元数据(主键、索引、枚举、可空性、注释),这些是生成质量的关键。从数据库读取时,可空性和注释往往需要额外查询信息表才能拿到。

命名转换

生成代码里最容易出错的是命名:数据库用 user_id,Go 用 UserID,JSON 用 userId,常量用 USER_ID。转换规则必须集中在一个模块里,而不是散落在模板各处。

def to_camel(s): ...
def to_pascal(s): ...
def to_snake(s): ...

# 首字母缩写词表是必须的,否则 user_id 会变成 UserId 而不是 UserID
ACRONYMS = {"id": "ID", "url": "URL", "api": "API", "db": "DB"}

缩写词表这类细节决定了生成物是否”看起来像人写的”。生成的代码如果命名风格与手写代码明显不同,会被人排斥,然后逐渐被手工修改,最后生成器就废弃了。

幂等重生成

模板生成必须是幂等的:同一份模型输入,重复生成得到完全相同的文件内容。这要求模板里不能出现时间戳、随机值、字典遍历顺序相关内容。

重生成策略有几种:

  • 整体覆盖:适用于纯生成物,不允许手工修改。
  • 标记区域保留:在文件里用注释标记可编辑区,生成时只替换标记外的内容。
  • 跳过已存在:脚手架类生成常用,避免覆盖已有文件。
// Code generated by gen-repo; DO NOT EDIT.
// 手工扩展请写在同目录的 order_custom.go 中

package repo

把扩展文件与生成文件分离的约定,比”标记区域”更可靠——它避免了有人把代码写进生成区域后被覆盖的悲剧,也让”这个文件是生成物”变成可见的事实。

AST 改写的优势

AST 改写直接操作语法树,带来的两个关键好处:

保留格式与注释。好的工具(jscodeshift、go/ast 配合打印器、JavaParser)只重建被修改的节点,其余部分按原样输出,因此 diff 里只有真正变化的行。这一点对评审至关重要:一次批量改造如果产出三千行 diff,其中两千八百行是格式重排,评审就无法进行,也可能掩盖真实改动。

精确定位。可以按语义匹配——”找到所有对 client.Do(ctx, req) 的调用”,而不受换行、别名、导入写法的影响。正则做不到这一点。

// codemod: 给所有 client.Do 调用加上 options 参数
module.exports = function (fileInfo, api) {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);
  let changed = 0;

  root.find(j.CallExpression, {
    callee: {
      type: 'MemberExpression',
      object: { name: 'client' },
      property: { name: 'Do' },
    },
  }).forEach((path) => {
    if (path.value.arguments.length === 2) {
      path.value.arguments.push(j.identifier('opts'));
      changed++;
    }
  });

  return changed > 0 ? root.toSource({ quote: 'single' }) : null;
};

返回 null 表示”这个文件没变”,工具会跳过它,最终 diff 保持干净。

codemod 的执行流程

一次批量改造的执行链是:解析 → 匹配 → 变换 → 重新打印。

  1. 解析:把源码读成 AST。注意语言版本和方言(JSX、TypeScript、装饰器),解析器配置不对会在第一步就失败。
  2. 匹配:用选择器找到目标节点。匹配条件要尽可能窄,宁可多跑几轮也不要一次匹配过宽。
  3. 变换:修改节点。要处理边界情况:已经是新写法的(跳过)、在注释里的(跳过)、在字符串里的(跳过)、动态调用无法静态分析的(标记出来人工处理)。
  4. 重新打印:输出源码并格式化。这一步要跑项目的格式化工具,保证风格一致。

第 3 步里的”标记无法处理的情况”常被省略,结果是一部分调用点被静默跳过。正确的做法是让 codemod 输出一份”未处理清单”,人工过一遍。

批量改造的安全网

在几百个文件上跑自动变换,必须有几层保护:

  • dry-run 优先:--dry 只输出将要修改的文件列表和 diff,不落盘。先整体看一遍,确认匹配范围正确。
  • diff 预览与抽样评审:随机抽十个 diff 仔细看,包括正常情况和边界情况(文件里有多处调用、调用跨行、参数本身是表达式)。
  • 分批提交:按模块或目录分批改,每批一个提交。出问题时 revert 的范围可控,也便于定位”是哪一批引入的回归”。
  • 测试与构建:每批改完跑一次完整测试。改造本身不改变行为,测试应该全绿;出现失败说明变换有语义错误。
  • 可回退:改造提交与行为变更提交分开,这样 revert 改造不会连带掉业务逻辑。

还有一个容易忽略的点:改造期间冻结相关文件的其他修改。codemod 基于某个时间点的代码生成 diff,如果中途有人改了同一批文件,合并冲突会很难处理。提前通知并约定一个短窗口,比事后解决冲突省事。

生成代码要不要入库

两种做法各有代价。

入库(提交到仓库):构建时不需要生成器,代码可检索、可跳转、diff 可见;代价是生成物与模型可能不同步,仓库里有大量重复代码,评审噪音大。

不入库(构建时生成):仓库干净,永远与模型同步;代价是构建需要先跑生成器,IDE 跳转与代码搜索在生成前失效,构建环境要装生成器工具链,新人上手多一步。

影响取舍的因素:

  • 生成物是否需要被 IDE 静态分析(不入库时,需要配置 IDE 的生成目录)。
  • 构建环境是否容易统一(多语言、多平台项目里,生成器依赖会成为负担)。
  • 使用方是否需要阅读生成物(SDK 类产物通常需要入库,因为使用方会看源码)。
  • 模型变更频率(高频变更的模型,不入库更合适)。

折中方案是:源码里只存模型和生成器,生成物在 CI 中产出并作为发布物发布;同时提供一个 make generate 目标供本地开发使用。这样兼顾了同步性和可用性,代价是构建流程多一个环节。

无论选哪种,都值得在 CI 里加一个检查:跑一次生成,然后 git diff --exit-code,不一致就失败。这条检查能挡住”改了模型忘了重新生成”这类最常见的问题。生成器写完之后,真正的工作量在维护——模型会变、语言会升级、格式化规则会调整,把生成流程纳入 CI 是让它长期可用的前提。

1.2k浏览量
51赞
1评论
1参与用户
10 分钟阅读时间

评论(1)

  • 宋识 · 2026-10-02 10:30

    看完感觉自己前两年都在瞎搞。

    回复