very-express
Version:
based on json schema and openapi, generate Express RestApi server with mongoDb
433 lines (334 loc) • 14.1 kB
Markdown
# tsoa → Ts.ED Migration Design
Date: 2026-06-10
Status: Draft
Branch: `migrate_to_tsed`
## 1. Motivation
当前 VeryExpress 用 tsoa 做路由注册 + OpenAPI 生成。tsoa 是编译期方案,读 TypeScript AST 从 `interface` 提取类型信息。缺点是 tsoa 维护长期不活跃,生态停滞。Ts.ED 是运行时方案,通过类装饰器注册 metadata,直接生成 OAS。
## 2. 核心架构
### 2.1 三层分离(安全边界)
Dto 和 Entity 完全独立生成(无继承关系),Entity 上的敏感字段不可能通过 Dto → OAS 链泄露。
```
OAS ← Ts.ED 从 @Property() 生成
│
Controller ← Ts.ED @Controller, 引用 Dto
│
Dto Layer ← Ts.ED class @Property()
├── ClientDto — 纯字段, 输入用 (POST/PUT body, Filter, VexRepository)
└── ClientDtoWithRelation — extends ClientDto, + whitelist relation, 输出用 (GET response)
│
─── 安全边界 ───
│
Entity Layer ← TypeORM class @Column(), 无 @Property(), 含敏感字段
└── ClientEntity — DB 全量字段 + 全量 relation
│
DB (PostgreSQL)
```
### 2.2 每个 Schema 生成 5 个文件
| 文件 | 类型 | 装饰器 | 用途 |
|------|------|--------|------|
| `_types/dtos/{Doc}Dto.gen.ts` | class | `@Property()` | 输入 body、Filter、VexRepository 绑定 |
| `_types/dtos/{Doc}DtoWithRelation.gen.ts` | class extends `{Doc}Dto` | `@Property(() => Target)` | 输出 response、带 whitelist relation |
| `_models/{Doc}Model.gen.ts` | class | `@Entity/@Column/@XtoY` | DB 实体,无 `@Property()` |
| `_controllers/{Doc}Controller.gen.ts` | class | `@Controller/@Get/@BodyParams` | Ts.ED 路由 handler |
| `_types/{Doc}.gen.ts` | enum + 工具 type | 无 | 枚举声明、Filter 辅助类型 |
## 3. Dto 设计
### 3.1 ClientDto(输入用)
```ts
// _types/dtos/ClientDto.gen.ts
import { Property, Required } from "@tsed/schema";
export class ClientDto {
@Property()
@Required()
name!: string;
@Property()
email?: string;
@Property()
clientType?: ClientTypeEnum;
}
```
- 不包含任何 relation 字段 — 保证 POST/PUT body 不接受 `profile: {...}`
- `@Required()` 对应 JSON Schema `required` 数组
- 枚举类型引用 `_types/{Doc}.gen.ts` 中生成的 enum
- `json-schema-to-typescript` 废弃 — 由自定义 DtoTemplate() 生成
### 3.2 ClientDtoWithRelation(输出用)
```ts
// _types/dtos/ClientDtoWithRelation.gen.ts
import { Property } from "@tsed/schema";
import { ProfileDtoWithRelation } from "./ProfileDtoWithRelation.gen";
export class ClientDtoWithRelation extends ClientDto {
// whitelisted one-to-many
@Property(() => ProfileDtoWithRelation)
profiles?: ProfileDtoWithRelation[];
// whitelisted many-to-one
@Property(() => UserDtoWithRelation)
user?: UserDtoWithRelation;
}
```
- extends `ClientDto` — 继承纯字段,按需添加 relation
- `@Property(() => TargetDtoWithRelation)` — Ts.ED 用 thunk 延迟解析,避免循环 import 的 TDZ
- 只包含 `joinWhitelist` 内的 relation
### 3.3 枚举
```ts
// _types/Client.gen.ts
export enum ClientTypeEnum {
basic = "basic",
premium = "premium"
}
```
被 `ClientDto` 和 `ClientEntity` 两方引用。
## 4. Entity 设计
```ts
// _models/ClientModel.gen.ts
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";
import { ClientTypeEnum } from "../_types/Client.gen";
@Entity("client")
export class ClientEntity {
@PrimaryGeneratedColumn("uuid")
_id!: string;
@Column({ type: "varchar", length: 100 })
name!: string;
@Column({ type: "varchar" })
passwordHash!: string; // 敏感字段 — 不在 Dto 中
@Column({ type: "enum", enum: ClientTypeEnum })
clientType?: ClientTypeEnum;
}
```
- 无 `@Property()` — 不做 OAS 源头
- 含全部 DB 字段 + 全量 relation
- 当前 `export * from "../_types/..."` 废弃 — Dto 独立 import
## 5. Controller 设计
### 5.1 装饰器映射
| tsoa | Ts.ED |
|------|-------|
| `@Route("client")` | `@Controller("/client")` |
| `@Tags("Client")` | `@Tag("Client")` |
| `@Get("{id}")` | `@Get("/:id")` |
| `@Post()` | `@Post()` |
| `@Put("{id}")` | `@Put("/:id")` |
| `@Patch("{id}")` | `@Patch("/:id")` |
| `@Delete("{id}")` | `@Delete("/:id")` |
| `@Body() body: Client` | `@BodyParams() body: ClientDto` |
| `@Path() id: string` | `@PathParams("id") id: string` |
| `@Query() join` | `@QueryParams("join") join: string[]` |
| `@Middlewares(Auth.mid)` | `@UseBefore(AuthMiddleware)` |
| `@Security("BearerAuth")` | `@Security("BearerAuth")` |
### 5.2 Controller 模板输出
```ts
// _controllers/ClientController.gen.ts
import { Controller, Get, Post, BodyParams, PathParams, QueryParams, UseBefore } from "@tsed/common";
import { Tag, Security, Property, Required } from "@tsed/schema";
import { ClientDto } from "../_types/dtos/ClientDto.gen";
import { ClientDtoWithRelation } from "../_types/dtos/ClientDtoWithRelation.gen";
import { ClientEntity } from "../_models/ClientModel.gen";
import { VexRepository, VexResponse, Filter, FieldFilter } from "../_types/vex";
import VexDb from "../_services/VexDb.gen";
import * as controllerFactory from "./_ControllerFactory.gen";
export type FilterClientDto = { [K in keyof ClientDto]?: FieldFilter<ClientDto[K]> } & Filter<ClientDto>;
@Controller("/client")
@Tag("Client")
@UseBefore(AuthenticationMiddleware)
export class ClientController extends controllerFactory._ControllerFactory {
private get repo(): VexRepository<ClientDto> {
return VexDb.getRepository(ClientEntity) as VexRepository<ClientDto>;
}
@Post("/search")
getList(@BodyParams() body: { filter: FilterClientDto, join?: string[] }): VexResponse<ClientDtoWithRelation[]> {
const data = await this.repo.find(body.filter, body.join);
return { result: { data } };
}
@Get("/:id")
get(@PathParams("id") id: string): VexResponse<ClientDtoWithRelation> {
const result = await this.repo.findOne({ _id: id });
if (!result) throw new VexResErr(404);
return { result };
}
@Post()
create(@BodyParams() body: ClientDto): VexResponse<ClientDto> {
const result = await this.repo.create(body);
return { result };
}
@Put("/:id")
replace(@PathParams("id") id: string, @BodyParams() body: ClientDto): VexResponse<ClientDto> {
const result = await this.repo.replace(id, body);
return { result };
}
@Patch("/:id")
update(@PathParams("id") id: string, @BodyParams() body: Partial<ClientDto>): VexResponse<ClientDto> {
const result = await this.repo.update(id, body);
return { result };
}
@Delete("/:id")
delete(@PathParams("id") id: string): VexResponse<void> {
const existing = await this.repo.findOne({ _id: id });
if (!existing) throw new VexResErr(404);
await this.repo.delete(id);
return { result: undefined };
}
}
```
### 5.3 `_ControllerFactory` 基类
```ts
// 当前: import { Controller } from "tsoa";
// 迁移后:
import { Controller } from "@tsed/common";
export class _ControllerFactory extends Controller {
protected isObjectId(id: string): boolean {
return /^[0-9a-fA-F]{24}$/.test(id);
}
}
```
### 5.4 VexRepository 绑定 Dto
Controller 的 repo 绑定到 `ClientDto`(纯字段),底层 `VexDb.getRepository(ClientEntity)` 执行 ORM 操作。通过类型断言桥接:
```ts
private get repo(): VexRepository<ClientDto> {
return VexDb.getRepository(ClientEntity) as VexRepository<ClientDto>;
}
```
### 5.5 Filter 类型
`keyof ClientDto` 在纯数据 class 上等价于 `keyof` interface—类型完整:
```ts
export type FilterClientDto = { [K in keyof ClientDto]?: FieldFilter<ClientDto[K]> } & Filter<ClientDto>;
```
Filter 类型是编译期类型工具,不影响 OAS。
## 6. 静态模板迁移
### 6.1 Middlewares
| 当前文件 | 迁移后 |
|---------|--------|
| `tsoaAuthentication.ts` | ❌ 废弃 |
| `Authentication.ts` | Ts.ED `@Middleware()` class |
| `DataIsolationContext.ts` | Ts.ED `@Middleware()` |
| `JoinWhitelistMiddleware.ts` | Ts.ED `@Middleware()` + 补充循环路径检测 |
Authentication 迁移示例:
```ts
import { Middleware, Context } from "@tsed/common";
@Middleware()
export class AuthenticationMiddleware {
async use(@Context() ctx: Context) {
const token = ctx.request.headers.authorization;
if (!token) throw new VexResErr(401, undefined, "Missing token");
ctx.set("user", verifyToken(token));
}
}
```
### 6.2 Routes
| 文件 | 迁移后 |
|------|--------|
| `authController.template.ts` | Ts.ED `@Controller` |
| `authRoute.template.ts` | 保留 Express Router (OAuth redirect) |
| `LoginUI.ts` | 保留 Express Router (HTML pages) |
| `OAuthRouteFactory.ts` | 保留 Express Router (passport callbacks) |
| `routes.template.ts` | **废弃** — 由 `@Configuration.mount` 替代 |
### 6.3 Server entrypoint
**迁移前**:
```ts
import { RegisterRoutes } from "./system/_routes/tsoa_routes";
async function main() {
const app = express();
RegisterRoutes(app);
app.use("/api/auth", AuthRoute.getRouter());
app.use("/", loginUI.getRouter());
}
```
**迁移后**:
```ts
import { PlatformExpress } from "@tsed/platform-express";
import { Configuration } from "@tsed/common";
@Configuration({
mount: { "/": [ClientController, AuthController] },
swagger: [{ path: "/swagger", specVersion: "3.0.3" }],
})
export class AppModule {}
async function main() {
const platform = await PlatformExpress.bootstrap(AppModule);
const app = platform.expressApp;
app.use("/api/auth", AuthRoute.getRouter());
app.use("/", loginUI.getRouter());
await platform.listen();
}
```
### 6.4 Services
所有 services 保持 static class,不引入 `@Injectable()`:
`VexDb`、`VexSystem`、`JWTService`、`JWTKeyStore`、`OAuthStrategyService`、`TypeOrmRepositoryAdapter`、`MongooseRepositoryAdapter`
### 6.5 OAS 生成
Ts.ED 从 `@Property()` 装饰器注册的 metadata 自动生成 OpenAPI spec,在 `@Configuration.swagger` 中指定 mount 路径。不再生成 `tsoa.json`。
## 7. 循环引用处理
### 7.1 现状(interface)
```ts
type ClientWithRelations = Client & {
profile?: Omit<ProfileWithRelations, 'client'>;
}
```
Omit 切断类型展开递归。
### 7.2 Class 方案(TypeScript 层面)
Class property 惰性解析,不需要 Omit:
```ts
class ClientDtoWithRelation extends ClientDto {
@Property(() => ProfileDtoWithRelation)
profile?: ProfileDtoWithRelation;
}
class ProfileDtoWithRelation extends ProfileDto {
@Property(() => ClientDtoWithRelation)
client?: ClientDtoWithRelation;
}
```
TypeScript 不会贪心展开 class property chain。循环 import 由 thunk `() => Target` 解决(调用时模块已加载)。
### 7.3 OAS 双向 \$ref(开放问题)
当 A 和 B 都在彼此的 whitelist 中时:
- `ClientDtoWithRelation.profile → $ref ProfileDtoWithRelation`
- `ProfileDtoWithRelation.client → $ref ClientDtoWithRelation`
OAS 3.x spec 本身不会因此循环展开——$ref 是静态引用定义。但某些 code generator 在解析双向 $ref 时可能出问题。
**状态**:与 tsoa→Ts.ED 迁移正交,不阻塞 migration,单独跟踪。
## 8. 迁移范围
### 8.1 Generator 改动
| 文件 | 改动 |
|------|------|
| `src/generators/interface/generator.ts` | **废弃** |
| `src/generators/dto/dto.generator.ts` | **新建** — JSON Schema → Dto class |
| `src/generators/dto/dto.template.ts` | **新建** — Dto 模板 |
| `src/generators/dto/dtoWithRelation.template.ts` | **新建** — DtoWithRelation 模板 |
| `src/generators/db/typeormEntity.generator.ts` | 移除 `export *`、移除 `Partial<Client>` |
| `src/generators/controller/controller.template.ts` | tsoa → Ts.ED 装饰器全套替换 |
| `src/generators/controller/controllers.generator.ts` | import path `_types/` → `_types/dtos/` |
| `src/generators/middlewares/RBACmiddleware.generator.ts` | 引用路径调整 |
| `src/generators/routes/routes.template.ts` | **废弃** |
| `src/generators/routes/authController.template.ts` | tsoa → Ts.ED |
| `src/generators/projectSettings/tsoaConfig.generator.ts` | **废弃** |
| `src/generators/projectSettings/package.template.json` | tsoa deps → Ts.ED deps |
| `src/generators/app/server.template.ts` | RegisterRoutes → PlatformExpress.bootstrap |
| `src/index.ts` (pipeline) | 删 interface generator, 加 dto generator |
### 8.2 静态模板改动
| 文件 | 改动 |
|------|------|
| `_middlewares/tsoaAuthentication.ts` | **删除** |
| `_middlewares/Authentication.ts` | 重写为 Ts.ED `@Middleware()` |
| `_middlewares/DataIsolationContext.ts` | 注册模式改 Ts.ED |
| `_middlewares/JoinWhitelistMiddleware.ts` | 注册模式改 Ts.ED |
| `_controllers/_ControllerFactory.ts` | tsoa `Controller` → Ts.ED `Controller` |
| `_projectSettings/package.json` | +Ts.ED deps, -tsoa |
## 9. 依赖变化
generated app 的 `package.json`:
```json
// 移除
"tsoa": "^6.x"
// 添加
"@tsed/common": "^8.x",
"@tsed/platform-express": "^8.x",
"@tsed/schema": "^8.x",
"@tsed/swagger": "^8.x"
```
本 repo 移除 `json-schema-to-typescript`。
## 10. 迁移策略
全量做完再切:在 `migrate_to_tsed` 分支上完成所有 generator 改动,一次合并到 main。
### 上线检查清单
- [ ] `_types/dtos/*` 生成正确
- [ ] ClientDto 不包含敏感字段、不包含未 whitelisted relation
- [ ] ClientDtoWithRelation 包含 whitelisted relation
- [ ] Entity 独立生成且与 Dto 无继承关系
- [ ] Controller 用 `@BodyParams() body: ClientDto`(非 Entity)
- [ ] Controller 返回用 `ClientDtoWithRelation`(非 Entity)
- [ ] Ts.ED 启动正常
- [ ] OAS 完整且不含 DB 内部字段
- [ ] POST body 不接受 relation 字段
- [ ] joinWhitelist middleware 正常工作
- [ ] OAuth 登录流程正常
- [ ] LoginUI 页面正常