路由
VextJS 采用 约定式文件路由 + 三段式路由定义,将文件路径自动映射为 URL 前缀,在文件内部通过 defineRoutes() 声明具体路由。
基本概念
文件路由映射
src/routes/ 目录下的每个文件自动映射为一个 URL 前缀:
三段式定义
VextJS 路由使用 三段式 (path, options, handler) 或 两段式 (path, handler) 定义:
// 三段式:path + options + handler
app.get(
"/list",
{
validate: { query: { page: "number:1-", limit: "number:1-100" } },
middlewares: ["auth"],
docs: { summary: "用户列表" },
},
async (req, res) => {
const { page, limit } = req.valid("query");
res.json(await app.services.user.findAll({ page, limit }));
},
);
// 两段式:path + handler(无 options)
app.get("/health", async (_req, res) => {
res.json({ status: "ok" });
});
三段式中第二个参数 options 是一个声明式配置对象,包含:
路由文件写法
每个路由文件使用 defineRoutes() 导出路由定义:
// src/routes/users.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// GET /users
app.get(
"/",
{
docs: { summary: "获取用户列表" },
},
async (req, res) => {
const users = await app.services.user.findAll();
res.json(users);
},
);
// GET /users/:id
app.get(
"/:id",
{
validate: { param: { id: "string!" } },
docs: { summary: "获取用户详情" },
},
async (req, res) => {
const { id } = req.valid("param");
const user = await app.services.user.findById(id);
if (!user) app.throw(404, "user.not_found");
res.json(user);
},
);
// POST /users
app.post(
"/",
{
validate: {
body: {
name: "string:1-50!",
email: "email!",
age: "number?",
},
},
middlewares: ["auth"],
docs: { summary: "创建用户", tags: ["用户管理"] },
},
async (req, res) => {
const data = req.valid("body");
const user = await app.services.user.create(data);
res.json(user, 201);
},
);
// PUT /users/:id
app.put(
"/:id",
{
validate: {
param: { id: "string!" },
body: { name: "string:1-50?", email: "email?" },
},
middlewares: ["auth"],
docs: { summary: "更新用户" },
},
async (req, res) => {
const { id } = req.valid("param");
const data = req.valid("body");
const user = await app.services.user.update(id, data);
res.json(user);
},
);
// DELETE /users/:id
app.delete(
"/:id",
{
validate: { param: { id: "string!" } },
middlewares: ["auth"],
docs: { summary: "删除用户" },
},
async (req, res) => {
const { id } = req.valid("param");
await app.services.user.delete(id);
res.status(204).json(null);
},
);
});
HTTP 方法
defineRoutes() 回调中的 app 对象支持以下 HTTP 方法:
动态路由参数
文件级动态参数
使用 [paramName] 作为文件名或目录名,自动转换为路由动态参数:
src/routes/users/[id].ts → /users/:id
src/routes/posts/[slug].ts → /posts/:slug
src/routes/[category]/[id].ts → /:category/:id
// src/routes/users/[id].ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// GET /users/:id — 文件级参数 :id 已包含在前缀中
app.get(
"/",
{
validate: { param: { id: "string!" } },
},
async (req, res) => {
const { id } = req.valid("param");
const user = await app.services.user.findById(id);
res.json(user);
},
);
// GET /users/:id/orders — 文件级参数 + 子路径
app.get(
"/orders",
{
validate: { param: { id: "string!" } },
},
async (req, res) => {
const { id } = req.valid("param");
const orders = await app.services.order.findByUserId(id);
res.json(orders);
},
);
});
路由内动态参数
在文件内部的路由路径中也可以使用 :paramName 语法:
// src/routes/users.ts
export default defineRoutes((app) => {
// GET /users/:id/posts/:postId
app.get(
"/:id/posts/:postId",
{
validate: {
param: { id: "string!", postId: "string!" },
},
},
async (req, res) => {
const { id, postId } = req.valid("param");
// ...
res.json({ userId: id, postId });
},
);
});
请求对象 (req)
路由 handler 的第一个参数 req 是框架统一的 VextRequest 对象,与底层 Adapter 解耦:
常用属性
app.post("/example", async (req, res) => {
req.method; // 'POST'
req.url; // '/example?foo=bar'
req.path; // '/example'
req.query; // { foo: 'bar' }
req.body; // 请求体(由 body-parser 中间件解析)
req.params; // 路径参数 { id: '123' }
req.headers; // 请求头(小写 key)
req.requestId; // 请求唯一标识(自动生成或从 X-Request-Id 透传)
req.ip; // 客户端 IP
req.protocol; // 'http' | 'https'
req.app; // VextApp 实例(可访问 services、logger、throw 等)
});
req.valid() — 获取校验后数据
当路由配置了 validate 选项时,使用 req.valid() 获取经过校验和类型转换后的数据:
app.get(
"/search",
{
validate: {
query: {
keyword: "string!",
page: "number:1-", // 自动将 query string 转为 number
limit: "number:1-100",
},
},
},
async (req, res) => {
const { keyword, page, limit } = req.valid("query");
// keyword: string, page: number, limit: number — 已类型转换
const results = await app.services.search.query(keyword, page, limit);
res.json(results);
},
);
req.valid() 支持四个位置:
类型提示
可以使用泛型获取更精确的类型提示:
const { id } = req.valid<{ id: string }>("param");
// id 的类型为 string
req.onClose() — 连接关闭钩子
注册请求关闭时的回调(客户端断开连接时触发),常用于 SSE / 长连接场景:
req.onClose(() => {
// 清理资源
});
响应对象 (res)
路由 handler 的第二个参数 res 是框架统一的 VextResponse 对象:
res.json() — JSON 响应
// 默认 200
res.json({ name: "Alice" });
// → { "code": 0, "data": { "name": "Alice" }, "requestId": "xxx" }
// 指定状态码
res.json(user, 201);
// 204 No Content(自动不发送消息体)
res.status(204).json(null);
响应包装
当 response-wrapper 中间件启用时(默认启用),res.json() 会自动将响应包装为统一格式:
{
"code": 0,
"data": { "...": "你的业务数据" },
"requestId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
错误响应由全局错误处理器统一返回:
{
"code": 404,
"message": "用户不存在",
"requestId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
res.text() — 纯文本响应
res.text("Hello World");
res.text("Not Found", 404);
res.stream() — 流式响应
import { createReadStream } from "node:fs";
app.get("/download/report", async (_req, res) => {
const stream = createReadStream("/path/to/report.csv");
res.stream(stream, "text/csv");
});
res.download() — 文件下载
app.get("/export", async (_req, res) => {
const stream = createReadStream("/path/to/data.xlsx");
res.download(
stream,
"report.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
res.redirect() — 重定向
res.redirect("/new-location"); // 302 临时重定向
res.redirect("/new-location", 301); // 301 永久重定向
链式调用
res.status() 和 res.setHeader() 支持链式调用:
res.status(201).setHeader("X-Custom-Header", "value").json(data);
res.statusCode — 读取状态码
在洋葱模型的 after-middleware 阶段,可以读取最终响应状态码:
const timing: VextMiddleware = async (req, res, next) => {
const start = Date.now();
await next();
console.log(
`${req.method} ${req.path} → ${res.statusCode} (${Date.now() - start}ms)`,
);
};
参数校验
VextJS 集成 schema-dsl,在路由 options.validate 中声明校验规则,框架自动执行校验并生成 OpenAPI 文档。
DSL 语法速查
校验位置
app.post(
"/users/:id/settings",
{
validate: {
param: {
id: "string!",
},
query: {
format: "json|xml",
},
header: {
"x-api-key": "string!",
},
body: {
nickname: "string:1-30!",
avatar: "url?",
notifications: "boolean!",
},
},
},
handler,
);
校验顺序:param → query → header → body。任一位置校验失败会立即返回 422 错误响应。
校验错误响应
校验失败时框架自动返回结构化的错误信息:
{
"code": 422,
"message": "Validation failed",
"errors": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "name", "message": "length must be between 1 and 50" }
],
"requestId": "xxx"
}
路由级中间件
通过 options.middlewares 为路由指定中间件。中间件必须先在 config/default.ts 的 middlewares 白名单中注册:
// src/config/default.ts
export default {
middlewares: ["auth", { name: "check-role", options: { roles: ["admin"] } }],
};
// src/routes/admin.ts
export default defineRoutes((app) => {
// 字符串引用
app.get(
"/dashboard",
{
middlewares: ["auth"],
},
handler,
);
// 对象引用(覆盖默认参数)
app.delete(
"/users/:id",
{
middlewares: [
"auth",
{ name: "check-role", options: { roles: ["superadmin"] } },
],
},
handler,
);
});
中间件按声明顺序执行,在 handler 之前运行。
OpenAPI 文档配置
通过 options.docs 配置路由的 OpenAPI 文档信息:
app.post(
"/users",
{
validate: {
body: { name: "string:1-50!", email: "email!" },
},
docs: {
summary: "创建用户",
description: "创建一个新用户,邮箱必须唯一。",
tags: ["用户管理"],
operationId: "createUser",
deprecated: false,
responses: {
201: {
description: "创建成功",
schema: { id: "string", name: "string", email: "email" },
},
409: {
description: "邮箱已存在",
},
},
},
},
handler,
);
隐藏路由
不希望出现在 OpenAPI 文档中的路由,设置 docs.hidden: true:
app.get(
"/internal/metrics",
{
docs: { hidden: true },
},
handler,
);
访问 app 对象
defineRoutes() 的回调参数 app 提供了框架的完整能力:
export default defineRoutes((app) => {
app.get("/example", async (req, res) => {
// 访问 service
const data = await app.services.user.findAll();
// 使用 logger
app.logger.info({ userId: req.params.id }, "Fetching user");
// 抛出 HTTP 错误
if (!data) app.throw(404, "not_found");
// 读取配置
const port = app.config.port;
res.json(data);
});
});
:::tip req.app 与闭包 app
路由 handler 中可以通过两种方式访问 app:
- 闭包
app:defineRoutes((app) => ...) 中的 app 参数
req.app:请求对象上的真实运行期 app 引用
对于 config、services、logger、throw 这类稳定引用,两者通常表现一致,闭包 app 写法也更简洁。
但要注意:defineRoutes() 内部会先把根 app 的属性拷贝到 collector,再把这个 collector 传给路由工厂;如果某个字段会在运行期被 app.extend() 替换为新对象引用(例如 Nacos 场景中的 app.remoteConfig),闭包 app 里捕获的旧引用不会自动刷新,此时应改为读取 req.app,或在 service 中通过 this.app 读取。
简言之:
- 静态/稳定字段 → 闭包
app 可继续使用
- 运行期动态替换字段 → 优先使用
req.app
:::
错误处理
app.throw() — 抛出 HTTP 错误
在路由或服务中使用 app.throw() 抛出错误,框架会统一处理并返回结构化响应:
// 基本用法
app.throw(404, "用户不存在");
// → { "code": 404, "message": "用户不存在", "requestId": "..." }
// 使用 i18n key(配合 locales/ 语言包)
app.throw(404, "user.not_found");
// → 自动翻译为当前请求语言的消息
// 带业务错误码
app.throw(400, "邮箱已注册", 10001);
// → { "code": 10001, "message": "邮箱已注册", "requestId": "..." }
// 带插值参数
app.throw(400, "balance.insufficient", { balance: 50 });
// → { "code": 20001, "message": "余额不足,当前余额 50", "requestId": "..." }
// 带插值参数 + 业务错误码
app.throw(400, "balance.insufficient", { balance: 50 }, 20001);
app.throw() 会终止当前请求处理流程(函数签名返回 never),无需在其后添加 return。
路由加载优先级
当存在可能冲突的路由时,router-loader 按以下规则处理:
- 静态路由优先于动态路由:
/users/list 优先于 /users/:id
- 文件按字母序排序:确保加载顺序确定性
- 同一前缀不允许重复定义:
routes/users.ts 和 routes/users/index.ts 不能同时存在(框架会报错)
排除规则
以下文件不会被当作路由加载:
- 测试文件:
*.test.ts、*.spec.ts
- 以
_ 或 . 开头的文件或目录
node_modules 目录
可以利用 _ 前缀创建路由共享的工具模块:
src/routes/
├── _utils.ts # 不会被当作路由加载
├── _types.ts # 共享类型定义
├── users.ts
└── orders.ts
完整示例
// src/routes/posts.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// GET /posts — 分页列表
app.get(
"/",
{
validate: {
query: {
page: "number:1-",
limit: "number:1-50",
status: "draft|published|archived",
},
},
docs: {
summary: "获取文章列表",
tags: ["文章"],
},
},
async (req, res) => {
const { page = 1, limit = 20, status } = req.valid("query");
const posts = await app.services.post.findAll({ page, limit, status });
res.json(posts);
},
);
// GET /posts/:id — 获取详情
app.get(
"/:id",
{
validate: { param: { id: "string!" } },
docs: { summary: "获取文章详情", tags: ["文章"] },
},
async (req, res) => {
const { id } = req.valid("param");
const post = await app.services.post.findById(id);
if (!post) app.throw(404, "post.not_found");
res.json(post);
},
);
// POST /posts — 创建文章(需要认证)
app.post(
"/",
{
validate: {
body: {
title: "string:1-200!",
content: "string:1-50000!",
tags: "string?",
},
},
middlewares: ["auth"],
docs: {
summary: "创建文章",
tags: ["文章"],
responses: {
201: { description: "创建成功" },
401: { description: "未认证" },
},
},
},
async (req, res) => {
const data = req.valid("body");
const post = await app.services.post.create({
...data,
authorId: (req as any).user.id,
});
res.json(post, 201);
},
);
// PATCH /posts/:id — 更新文章
app.patch(
"/:id",
{
validate: {
param: { id: "string!" },
body: {
title: "string:1-200?",
content: "string:1-50000?",
status: "draft|published|archived",
},
},
middlewares: ["auth"],
docs: { summary: "更新文章", tags: ["文章"] },
},
async (req, res) => {
const { id } = req.valid("param");
const data = req.valid("body");
const post = await app.services.post.update(id, data);
res.json(post);
},
);
// DELETE /posts/:id — 删除文章
app.delete(
"/:id",
{
validate: { param: { id: "string!" } },
middlewares: ["auth"],
docs: { summary: "删除文章", tags: ["文章"] },
},
async (req, res) => {
const { id } = req.valid("param");
await app.services.post.delete(id);
res.status(204).json(null);
},
);
});
下一步