@guangnao/webapi
v1.6.4
Published
TypeScript 后端框架:装饰器路由 + DI + ORM + 缓存 + 实时通信 + 鉴权,一个包全带
Maintainers
Readme
@guangnao/webapi
TypeScript 后端框架。控制器、依赖注入、过滤器管道、ORM、鉴权、实时通信,一个包全带。
如果你写过 ASP.NET Core Web API,这个框架的形状你已经认识了 —— ControllerBase、[HttpGet]、[FromBody]、四段过滤器管道、构造函数注入、app.UseXxx(),都是同一套。下面每一节都按「你在 .NET 里怎么写 → 这里怎么写」对着列。
npm i @guangnao/webapiNode ≥ 20.12,ESM only(package.json 里要有 "type": "module")。
一分钟上手
Program.cs 对应 main.ts:
import 'reflect-metadata'; // 必须排在框架之前
import { WebApplication, NodeHttpAdapter, ControllerBase, Controller, Get, Query } from '@guangnao/webapi';
@Controller('/api/hello') // [Route("api/hello")]
class HelloController extends ControllerBase {
@Get('/') // [HttpGet]
hello(@Query('name') name = 'world') { // [FromQuery]
return this.ok({ message: `hello, ${name}` }); // return Ok(...)
}
}
const app = WebApplication.create()
.useResponseFormat() // 统一响应信封,见下文
.build();
app.useAdapter(new NodeHttpAdapter()); // 传输层:内置 Node http
app.useController(HelloController); // 或 await app.scanModules('./src/modules') 自动发现
await app.listen(3000);useController 逐个注册;scanModules(dir) 按目录扫描,等价于 .NET 的自动控制器发现——控制器、服务、实体、WebSocket 处理器、定时任务都能被认出来。
推荐目录结构
按 feature 分目录,一个功能的控制器 / 服务 / 实体放在一起——不是 .NET 那种 Controllers/ Services/ Models/ 按类型横切。scanModules() 递归扫描整棵树,照下面这样摆就能直接跑:
my-api/
├── .env # 配置(dotenv 读进 process.env)
├── package.json # 必须有 "type": "module"
├── tsconfig.json
├── public/ # 静态资源(可选,useStaticFiles 的 root)
│ ├── index.html
│ └── assets/
└── src/
├── main.ts # ≈ Program.cs:装配 + 启动
└── modules/ # ← scanModules 扫这里
├── users/
│ ├── users.controller.ts
│ ├── users.service.ts
│ └── users.entity.ts
├── posts/
│ ├── posts.controller.ts
│ ├── posts.service.ts
│ └── posts.entity.ts
├── realtime/
│ ├── events.controller.ts # @SSE()
│ └── echo.ws.ts # @WebSocketHandler()
└── shared/
├── error.filter.ts # @GlobalFilter()
└── jobs.ts # @Scheduler() + @Cron()scanModules 的四条规则
- 不需要文件名约定。
.controller.ts/.service.ts这些后缀纯粹是给人看的,扫描器只看类上的装饰器来分类:@Controller→ 控制器,@Service/@Injectable→ 服务,@Entity→ 实体,@WebSocketHandler→ WS 处理器,@Scheduler→ 定时任务,@GlobalFilter(或实现了四个过滤器接口中任一钩子)→ 过滤器。叫foo.ts一样能被认出来。 - 扫
.ts还是.js由入口决定。入口以.ts结尾(tsx src/main.ts)就扫**/*.ts;入口是.js(编译后node dist/main.js)就扫**/*.js。这一条是自动的,但意味着编译部署时src/modules的产物必须跟着进dist,否则线上一个模块都扫不到。*.spec.ts*.test.ts*.d.ts与node_modules/dist会被跳过。 - 类必须
export。扫描器读的是模块的导出表,@Service()打在一个没导出的类上,它就不会被登记——而控制器注入它时才报错,错在别处、现象在别处。 - 路径相对
process.cwd(),不是相对main.ts。写'./src/modules'就得从项目根启动。想不受启动目录影响,用new URL('./modules', import.meta.url).pathname(demo 就是这么写的)。
package.json
{
"name": "my-api",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"start": "tsx src/main.ts",
"build": "tsc",
"serve": "node dist/main.js"
},
"dependencies": {
"@guangnao/webapi": "^1.6.0",
"dotenv": "^17.0.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"@types/node": "^20.19.0"
}
}"type": "module" 不能少——这个包是 ESM only。
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}strictPropertyInitialization: false 是为了实体和 DTO——@Column() title!: string 由框架赋值,开着它每个字段都得写初始值。
emitDecoratorMetadata 开着没坏处,但别指望它:只有 tsc 会真的发射,tsx/esbuild 不会(见上文那一节)。参数类型一律显式写,两种构建方式下行为才一致。
.env
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_NAME=my_api
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PWD=
REDIS_DB=0
JWT_SECRET=change-mesrc/main.ts
import 'dotenv/config'; // 读 .env → process.env,必须排在其它 import 之前
import 'reflect-metadata';
import { WebApplication, NodeHttpAdapter } from '@guangnao/webapi';
const app = WebApplication.create()
.useResponseFormat({ timestamp: true })
.useJwt({ secret: process.env.JWT_SECRET! })
.useOpenApi({ title: 'My API', version: '1.0.0' })
.useStaticFiles({ root: './public', prefix: '/static', maxAge: 3600 })
.useUpload({ maxFileSize: 5 * 1024 * 1024, maxFiles: 5 })
.useCors()
.useHealth('/health') // 健康检查端点
.build();
app.useAdapter(new NodeHttpAdapter());
await app.useDatabase({
dialect: 'mysql',
host: process.env.DB_HOST, port: Number(process.env.DB_PORT),
username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME,
autoSync: { alter: true }, // 开发期自动对齐表结构;生产请关掉
});
await app.useRedis({
host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT),
...(process.env.REDIS_PWD ? { password: process.env.REDIS_PWD } : {}), // 空串别当密码传
});
await app.scanModules(new URL('./modules', import.meta.url).pathname);
await app.listen(3000);
// 优雅停机:释放数据库连接池与 Redis 连接
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
process.once(sig, () => void app.close().then(() => process.exit(0)));
}useDatabase / useRedis 要 await(它们真的去连),其余 useXxx 是建造器上的链式配置。数据库或 Redis 连不上时 await 会抛——包在 try/catch 里就能降级启动(demo 就是这么处理的,没有外部依赖时仍然能起来看路由)。
编译部署
npm run build && npm run serveTypeScript 编译到 ESM 有个众所周知的坑:产物里的相对 import 必须带 .js 后缀,所以源码里就得写 from './users.service.js'。嫌麻烦就直接用 tsx src/main.ts 跑生产——它不需要预编译,也没有这个问题。
总对照表
| ASP.NET Core | @guangnao/webapi |
| --- | --- |
| ControllerBase | ControllerBase |
| [ApiController] [Route("api/x")] | @Controller('/api/x') |
| [HttpGet("{id}")] | @Get('/:id') |
| [FromBody] [FromQuery] [FromRoute] | @Body() @Query() @Param() |
| [FromHeader] / Cookie | @Header() @Cookie() |
| Ok() NotFound() BadRequest() | this.ok() this.notFound() this.badRequest() |
| services.AddScoped<T>() | @Service({ scope: 'scoped' }) |
| 构造函数注入 | 构造函数注入,但必须写 @Inject(T)(见下节) |
| app.UseCors() app.UseAuthentication() | .useCors() .useJwt() |
| 自定义中间件 | app.useMiddleware({ invoke(ctx, next) {} }) |
| IAuthorizationFilter / IActionFilter / IExceptionFilter / IResultFilter | 同名四个接口,语义一致 |
| [Authorize(Roles = "admin")] [AllowAnonymous] | @Authorize('admin') @AllowAnonymous() |
| EF Core DbContext / DbSet<T> | app.useDatabase() / Repository<T> |
| [Required] [EmailAddress] [MinLength] | @IsRequired() @IsEmail() @MinLength() |
| Swashbuckle / Swagger UI | .useOpenApi() → /openapi.json + /docs |
| BackgroundService / Quartz | @Scheduler() + @Cron() |
| SignalR | @WebSocketHandler() / @SSE() |
| appsettings.json + IOptions<T> | .env + process.env(框架不代管配置) |
⚠️ 唯一必须记住的差异:参数类型要显式写
.NET 从方法签名就知道 int id。这里不行,必须把类型作为装饰器参数传进去:
@Get('/items/:id')
detail(@Param('id', Number) id: number, // ← 第二个参数不可省
@Query('page', Number) page = 1,
@Query('all', Boolean) all = false) { … }
@Post('/signup')
signup(@Body(SignupDto) dto: SignupDto) { … } // ← 不传 DTO 类,校验不会跑
constructor(@Inject(UsersService) private readonly users: UsersService) { super(); }
// ^^^^^^^^^^^^^^^^^^^^^ 不写的话解析不出依赖,请求直接 500原因:TypeScript 的 design:paramtypes 元数据只有 tsc 会发射,esbuild / swc / tsx / Bun 一律不发(esbuild 明确表示不打算支持 emitDecoratorMetadata)。依赖反射的写法在这些工具下会静默失效,而且不报错:
| 写法 | tsc 构建 | esbuild / tsx 构建 |
| --- | --- | --- |
| @Query('page') page: number | 收到 7 | 收到 "7",page + 1 得 "71" |
| @Query('on') on: boolean | 收到 false | 收到 "false",而 if (on) 为真 |
| @Body() dto: SignupDto | 校验会跑 | 校验一条都不跑,非法数据原样 200 通过 |
| constructor(private s: FooService) | 注入成功 | 解析不出依赖,请求 500 |
显式传类型是唯一在任何构建工具下都成立的写法。不传类型也是合法的——那就是拿字符串,行为可预期。
控制器与返回值
@Controller('/api/users')
export class UsersController extends ControllerBase {
constructor(@Inject(UsersService) private readonly users: UsersService) { super(); }
@Get('/')
async list(@Query('page', Number) page = 1) {
return this.ok(await this.users.list(page)); // 200
}
@Get('/:id')
async detail(@Param('id', Number) id: number) {
const user = await this.users.byId(id);
if (!user) return this.notFound('用户不存在'); // 404
return this.ok(user);
}
@Post('/')
async create(@Body(CreateUserDto) dto: CreateUserDto) {
return this.created(await this.users.create(dto)); // 201
}
@Delete('/:id')
async remove(@Param('id', Number) id: number) {
await this.users.remove(id);
return this.noContent(); // 204
}
}| 方法 | .NET | 行为 |
| --- | --- | --- |
| this.ok(data) | Ok(data) | 200 |
| this.created(data) | Created(...) | 201 |
| this.noContent() | NoContent() | 204 |
| this.status(code, data) | StatusCode(code, data) | 任意状态码 |
| this.badRequest(msg, errors?) | BadRequest(...) | 400,抛异常(下同) |
| this.unauthorized(msg) | Unauthorized() | 401 |
| this.forbidden(msg) | Forbid() | 403 |
| this.notFound(msg) | NotFound() | 404 |
| this.redirect(url, 302) | Redirect(url) | 重定向 |
注意错误类方法是 throw(返回类型 never),所以 return this.notFound() 和 this.notFound() 效果相同,写 return 只是为了让读代码的人看清这里结束了。
上下文对象:this.context(≈ HttpContext)、this.request / this.response、this.user(≈ User / ClaimsPrincipal)、this.userId。
响应信封
.useResponseFormat() 会把所有返回值包成统一结构——.NET 里通常自己写 ApiResponse<T> 或者 Result 过滤器,这里是内置的:
{ "code": 200, "message": "success", "data": { "…" }, "timestamp": "2026-08-04T…" }不需要就别调 useResponseFormat(),返回值原样出去。
依赖注入
import { Service, ServiceBase, Injectable, Inject } from '@guangnao/webapi';
@Service() // 默认 singleton
export class UsersService extends ServiceBase { … }
@Service({ scope: 'scoped' }) // services.AddScoped<T>()
export class RequestScopedThing { … }
@Service({ scope: 'transient' }) // services.AddTransient<T>()
export class Throwaway { … }三种生命周期与 .NET 一一对应:singleton(默认)/ scoped(每请求)/ transient(每次解析)。
注入时必须显式写 token——原因同上一节,构造函数的参数类型也是靠 design:paramtypes 反射的:
@Controller('/api/users')
export class UsersController extends ControllerBase {
constructor(
@Inject(UsersService) private readonly users: UsersService,
@InjectRepository(User) private readonly repo: Repository<User>,
@Optional() @Inject(MetricsService) private readonly metrics?: MetricsService,
) { super(); }
}漏写 @Inject 不会有编译错误,也不会有启动警告——直到请求打进来,解析不出依赖,500。
scanModules() 会把 @Service() 类自动登记,不需要一条条 AddScoped。
中间件与过滤器
中间件(≈ .NET 的 app.Use(async (ctx, next) => …))跑在路由之前:
app.useMiddleware({
async invoke(ctx, next) {
const started = Date.now();
await next();
console.log(`${ctx.method} ${ctx.path} ${Date.now() - started}ms`);
},
});过滤器跑在路由之后、围绕 action,四个接口跟 ASP.NET Core 的管道同名同义:
import { GlobalFilter, UseFilters } from '@guangnao/webapi';
import type { IExceptionFilter, IActionFilter, IAuthorizationFilter, IResultFilter } from '@guangnao/webapi';
@GlobalFilter() // 全局注册,≈ options.Filters.Add<T>()
export class ErrorLogFilter implements IExceptionFilter {
onException(ctx, error: Error) { console.error(ctx.path, error); }
}
@UseFilters(SomeFilter) // 挂在单个控制器/方法上
@Controller('/api/x')
class XController extends ControllerBase { … }| 接口 | 钩子 | .NET 对应 |
| --- | --- | --- |
| IAuthorizationFilter | onAuthorization | 同名,最先跑 |
| IActionFilter | onActionExecuting / onActionExecuted | 同名。onActionExecuting 返回 ActionResult 可短路 action(缓存命中就是这么做的) |
| IExceptionFilter | onException | 同名 |
| IResultFilter | onResultExecuting / onResultExecuted | 同名 |
ORM
EF Core 的 DbContext + DbSet<T> 在这里是 useDatabase() + Repository<T>(底层 Sequelize,支持 MySQL / PostgreSQL / SQLite)。
import { Entity, Column, Index, NotNull, Comment, BelongsTo, HasMany } from '@guangnao/webapi';
@Index({ name: 'idx_posts_user_created', fields: ['userId', 'createdAt'] }) // 复合索引
@Entity('posts') // [Table("posts")]
export class Post {
@Column({ type: 'INTEGER', primaryKey: true, autoIncrement: true })
id!: number;
@Column({ type: 'STRING' }) @NotNull()
title!: string;
@Column({ type: 'TEXT', allowNull: true }) @Comment('正文,可为空')
body?: string;
@Column({ type: 'INTEGER', allowNull: false })
userId!: number;
@BelongsTo(() => User, 'userId', 'author') // 第三个参数 = 查询 include 时的字段名
author?: User;
}仓储通过构造函数注入,≈ .NET 里注入 DbContext 或 IRepository<T>:
import { Service, ServiceBase, InjectRepository, Repository } from '@guangnao/webapi';
@Service()
export class PostsService extends ServiceBase {
constructor(@InjectRepository(Post) private readonly repo: Repository<Post>) { super(); }
list() { return this.repo.findAll({ include: [{ model: User, as: 'author' }] }); }
byId(id: number) { return this.repo.findByPk(id); }
page(offset = 0) { return this.repo.findAndCountAll({ offset, limit: 20 }); }
create(data: Partial<Post>) { return this.repo.create(data); }
update(id: number, d: Partial<Post>) { return this.repo.update(d, { where: { id } }); }
remove(id: number) { return this.repo.destroy({ where: { id } }); }
}连接(autoSync 相当于 EF 的 EnsureCreated / 自动迁移,生产环境请关掉):
await app.useDatabase({
dialect: 'mysql', host: 'localhost', port: 3306,
username: 'root', password: '…', database: 'app',
autoSync: { alter: true },
});数据库约束冲突会被翻译成合适的 HTTP 状态:唯一键冲突 → 409(消息里带上是哪个约束),外键不存在 → 400,模型校验失败 → 422。不会像裸 Sequelize 那样统统冒成 500。
校验
DataAnnotations 的对应物,装饰器打在 DTO 属性上:
import { IsRequired, IsEmail, MinLength, MaxLength, Min, Max, Range, Matches, IsIn, ValidNested } from '@guangnao/webapi';
export class SignupDto {
@IsRequired() @IsEmail() email!: string;
@IsRequired() @MinLength(8) password!: string;
@Min(0) @Max(150) age?: number;
@IsIn(['admin', 'user']) role?: string;
}校验不通过直接 400,响应体里逐字段列出原因——不需要像 .NET 那样手动检查 ModelState.IsValid。前提是 @Body(SignupDto) 把类传进去了(见上文的差异章节)。
鉴权
const app = WebApplication.create().useJwt({ secret: process.env.JWT_SECRET! }).build();@Authorize() // 类级:整个控制器都要登录
@Controller('/api/vault')
class VaultController extends ControllerBase {
@Get('/items') items() { return this.ok(this.user); } // this.user = JWT payload
@AllowAnonymous() // 在受保护的类里开个口子
@Get('/health') health() { return this.ok({ ok: true }); }
}
@Authorize('admin') // 方法级:还要角色
@Get('/admin') adminOnly() { … }优先级:方法 @AllowAnonymous > 方法 @Authorize > 类 @Authorize > 公开。
401 与 403 是分开的:没带 token / token 无效 → 401;已登录但角色不匹配 → 403。JWT 中间件验不过时按「未登录」处理而不是当场拒绝,所以公开路由不会因为带了个坏 token 就打不开。
另有 OAuth2(微信 / 支付宝 / GitHub / Google / 钉钉 / QQ / 微软)与 CAS 式单点登录(.useOAuth() / .useSso())。SSO 的 allowedServices 白名单必须配——不配会直接拒绝带 service= 的登录请求,因为放行任意回调地址等于把用户的登录票据寄给对方。
API 文档
const app = WebApplication.create()
.useOpenApi({ title: 'My API', version: '1.0.0' })
.build();/openapi.json 拿规范,/docs 是 Swagger UI(内存直出,不需要配静态目录)。标注装饰器与 Swashbuckle 的注解一一对应:
@ApiTags('Users') // [ApiExplorerSettings(GroupName=...)]
@Controller('/api/users')
class UsersController extends ControllerBase {
@ApiOperation('取单个用户', '按主键查询。') // <summary> XML 注释
@ApiResponse(200, '用户详情') // [ProducesResponseType(200)]
@ApiResponse(404, '用户不存在')
@Get('/:id') detail(@Param('id', Number) id: number) { … }
@ApiIgnore() // [ApiExplorerSettings(IgnoreApi=true)]
@Get('/_internal/dump') dump() { … } // 路由照常能打,只是不进文档
}
class CreateUserDto {
@ApiProperty({ description: '邮箱', example: '[email protected]' })
email!: string;
}缓存与限流
需要先 await app.useRedis({ host, port })。
@Cache('posts:by-user:$userId', 60) // 命中直接返回,不进 action;$userId 依次从 params、query 取
@Get('/by-user') byUser(@Query('userId', Number) userId: number) { … }
@CacheEvict('posts:by-user:*') // action 跑完按模式删键
@Throttle(20, 60) // 每分钟 20 次,计数在 Redis(多实例共享)
@Post('/') create(@Body(CreatePostDto) dto: CreatePostDto) { … }@Cache 大致相当于 .NET 的 [ResponseCache] + IDistributedCache,但缓存的是 action 返回值而不是 HTTP 响应;@Throttle 相当于 AddRateLimiter。
实时通信
SignalR 的两个替代:
@Controller('/api/events')
class EventsController extends ControllerBase {
@SSE() // 服务端推送:async generator,yield 什么客户端收什么
async *stream() {
for (let i = 0; i < 10; i++) {
yield { event: 'tick', data: { i } };
await new Promise(r => setTimeout(r, 1000));
}
}
}
@WebSocketHandler('/ws/echo') // 全双工
class EchoSocket {
onConnection(ws: WebSocket) { ws.send('welcome'); }
onMessage(ws: WebSocket, msg: string | Buffer) { ws.send(msg); }
onClose() { }
}要从别处往 SSE 频道推消息用 SseHub.getInstance().publish(channel, event, data)——相当于 SignalR 的 IHubContext<T>。
后台任务
BackgroundService / Quartz 的对应物:
import { Scheduler, Cron, Interval, Timeout } from '@guangnao/webapi';
@Scheduler()
class Jobs {
@Cron('0 */5 * * * *') everyFiveMinutes() { … } // 6 字段,第一位是秒
@Interval(30_000) every30s() { … }
@Timeout(5_000) onceAfterStartup() { … }
}文件与静态资源
const app = WebApplication.create()
.useUpload({ maxFileSize: 5 * 1024 * 1024, maxTotalSize: 20 * 1024 * 1024, maxFiles: 5 })
.useStaticFiles({ root: './public', prefix: '/static', maxAge: 3600 }) // app.UseStaticFiles()
.build();@Post('/upload')
async upload() {
const { fields, files } = await this.context.parseMultipart(); // 整收:全进内存
return this.ok({ count: files.length });
}
@Post('/big')
async big() {
const upload = this.context.multipartFiles(); // 流式:不进内存
for await (const file of upload) {
await pipeline(file.stream!, createWriteStream(`/data/${file.filename}`));
}
return this.ok({ fields: upload.fields }); // 字段要迭代走完才齐
}超限、类型不符会返回 413 / 415(而不是 500),且不会像某些实现那样悄悄把文件截断后返回 200。SPA 前端用 useStaticFiles({ spa: true, exclude: ['/api'] }),记得把 API 前缀排除掉。
GraphQL
const app = WebApplication.create().useGraphQL({ resolvers: [BookResolver] }).build();@ObjectType()
class Book {
@Field(() => ID) id!: string;
@Field(() => String) title!: string;
@Field(() => Int) year!: number;
}
@Resolver()
class BookResolver {
@GqlQuery(() => [Book]) // 注意:导出名是 GqlQuery
books() { … } // 避免与取查询参数的 @Query 撞名
@Mutation(() => Book)
addBook(@Arg('input', () => AddBookInput) input: AddBookInput) { … }
}/graphql 是端点,/graphql-ui 是 GraphiQL。N+1 用 createDataLoader(batchFn),每请求建一个(放进 context 工厂里)。
配置
框架不代管配置——没有 appsettings.json / IOptions<T> 那一套。用 dotenv 读 .env 到 process.env 即可:
import 'dotenv/config'; // 要排在其它 import 之前运行时依赖
sequelize、ioredis、graphql、ws、busboy、validator、glob、reflect-metadata 会随包一起装。用不到的特性不 import 就不会加载,但仍然占磁盘——这是全家桶包的代价。
uWebSockets.js(更快的传输层)不在依赖里,要用得自己装;不装就走内置的 NodeHttpAdapter。
