
做后端开发这些年我最大的感受就是项目启动时最耗时间的不是业务逻辑而是那些毫无技术含量却必须写的样板代码。尤其是接口三层结构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 × 三层结构 × Codet3code 这个项目名拆开看其实反映了我的三个设计原则。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, pagepage, sizesize) 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_length200) content: str Field(, max_length10000) 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 侧文件读写时明确指定 encodingutf-8绝不依赖平台默认编码。Windows 上踩过一次坑后我把所有 open 函数都改成了显式传参。空目录丢失也是个细节问题。git 不追踪空目录如果生成的某个子目录下没有文件输出结构就会被省略。t3code 采用“每个目录都生成一个 .gitkeep 文件”的策略保证目录层级稳定同时也方便调试时候看到真实的三层结构。最后是模板语法错误定位难的问题。Jinja2 报错信息通常能指出模板名和行号但有时模板字符串被拼接得很长定位困难。我会在渲染前先对每个模板文件做一次语法预编译使用 Environment.parse 方法提前发现问题而不是等生成中途再崩溃。写在最后的实践经验t3code 这个项目从最初的脚本一路演变成现在这个可用的命令行工具我最大的感触不是代码写得多精巧而是“减少重复劳动”这件事本身有巨大的杠杆效应。之前手工添加一个实体前后要改六个文件大概二十分钟现在改一份 YAML十秒跑完命令剩下的时间都花在真正有业务价值的逻辑上。如果你也经常被重复代码折磨我建议不要一上来就追求大而全的代码生成平台先把你当前项目里最重复的 CRUD 流程固化成模板哪怕只是几个 j2 文件加一个解析脚本带来的效率提升也会让你惊喜。踩过几次坑之后我的体会是工具不必覆盖所有场景把最常用的路径做到稳定、可预期、可调试就已经是成功的内部基础设施了。