☰
t3code 实践指南:TypeScript 三层架构与代码生成落地
2026/10/28 6:32:16 网站建设 项目流程

1. 从“t3code”这个标题说起:它到底是什么,能解决什么问题

第一次看到“t3code”这个词,很多人会一头雾水。它不像“Vue”“React”那样一眼能看出是前端框架,也不像“Docker”“K8s”那样自带运维属性。我最初接触到它,是在一个做低代码平台的朋友那里,他提到团队内部用一套叫“t3code”的编码规范加工具链,把重复性的业务代码生成效率提升了将近一倍。后来陆续在几个技术社区看到有人讨论,才意识到“t3code”并不是某一个官方框架的名字,而更像是一个围绕“TypeScript + 三层架构 + 代码生成”形成的实践体系——有人把它当作一套脚手架约定,有人把它当作一种代码组织哲学,还有人直接把它做成了一个内部 CLI 工具。

那它到底能做什么?简单说,t3code 解决的是中大型 TypeScript 项目里“重复代码多、分层混乱、类型定义散落各处”这三个老大难问题。它通过一套约定好的目录结构、命名规则和生成脚本,让开发者用一条命令就能产出从数据库模型到 API 接口再到前端调用层的完整链路代码。适合谁来参考?如果你正在带一个 3 到 10 人的前端或全栈团队,项目里已经有几十个甚至上百个接口,每次加字段都要改五六个文件,那 t3code 这套思路值得你花时间研究。如果你只是写个人小项目,可能用不上这么重的约定,但里面关于类型复用和分层解耦的技巧依然可以借鉴。

我写这篇东西,不是要给你安利某个具体工具,而是把这套实践背后的设计逻辑、落地步骤、踩坑记录完整拆开讲清楚。你看完可以自己决定要不要在团队里推行,或者只挑其中一两个点用到现有项目里。全文会围绕 t3code 的核心思路展开,包括它为什么这样分层、生成脚本怎么写、类型怎么串联、遇到冲突怎么排查,尽量做到你照着做就能跑起来。

2. t3code 的整体设计思路与分层逻辑拆解

2.1 为什么是“三层”而不是“两层”或“四层”

t3code 这个名字里的“t3”,最主流的解释就是TypeScript 的三层架构。哪三层?我见过的落地版本里,比较通用的是:数据访问层(Data Access Layer)、业务逻辑层(Service Layer)、接口/控制器层(Controller Layer)。有人会把“类型定义层”单独拎出来算第四层,但 t3code 的实践者通常把类型定义直接嵌入这三层里,通过types目录统一导出,不单独算一层。

为什么偏偏是三层?我自己的体会是,两层(比如只有 Model 和 Controller)在业务简单时够用,但一旦出现“同一个数据操作被多个接口复用”的情况,逻辑就会往 Controller 里堆,最后 Controller 变成几百行的怪物。四层(多加一个 Repository 层)又太啰嗦,小团队维护成本高。三层刚好卡在中间:Service 层承载可复用的业务规则,Controller 只负责参数校验和响应组装,Data Access 只关心数据库或外部 API 的读写。

注意:t3code 的三层不是强制你每个表都要写三个文件。对于纯 CRUD 的简单实体,Service 层可以很薄,甚至直接透传。它的价值在于当业务变复杂时,你有地方放代码,而不是临时找地方塞。

2.2 目录结构约定:让每个人都知道代码该放哪

t3code 最直观的落地表现就是一套固定的目录结构。我参考过几个团队的做法,下面这个版本是共识度比较高的:

src/ modules/ user/ user.controller.ts user.service.ts user.dao.ts user.types.ts user.dto.ts order/ order.controller.ts order.service.ts order.dao.ts order.types.ts order.dto.ts common/ middlewares/ utils/ constants/ app.ts

按业务模块划分,而不是按技术角色划分(比如把所有 controller 放一个目录)。这个选择背后有很实际的理由:当你改一个用户相关的需求时,所有相关文件都在modules/user/下面,不用在三个大目录之间来回跳。我试过两种组织方式,按模块划分在代码 review 和新人上手时明显更友好。

user.types.ts放数据库实体类型和领域类型,user.dto.ts放接口入参和出参的传输对象类型。这两个分开是有讲究的:数据库字段可能叫created_at,但接口返回给前端可能叫createdAt,DTO 就是做这层转换的。很多团队偷懒混在一起,结果数据库改个字段名,前端全炸。

2.3 代码生成在 t3code 里的角色

