第九章 · 代码生成器(infra 模块)
这是整个平台”生产力放大“的一个功能:你只要把一张表建好,能在界面上点点点,就能从前端到后端把一整条 CRUD 代码”吐”出来。这一章拆开
CodegenEngine那一板一眼的”模板 + 上下文绑定”机制,并讲清主子表、树表的”个性化”是怎么在模板里被动态裁剪的。
1. 模块概述
代码生成器位于 yudao-module-infra 模块内。核心分工:
| 组件 | 位置 | 职责 |
|---|---|---|
CodegenTableDO / CodegenColumnDO |
dal/dataobject/codegen |
记录”要生成哪张表、每列什么类型”,本身也是两张被持久化的表 |
CodegenBuilder |
service/codegen/inner |
从数据库元数据(information_schema / 方言 SQL)反推 DO/列模型 |
CodegenEngine |
service/codegen/inner |
真正的”模板渲染器”:把表/列模型绑定进上下文,套用 .vm 模板产出代码 |
CodegenController |
controller/admin/codegen |
暴露 list / preview / download / create-list / update 等端点 |
CodegenProperties |
framework/codegen/config |
配置生成包的根路径、前端类型等 |
一句话理解:建表 → Builder 把表结构读成 Java 模型 → 前端把模型保存进 infra_codegen_table / column → Engine 对着约 41 个 .vm 模板把它渲染成可落盘的代码 → download 打包成 zip。
它在全工程中的位置:
graph LR
DB[(MySQL
目标业务表)] -->|方言 SQL 读元数据| BUILDER[CodegenBuilder]
BUILDER -->|CodegenTableDO/ColumnDO| SAVE[(infra_codegen_table
infra_codegen_column)]
SAVE -->|被加载| ENGINE[CodegenEngine]
VMT["codegen/java|vue|vue3/*.vm 模板
约41个"] --> ENGINE
ENGINE -->|渲染 + prettyCode 格式化| CODES[Map<路径, 代码>]
CODES -->|preview 展示| FRONT[前端预览]
CODES -->|download zip| ZIP[可导入工程的代码包]
🔑 关键设计:**生成代码不是”记住每张表的输出”,而是”用一套通用模板 + 每张表的差异变量”**。差异(表名、字段、模块、权限前缀)全在绑定上下文
bindingMap里,模板据此分叉。这样新增一种”表形态”只需要新模板,不需要新引擎。
2. 命名体系与易混淆对象对比
2.1 命名规律拆解(模板路径族)
CodegenEngine 里大量 xxxTemplatePath / xxxFilePath 方法,它们的命名高度模式化:
| 前缀/命名 | 对象 | 含义 |
|---|---|---|
javaTemplatePath |
后端模板地址 | codegen/java/<子路径>.vm |
vueTemplatePath |
Vue2 模板地址 | codegen/vue/<子路径>.vm |
vue3TemplatePath |
Vue3 (Element Plus) 模板地址 | codegen/vue3/<子路径>.vm |
vue3VbenTemplatePath |
Vue3 (vben) 模板地址 | codegen/vue3_vben/<子路径>.vm |
javaModuleImpl... / Api / TestPath |
后端生成目标路径 | 按 `biz |
vueFilePath / vue3FilePath |
前端生成目标路径 | 拼进 `yudao-ui-…vue2 |
命名规律总结:xxxTemplatePath 管”模板从哪读“(统一带 .vm 后缀),xxxFilePath 管”生成的文件写到哪“(按模块/源码根/包路径拼装)。两者必须成对出现,getTemplates 里就是”模板 key → 生成路径 value”的映射表。
2.2 易混淆对象对比:execute vs generateSubCode
| 对比维度 | execute(...) |
generateSubCode(...) |
|---|---|---|
| 职责 | 顶层入口:初始化上下文、取模板、逐个生成 | 专门处理”主子表”的子表生成 |
| 是否直接渲染 | 是(对普通/树表模板) | 否,它委托 generateCode,且按 subIndex 循环 |
| 处理个性化 | 只过滤”树表多余的 PageReqVO/ListReqVO” | 按 _normal/_erp/_inner 模板名匹配 主子表类型 |
| 一句话区分 | 是”对所有模板开总体调度”;是”主子表专属的裁剪 + 循环”。 |
再对比 CodegenTableDO(一张要生成的表)与 CodegenColumnDO(这张表的某一列)——前者是”行”级描述,后者是”列”级描述,二者是一对多关系,靠 tableId 关联。
3. API Signatures
调用关系图(一次”预览代码”的完整路径):
graph TD
C[CodegenController.preview] --> S[CodegenService.previewCode]
S --> LOAD[获取 CodegenTableDO + columns + subTables]
LOAD --> E[CodegenEngine.execute]
E --> BIND[initBindingMap 构造上下文]
E --> TPL[getTemplates 选前后端模板]
TPL --> R{subIndex 或 树表?}
R -->|主子表| SUB[generateSubCode 循环渲染子表]
SUB --> G[generateCode]
R -->|树表| FILTER[跳过 PageReqVO/ListReqVO 冗余类]
R -->|普通| G
G --> PRETTY[prettyCode 格式修正]
PRETTY --> MAP[Map<路径, 代码>]
MAP --> C -->|CommonResult.success| FRONT
C -->|download| ZIP[ZipUtil 打包返回]
3.1 Engine 核心入口
1 |
|
3.2 Controller 端点(CodegenController.java)
1 | public class CodegenController { |
所有端点带 @Valid 校验,且都走 @ss.hasPermission(第八章 的权限注解)。download 用 ZipUtil.zip(fileEntrys) 把 Map<String,String> 的代码说成一个内存 zip 返回。
关键方法用途:
execute返回Map<路径, 代码>——路径与内容解耦:下载时路径当 zip 内文件名,预览时路径当标签。getTemplates(frontType)用FRONT_TEMPLATES.row(frontType)按前端框架(Vue2/Vue3/vben)只取该行模板;SERVER_TEMPLATES是恒有的后端模板。prettyCode是全流程”最后一道质检”,专修 Vue 模板常见的多余逗号/未用 import。
4. 数据结构深度解析
4.a 结构体存在的理由:模板映射 SERVER_TEMPLATES / FRONT_TEMPLATES
生成器的信息核心不在”表模型”,而在两张静态映射表:它们把”某个模板文件”对应到”某个生成路径”。为什么用有序 LinkedHashMap?因为生成的文件之间有约定俗成的顺序(VO → Controller → Service → Mapper → SQL),有序保证了下载 zip 的结构可预期。
FRONT_TEMPLATES用 Guava 的Table<Integer, String, String>(行=前端类型,列=模板,值=路径),正是为了支持”同一模板在不同前端类型下产出不同路径“。
4.b 数据:模板映射表(节选)
1 | // 后端模板:key=模板地址,value=生成路径(用 ${...} 占位符) |
4.c 字段三层分析表(bindingMap 里的代表性变量)
| 变量 | 设计动机(为什么需要) | 反事实(如果去掉会怎样) | 替代方案(还能怎么做) |
|---|---|---|---|
basePackage |
生成文件包路径的根(cn.iocoder.yudao) |
无法确定 import/包名 | 硬编码,不灵活 |
simpleClassName |
去掉模块前缀后的短类名(TestDictType→DictType) |
生成类名冗余、方法/${} 全带前缀 | 加载时用一个复杂类名变量,模板可读性大减 |
permissionPrefix |
权限串前缀(system:dict-type) |
Controller 的 @ss.hasPermission 无法生成 |
手写权限串,易漂移 |
sceneEnum |
生成场景(admin 后台/member 端基础包差异) | 前端/后端包路径选错 | 用 if-else 硬编码,理解成本高 |
baseDOFields |
让模板知道哪些字段属于 BaseDO(不算业务列) |
模板误把 createTime 也当业务字段渲染 |
每个模板各自写死,易漏 |
5. 函数逐行精讲
5.a 场景卡片
**函数:
CodegenEngine.execute**(源码:238-268)
- 调用时机:用户在”代码生成”页点预览 / 下载时。
- 典型调用者:
CodegenService.previewCode / downloadCode。- 前置条件:
CodegenTableDO及其CodegenColumnDO列表已就绪(可能含子表)。- 目的:把一张表的模型,通过模板渲染成后端 + 前端整套 CRUD 代码。
5.b 逐行注释式精讲
1 | public Map<String, String> execute(CodegenTableDO table, List<CodegenColumnDO> columns, |
🔍 连贯细节:
isTree会造成 PageReqVO 与 ListReqVO 二选一——树表列表查询走”下级列表”,非树表走”分页”,所以只生成其中一种 VO,避免产出多余且引用的死类。这是”靠模板名做语义判断”的巧思,属于引擎层做裁剪。
5.c 场景卡片:initBindingMap
**函数:
initBindingMap**(源码:344-414)
- 调用时机:每次
execute开头。- 目的:在全局变量之上叠加”只属于这一张表”的变量(短类名、权限前缀、树父子列、子表关联列)。
1 | private Map<String, Object> initBindingMap(CodegenTableDO table, List<CodegenColumnDO> columns, |
5.d 场景卡片:generateSubCode
**函数:
generateSubCode**(源码:279-306)
- 调用时机:遇到
_sub模板时。- 目的:根据主子表类型(normal/erp/inner)只取匹配的模板,并对每个子表各渲染一次。
1 | private void generateSubCode(CodegenTableDO table, List<CodegenTableDO> subTables, |
5.e 场景卡片:prettyCode
**函数:
prettyCode**(源码:317-342)
- 调用时机:每个模板渲染完成后。
- 目的:因为模板里”该不该引入/保留”无法静态确定,统一在渲染后做字符串层清理,保证生成代码能通过前端 lint。
1 | private String prettyCode(String content) { |
🔍 设计哲学:模板尽量”简单直白”——宁可多打几句 import、多写个
,,格式化这种”能不能编译/lint 通过”的事交给prettyCode统一兜底。模板的可读性优先于生成代码的干净度,是维护约 41 个模板的现实取舍。
6. 关键算法剖析:模板渲染 + 表形态裁剪
6.1 算法思想
生成代码 = 静态模板骨架 + 动态上下文变量。引擎不做任何”逐字段拼字符串”的硬编码,而是把差异全部收敛进 bindingMap,交给 Velocity 渲染 ${table.className} 等占位符。算法核心是一张”模板选择 + 形态裁剪”的判定树:
flowchart TD
TPL["getTemplates: SERVER + FRONT_TEMPLATES.row(frontType)"] --> VM[("vm 模板")]
VM --> A{"模板名含 _sub?"}
A -->|是| B{"有没有子表?"}
B -->|无| X["跳过"]
B -->|有| C{"匹配 normal/erp/inner?"}
C -->|匹配| D["每个子表 render 一次"]
A -->|否| E{"是树表? 且模板是 pageReqVO?"}
E -->|是| X
A -->|否| F{"非树表? 且模板是 listReqVO?"}
F -->|是| X
E -->|否| G["默认 render 一次"]
D --> H["prettyCode 清理"]
G --> H
X --> H
6.2 复杂度与边界
- 复杂度:$O(\text{模板数} \times \text{渲染成本})$,子表场景最坏再乘以子表数——代码生成属低频操作,主要代价是 Velocity 渲染与字符串处理。
- 边界处理:
- 主子表模型里
_sub模板没有子表时直接返回,不产出空文件; - 三种主子表变体(normal/erp/inner)用模板名特征符分别裁剪,避免各自产出另一变体的 form/list;
unitTestEnable=false时getTemplates剔除测试模板与 h2.sql,避免产出一堆用不上的单测。formatFilePath里${subTable.xxx}仅在subIndex != null时才替换,普通表路径里本不含这些占位符,渲染零影响。
- 主子表模型里
7. 设计决策分析
- 为什么用 Hutool 抽象而非直接用 Velocity? 源码注释明说:Freemarker/Velocity/Thymeleaf 太多,用 Hutool 的
Template抽象可随时换引擎。平台优先”可替换性”。 - 为什么模板路径和生成路径要绕两层占位符(
${table.className}等)? 因为模板是通用的,只有路径里表相关部分随表变化。若写死真实类名,一张表一套模板,完全违背”通用模板”初衷。 - 为什么 UI 模板按前端类型用
Table的”行”来切? 同一个index.vue逻辑,在 Vue2 / Vue3 / vben 下有完全不同的语法与目录。把frontType作为行号取FRONT_TEMPLATES.row(frontType),一处配置、前端切换零改动,符合平台”前后端分离、多前端并存”的产品形态。
8. 学习检查点
📝 本章小结
- 生成器 =
Builder(读库元数据) +Table/ColumnDO(持久化模型) +Engine(模板渲染) +Controller(端点)。 CodegenEngine.execute用SERVER_TEMPLATES+FRONT_TEMPLATES.row(frontType)选出模板,逐模板渲染成Map<路径, 代码>。- 差异全部收敛进
bindingMap:短类名、权限前缀、树父子字段、主子表关联列。 - 树表/主子表通过模板名特征符(
_sub、_normal/_erp/_inner、pageReqVO/listReqVO)动态裁剪。 prettyCode是生成后的统一”代码 lint 补丁”,让模板尽量直白、由引擎兜住格式。
🤔 思考题
isTree会让模板在 PageReqVO 和 ListReqVO 之间二选一,为什么是”互斥”而不是”都生成”?(提示:树表列表接口形态、死代码)参考答案
树表的列表查询走”查下级节点下级”(
listByParentId之类),参数是父节点 id 而非分页页码,因此用ListReqVO;非树表走分页查询,用PageReqVO。若两者都生成,只会产出一个在 Controller 里根本没被引用的 VO 类,属于死代码,还会触发 lint 的未使用告警。所以用isTree把模板按形态二选一,让产出”刚好够用”。prettyCode里”StrUtil.count(content, "dateFormatter") == 1就删掉整行”——为什么用计数 1 而不是”直接删”?如果计数是 2 会怎样?(提示:占位 import 只出现一次的语义)参考答案
出现在 import 段的
dateFormatter是”占位引入”,真正动手渲染时模板会把它用到具体字段上;只出现一次就说明”引了但一个字段都没用”,删除该 import 行安全。若计数 $\geq$ 2,则说明至少有一处实际渲染使用了它(import 一次 + 使用处),此时不能整行删,否则使用点的dateFormatter会悬空。这是”用出现次数推断是否真的被用到”的启发式,靠的是模板产出的规律性。主子表模板
_sub的裁剪用模板名含_normal/_erp/_inner判断,如果模板名里也恰好含_sub(如form_sub_inner.vue)会怎样?(提示:isSubTemplate与后续contains("_inner")的先后关系)参考答案
isSubTemplate只判断是否含_sub,form_sub_inner.vue同时含_sub和_inner——它会先进generateSubCode,在里面各_normal/_erp/_inner分支里匹配MASTER_INNER等主子表类型。也就是说”_sub决定总入口,_inner决定匹配哪种主子表变体”,二者是嵌套的两级判断而非互斥,设计者用不同关键词区分”是不是子表”与”是哪种子表”,互不干扰。