三年前我第一次接手团队里那个订单管理系统的时候,第一件事是挨个翻后端Controller层的代码。三个开发各自维护了几百个接口,同名接口的实现风格能差出两个版本:有人把响应包在统一对象里,有人直接返回裸Map,还有人把参数校验写到了Service层。规范文档写了厚厚一叠,但代码和规范对不上,每次评审都要靠人肉提醒。那会儿我开始认真琢磨一个事:逻辑这么相近的接口、列表页、数据访问代码,能不能让一套代码自动生成框架来产出。到现在,这套框架已经让团队里六七成的新模块不再手写底层的重复代码。这篇文章不聊宏大的低代码平台,就围绕这个自建框架的设计思路、落地实现、踩坑记录,把从零到跑通的完整链路讲透。
1. 先想清楚要解决什么问题:重复的不是代码,是规范缺口
做这个框架之前,我们先用两周时间盘了盘现有项目里的重复情况,结论很重要:真正拖慢开发的不是"打字的速度",而是每个人对"同一个功能该怎么写"的认知不一致。框架要解决的,不是帮你把键盘敲得更快,而是把团队约定固化到产物里。
1.1 三个层面的重复代码到底有多浪费
我把日常开发里的重复分成三个层面看。
接口层:增删改查这四类接口高度相似。无非是接收请求参数、调用服务、组装响应,Controller的结构几乎一模一样。差别只在类名、字段名、参数校验规则。
数据访问层:单表查询、按ID查询、按条件分页查询,这些SQL和对应的映射配置单调但量大。每个新表进来,都要把上一张表的SQL复制一遍再改表名,改漏一个字段就是线上问题。
前端表单和列表页:后端定义了字段,前端要照着写输入框、下拉框、表格列。后端改一个字段名,前端要同步找三四个文件。这类重复最隐蔽,因为大家默认"本来就是两个端,各写各的",但效率损耗恰恰在这里。
三个层面叠起来,一个新模块从建表到前后端能跑通,最快也要两三天,其中一多半时间在复制粘贴。更麻烦的是,每次复制都会引入风格偏移,代码仓库慢慢就成了一锅杂烩。
1.2 现有脚手架和生成工具为什么不够用
团队一开始也尝试过市面上的通用生成工具,但落地时发现几个问题。
通用工具生成出来的代码是"平均风格",不会长成我们团队规约要求的样子。比如统一响应体、日志埋点、异常处理的写法,每个团队自己都有约定,通用工具没法默认匹配。
另一个问题是通用工具只生成单点代码。它能帮你生成一个实体类,但没法帮你把Controller、Service、Mapper、前端页面串成一条完整的业务链路。生成出来的碎片还要自己手动拼,拼的过程又把风格差异带回来了。
第三个问题更实际:团队里几个项目的技术栈不完全一致,有的用Java,有的用Python写部分服务。通用工具通常只支持一种主流行,做不到一套元数据描述多端出码。
所以与其在别人工具外围包壳子,不如按自己团队规范写一套生成器。这个决策是框架最大的根基:不是找最强大的工具,而是让生成结果严格等于团队规约。
1.3 框架生成范围的边界划分
边界问题在项目一开始就必须说清楚,否则框架会变成"什么都要管"的怪物。
我们最终划定:框架负责生成有明确规律、可被模板完整描述的部分——实体定义、数据访问层基础方法、Controller的CRUD骨架、前端列表页和表单页的初版。框架不负责生成强业务逻辑,比如订单金额计算、库存扣减、审批流转这类代码,仍然由开发手写。
这个边界的依据很简单:模板能描述的部分,才有固化价值;一旦逻辑变成"每个业务都不一样",硬塞进模板只会让模板里堆满if else,最后改模板比写业务还痛苦。
划完边界后,框架的定位就清晰了:它是业务的"装配工",不是业务的"发明家"。它把百分之七十的重复劳动接走,让开发把精力聚焦在剩下的百分之三十里。
2. 元数据驱动:用一份JSON描述把"事实来源"固定下来
框架的核心不是模板,而是元数据。这个认知我们费了不少周折才建立。最初一版生成器直接让用户填表单、勾选项,结果发现配置界面本身成了瓶颈。后来改成用一份JSON描述模块的字段、类型、关系、展示规则,一切生成动作都从这份JSON出发。
2.1 元数据文件的核心字段设计
一份元数据文件至少要覆盖这几类信息:模块基本信息、字段列表、字段属性、页面展示规则、查询条件。
模块基本信息包括模块名、中英文名称、所属应用、数据表名、主键策略。字段列表是核心,每个字段要描述字段名、业务含义、Java类型、数据库类型、是否必填、默认值、是否为查询条件、前端控件类型、字典编码等。
我拿订单模块举个例子,元数据大概是这个风格:
{ "moduleName": "order", "moduleAlias": "订单", "tableName": "t_order", "primaryKey": "id", "fields": [ { "fieldName": "id", "fieldAlias": "主键", "javaType": "Long", "dbType": "bigint", "primary": true, "autoIncrement": true }, { "fieldName": "orderNo", "fieldAlias": "订单号", "javaType": "String", "dbType": "varchar(32)", "required": true, "queryCondition": "EQ", "uiControl": "input" }, { "fieldName": "status", "fieldAlias": "订单状态", "javaType": "Integer", "dbType": "int", "dictCode": "order_status", "uiControl": "select" }, { "fieldName": "amount", "fieldAlias": "订单金额", "javaType": "BigDecimal", "dbType": "decimal(10,2)", "uiControl": "inputNumber" }, { "fieldName": "createdTime", "fieldAlias": "创建时间", "javaType": "LocalDateTime", "dbType": "datetime", "uiControl": "datetime" } ] }这份JSON就是唯一事实来源。数据库表、Java实体、接口接收参数、前端表单组件,全部由它推导出来。人工改代码之前先改它,改完再重新生成,从机制上让规范落地。
实际落地时我们给元数据增加了schema版本号,避免不同模块的配置因为版本不同而解析出错。这是后来被迫加上的,早期没有版本号,升级解析器后旧模块的配置就要全部人工核对,非常痛苦。
2.2 Java类型、数据库类型、前端控件类型之间的映射规则
元数据写一次,要同时服务于后端和前端,所以类型映射是框架里最需要耐心的部分。
我们的映射策略是:元数据里只写"逻辑类型",由生成器按目标端转换成具体类型。逻辑类型大体有数值型、字符串型、布尔型、日期型、时间型、日期时间型、金额型、大文本型、枚举型这几类。
逻辑类型表直接决定了模板怎么写:
| 逻辑类型 | 数据库类型 | Java类型 | 默认前端控件 |
|---|---|---|---|
| STRING | varchar(n) | String | input |
| INTEGER | int | Integer | inputNumber |
| LONG | bigint | Long | inputNumber |
| DECIMAL | decimal(p,s) | BigDecimal | inputNumber |
| BOOLEAN | tinyint(1) | Boolean | switch |
| DATE_TIME | datetime | LocalDateTime | datetime |
| TEXT | text | String | textarea |
| ENUM | int | Integer | select |
这张表不是拍脑袋定的,而是跟着团队项目里现有代码逐个核对出来的。比如团队约定金额一律用BigDecimal,不允许用Double,映射表里就固化了这个规约;时间字段统一LocalDateTime,不用java.util.Date,这也是映射表强制的结果。
所以映射表其实承担了双重职责:它既是类型转换依据,也是技术规范检查单。新增项目如果出现映射表里没有的类型,会先卡在生成器外头,逼着团队先讨论规约,而不是各自随意引入。
2.3 为什么会踩"模型和模板脱节"的坑
框架第一版交付时,我们犯过一个经典错误:把类型映射逻辑写死在模板的if判断里。模板里堆了几十行"如果是DECIMAL就输出BigDecimal,如果是STRING就输出String"。
结果就是生成器内核很轻,模板却越来越重。后来要加一个"金额字段在列表页右对齐"的需求,我们得在每个相关模板里同步改判断逻辑,漏改一个模板,生成出来的页面就对不齐。
教训是:类型判断、控件映射不能混入模板,必须前移到元数据解析阶段。模板只做数据填充和简单循环,所有分支决策都发生在解析器里,把渲染需要的最终值算好,模板拿到的已经是"答案"。
改版之后,模板变得非常干净,基本就是"遍历字段列表,输出属性声明"这种程度。这让我后来跟人说框架设计时,反复强调一句话:模板是哑的,逻辑都在解析器里。这样调试方便,模板也几乎不需要单元测试。
3. 生成器内核:模板渲染只是最后一步
很多人以为代码生成框架就是"模板+替换",真正做下来才发现,模板渲染只占整个生成器不到三分之一的代码量。更复杂的部分在解析、校验、冲突处理这些看不见的地方。
3.1 解析、渲染、校验三段式架构
生成器的处理流程我习惯分成三段:解析元数据、渲染模板、校验产物。
解析阶段把JSON元数据读进来,完成类型映射、字段关系检查、命名规范化。这个阶段会输出一个"中间模型",里面每个字段都已经算好了Java类型、数据库列名、前端控件类型、校验规则等。中间模型是一个普通的内存对象,模板能拿到什么字段、循环什么列表,都在这里定义好。
渲染阶段就比较机械了:根据目标产物类型,选择对应模板,把中间模型的数据填进去。后端Java类、XML配置、前端Vue文件,分别走不同的模板目录。
校验阶段是框架区别于普通脚本的关键。生成完成后,框架会自动做几件事:检查产物目录里是否已有同名文件,检查生成代码里的包名和import是否完整,检查字段名是否包含非法字符,必要时甚至调用本地的编译命令跑一次语法校验。这一步可以把大部分低级错误拦在落盘之前。
三段式最大的好处是每段可以独立测试。解析器有问题,只需喂元数据看中间模型;模板有问题,可以直接用写死的中间模型渲染;校验有问题,可以用生成产物做回归。排障范围小,效率高。
3.2 模板选型的对比与取舍
模板引擎我们先后对比过好几种,最终选了Velocity。选择理由不复杂:语法简单、和Java无缝集成、模板文件不需要编译。
Velocity的语法量很少,新成员半天就能上手。这个对框架很重要,因为模板不是框架作者一个人维护的,团队里的后端、前端都会来调整自己那部分的模板。如果模板引擎本身学习成本高,谁都不愿意改,模板就会慢慢和团队实际风格脱节。
FreeMarker也很成熟,功能更丰富,但对团队来说稍显重,模板里能写复杂逻辑,反而容易把前面的"模板是哑的"原则破坏掉。Java模板我们用过一阵,调试方便,但写遍历和条件判断非常啰嗦,模板文件读起来不够清爽。
最后还有一个选型标准容易被忽略:生成器本身是用Java写的,模板引擎最好能在Java进程里直接加载资源,不依赖额外运行时。Velocity在这点上最省心,一个jar包搞定,服务器上不需要装任何东西。
3.3 增量生成与冲突保护
代码生成框架最怕的不是生成不出代码,而是生成重复代码把原有逻辑覆盖掉。我们设计了一个"先保护、后生成"的策略。
默认规则:生成器执行前会先扫描目标目录,如果发现目标文件已存在,就把老文件改名成带.bak前缀的备份,新文件正常写入。如果生成过程报错,自动回滚,把备份文件改回来。
但备份策略也有失控的时候——人工改动过的文件频繁被备份,git里会堆满垃圾。所以后来改成更严格的策略:生成器在元数据里记录每个生成文件的"指纹",指纹是元数据版本的哈希。重新生成时,如果目标文件指纹和当前元数据指纹一致,说明文件从未被人工改过,可以直接覆盖;如果指纹不一致,说明文件被改过,就跳过并提示开发人工合并。
这套思路解决了一个关键矛盾:既想要生成代码的可重复性,又不想覆盖人工逻辑。团队里把它叫"聪明覆盖",它的规则很简单:机器生成的部分,机器可以重来;人工改过的部分,机器绝不碰。
4. 一个订单模块从元数据到运行代码的完整链路
光讲架构有点虚,我拿一个真实的订单模块走一遍全流程。这个模块当时从零开始跑框架,前后不到两个小时就拿到了一份能编译、能联调的代码。
4.1 实际的元数据配置样例
订单模块的元数据比前面示例更复杂一点,加了创建人、更新人等审计字段,还有一个关联客户的字典。
{ "moduleName": "order", "moduleAlias": "订单管理", "tableName": "t_order", "primaryKey": "id", "auditFields": ["createdBy", "updatedBy", "createdTime", "updatedTime"], "fields": [ { "fieldName": "id", "fieldAlias": "主键", "javaType": "Long", "dbType": "bigint", "primary": true }, { "fieldName": "customerNo", "fieldAlias": "客户编号", "javaType": "String", "dbType": "varchar(32)", "queryCondition": "LIKE" }, { "fieldName": "orderNo", "fieldAlias": "订单号", "javaType": "String", "dbType": "varchar(32)", "queryCondition": "EQ", "required": true }, { "fieldName": "status", "fieldAlias": "状态", "javaType": "Integer", "dbType": "int", "dictCode": "order_status", "uiControl": "select" }, { "fieldName": "remark", "fieldAlias": "备注", "javaType": "String", "dbType": "varchar(255)", "uiControl": "textarea" } ] }写这份配置的人不需要会Java,也不需要会前端,只需要知道业务上有哪些字段、每个字段大概什么含义。这大大降低了上手门槛,后来团队里测试同事也能独立承担元数据编写工作。
4.2 模板与渲染产物如何对应
元数据解析完成之后,生成器会根据设定的目标清单,依次调用模板。
拿Java实体类举例,模板只需要做循环和取值:
package ${basePackage}.entity; import lombok.Data; import java.time.LocalDateTime; import java.math.BigDecimal; @Data public class ${entityName} { #foreach($field in $fields) /** * ${field.fieldAlias} */ private ${field.javaType} ${field.fieldName}; #end }模板里的$fields就是中间模型里的字段列表,每个字段已经算好了javaType和fieldName。开发只需要关注"要不要额外加个字段备注",不需要操心大括号和import,模板都处理好了。
Controller模板类似,会生成统一的CRUD端点:分页查询、详情、新增、修改、删除。所有端点都自动包装了统一响应体,并加了基础的参数校验注解。前端模板则会生成列表页、表单页、路由配置三项,字段与控件类型一一对应。
整个链路跑完,产物目录大概是这个形态:
src/main/java/com/xxx/order/ controller/OrderController.java service/OrderService.java service/impl/OrderServiceImpl.java mapper/OrderMapper.java entity/Order.java dto/OrderQueryDTO.java src/main/resources/mapper/OrderMapper.xml src/main/webapp/src/views/order/ OrderList.vue OrderForm.vue orderRouter.js每个文件都是完整可编译的骨架,业务开发拿到之后只需要往Service实现里填真正的业务逻辑。
4.3 生成结果的验证方式
跑完生成器只是第一步,我们规定生成结果必须通过三级验证才能算完成。
第一级是自动校验:生成器用Java编译器的API对后端产物做一次内存编译,把编译错误信息直接反馈出来。前端产物则做一次基础的标签闭合检查,再用脚本核对import引用。
第二级是人工抽查:开发打开关键文件对照元数据,确认字段没丢、类型没错、注释和别名一致。重点是看Controller参数和前端表单字段是否一一对应,这是最容易漏的地方。
第三级是联调冒烟:启动应用,用生成的接口走一遍增删改查,前端页面跑一遍列表查询和新增提交。这个环节不测业务逻辑,只验证框架生成的骨架本身可以端到端跑通。
三级验证全部通过后,模块代码才允许提交到主干。这套流程持续跑了一年多,生成代码的返工率从最初的百分之三四十降到了百分之五以内。
5. 引入AI辅助后,自然语言如何变成可执行代码
框架跑顺之后,我们又开始琢磨怎么降低写元数据的心智负担。毕竟模块字段再多,一张表几十个字段写配置也挺费劲。这个需求正好赶上了一波大模型能力爆发,于是我们给框架加了一个智能辅助入口。
5.1 为什么让AI只负责生成元数据
关于AI,我们定了一条底线:AI不直接生成代码文件,只负责把自然语言翻译成结构化元数据。代码文件的最终组装仍然由模板引擎完成。
这么定的原因很实际。直接让AI生成代码,输出风格不可控,可能今天生成带lombok的,明天生成带setter的,模板的规范就很难统一。而把输出限制在JSON格式的元数据上,AI的能力边界正好匹配——它能理解"订单模块有哪些字段、状态是枚举"这种描述,也能输出规范的JSON。
更大的好处是元数据是可校验的。AI生成的JSON可以被解析器先跑一遍,字段缺失、类型不合法都能在生成前发现。我们的框架在解析前增加了一个"元数据体检"步骤,做必填项检查和类型白名单检查,AI输出不合格就直接打回重写。
5.2 约束AI输出格式的关键写法
要让AI输出可用的元数据,最关键的是把格式约束写清楚。我们在提示词里固定了几个要素:字段命名规范、类型枚举范围、必须包含的审计字段、JSON的层级结构。
给AI的说明大概是这样一个套路:先说明这是一个代码生成框架的元数据生成任务,然后给一段示例JSON作为格式锚点,再列出字段类型可选范围,最后强调只输出JSON本身,不要任何解释文字。
只输出JSON这个要求很重要。AI一旦在JSON前后加解释,解析就会失败。实测中大部分格式问题都是解释文字混入造成的,所以提示词里要反复强调"输出必须能被解析器直接parse,如果无法确定字段类型就使用STRING这个兜底值"。
把兜底逻辑也交给AI的好处是,它在不确定某个字段类型时不会自己瞎猜新增一种类型,而是服从一个我们事先定好的保守值。这样哪怕AI理解有偏差,生成结果至少还能通过解析,不会把整个链路卡死。
5.3 生成结果的人工校验清单
AI生成的元数据只是草稿,人工确认环节依然保留。我们把校验清单做成了一页纸,开发对照着看就行。
第一项查字段名:是否遵循驼峰命名,是否和建表脚本的注释一致。第二项查类型选择:金额字段必须是DECIMAL,时间字段必须是DATE_TIME,状态字段要用ENUM并关联字典。第三项检查查询条件字段数量,一个列表页的查询条件建议不超过五个,多出来的要砍掉。
还有一项容易被忽略:AI会遗漏枚举字段的dictCode。如果状态是枚举但没配字典编码,前端下拉框就渲染成普通输入框,联调时才暴露。所以我们把dictCode设置成了必检项,AI生成的配置里如果没有就补上。
这个流程跑下来,AI承担的其实是从"人写JSON"变成"人审JSON",工作量减少不少,而且因为AI不会疲倦,字段遗漏的概率比手工写配置要低。但核心的领域判断仍然留给人,AI只是个翻译器。
6. 上线后踩过的坑:从命名混乱到模板失控
框架不是上线就完事了,真正让它变稳定的是后面半年里一个接一个的坑。我把印象最深的三个记录下来,给想在自己团队搞类似框架的人提个醒。
6.1 大小写命名不一致引发的编译问题
早期框架对元数据的命名没有强校验,导致同一个模块里,有人把字段写成createTime,有人写成create_time,生成的Java代码一会儿是驼峰,一会儿是下划线。
这个问题很隐蔽,单看某一个生成文件没问题,一旦Java代码里用反射读取数据库列名,就会出现字段对不上、运行时报错。我们花了一个多星期排查一个诡异的"字段找不到"问题,最后定位到是生成器里两个模板对同一字段的命名规范化逻辑不一致。
修复方案是统一在解析阶段做一次命名规范化,字段名一律转驼峰,数据库列名保留原始写法,并由映射表维护两者对应。同时增加一个校验规则:元数据里的字段名如果包含下划线就报错,强制开发在配置里就用Java侧命名。这个改动上线后,命名类问题基本清零。
6.2 数据库保留字和多租户字段的映射坑
第二个坑来自数据库保留字。元数据里一个字段名叫desc,生成到XML里直接报语法错误。SQL里的desc是排序关键字,列名必须加反引号才能用。
我们最初的处理是在模板里统一对所有列名加反引号,后来又发现有些数据库不支持这种写法,导致SQL执行失败。最后改成维护一份保留字清单,解析阶段检测到字段名命中保留字,就在该列名上强制加反引号,其它列名保持原样。
和这个问题捆绑出现的是多租户字段。团队后来引入了租户隔离,要求每张表都要有tenantId字段。我们开始在元数据里手工加,漏了一张表就导致租户数据串号。后来直接在生成器的解析阶段统一注入audit字段和租户字段,不允许在元数据里配置——该加的总是会加,不该省的也不用纠结了。
6.3 模板库版本升级时的幂等迁移
框架用了两年后,我们做了一次大的模板改版,把前端从旧技术栈切换到新框架。这时候暴露了模板升级的最大痛点:旧的生成代码散落在各个项目里,模板改了,不可能把所有已生成的代码重新跑一遍。
我们采用的策略是"只向前,不回头"。模板大版本只影响新生成模块,老模块保持旧代码继续运行,不强制重生成。同时把模板版本号记录在每个生成文件的首部注释里,后续哪个模块要做技术栈升级,先看版本号,再决定是否需要重新生成。
这个策略在管理上很讲得通:生成代码一旦进入业务迭代,就会和手写代码融为一体,强行重生成反而会破坏已有业务。框架要提供的是"新代码按新规范出生",而不是"历史代码按新规范回炉"。想清楚这一点,模板升级的压力就小了很多,团队也敢放心去改模板了。
用到第三年的时候,我最大的感受是:一套代码自动生成框架的价值,不在于一开始生成了多少代码,而在于它帮团队把规范从文档里搬进了流水线里。框架跑得越久,团队里新老成员写出来的代码越像同一个人写的。最后的经验就一条:别急着追求生成覆盖面,先把"哪些该生成、哪些不该生成"的边界立住,再把模板做得越笨越好。这样无论后面接AI还是换技术栈,框架都不会被某一次技术变革颠覆掉。