
t3code 这类名字第一眼确实容易被当成某个语言规范或者黑话缩写。但在我实际的工作流里它是一套解决“重复代码最后一公里”的小工具与其每次都从旧模块复制一份把user改成order、把User改成Order改到一半发现漏掉一个引用不如把“模板 参数 生成规则”固化成一条命令。t3code 不是那种几百兆的代码生成平台它就想做一件事把人工复制改名的过程换成可配置、可重复、可审计的文件生成。如果你是做后端多模块项目、微服务拆分或者经常要给团队开一批新页面和接口的开发者这篇文章会比较有参考价值。即使你完全没接触过代码生成器下面也会从核心思路、配置写法、完整实操到踩坑记录一步步讲清楚。我后面会用实际项目里的模板作为例子保证看完可以直接把同样套路搬到自己团队里。1. 先看 t3code 到底解决什么问题1.1 手工复制改名的成本比你想象的高我见过很多项目在早期效率还行等模块多了之后就特别难受。项目里明明有一套约定好的分层结构比如controller/service/mapper/entity新加一个业务模块就要手动创建一整套文件。操作通常是这样找个最接近的旧模块全选复制然后全局替换包名、类名、变量名再打开几个关键文件手动调引用关系。这个过程偶尔做一次没问题但每月都在做的时候问题就来了。一是漏改字符串里可能残留UserService旧引用二是风格漂移不同人复制出来的文件缩进、命名、注释格式都不一样三是不可审计两个月后没人记得某个字段当初为什么要这样生成。我统计过一个不算复杂的配置管理模块手工复制加改名加自测大概花费半小时到四十分钟其中真正写业务逻辑的时间不到一半。t3code 把这些动作压缩成一次渲染和一次文件写入模型、仓库、业务状态流转、数据库脚本都能统一走模板生成。1.2 t3code 的三段式设计Template、Transform、Code工具名里的 t3我理解成三个动作的缩写Template 负责描述文件长什么样Transform 负责把变量做命名转换和值映射Code 是最终落地的文件内容。三段式设计带来的直接好处是逻辑分离模板和生成脚本不用混在一起。实际运行时可以简化为五步读取一个t3code.yaml配置清单里面声明模板目录、生成目录、必需变量和可选变量。递归扫描模板目录里所有文件。根据文件名和内容中的占位符逐文件做渲染。把占位符渲染后的内容组装到目标路径。在目标目录检查是否存在同名文件默认不覆盖加上--overwrite才允许覆盖。这套流程看起来很普通但普通恰恰是它的优势。团队接手时不需要学习一套复杂的 DSL也不需要理解大型框架的插件机制。模板就是一个文本文件加几个占位符生成行为全部由配置清单接管。1.3 为什么不直接用 shell 脚本和 sed看到这里你可能会想这不就是个find sed循环吗手工写个 bash 脚本不也能替换。确实能但我踩过不少坑之后觉得shell 方案在低复杂度的单文件替换上没问题一旦涉及多文件、命名转换、默认值、冲突检查就很容易出错。举一个实际例子假设模板里需要把user_profile转换成UserProfile再转换成USER_PROFILE。纯sed你得写一堆正则变换而且这些转换规则散落在脚本各处换个项目又要重写。t3code 的做法是把变量和变量变换统一写在配置里渲染引擎负责处理脚本里不写任何替换逻辑。另一个问题是 dry-run生成前能先输出“将要创建哪些文件、哪些文件会被覆盖、每个占位符被替换成什么”这是手工脚本很难稳定提供的。安全性和一致性比“少依赖一个工具”重要得多。2. t3code 的核心配置与模板语言2.1 模板目录要这样组织项目里模板不宜放在任意的散落目录我建议一个独立目录里单建一个t3code.yaml下面挂files/目录。结构大致这样templates/ t3code.yaml files/ src/ main/ java/ com/example/{module_name}/ controller/ {entity_name}Controller.java.tpl service/ {entity_name}Service.java.tpl repository/ {entity_name}Repository.java.tpl这里的{module_name}和{entity_name}不是最终目录而是占位符目录。渲染时会把它们替换成真实值。使用.tpl后缀而不是直接用.java是为了避免 IDE 在模板阶段就尝试做语法高亮和自动格式化打乱模板里的占位符缩进。生成后再由 t3code 改写为.java这一步在配置里写清楚就可以了。这套结构最大的好处是新模块的源码在磁盘上的最终路径和模板目录是一一对应的。人眼扫描模板就能预判生成结果不需要额外读复杂的 routing 规则。2.2 配置清单变量越多使用成本越高t3code.yaml 是生成器的入口它本身不是配置文件格式的新发明更像一个集中声明表。下面是我实际用的一套精简配置大家可以直接复制修改。template: demo-service description: 生成一个带基础 CRUD 的业务服务模块 sourceDir: files targetDir: generated/src overwrite: false variables: module_name: description: 模块名用于包名和目录名 required: true entity_name: description: 业务实体名例如 User required: true table_name: description: 数据库表名 default: author: description: 代码注释里的作者 default: t3code nameRules: - name: module_package value: {{ module_name | snake_case }} - name: entity_class value: {{ entity_name | pascal_case }}变量不要贪多。我见过有人把作者邮箱、创建日期、版权年份全部作为每次必填变量结果团队成员每次生成都要多敲一长串参数工具用起来反而费劲。原则上常量类信息直接写在模板里跨项目才需要变的才提升为变量。默认值能覆盖 80% 的日常场景不要把常用值设计成必填项。2.3 占位符与命名转换规则模板里最常用的占位符就是{{ variable_name }}。需要特别注意直接用变量名做替换往往不够因为同一个实体在不同位置有完全不同的命名风格。比如数据库字段user_profile在 Java 类属性里可能叫userProfile在类名里是UserProfile在 SQL 里是user_profile在常量里是USER_PROFILE。这几个不是同一个东西但都来源于同一个变量。t3code 的做法是引入过滤器在渲染阶段直接转换。常用规则整理如下占位符写法输入示例输出示例适用场景{{ entity_name | snake_case }}UserProfileuser_profile表名、文件名{{ entity_name | pascal_case }}user_profileUserProfile类名{{ entity_name | camel_case }}user_profileuserProfile属性名{{ entity_name | constant_case }}user_profileUSER_PROFILE常量、枚举我用过的 Filter 并不多但pascal_case和camel_case是绝对核心。实现逻辑也不算复杂核心代码类似下面这段它的作用就是把带下划线的变量名统一转换成目标风格。import re def snake_case(value: str) - str: s1 re.sub(r([a-z0-9])([A-Z]), r\1_\2, value) return s1.replace(-, _).lower() def pascal_case(value: str) - str: parts re.split(r[_-], value) capitalized [p[:1].upper() p[1:] for p in parts if p] return .join(capitalized) def camel_case(value: str) - str: pascal pascal_case(value) if not pascal: return pascal return pascal[:1].lower() pascal[1:] def constant_case(value: str) - str: return snake_case(value).upper()这段逻辑看起来简单但实际工程的坑都在边界情况。像userURL这类既有大写又有连续大写缩写的输入简单的例子可能转换成user_u_r_l不符合团队预期。所以模板里我一般建议规范变量输入所有源头变量统一传user_profile这种小写下划线格式过滤器的责任是“从下划线转换成其他风格”而不是“猜用户想表达什么”。2.4 渲染时不要做无脑全文替换模板渲染最容易踩的坑就是以为可以字符串全局替换。如果一个文件里出现了{{ user_profile }}和{{ user_profile | constant_case }}直接用朴素的替换逻辑会出问题因为第二处带着过滤器的占位符不能简单替换成原始值。正确做法是把占位符解析成“变量 过滤器链”渲染时先处理过滤器再取变量值。我建议模板文件里尽量少用过多嵌套的过滤器链。不是因为实现不了而是因为可读性会迅速下降。团队里别人打开模板看到一个{{ module_name | snake_case | upper }}如果不熟悉工具第一反应是困惑。与其压行程不如在模板中显式写出一个经过命名的变量字段比如module_package让配置里的nameRules去维护转换关系。3. 实操记录用 t3code 生成一个标准服务模块3.1 初始化模板仓库我建议把模板集合放在一个独立的 Git 仓库里和主项目仓库分开。这样模板可以有自己的版本历史改模板不会直接干扰线上代码。初始化只需要一条命令t3code init templates/demo-service执行后会生成一个最小可用的目录和一份示例配置。常见的交互式初始化还会问两个问题模板生成的默认文件后缀以及变量默认来源。如果没有特别需要直接使用默认值。3.2 编写模板文件我们以生成一个 Java 风格的 Controller 为例。实际团队未必都用 Java这里只关注模板写法换成 Python、Go 或 TypeScript 都一样。模板文件路径放在前面那个式子的controller/{entity_name}Controller.java.tpl下面内容可以这样写package com.example.{{ module_name | snake_case }}.controller; import com.example.{{ module_name | snake_case }}.service.{{ entity_name | pascal_case }}Service; import org.springframework.web.bind.annotation.*; import java.util.List; RestController RequestMapping(/{{ entity_name | snake_case }}) public class {{ entity_name | pascal_case }}Controller { private final {{ entity_name | pascal_case }}Service service; public {{ entity_name | pascal_case }}Controller({{ entity_name | pascal_case }}Service service) { this.service service; } GetMapping public List{{ entity_name | pascal_case }} list() { return service.list(); } PostMapping public {{ entity_name | pascal_case }} create(RequestBody {{ entity_name | pascal_case }} data) { return service.create(data); } }这里有几个明显的细节。第一{{ entity_name | camel_case }}适合用于变量前缀比如业务服务接口对实体对象的传入第二RequestMapping路径中使用下划线命名是 Java 工程里很常见的约定所以不能照搬类名第三模板文件缩进不能随意混用空格和 Tab否则生成后的代码会立刻被格式化工具检测出来。3.3 执行生成命令模板用完开始生成时命令需要把配置文件和变量传进去。t3code render \ --config templates/demo-service/t3code.yaml \ --set module_nameorder \ --set entity_nameOrder \ --set table_nameoms_order \ --dry-run这里必须先跑--dry-run。它会打印出所有准备生成的文件路径、命中的变量、每个文件的大小变化不会真正写入磁盘。输出大概长这样[DRY RUN] generated/src/com/example/order/controller/OrderController.java [DRY RUN] generated/src/com/example/order/service/OrderService.java [DRY RUN] generated/src/com/example/order/repository/OrderRepository.java [DRY RUN] 3 files will be generated, 0 skipped, 0 overwritten确认信息无误后再去掉--dry-run加上--apply属性真正确认生成t3code render \ --config templates/demo-service/t3code.yaml \ --set module_nameorder \ --set entity_nameOrder \ --set table_nameoms_order \ --apply生成的目录结构和模板里结构保持一致目标根目录是generated/src。如果你不希望生成在项目源码树里可以调整targetDir到build/generated之类的临时目录生成后再手动筛选。3.4 生成后的代码必须做格式化检查模板生成的代码不等于可以直接提交。就算模板写得再规范遇到缩进错误或长行拆分依然会让项目里的 lint 报错。我的习惯是生成后立刻跑一遍项目自带的格式化工具比如 Java 项目用spotless前端项目用prettierPython 项目用ruff format。这一步不是多余的它有两个作用。一是把模板风格和团队现有代码风格强行对齐二是通过命令行输出暴露模板里经常出现的小问题比如某个文件里多了一个空格或者少了一段空行。在我的经验里模板生成代码的格式化检查更应该在 CI 里自动做而不是等开发者本地发现问题。4. 常见问题与排错技巧4.1 替换后发现一半占位符还是原样这个问题出现频率最高原因通常是占位符写法和配置里的变量名对不上。比如配置里定义的是entity_name模板里写成了{{ entityName }}或者模板某个文件是二进制编码格式字符串替换无法正确匹配。排查方法很简单跑一次 dry-run然后查看输出中是否有遗漏。我建议把 dry-run 输出和模板扫描结果一起看它会显示哪些文件匹配变量、哪些文件完全没匹配。如果个别文件始终不替换检查文件扩展名是否在ignoreExtensions列表里比如.png、.jpg。对于二进制文件不要用文本模板去渲染应当放到一个单独目录配置里指定为copyOnly。4.2 生成的路径和预期的目录层级不一致这种问题一般不是模板写着有问题而是目标目录和模板目录的映射规则没对齐。默认行为是模板目录的相对路径全部保留但在某些项目里你只希望生成部分层级比如不想要顶层files不同团队配置很不一样。我的做法是在t3code.yaml里显式配置路径裁剪规则。比如pathRewrite: - from: /src/main/java/ to: /src/generated/java/写完这个规则后记忆成本更低因为生成器不再依赖目录结构猜测而是直接看配置。遇到路径里同时含有多个变量时还得注意变量默认值不能出现空字符串直接当作文件夹名那会生成一个叫/的目录非常难清理。4.3 覆盖了不想覆盖的文件默认不覆盖是我的底线习惯。虽然配置里有overwrite: true我还是建议只在明确需要全量重新生成时打开。日常操作中如果只生成一个新模块没必要覆盖旧代码。因为代码生成器生成的文件有时候会在生成后被业务逻辑修改一旦覆盖这些修改全丢了。更稳妥的办法是用--check模式跑一遍它可以在生成之前对比同名文件差异。如果有差异就停止并输出差异详情相当于把“人工确认”内置进流程。4.4 团队协作时模板本身也要被监管t3code 的模板不是一个一次性脚本它长期存在于团队代码库里改动影响面很大。所以模板仓库别放开权限让所有人随便改。至少要做三件事模板代码要有版本号并和项目版本号解耦。CI 里加一个t3code validate步骤专门检查 YAML 语法、变量引用是否有配对的结束符。模板变更走 PR 评审至少要有两个以上维护者看过 diff。只有模板稳定生成的代码才会稳定。很多人只关注生成器执行一次的结果忽略了模板本身也是一种代码资产这类资产一旦长毛后面所有人都会被带偏。4.5 小技巧让生成器输出可以读懂的变更摘要最后分享一个我自己用得非常多的小技巧。在t3code.yaml里加一段 postAction生成完后自动执行一段脚本比如在测试阶段直接运行git diff --stat。这样每次命令跑完控制台都会显示这次到底新增或者改动了哪些文件。输出不会太长但比打印一堆生成日志有用得多。这个习惯能强迫我每次生成都扫一眼 diff防止某些模板文件在渲染时被意外整成空文件而这种问题只有看 diff 时才最容易发现。