代码生成与领域特定语言

拆解低代码平台的代码生成与 DSL 设计:逃生舱与可导出性、模板式与 AST 式两种生成路线、DSL 的设计原则、元数据到代码的映射、生成代码的可读性、生成与手写的往返工程边界、生成物的独立性、校验与幂等,以及后端 CRUD 与前端页面与类型定义三类场景的落地,给出可运行的生成器实现。

引言

低代码平台常被质疑「锁定」:搭建出来的应用只能在平台里跑,一旦平台变更或停服,资产就归零。破解锁定的关键能力就是代码生成——把元数据翻译成可独立运行、可人工维护的代码。这既是「逃生舱」,也是平台从「玩具」升级为「基础设施」的门槛。

代码生成的价值不止于逃生。它还能:让低代码产物融入现有 CI/CD、让工程师在生成的基础上继续手写、把平台从「运行时解释」转为「构建期编译」以获得更好的性能。但生成代码也带来新问题:生成物可读性差、生成与手写的边界模糊、重新生成会覆盖手工修改。

本文按「定位 → 逃生舱 → 两条生成路线 → DSL 设计 → 映射 → 可读性 → 往返工程 → 独立性 → 校验幂等 → 三类场景」展开,给出模板式生成器、AST 生成与 DSL 设计的具体做法。读完后你应当能判断:什么该生成、什么不该生成,以及如何让生成物长期可维护。

目录

  1. 代码生成在低代码中的位置
  2. 逃生舱与可导出性
  3. 模板式生成与 AST 式生成
  4. DSL 设计原则
  5. 从元数据到代码的映射
  6. 生成代码的可读性
  7. 生成与手写的往返工程
  8. 生成物与平台的关系
  9. 校验与幂等
  10. 三类典型生成场景

1. 代码生成在低代码中的位置

低代码的两种执行模式:
  解释执行:运行时读元数据 → 解释 → 渲染
    优点:改配置即时生效
    缺点:运行时开销、依赖平台

  生成执行:构建期元数据 → 生成代码 → 部署
    优点:性能好、可脱离平台、可人工维护
    缺点:有构建步骤、改动有延迟

成熟平台通常两者并存:
  内部使用 → 解释(快)
  对外交付/关键系统 → 生成(稳、可掌控)

代码生成是平台「出圈」的能力:让低代码产物能进入传统研发流程。

2. 逃生舱与可导出性

逃生舱的设计目标:平台消失后,产物仍能运行。

逃生舱的三个层次:
  L1 导出元数据(JSON)
     最弱,只有平台能解释
  L2 导出配置 + 运行时(平台运行时开源/可部署)
     中等,脱离平台服务但依赖运行时
  L3 导出可独立运行的代码(框架无关或标准框架)
     最强,完全脱离平台

建议目标:至少 L2,关键场景 L3

评估一个平台时,直接问:「如果平台明天停服,我的应用还能跑吗?」答案决定了锁定程度。

3. 模板式生成与 AST 式生成

两条主流生成路线,各有取舍。

模板式(Template-based):
  用模板字符串 + 占位符生成文本
  优点:简单、上手快、适合代码结构固定的场景
  缺点:拼接易出错、难保证语法正确、重构难

AST 式(AST-based):
  构造抽象语法树,再序列化为代码
  优点:语法保证正确、可格式化、可分析
  缺点:需要目标语言的 AST 库、实现复杂
// 模板式:简单直接,但需小心转义
function genEntityModel(entity: Entity): string {
  const fields = entity.fields
    .map((f) => `  ${f.name}: ${tsType(f.type)};`)
    .join('\n');
  return `export interface ${pascal(entity.name)} {\n${fields}\n}\n`;
}

// 输出
// export interface Order {
//   id: string;
//   amount: number;
// }
// AST 式:用 ts-morph 构造,天然保证语法正确
import { Project } from 'ts-morph';

function genInterface(entity: Entity) {
  const project = new Project();
  const file = project.createSourceFile('types.ts');
  file.addInterface({
    name: pascal(entity.name),
    properties: entity.fields.map((f) => ({
      name: f.name,
      type: tsType(f.type),
    })),
  });
  return file.getFullText();
}