t3code 之所以能提升效率,核心在于把“写模板代码”这件事自动化。一个标准的增删改查模块,手写的话大概要 200 到 300 行,其中 80% 是重复的。t3code 的做法是:你提供一份实体描述(可以是 JSON、YAML,甚至直接读数据库表结构),生成脚本自动产出 controller、service、dao、types、dto 五个文件的基础骨架。

这里有个关键决策:生成的是骨架还是完整代码?我见过两种流派。一种生成完整可运行代码,你改业务逻辑时直接在上面改;另一种只生成带TODO标记的骨架,强制你填充。t3code 的实践者更倾向于后者,因为完整生成容易让人懒得看生成内容,出了问题不好排查。骨架生成则逼着你至少过一遍每个文件,知道业务逻辑写在哪。

生成脚本本身用什么写?Node.js 环境下,最顺手的是用plop或者自己写一个基于fs和模板字符串的 CLI。plop 的好处是模板管理清晰,交互式问答体验好;自己写的好处是灵活,可以对接数据库元数据。我两个都用过,如果团队没有特殊需求,plop 上手更快。

3. 核心细节解析:类型串联、命名规范与生成脚本实操

3.1 类型定义怎么串起来才不乱

t3code 里最容易出问题的就是类型。我踩过的坑是:DAO 层返回一个数据库实体类型,Service 层加工后返回一个领域类型,Controller 层又转成 DTO 类型,三层类型如果各写各的,改一个字段要改三处,还容易漏。

比较稳妥的做法是用 TypeScript 的工具类型做派生。比如:

// user.types.ts export interface UserEntity { id: number; user_name: string; created_at: Date; } // user.dto.ts import { UserEntity } from './user.types'; export type UserResponseDto = Omit<UserEntity, 'created_at'> & { createdAt: string; }; export type CreateUserDto = Pick<UserEntity, 'user_name'>;

这样数据库实体一变,DTO 通过Omit、Pick、Partial这些工具类型自动跟着变,编译器会提醒你哪里不兼容。比手写两遍类型可靠得多。t3code 的生成脚本里,这一步是自动完成的:读实体定义,按规则生成对应的 DTO 派生类型。

提示:不要过度使用Omit和Pick嵌套,超过两层派生可读性会急剧下降。遇到复杂转换,宁可显式写一个映射函数,也不要堆工具类型。

3.2 命名规范:看似小事,实则决定生成脚本能不能跑通

t3code 的生成脚本依赖命名约定来推断文件路径和类名。如果命名乱了,脚本就找不到对应文件。所以推行 t3code 之前,团队必须先统一命名。我总结的规则如下:

元素规范示例
模块目录小写单数user、order
控制器文件模块名 +.controller.tsuser.controller.ts
服务文件模块名 +.service.tsuser.service.ts
数据访问文件模块名 +.dao.tsuser.dao.ts
实体类型文件模块名 +.types.tsuser.types.ts
DTO 文件模块名 +.dto.tsuser.dto.ts
类名大驼峰 + 角色后缀UserController、UserService
方法名动词 + 名词getUserById、createOrder

这套规则看起来死板,但正是这种死板让生成脚本可以“猜”出所有路径。比如脚本收到user这个模块名,就能拼出src/modules/user/user.controller.ts,并往里面插入UserController类。如果团队里有人把文件叫userCtrl.ts,脚本就废了。

3.3 生成脚本的核心逻辑拆解

我拿一个简化版的生成脚本来讲,基于 Node.js 和fs-extra,不依赖 plop,方便你理解原理后自己改造。

// scripts/generate.js const fs = require('fs-extra'); const path = require('path'); const moduleName = process.argv[2]; // 比如 node generate.js user if (!moduleName) { console.error('请提供模块名,例如:node generate.js user'); process.exit(1); } const targetDir = path.join(__dirname, '..', 'src', 'modules', moduleName); if (fs.existsSync(targetDir)) { console.error(`模块 ${moduleName} 已存在,请勿重复生成`); process.exit(1); } fs.ensureDirSync(targetDir); const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); const className = capitalize(moduleName); const templates = { [`${moduleName}.controller.ts`]: `import { ${className}Service } from './${moduleName}.service'; export class ${className}Controller { private service = new ${className}Service(); // TODO: 实现接口方法 } `, [`${moduleName}.service.ts`]: `import { ${className}Dao } from './${moduleName}.dao'; export class ${className}Service { private dao = new ${className}Dao(); // TODO: 实现业务逻辑 } `, [`${moduleName}.dao.ts`]: `export class ${className}Dao { // TODO: 实现数据访问 } `, [`${moduleName}.types.ts`]: `export interface ${className}Entity { id: number; // TODO: 补充字段 } `, [`${moduleName}.dto.ts`]: `import { ${className}Entity } from './${moduleName}.types'; export type ${className}ResponseDto = ${className}Entity; `, }; Object.entries(templates).forEach(([filename, content]) => { fs.writeFileSync(path.join(targetDir, filename), content, 'utf8'); console.log(`已生成 ${filename}`); }); console.log(`模块 ${moduleName} 生成完毕`);

