路由

VextJS 采用 约定式文件路由 + 三段式路由定义,将文件路径自动映射为 URL 前缀,在文件内部通过 defineRoutes() 声明具体路由。

基本概念

文件路由映射

src/routes/ 目录下的每个文件自动映射为一个 URL 前缀:

文件路径URL 前缀
routes/index.ts/
routes/users.ts/users
routes/users/index.ts/users
routes/users/[id].ts/users/:id
routes/admin/settings.ts/admin/settings
routes/api/v1/index.ts/api/v1

三段式定义

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 是一个声明式配置对象,包含:

字段说明
validate参数校验规则(query / body / param / header)
middlewares路由级中间件引用
docsOpenAPI 文档配置
override路由级配置覆盖(限流、超时等)

路由文件写法

每个路由文件使用 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 方法:

方法用法常见场景
app.get()查询资源列表查询、详情获取
app.post()创建资源表单提交、资源创建
app.put()全量更新资源替换
app.patch()部分更新字段级更新
app.delete()删除资源资源删除
app.head()获取头信息资源存在性检查
app.options()预检请求CORS 预检(通常由框架自动处理)

动态路由参数

文件级动态参数

使用 [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() 支持四个位置:

参数数据来源说明
'query'req.queryURL 查询参数
'body'req.body请求体
'param'req.params路径动态参数
'header'req.headers请求头
类型提示

可以使用泛型获取更精确的类型提示:

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 语法速查

DSL 表达式含义
'string!'必填字符串
'string?'可选字符串
'string:1-50'字符串,长度 1-50
'string:1-50!'必填字符串,长度 1-50
'number!'必填数字
'number:1-'数字,最小值 1(无上限)
'number:1-100'数字,范围 1-100
'email!'必填,邮箱格式
'url?'可选,URL 格式
'boolean!'必填布尔值
'admin|user|guest'枚举值
'date!'必填日期字符串

校验位置

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,
);

校验顺序:paramqueryheaderbody。任一位置校验失败会立即返回 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.tsmiddlewares 白名单中注册:

// 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

  • 闭包 appdefineRoutes((app) => ...) 中的 app 参数
  • req.app:请求对象上的真实运行期 app 引用

对于 configservicesloggerthrow 这类稳定引用,两者通常表现一致,闭包 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 按以下规则处理:

  1. 静态路由优先于动态路由/users/list 优先于 /users/:id
  2. 文件按字母序排序:确保加载顺序确定性
  3. 同一前缀不允许重复定义routes/users.tsroutes/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);
    },
  );
});

下一步