做后端开发的朋友应该都有过这种经历:拿到需求先写实体类,再写Mapper,再写Service接口和实现,最后是Controller,一张表的增删改查写下来少说几十行,多则上百行,而且每个表都几乎一个套路。写一次两次还行,一旦一个模块有二十多张表,全是一样的骨架,只是字段不同,那感觉真的是在浪费生命。我早年在做企业管理系统的时候就撞上过这种场景,后来实在受不了,花了一个周末写了一个简单的代码生成器,从那之后再也没手写过一张表的CRUD。这篇文章就把我从元数据设计、模板制作到最终落地的完整过程写出来,包括我踩过的坑和最终的架构取舍,希望能给正准备做代码生成器的朋友一些可参考的起点。
1. 先搞清楚代码生成器到底要解决什么问题
1.1 重复代码不是靠"勤奋"解决的
先抛一个结论:代码生成器本质上不是"自动写代码"的工具,而是"把已经写过的代码复用起来"的工具。它解决的问题从来不是"代码不会写",而是"代码不想再写第二遍"。CRUD、结构体定义、接口模板、配置文件,这些内容的变动只有字段和名称,骨架完全一样,手写的效率极低,而且容易出错。复制粘贴当然快,但粘完之后要全局改类名、改字段名,一旦漏改,编译期或者测试期才暴露,修起来反而更费劲。代码生成器就是把这个过程变成"定义一次结构,输出所有模板"。
这个思路不止适用于后端CRUD,还可以延伸到配置文件、测试脚手架、API文档生成。我见过有团队甚至用生成器产出自动化测试的骨架,每个新模块生成完业务代码的同时,单测链路也搭起来了。所以判断要不要做代码生成器,先看你的项目里有没有"高频重复且结构固定"的产出物,有,就值得做。
当然也不是所有项目都适合。如果你维护的是一个长生命周期的遗留系统,代码风格极不统一,或者项目本身只有十来张表、团队也就两三个人,那引入生成器的收益可能没那么高。先把这个前提想清楚,再动手,不然容易做到一半发现投入产出比不划算。
1.2 代码生成器的几种典型形态
代码生成器并不只有一种样子。按使用入口分,常见的有以下几类:
- 命令行工具型:输入元数据文件或参数,输出文件到目标目录,适合个人或小组使用,也容易接入CI/CD流水线。
- IDE插件型:在IDEA、VS Code里通过右键菜单触发,适合低频使用、交互要求高的场景。
- 脚手架型:用于初始化整个项目结构,比如基于某框架生成一套带配置、目录规范、基础工具类的工程。
- 在线平台型:在网页上配置元数据,通过API或下载方式拿代码,适合团队内统一治理的场景。
这几种形态的底层逻辑差不多,只是入口和自动化程度不同。我做的是命令行工具型,因为它最容易起步,不依赖IDE插件接口,也不需要一个在线服务来支撑,几个文件丢到仓库里就能跑。后面如果真要做到团队级,再包一层Web服务或者IDE插件也不难,核心的模板和解析逻辑都可以复用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的设计:核心模块与数据流
2.1 元数据模型:一切生成的源头
代码生成器的核心输入是元数据,不是代码。什么意思?比如我要生成一张表的CRUD,我只需要告诉生成器:表名、模块名、字段列表、每个字段的类型和注释。生成器根据这些信息,套用模板,输出实体、Mapper、Service、Controller。元数据就是生成器对业务世界的抽象。
元数据从哪里来,三种常见路径:
- 手写JSON/YAML定义,适合新项目,信息可控。
- 从数据库连接读取表结构,适合已有数据库表、需要快速落地代码的场景。
- 从已有实体类反射得到,适合老项目反向生成,但容易依赖运行时环境,新手不推荐。
我建议先用手写JSON或者读表结构的方式,因为它们输入足够结构化,不会有太多隐藏状态。一个典型的元数据定义长这样:
json复制{
"tableName": "sys_user",
"moduleName": "system",
"fields": [
{ "name": "id", "type": "bigint", "comment": "主键", "nullable": false, "primaryKey": true },
{ "name": "username", "type": "varchar", "length": 64, "comment": "用户名", "nullable": false },
{ "name": "status", "type": "tinyint", "comment": "状态", "default": "1" }
]
}
这看起来简单,但它已经包含生成代码所需的全部信息。字段的type、length、nullable、default会被用来映射到目标语言的类型、校验注解、数据库注释等。设计元数据模型时一定要考虑"这个信息将来会不会被用到",宁可多一点,不要少。比如字段的primaryKey,如果你不确定要不要,先留着,后面生成主键策略、MyBatis的id回填时会用到。
2.2 模板引擎选型与理由
模板引擎是代码生成器最核心的依赖。主流的几个我都接触过:Freemarker、Velocity是Java体系里用得多的,Jinja2是Python体系里用得多的,EJS属于JavaScript生态,Go的text/template也很常见。我自己最终选了Python + Jinja2的组合,理由是:
- 语法简单,对写代码的人几乎没有学习成本,和写普通Python模板差不多。
- 自带循环、条件、过滤器,处理命名转换和类型映射非常顺手。
- Python作为脚本语言做文本处理很爽,遍历文件、写文件、处理编码都很自然。
- 不依赖JVM环境,发给同事就是几个py文件,pip装个依赖就能跑。
但如果你整个技术栈是Java,那用Freemarker或Velocity也很合理。没有绝对最优的模板引擎,只有和你团队最匹配的。选型的关键标准就三个:团队熟悉度、模板中是否需要复杂逻辑、是否需要独立环境运行。生成场景压根不需要极致性能,Jinja2和Freemarker这类模板引擎的渲染速度都完全够用。
2.3 代码生成器的整体流程
整个生成过程可以抽象成五步:
- 读取元数据(JSON/数据库)。
- 解析并标准化元数据,补全默认值、字段类型映射。
- 准备模板上下文,把元数据转换成模板中可直接使用的数据结构。
- 遍历模板列表,逐个渲染出字符串。
- 输出文件,按策略处理同名文件(覆盖、跳过、备份)。
这五步每一步都有坑。第三步和第五步最容易被忽略。很多人一开始把元数据直接原样丢给模板,结果模板里到处是if判断,维护成本极高。正确做法是进入模板前先做一次"上下文整理",把需要多次使用的派生值提前算好,比如字段的getter名、setter名、下划线转驼峰后的名字、数据库类型到目标语言类型的映射结果。第五步文件覆盖策略更是实际开发中一定会遇到的问题,后面我会展开讲。
3. 核心实现:从元数据到代码文件的完整链路
3.1 定义元数据模型
我选择用Python的dataclass来承载元数据。相比手写字典,dataclass有类型提示,可读性更好,也方便加派生属性。基于前面JSON的例子,元数据模型可以写成这样:
python复制from dataclasses import dataclass, field
from typing import List
@dataclass
class FieldMeta:
name: str # 数据库字段名,如 user_name
type: str # 数据库类型,如 varchar
length: int = 0
nullable: bool = True
primary_key: bool = False
comment: str = ""
default: str = ""
java_type: str = "" # 映射后的Java类型,后续填充
@property
def property_name(self) -> str:
parts = self.name.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])
@property
def class_name(self) -> str:
return "".join(p.capitalize() for p in self.name.split("_"))
@dataclass
class TableMeta:
table_name: str
module_name: str
fields: List[FieldMeta] = field(default_factory=list)
@property
def class_name(self) -> str:
return "".join(p.capitalize() for p in self.table_name.split("_"))
这段代码有几个细节值得说明。property_name用于Java实体属性名,首字母小写驼峰,比如user_name变成userName;class_name用于类名或getter方法名,首字母大写驼峰,比如UserName。这两个属性必须在元数据阶段就计算好,而不是到了模板里再转换。模板里直接用field.property_name、field.class_name,逻辑干净很多。
3.2 数据库类型到目标语言的类型映射
这是绝大多数人都会踩坑的地方。数据库里的int、varchar、datetime,到了Java是Integer、String、Date,到了Python是int、str、datetime。这个映射表不能写死在模板的if分支里,应该独立放在一个映射模块里,并支持自定义扩展。以Java为例,一个最小可用的映射函数长这样:
python复制def map_db_type_to_java(db_type: str, length: int) -> str:
db_type = db_type.lower()
if db_type in ("int", "tinyint", "smallint"):
return "Integer"
if db_type == "bigint":
return "Long"
if db_type in ("varchar", "char", "text"):
return "String"
if db_type in ("datetime", "timestamp"):
return "Date"
if db_type in ("decimal", "float", "double"):
return "BigDecimal"
return "String"
这里要提醒一句:tinyint不一定都是Integer,有些团队喜欢把布尔字段也用tinyint存,此时映射到Boolean更合理。所以映射函数要允许按字段名或字段注释覆盖默认策略,在元数据里显式指定目标类型。映射表独立出来的好处是,换一条技术栈时只需要替换这个函数,模板不需要改动。
3.3 编写模板文件
下面的模板用Jinja2语法写一个Java实体类:
jinja2复制package com.example.{{ module_name }}.entity;
import java.util.Date;
public class {{ table.class_name }} {
{% for field in table.fields %}
/** {{ field.comment }} */
private {{ field.java_type }} {{ field.property_name }};
{% endfor %}
{% for field in table.fields %}
public {{ field.java_type }} get{{ field.class_name }}() {
return {{ field.property_name }};
}
public void set{{ field.class_name }}({{ field.java_type }} {{ field.property_name }}) {
this.{{ field.property_name }} = {{ field.property_name }};
}
{% endfor %}
}
模板里出现了table.class_name和field.class_name,它们都是元数据模型里预计算的属性。Jinja2的循环和if天然支持这类逻辑,但我在实际写模板时给自己定了一个规矩:模板里只做展示和循环,不做复杂逻辑判断。像"这个字段是否需要加校验注解"这种逻辑,我会在上下文准备阶段先算好,放到字段的annotations列表里,模板只需遍历输出。一旦模板开始嵌套复杂的if和for,后面维护起来就是灾难。
3.4 渲染与文件输出逻辑
Jinja2渲染本身很简单:
python复制from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("entity.java.j2")
content = template.render(table=table)
渲染结果只是一段字符串,真正容易出问题的是文件输出路径。我建议先定义目标项目的目录结构规则,比如:
- 实体类 →
src/main/java/com/example/{module}/entity/{ClassName}.java - Mapper接口 →
src/main/java/com/example/{module}/mapper/{ClassName}Mapper.java - XML映射文件 →
src/main/resources/mapper/{module}/{ClassName}Mapper.xml
每个模板开头都要维护一个output_path变量,或者单独做一个路径映射表。生成器渲染前先读路径规则,再计算最终输出路径。这样模板和路径规则放在一起,看模板就知道它会生成到哪里,团队协作时非常好维护。
4. 实战示例:一个CRUD代码生成器的完整实现
4.1 核心生成入口
把上面几个模块串起来,生成入口可以写成这样:
python复制import json
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
def load_metadata(file_path: str) -> TableMeta:
data = json.loads(Path(file_path).read_text(encoding="utf-8"))
fields = []
for item in data["fields"]:
field = FieldMeta(
name=item["name"],
type=item["type"],
length=item.get("length", 0),
nullable=item.get("nullable", True),
primary_key=item.get("primaryKey", False),
comment=item.get("comment", ""),
default=item.get("default", ""),
)
field.java_type = map_db_type_to_java(field.type, field.length)
fields.append(field)
return TableMeta(
table_name=data["tableName"],
module_name=data["moduleName"],
fields=fields,
)
def build_output_path(template_name: str, table: TableMeta) -> str:
base = "generated/"
mappings = {
"entity.java.j2": f"src/main/java/com/example/{table.module_name}/entity/{table.class_name}.java",
"mapper.java.j2": f"src/main/java/com/example/{table.module_name}/mapper/{table.class_name}Mapper.java",
"service.java.j2": f"src/main/java/com/example/{table.module_name}/service/{table.class_name}Service.java",
"controller.java.j2": f"src/main/java/com/example/{table.module_name}/controller/{table.class_name}Controller.java",
}
return base + mappings[template_name]
def run_generator(meta_file: str):
table = load_metadata(meta_file)
env = Environment(loader=FileSystemLoader("templates"))
for template_name in ["entity.java.j2", "mapper.java.j2", "service.java.j2", "controller.java.j2"]:
template = env.get_template(template_name)
content = template.render(table=table)
output_path = build_output_path(template_name, table)
write_with_strategy(output_path, content)
if __name__ == "__main__":
run_generator("metadata/sys_user.json")
这段代码虽然短,但覆盖了完整链路:读元数据、渲染模板、输出文件。运行之后,在generated/目录下就会生成一个标准的Java工程结构。第一版的时候建议只用一张表做验证,跑通了再加批量处理。
4.2 增量生成与手动代码保护
代码生成器最大的争议是"生成的代码不能手改,改了下次生成就丢了"。纯覆盖式生成的代码一般不建议手改,生成物应当视为构建产物。但实际开发中,Controller可能要多加一个接口,实体类可能要多加一个字段,怎么办?
我的方案是分文件管理:
- 完全生成型文件:实体、DTO、Mapper接口,字段和表结构是纯机械对应,全部覆盖也没问题。
- 半自由型文件:Service、Controller,允许手写业务逻辑,生成时优先检查文件是否存在,存在就不覆盖,或者只做增量检查。
- 完全不生成型文件:业务领域逻辑、策略类,代码生成器不应该碰。
这个分类看着简单,却是设计代码生成器时最容易被忽略的决策。一开始我想的是一股脑生成20个文件,后来发现Service里手写的逻辑经常被覆盖,改成"文件存在则跳过"之后才稳定下来。具体落到代码里,就是:
python复制def write_with_strategy(path: str, content: str, if_exists: str = "skip"):
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
if if_exists == "skip":
print(f"SKIP {path}")
return
elif if_exists == "backup":
backup_path = target.with_suffix(target.suffix + ".bak")
backup_path.write_text(target.read_text(encoding="utf-8"), encoding="utf-8")
target.write_text(content, encoding="utf-8")
写入策略我实现了三种:skip跳过已存在的文件、backup备份后覆盖、默认直接覆盖。命令行工具型生成器建议默认skip,团队工具可以做成可配置项。
4.3 生成结果的验证闭环
生成完代码不等于完事,我后来还加了一个自动验证环节。具体做法是:如果目标项目是Maven工程,生成后自动跑一遍mvn compile,编译没过就报错退出;如果是Python项目就执行python -m compileall。这一步虽然简单,但能提前暴露很多模板错误,比如命名拼错了、类型映射成一个不存在的类、引用的包没导入。
没有这个验证环节,模板出错往往要运行到对应功能模块才能发现。加了之后,生成器和编译验证成了一个流水线,生成完马上知道模板有没有问题。这里有一个实测经验:很多类型映射错误是编译到一半才暴露的,比如映射成了String但导入了java.util.Date。所以在模板里要严格按字段的java_type去管理import,不要图省事一股脑把所有可能的类都导入。
5. 我在开发代码生成器时踩过的那些坑
5.1 模板里塞业务逻辑,维护成本爆炸
我第一次写生成器时,把字段是否为主键、是否逻辑删除、是否需要加密等判断全部写在模板里,结果模板长度越来越失控,一个模板两百多行,循环套循环,完全没法看。后来统一改为在上下文准备阶段把这些判断转换成布尔属性,模板只做简单输出。比如field.is_primary_key这种判断放在模型里,模板只需:
jinja2复制{% if field.primary_key %}
@Id
{% endif %}
模板里的逻辑越少,你的生成器越好维护。这是我最想分享的一点。
5.2 命名规范不一致
不同项目的命名规范差异很大:有的用sys_user,有的用SysUser,有的用sysUser。如果生成器只认一种命名,换个项目就歇菜。我最终的做法是:元数据里同时维护表名、类名、属性名三种命名,宁可多写几个字段,也不要在生成时依靠正则去猜。所有命名转换都在元数据解析阶段完成,模板里只用转换后的结果。这样即使团队从Java切到Python,也只需要改类型映射和模板,命名转换逻辑可以复用。
5.3 BOM、换行符与编码问题
Windows和Linux换行符不同,如果模板保存的是CRLF,生成到Linux环境中编译会出现很多诡异的问题。我强烈建议模板文件统一保存为LF换行,并在生成时设置newline_sequence="\n"。另外还有一个容易被忽略的坑是BOM头,用某些Windows编辑器保存的模板会带UTF-8 BOM,生成到Java源码后会直接导致编译报错。我的办法是写入文件时强制用无BOM的UTF-8编码。
5.4 生成器自身的版本管理
代码生成器输出的是代码,但生成器本身也是代码,也依赖版本演化。我吃过一个亏:生成器升级了字段类型映射规则,旧的元数据格式不兼容,结果整个团队的项目代码生成风格前后不一致。后来我把元数据格式和模板目录一起纳入Git管理,并且给生成规则加了一个版本号,每次改动都要在配置里写明"该版本适用于哪些元数据版本"。团队一大,这个版本约定一定要有。
5.5 常见问题速查表
| 现象 | 原因 | 处理办法 |
|---|---|---|
| 生成文件是空的 | 元数据字段为空,或字段类型映射失败 | 检查JSON元数据,打印field变量确认属性 |
| 生成代码出现乱码 | 模板文件带BOM或编码不一致 | 模板统一UTF-8无BOM,写入也用UTF-8 |
| 手写代码被覆盖 | 文件输出策略未设置为skip | 检查write_with_strategy的if_exists参数 |
| 类型映射报错 | 遇到未映射的数据库类型 | 在映射函数增加分支,或在元数据显式指定 |
| Windows下换行异常 | 模板保存为CRLF导致 | 模板保存为LF,生成时设置统一换行 |
6. 如何把代码生成器做成团队基础能力
6.1 从个人脚本到共享工具的升级路径
个人脚本和团队工具之间差着几件事:可配置、可复用、可解释。可配置是指目标项目的基础包名、项目目录结构、框架版本这些信息不能写死在脚本里,要放到配置文件;可复用是指多个项目可以共用一套模板,差异通过配置项隔离;可解释是指团队成员能够看懂每个模板生成的文件是什么、放在哪、能不能手改。做到这三点,你的生成器才不是作者私有物。
我现在的做法是:生成器仓库里放一个README.md,把支持的模板清单、元数据格式示例、覆盖策略说明都写清楚。新人进来先看README,而不是来问我。
6.2 模板仓库与代码评审
模板是生成器的灵魂,应该像普通代码一样走评审流程。我现在的做法是:模板放在单独的Git仓库里,每次修改模板,必须同步更新一个变更记录文件,说明影响范围。生成的代码虽然不进评审,但生成规则进评审,这样可以避免团队成员各自为政,私下改模板导致代码风格失控。
另外一个经验是:模板命名要规范且稳定。比如entity.java.j2表示实体类模板,后面的.j2明确告诉你这是Jinja2模板,不是最终文件。不要出现template1.txt这种名字,时间一长自己都认不出来。
6.3 未来的扩展方向
代码生成器往深了做,可以接很多方向:从数据库反向生成元数据、对接API文档导出、生成前端页面和表单校验、甚至接入大语言模型做字段注释补全和业务命名建议。后续我一直想做的是一键从SQL DDL文件生成完整的"实体+Mapper+Service+Controller+Vue页面"全套代码,数据流越长,节省的时间越明显。
最后说点私人的体会。代码生成器这个东西,最核心的价值不是省那几个小时写CRUD的时间,而是把团队的编码规范固化成了可执行的东西。新人来了不用背规范,生成出来的代码天然符合约定,review的压力也小了很多。如果你也在被重复劳动折磨,与其继续复制粘贴,不如花点时间做一个自己的生成器。它本身就是一次非常好的设计练习,从数据建模到模板设计到工具工程化,几乎涵盖了日常开发的全部基本功。工具是慢慢长出来的,第一版丑没关系,能用就行。