选择标准:生成物结构固定、简单 → 模板式;生成物复杂、需保证语法、需后续处理 → AST 式。

4. DSL 设计原则

DSL 是「面向特定领域的语言」,低代码的元数据本身就是一种 DSL。

好的 DSL 特征:
  1. 表达力刚好覆盖领域(不多不少)
  2. 可读性高(非专家也能看懂)
  3. 可校验(能静态检查)
  4. 可演化(向后兼容)
  5. 有边界(不图灵完备)

差的 DSL 特征:
  万能、什么都想表达 → 变成「用 JSON 写代码」
# 一个良好的流程 DSL 片段:语义清晰、可校验
flow: leave_approval
steps:
  - id: submit
    type: form
    form: leave_form
  - id: approve
    type: approval
    assignee: { role: manager }
    condition: "{{ days <= 3 }}"
  - id: hr_approve
    type: approval
    assignee: { role: hr }
    condition: "{{ days > 3 }}"

设计 DSL 的核心原则是克制:宁可少一种结构,也不要为了「灵活性」引入会破坏可分析性的特性(如任意函数、动态跳转)。

5. 从元数据到代码的映射

生成的核心是映射:元数据的每个概念对应目标语言的什么。

元数据后端代码前端代码类型定义
实体Model / Table接口类型interface
字段类型列类型表单控件TS 类型
关系关联查询级联选择器嵌套类型
枚举常量 / 约束下拉选项union type
校验服务端校验前端校验无
权限中间件条件渲染无
function tsType(t: FieldType): string {
  switch (t) {
    case 'string': case 'text': case 'date': case 'datetime':
    case 'enum': return 'string';
    case 'integer': case 'decimal': return 'number';
    case 'boolean': return 'boolean';
    case 'json': return 'unknown';
    case 'ref': return 'string';       // 存 id
    default: return 'unknown';
  }
}

映射表要显式维护,而不是散落在生成逻辑里。它是最容易因新增字段类型而遗漏的地方。

6. 生成代码的可读性

生成代码最终可能被人工阅读和修改,可读性很重要。

可读性要点:
  1. 格式规范:生成的代码应通过 prettier/eslint
  2. 命名合理:用业务名而非 id
  3. 注释来源:把元数据里的 label/comment 变成注释
  4. 稳定排序:字段顺序稳定,避免无意义 diff
  5. 文件头:标注「自动生成,勿手改」
const HEADER = `/* eslint-disable */
// 此文件由低代码平台自动生成,请勿手动修改
// 生成时间: ${new Date().toISOString()}
// 来源: app_1024/order@v3
`;

生成时间戳会导致每次生成都产生 diff,实践中要么去掉时间戳,要么用元数据版本号替代。

7. 生成与手写的往返工程

最大的难题:用户想改生成物,但下次生成会覆盖。

三种策略:
  A. 生成物只读
     用户改生成物即失去平台同步,简单但僵硬
  B. 生成物可改,平台不覆盖已改文件
     用哈希检测手工修改,跳过这些文件
  C. 生成到独立文件,手写放另一文件
     生成基类/接口,手写实现继承/实现它
     推荐:最干净,往返安全
// 策略 C:生成基类,手写扩展
// 生成文件:order.generated.ts
export class OrderServiceBase {
  async findById(id: string) { /* 自动生成 */ }
}

// 手写文件:order.service.ts(平台不覆盖)
import { OrderServiceBase } from './order.generated';
export class OrderService extends OrderServiceBase {
  async findWithDiscount(id: string) {
    const o = await this.findById(id);
    return { ...o, discount: computeDiscount(o) };
  }
}

「生成基类 + 手写子类」是往返工程的标准解法:重新生成只影响基类,手写代码不受影响。

8. 生成物与平台的关系

生成物有两种定位,决定了架构。

定位 A:一次性导出(快照)
  导出后与平台脱钩,后续在代码库独立演进
  适合:交付给客户、长期维护的系统

定位 B:持续同步(单向生成)
  平台是唯一真相,每次改动重新生成
  适合:平台内持续迭代的应用

混合:
  结构由平台生成(持续同步)
  业务逻辑手写(独立演进)