这个脚本只有 50 行左右,但已经能跑通核心流程。你执行node scripts/generate.js user,它就会在src/modules/user/下生成五个文件,并且类名、导入路径都自动对齐。实际项目中,你可以把模板抽到单独的.hbs或.tpl文件里,用占位符替换,维护起来更清晰。

注意:生成脚本一定要做“目录已存在”检查。我见过有人不小心重复执行,把已有文件覆盖了,半天工作白费。加一行fs.existsSync判断,成本极低,收益极大。

4. 完整实操过程:从零搭建一个 t3code 风格模块

4.1 环境准备与依赖安装

假设你有一个空的 TypeScript 项目,先初始化:

mkdir t3code-demo && cd t3code-demo npm init -y npm install typescript ts-node @types/node fs-extra --save-dev npx tsc --init

tsconfig.json里建议开启strict和experimentalDecorators(如果你用装饰器做路由)。目录结构先建好:

mkdir -p src/modules src/common/utils scripts

然后把上面那个生成脚本保存到scripts/generate.js。在package.json里加一条命令:

"scripts": { "gen": "node scripts/generate.js" }

这样以后执行npm run gen user就能生成模块。

4.2 生成第一个模块并填充业务逻辑

执行npm run gen user,你会看到五个文件出现在src/modules/user/下。接下来以“创建用户”为例,填充逻辑。

先改user.types.ts:

export interface UserEntity { id: number; userName: string; email: string; createdAt: Date; }

再改user.dto.ts:

import { UserEntity } from './user.types'; export type CreateUserDto = Pick<UserEntity, 'userName' | 'email'>; export type UserResponseDto = Omit<UserEntity, 'createdAt'> & { createdAt: string; };

DAO 层模拟数据库写入:

import { UserEntity } from './user.types'; const mockDb: UserEntity[] = []; export class UserDao { async insert(user: Omit<UserEntity, 'id' | 'createdAt'>): Promise<UserEntity> { const entity: UserEntity = { id: mockDb.length + 1, createdAt: new Date(), ...user, }; mockDb.push(entity); return entity; } async findByEmail(email: string): Promise<UserEntity | undefined> { return mockDb.find((u) => u.email === email); } }

Service 层做业务校验:

import { UserDao } from './user.dao'; import { CreateUserDto } from './user.dto'; export class UserService { private dao = new UserDao(); async createUser(dto: CreateUserDto) { const existing = await this.dao.findByEmail(dto.email); if (existing) { throw new Error('邮箱已被注册'); } return this.dao.insert(dto); } }

Controller 层组装响应:

import { UserService } from './user.service'; import { CreateUserDto, UserResponseDto } from './user.dto'; export class UserController { private service = new UserService(); async handleCreate(body: CreateUserDto): Promise<UserResponseDto> { const entity = await this.service.createUser(body); return { id: entity.id, userName: entity.userName, email: entity.email, createdAt: entity.createdAt.toISOString(), }; } }

这样一条链路就通了。你可以写个简单的测试脚本调用UserController.handleCreate,验证类型是否对齐、逻辑是否跑通。

4.3 参数校验与错误处理的统一约定

t3code 风格里,参数校验通常放在 Controller 层入口,用zod或class-validator。我倾向zod,因为它和 TypeScript 类型推导结合得更好。比如:

import { z } from 'zod'; const createUserSchema = z.object({ userName: z.string().min(2).max(20), email: z.string().email(), }); export type CreateUserDto = z.infer<typeof createUserSchema>;

这样 DTO 类型直接从 schema 推导,不用手写两遍。校验失败时统一抛出一个自定义的ValidationError,在全局错误中间件里捕获并返回 400。这套约定一旦定下来,所有模块都照做,新人进来不用问“参数校验写哪”。

提示:错误码建议用常量枚举,不要散落魔法数字。比如ErrorCode.EMAIL_EXISTS = 1001,前端可以根据错误码做国际化提示,比解析错误消息字符串可靠。

5. 常见问题与排查技巧实录

5.1 生成脚本报“模块已存在”但我想重新生成怎么办

