t3code:模板驱动代码生成,一键搭建三层架构 CRUD 骨架
做后端开发这些年,我最大的感受就是:项目启动时最耗时间的不是业务逻辑,而是那些毫无技术含量却必须写的样板代码。尤其是接口三层结构,Controller、Service、Repository,逻辑大同小异,字段换来换去,改个实体就要同步改一堆文件。我一度怀疑自己是在当人肉复制粘贴机。后来我把自己常用的套路整理成了一个命令行工具,代号就叫 t3code。这名字没啥高深含义,Template + Three-Tier + Code,模板驱动、三层结构、代码生成,三个词就把痛点说清了。这个项目用 Python 写的,核心逻辑不复杂,但解决的是非常实在的问题:给我一份实体描述文件,我还你一整棵可运行的项目骨架。这篇文章就把整个项目的设计思路、核心实现、实操过程和踩坑记录完整拆一遍,适合正在折腾脚手架、或者受够了手写重复代码的朋友参考。
1. 为什么会有 t3code:从重复劳动到模板驱动
1.1 传统脚手架方案的三个痛点
先说说我为什么没直接拿现成的脚手架工具硬套。市面上的脚手架不是没有,但用下来总有三个让我难受的地方。
第一个痛点是“生成完就断气”。大部分脚手架帮你把项目初始化好,目录结构出来了,然后呢?后面每次新增一个业务实体,你还是得手动去补 Controller、Service、Repository、DTO、数据库映射,脚手架帮不到你。t3code 的定位不一样,它不只管初始化,更管后续的“增量生成”。
第二个痛点是“重但不够贴合”。很多脚手架自带一套全家桶,又是注册中心又是配置中心,一个小项目被塞得满满的。我实际要的可能就是 FastAPI + SQLAlchemy 这种轻量组合,脚手架反而成了负担。t3code 走的是极简路线,核心只有两件事:解析 YAML 描述文件、用模板渲染代码文件,其余都交给用户自己定。
第三个痛点是“改一处要改一串”。手工编码时,实体加一个字段,要动数据库迁移、实体类、DTO 转换、前端类型定义,漏一个就等着联调翻车。t3code 把实体定义收敛到一份 schema 文件里,所有层级的代码都从同一份数据源生成,从根上消除“改漏了”的隐患。
1.2 t3code 的设计取舍:Template × 三层结构 × Code
t3code 这个项目名拆开看,其实反映了我的三个设计原则。
T 代表 Template,模板是一等公民。生成器本身不写死任何业务代码,所有产物都是模板渲染的结果。这意味着团队里任何一个人都能修改模板来适配自己的项目规范,而不需要懂 Python 解析器的内部实现。模板放在 templates 目录下,改了模板立刻生效,这种“配置优于代码”的思路让我后续维护成本低了很多。
3 代表 Three-Tier,即 Controller / Service / Repository 三层。不是说我推崇所有项目都该三层,而是这套结构在业务系统里最通用,也是我日常用得最多的。三层拆开后,每一层都能单独测试,Service 层不依赖 HTTP 细节,Repository 层只关心数据存取。t3code 生成的就是这样一套边界清晰的骨架,而不是一个大而全的混乱目录。
Code 代表代码生成,但这里的“生成”不是简单地把字符串拼到一起。t3code 是所有代码文件的唯一出口,生成逻辑里包含了命名转换、类型映射、路径规划、重复文件保护等规则。简单说,它像一条流水线,输入是 schema,输出是工程代码,中间每一步都有明确职责。
2. t3code 的核心细节解析与实操要点
2.1 描述文件怎么设计:一份 schema 管住所有信息
t3code 的输入是一份 YAML 描述文件,我习惯叫它 schema.yaml。为什么选 YAML 而不是 JSON?因为 YAML 支持注释,写字段说明的时候特别方便。JSON 虽然改起来机器友好,但人眼读长列表时非常痛苦,尤其是字段多的时候,一个逗号错了整个文件就废了。
schema 里最核心的部分是实体定义。拿一个文章模块举例:
project: name: blog-api language: python framework: fastapi entities: - name: article table: articles fields: - name: id type: int primary: true - name: title type: string required: true max_length: 200 - name: content type: text required: false - name: published_at type: datetime required: false这里的每个字段都不是白写的。id 会被用来生成主键注释和索引逻辑,title 的 max_length 会映射到 Pydantic Schema 的 max_length 校验、SQLAlchemy Column 的 String(200),published_at 的 datetime 类型会决定序列化时的格式。也就是说,同一份字段信息,在 Controller、Service、Repository、Schema 四层代码里被重复使用,这就是模板驱动最大的价值。
设计 schema 时我有一个原则:能用匿名嵌套就别用复杂引号,能少写一层就别硬加层级。初期版本我加过一个 global 段放公共配置,后来发现这会把问题搞复杂,因为模板里访问变量的路径变深,出错概率直线上升。现在只保留 project 和 entities 两个顶层段,简单粗暴但好用。
2.2 模板引擎怎么选:Jinja2 撑起全场
生成器内部渲染用的模板引擎,我选了 Jinja2。这个选择几乎不需要犹豫,生态成熟,语法有一大批人熟悉,而且支持自定义过滤器。另一个备选方案是 String.Template,够轻但没有逻辑能力,稍微遇到条件判断就要在外面拼字符串,等于退回手工拼接的老路。还有个备选是 Mako,语法灵活但是控制能力太强,容易让模板里写满业务代码,反而不利于约束结构。
Jinja2 的引入让模板可以做到“带逻辑但不过度”。比如在生成 ArticleRepository 的时候,我可以在模板里这样处理主键字段:
class {{ entity_name }}Repository: def get(self, id: int): return self.session.get({{ entity_model }}, id) def list(self, page: int = 1, size: int = 10): query = select({{ entity_model }}) {% if has_deleted_field %} query = query.where({{ entity_model }}.deleted.is_(False)) {% endif %} result = db.paginate(query, page=page, size=size) return resulthas_deleted_field这个变量是我在生成器里预计算好的,只要实体字段里存在 deleted 字段,模板就自动生成软删除过滤逻辑。通过这种“预计算变量注入模板”的方式,模板代码本身保持很干净,不需要写复杂表达式,逻辑判断全部收敛到 Python 端。
自定义过滤器也是提效利器。比如字段名转换成数据库列名,我会写一个 snake_to_camel 过滤器,以及一个 pluralize 过滤器。这样模板里写{{ field.name | snake_to_camel }},输出就是前端要的驼峰字段名。省掉了在多个文件里手动改名的步骤,这个细节在生成前端类型定义文件时格外好用。
2.3 三层结构生成规则:Controller 薄、Service 厚、Repository 专
t3code 默认生成三层代码,但不是简单地产出三个文件,而是每层有明确的职责边界和内容规划。
Controller 层做得尽量薄。它只负责 HTTP 入参出参、状态码、路由注册,不碰任何业务逻辑。模板里固定生成四个方法:create、get、update、delete。这四件套覆盖了绝大多数资源型接口。路由前缀直接用实体名的复数形式,比如 /articles。Controller 层直接调用 Service 层,不直接操作数据库,这是硬性约定。
Service 层是核心,业务规则都落在这层。模板生成的初始版本包含事务包裹、实体到 DTO 的转换、存在性校验。比如更新操作,默认会先查询原记录,不存在就抛异常,然后才做字段更新。这样生成的代码虽然不是完整业务,但已经把最容易遗漏的校验逻辑铺底了。
Repository 层只负责数据存取。SQLAlchemy 的 Session 注入、模型类绑定、基本 CRUD 都在这一层。模板里有一个原则:任何 Controller 不能绕过 Service 直接调用 Repository。这个约束没法靠代码强制,只能在模板注释里醒目地提示。但实践下来,只要生成代码时保持三层结构清晰,后来者也会自然遵守同样的分层习惯。
3. 实操过程:从零生成一个可用项目
3.1 安装与初始化:t3code 怎么跑起来
安装采用 pip 方式,项目发布到私有包仓库后,一行命令就能装好:
pip install t3code装好后先初始化目录结构。t3code 的设计是“项目目录里放模板,模板目录旁边放输出目录”,这样生成代码和源模板互不污染。执行:
t3code init my_blog这条命令会生成如下结构:
my_blog/ ├── schema.yaml ├── templates/ │ ├── controller.j2 │ ├── service.j2 │ ├── repository.j2 │ └── schema_dto.j2 └── output/init 命令的另一作用是把默认模板复制到本地。我不喜欢把内置模板藏在安装包里,因为用户一定会想改模板。改成显式复制到项目目录后,用户改模板只是个编辑文件的操作,不需要了解包内部结构。
3.2 编写 schema.yaml:从实际情况推导配置
拿到一个真实需求时,我会先把实体字段整理到纸上,然后才落到 YAML 文件。明确一下 t3code 的命名约定:实体名用小写单数,表名用下划线复数。例如 article 对应 articles。这个约定尽量跟多数团队的编码规范保持一致,避免生成后还要二次改名。
多实体场景下,schema 文件写起来会多一些,但结构是一样的:
entities: - name: user fields: - name: id type: int primary: true - name: nickname type: string required: true - name: post fields: - name: id type: int primary: true - name: author_id type: int required: true - name: content type: text required: true这里有个细节:author_id 是外键字段,但 schema 里不需要声明外键关系。t3code 只做单表 CRUD 骨架,复杂的多表关联逻辑留给用户在生成的 Service 里自己补。这是有意的简化,因为关联逻辑千变万化,模板硬做只会产出一堆用户要删掉的代码,反而增加清理成本。
3.3 运行生成命令并验证产物
schema 准备好后,运行生成命令:
t3code generate --schema schema.yaml --output ./output生成过程的日志会打印每个文件的落盘路径。默认情况下,如果目标路径已有同名文件,生成器会跳过而不是覆盖,这个保护逻辑救过我很多次。真要重新生成,需要显式加 --force 参数:
t3code generate --schema schema.yaml --output ./output --force以 article 实体为例,生成的产物包含四个核心文件:
output/app/controllers/article_controller.py output/app/services/article_service.py output/app/repositories/article_repository.py output/app/schemas/article_schema.py我拿 article_schema.py 举例,看生成器如何把 YAML 里的字段定义翻译成 Pydantic 模型:
from datetime import datetime from pydantic import BaseModel, Field class ArticleBase(BaseModel): title: str = Field(..., max_length=200) content: str = Field("", max_length=10000) published_at: datetime | None = None class ArticleCreate(ArticleBase): pass class ArticleUpdate(ArticleBase): pass class ArticleInDB(BaseModel): id: int title: str content: str published_at: datetime | None = None class Config: from_attributes = True能看到 max_length 从 YAML 定义里带了出来,required 字段转成了必填参数,primary 字段 id 从创建请求模型里被排除,但保留在读取模型里。这个细节非常重要:创建时客户端不应该传 id,而显示时需要有 id。模板通过判断 primary 布尔值来分流归属,避免了新手最容易犯的“创建接口允许客户端指定主键”问题。
3.4 自定义模板的正确姿势
默认模板只是起点,实际项目里几乎一定要改。t3code 支持模板继承和变量覆盖,常见做法是修改模板里的类名和注释格式。比如团队要求所有 Repository 方法必须带日志,我会在 repository.j2 中加入:
import logging logger = logging.getLogger(__name__) class {{ entity_name | capitalize }}Repository: """{{ entity_name }} repository implementation."""这里有个重要技巧:模板里引用实体名时不要直接用原始小写名字,而是通过过滤器先做 capitalize 或 camel_case 转换。否则生成出来的类名就是 articleRepository 这种半吊子风格,还得手工修大小写。
另一个常见需求是前端 API 文件。t3code 允许在模板目录里放任意后缀的 .j2 文件,它不限定必须是 Python 代码。比如我加了一个 api_client.ts.j2,同一个 schema 就能生成前端 TypeScript 的 API 调用函数。一份 schema 多处产出,这才是模板驱动应有的打开方式。
4. 常见问题与排查技巧实录
4.1 Jinja2 变量访问报错:搞懂作用域规则
第一次跑生成时,我最常遇到的就是模板里取不到变量。典型报错是UndefinedError: 'field' is undefined。这是因为在 for 循环里使用了循环变量field,但在循环外再次访问了它。Jinja2 中的作用域跟 Python 不完全一样,for 循环里的变量默认不会泄漏到循环外。
解决方式很简单:在把上下文传给模板前,把需要跨循环使用的数据预先计算成独立变量。比如我想在文件头部生成所有字段的校验信息汇总,就在 Python 端这样处理:
field_info_list = [] for ent in schema["entities"]: for f in ent["fields"]: field_info_list.append({ "entity": ent["name"], "field": f["name"], "required": f.get("required", False), "max_length": f.get("max_length", 255), }) context["field_info_list"] = field_info_list这样模板里就可以直接遍历 field_info_list,而不需要绕回原来的实体循环。核心经验是:模板里不要做复杂数据整理,把所有需要的数据提前在生成器里准备好。模板负责“怎么展示”,Python 负责“算什么数据”。
4.2 YAML 解析失败:缩进和特殊字符的坑
YAML 对缩进极其敏感,一个空格错位就解析失败。更隐蔽的是某些业务字段里带特殊字符,比如字段描述里含冒号加空格,YAML 就会误判成字典结构。
我的处理方案是:字段的 description 属性尽量用单引号包起来:
- name: content type: text description: 'content: plain text or markdown, up to 10000 chars'同时我会在 t3code 里做一层解析预处理,如果发现yaml.YAMLError,就把错误信息里的行号、列号原样抛给用户,而不是只显示一句 ParseError。这个体验改进很关键,因为它把“哪一行有问题”直接摆在用户面前,不需要用户拿着编辑器反复数空格。
4.3 文件覆盖保护:为什么必须要做
开发 t3code 初期,我犯过一个挺大的错误:生成器每次都直接覆盖同名文件。有一次我改了模板,重新生成整个项目,结果 output 目录里那些我手动改过的业务代码全部被回滚成初始模板。那一刻我就决定必须引入覆盖保护机制。
具体策略是:目标文件存在且内容与将要写入的内容不同,默认跳过并打一条 warning;加了 --force 才覆盖。但还有一个细节:如果文件存在但内容和生成内容完全一致,即使在 force 模式也不做磁盘写入,减少无效 IO 和文件变更记录噪音。
这个策略后来演变出了一个配套命令diff,专门比较生成结果和现有文件的差异,输出一个统一格式的 diff。我习惯在每次模板修改后用 diff 命令核对变更,确认没问题再跑 --force,把事故概率降到最低。
4.4 与其他方案的对比参考
用了一段时间 t3code 之后,我整理过一份简单的对比表,方便团队评估要不要引入这个工具。没有说谁比谁好,只是看场景适配度:
| 方案 | 增量生成 | 模板定制难度 | 适合场景 |
|---|---|---|---|
| 官方脚手架 | 低,通常只初始化 | 低,跟着约定走 | 一次性建项目 |
| 手写复制粘贴 | 可接受,但效率低 | 高,全靠自觉 | 实体少,改动少 |
| t3code | 高,改 schema 即重生成 | 高,改 .j2 文件 | 实体多,CRUD 密集,需要统一规范 |
表格里其实藏着关键判断:如果你的项目实体超过五个,增删字段的次数频繁,那手写和维护一致性的成本会明显高于让生成器统一处理。反过来,如果只是搭个 Demo,真没必要引入生成器,脚手架一把梭更快。
4.5 还有几个值得说的细节坑
生成文件里中文注释乱码,这是老生常谈但很容易犯的问题。t3code 统一在文件开头声明 UTF-8 编码,模板里的注释也强制用 UTF-8 保存。Python 侧文件读写时明确指定 encoding="utf-8",绝不依赖平台默认编码。Windows 上踩过一次坑后,我把所有 open 函数都改成了显式传参。
空目录丢失也是个细节问题。git 不追踪空目录,如果生成的某个子目录下没有文件,输出结构就会被省略。t3code 采用“每个目录都生成一个 .gitkeep 文件”的策略,保证目录层级稳定,同时也方便调试时候看到真实的三层结构。
最后是模板语法错误定位难的问题。Jinja2 报错信息通常能指出模板名和行号,但有时模板字符串被拼接得很长,定位困难。我会在渲染前先对每个模板文件做一次语法预编译,使用 Environment.parse 方法提前发现问题,而不是等生成中途再崩溃。
写在最后的实践经验
t3code 这个项目从最初的脚本一路演变成现在这个可用的命令行工具,我最大的感触不是代码写得多精巧,而是“减少重复劳动”这件事本身有巨大的杠杆效应。之前手工添加一个实体,前后要改六个文件,大概二十分钟,现在改一份 YAML,十秒跑完命令,剩下的时间都花在真正有业务价值的逻辑上。如果你也经常被重复代码折磨,我建议不要一上来就追求大而全的代码生成平台,先把你当前项目里最重复的 CRUD 流程固化成模板,哪怕只是几个 j2 文件加一个解析脚本,带来的效率提升也会让你惊喜。踩过几次坑之后,我的体会是:工具不必覆盖所有场景,把最常用的路径做到稳定、可预期、可调试,就已经是成功的内部基础设施了。