定位 A 与 B 不可混用:一旦选择持续同步,生成物就不能手改;一旦选择快照,平台后续改动不会同步。

9. 校验与幂等

生成器本身也需要工程保障。

生成器质量保障:
  1. 幂等:相同输入产生相同输出(可 diff 校验)
  2. 可编译:生成物必须通过编译(CI 校验)
  3. 确定性:字段排序稳定、无随机/时间戳
  4. 快照测试:对生成结果做快照,变更需显式确认
  5. 往返测试:生成 → 解析 → 再生成,结果一致
// 幂等测试
test('generator is idempotent', () => {
  const a = generate(metadata);
  const b = generate(metadata);
  expect(a).toEqual(b);
});

「生成物必须能编译」是最基本也最容易忽略的约束:没有编译校验,生成器的一个 bug 会在用户那里才暴露。

10. 三类典型生成场景

场景 1:后端 CRUD
  元数据 → Controller/Service/Repository + 路由 + 校验
  收益:省去大量样板代码

场景 2:前端页面
  元数据 → 页面组件 + 表单 + 表格
  收益:把搭建结果变成可维护的前端代码

场景 3:类型定义
  元数据 → TypeScript 类型 + API 客户端
  收益:前后端类型一致,编译期发现不匹配
// 场景 3:生成类型安全的 API 客户端
function genApiClient(entities: Entity[]): string {
  return entities.map((e) => `
export const ${camel(e.name)}Api = {
  list: (params?: ListParams) => http.get<${pascal(e.name)}[]>('/${e.name}', { params }),
  get: (id: string) => http.get<${pascal(e.name)}>('/${e.name}/' + id),
  create: (data: Omit<${pascal(e.name)}, 'id'>) => http.post('/${e.name}', data),
};`).join('\n');
}

类型定义生成是最「无争议」的场景:它不替代业务代码,只消除前后端的类型漂移,收益明确、风险极低。

权衡取舍

决策点选项 A选项 B建议
生成方式模板AST结构固定用模板,复杂用 AST
逃生舱仅导出元数据生成独立代码关键系统用独立代码
手写边界覆盖生成物基类+子类基类+子类,往返安全
与平台关系快照持续同步二者不可混用,需明确
时间戳写入文件头用版本号版本号,避免无谓 diff

常见坑清单

  1. 生成物被手改又重生成:手工修改被覆盖,应用「基类+子类」模式。
  2. 生成代码不可编译:生成器 bug 到用户处才暴露,CI 必须编译校验。
  3. 无幂等保证:相同输入产生不同输出,diff 噪声大,需确定性与稳定排序。
  4. 时间戳进文件头:每次生成都 diff,应改用元数据版本号。
  5. 模板拼接不转义:字段名含特殊字符时生成语法错误代码,需转义或改用 AST。
  6. 快照与持续同步混用:用户以为改了会同步,实际不会,需明确并文档化。
  7. 映射表散落各处:新增字段类型时遗漏,需集中维护映射表。
  8. DSL 图灵完备:退化为「用 JSON 写代码」,不可分析不可校验。
  9. 生成物无文件头标识:用户不知道是自动生成,容易误改。
  10. 无往返测试:生成 → 解析 → 再生成不一致,说明生成器有损。

小结

代码生成与 DSL 的核心价值是破解锁定:让低代码产物能进入传统研发流程、能被人工维护、能在平台消失后存活。实现骨架是「逃生舱分级 → 模板/AST 两条路线 → DSL 克制设计 → 显式映射表 → 可读性 → 往返工程 → 独立性 → 校验幂等」。

最关键的工程决策是「生成与手写的边界」。答案几乎总是「生成基类/接口,手写实现」:重新生成不影响手写代码,手写代码也能享受生成的结构。这一条决定了生成物能否长期存活。

元数据是生成的输入,因此生成的品质上限由 元数据驱动架构设计 的 Schema 品质决定;而生成物服务的多环境与多租户,见 多租户与 SaaS 化落地 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「低代码」更多文章

  1. 自定义代码与逃生舱
  2. 低代码应用测试与质量
  3. 连接器与 API 编排