这是最高频的问题。我的建议是:不要直接删目录重新生成,因为里面可能已经有你手写的业务逻辑。正确做法是给生成脚本加一个--force参数,但--force只覆盖那些带TODO标记的骨架文件,对于已经填充过逻辑的文件跳过。实现方式很简单:读取文件内容,如果包含TODO就覆盖,否则打印“跳过 user.service.ts(已有自定义逻辑)”。

5.2 类型派生导致编译报错怎么快速定位

用Omit、Pick派生类型时,如果基础类型改了字段名,派生类型不会自动报错,而是变成never或缺失字段。排查技巧是:在派生类型定义处加一行type _Check = UserResponseDto;,然后把鼠标悬停在_Check上,IDE 会展开最终类型,一眼就能看出哪个字段丢了。比逐个文件翻快得多。

5.3 循环依赖:Service 互相引用怎么破

t3code 分层后,有时UserService需要调OrderService,OrderService又需要调UserService,形成循环依赖。Node.js 下表现为某个 import 变成undefined。解决办法有两个:一是把共享逻辑抽到第三个 Service(比如SharedService),双方都依赖它;二是用依赖注入容器(如tsyringe)延迟解析。我倾向第一种,简单直接,不引入额外框架。

5.4 常见问题速查表

问题现象可能原因解决方向
生成脚本找不到模块目录模块名拼写错误或未执行 mkdir检查src/modules/下是否存在对应目录
编译报“类型不匹配”DTO 与 Entity 字段不一致用工具类型派生,避免手写重复类型
运行时 import 为 undefined循环依赖抽取共享 Service 或调整依赖方向
接口返回字段缺失Controller 未做 DTO 转换检查 Controller 是否直接返回了 Entity
生成的文件类名不对模块名含下划线或大写统一用小写单数模块名

5.5 我踩过的一个真实坑:时间字段序列化

有一次 DAO 返回的createdAt是Date对象,Controller 直接透传给前端,JSON 序列化后变成 ISO 字符串,前端解析没问题。但后来换了一个序列化库,把Date转成了时间戳数字,前端全炸。从那以后,我在 t3code 的 DTO 转换层强制要求:所有时间字段在 Controller 层显式调用.toISOString(),不依赖序列化库的默认行为。这个约定写进了生成脚本的模板里,新模块自动带上。

6. 工具选型与团队推行建议

6.1 生成工具:plop vs 自研 CLI

plop 的优势是模板用 Handlebars,支持条件判断和循环,交互式问答体验好,适合模块结构复杂、需要根据用户输入生成不同文件的场景。自研 CLI 的优势是轻量、可控,可以直接读数据库表结构生成实体类型,适合已有数据库、想反向生成代码的团队。

我的建议是:如果只是统一代码骨架,用 plop;如果要对齐数据库元数据,自研 CLI。两者不冲突,可以先用 plop 管模板,再写一个脚本读数据库生成 plop 的输入参数。

6.2 推行 t3code 时怎么减少团队阻力

最大的阻力来自“为什么要多写一个 dao 文件”。我的经验是,不要一上来就强制所有模块三层齐全。先在新模块试点,让团队看到生成脚本省下的时间,再逐步把老模块迁移。迁移时也不要重写,而是“下次改这个模块时顺手拆层”。另外,把生成脚本做成npm run gen这种零门槛命令,比写一堆文档管用。

6.3 和现有框架怎么结合

t3code 本身不绑定框架。用 Express 就把 Controller 方法挂到路由上;用 NestJS 就把 Controller 类加上@Controller装饰器,Service 加@Injectable。生成脚本可以根据项目里是否检测到@nestjs/core依赖,自动切换模板。这个适配层值得花时间做,因为一旦生成出来的代码和框架风格一致,团队接受度会高很多。

7. 后续扩展方向与个人体会

t3code 这套东西跑通之后,能扩展的地方不少。比如把生成脚本和 OpenAPI 文档打通,生成代码的同时产出接口文档;或者把 DTO 校验规则直接导出成前端可用的 TypeScript 类型包,前后端共享一份类型定义。我最近在试的是把生成脚本接入 CI,每次新增数据库表时自动生成模块骨架并提交 PR,人工只需要填充业务逻辑。

我个人在实际操作中的体会是:t3code 的价值不在于“生成了多少代码”,而在于它强制团队在动手写之前先想清楚分层和类型。很多时候,生成脚本跑完,你看着那五个空文件,就已经知道业务逻辑该放哪了。这种“先约定后编码”的习惯,比省下的那点打字时间值钱得多。如果你准备在团队里推,建议先从一个小模块开始,把生成脚本和命名规范跑顺,再逐步铺开。别贪快,分层这件事,乱一次要花十倍时间收拾。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询