Chapter 02

路由与控制器

掌握 Vapor Trie 树路由的高效匹配原理,用 RouteCollection 构建可维护的 RESTful API 结构。

路由系统原理:为什么用 Trie 树?

路由(Routing)是 Web 框架最核心的功能之一:将一个 HTTP 请求(方法 + 路径)映射到对应的处理函数。不同框架采用不同的路由匹配策略,性能差异显著。

最朴素的做法是遍历路由表,对每条规则进行字符串匹配——这是 O(n) 时间复杂度,路由越多越慢。Vapor 采用 Trie 树(前缀树)数据结构,将所有路由路径组织成一棵树,路径中每个 / 分隔的片段是一个节点。匹配时沿树向下走,时间复杂度接近 O(路径深度),与路由总数无关,适合路由规模大的应用。

注册路由: GET /users GET /users/:id GET /users/:id/posts POST /users GET /articles Trie 路由树结构: [root] / \ users articles / \ \ (GET) :id (GET) \ posts / \ (GET) (POST for /users) ← 注意:方法绑定在节点上 匹配 GET /users/42/posts: root → "users" → ":id"(capture 42) → "posts" → GET handler 时间复杂度:O(3),与路由总数无关

Trie 树的另一个优势是参数捕获的自然性:以 : 开头的节点(如 :id)会自动捕获对应位置的值,通配符 *catchall 可以捕获剩余所有路径片段。

核心概念词典

Route
一条路由规则,由 HTTP 方法(GET/POST/PUT/DELETE/PATCH 等)、路径模式(如 /users/:id)和处理函数(Handler)三部分组成。Vapor 中通过 app.get()app.post() 等方法注册。
RouteCollection
路由集合协议,让你把相关路由组织到一个类/结构体中,通过实现 boot(routes:) 方法注册路由。将路由按资源(User、Post 等)拆分到不同的 RouteCollection,保持 routes.swift 整洁。
PathComponent
路径组件的枚举类型,分为:.constant("users") 精确匹配、.parameter("id") 参数捕获、.anything 匹配任意单段、.catchall 匹配剩余所有段。字符串字面量会自动转换为对应类型。
HTTPMethod
HTTP 方法的类型安全枚举:.GET.POST.PUT.DELETE.PATCH.HEAD.OPTIONS。Vapor 为最常用的方法提供了简写(app.getapp.post 等)。
RoutesBuilder
路由构建器协议,ApplicationRouter 都实现了此协议。routes.grouped("api", "v1") 返回一个带前缀的子构建器,在其上注册的路由会自动加上前缀,避免重复书写。

路由注册基础

Vapor 提供了语义清晰的路由注册 API,支持所有标准 HTTP 方法。处理函数接收 Request 对象,返回任何实现了 AsyncResponseEncodable 协议的类型(包括 String、JSON Encodable 结构体、Response 等)。

import Vapor

func routes(_ app: Application) throws {

    // ── 基础 HTTP 方法 ──────────────────────────────
    app.get("hello")           { req async in "GET Hello"  }
    app.post("hello")          { req async in "POST Hello" }
    app.put("hello")           { req async in "PUT Hello"  }
    app.delete("hello")        { req async in "DEL Hello"  }
    app.patch("hello")         { req async in "PATCH Hello"}

    // ── 路径参数 :name ──────────────────────────────
    app.get("users", ":id") { req async throws -> String in
        // require 如果参数不存在或类型不匹配会 throw 400
        let id = try req.parameters.require("id", as: UUID.self)
        return "User ID: \(id)"
    }

    // ── 查询参数 ?page=1&limit=20 ───────────────────
    app.get("users") { req async throws -> String in
        let page  = req.query[Int.self, at: "page"]  ?? 1
        let limit = req.query[Int.self, at: "limit"] ?? 20
        return "page=\(page) limit=\(limit)"
    }

    // ── 通配符 *catchall ────────────────────────────
    app.get("files", "**") { req async throws -> String in
        let path = req.parameters.getCatchall().joined(separator: "/")
        return "File path: \(path)"
    }
}

RouteCollection:组织路由

将所有路由都堆在 routes.swift 里很快会变成灾难。RouteCollection 协议让你按资源类型拆分路由,每个控制器负责自己的路由段。这是 Vapor 官方推荐的代码组织方式。

// Sources/App/Controllers/UserController.swift
import Vapor

struct UserController: RouteCollection {

    // boot 方法:在此注册该控制器的所有路由
    func boot(routes: RoutesBuilder) throws {
        // 路由前缀:/users
        let users = routes.grouped("users")

        users.get(use: index)           // GET  /users
        users.post(use: create)          // POST /users
        users.get(":id", use: show)      // GET  /users/:id
        users.put(":id", use: update)    // PUT  /users/:id
        users.delete(":id", use: delete) // DEL  /users/:id
    }

    // GET /users — 获取用户列表
    func index(req: Request) async throws -> [UserResponse] {
        let users = try await User.query(on: req.db).all()
        return users.map { UserResponse(from: $0) }
    }

    // POST /users — 创建用户
    func create(req: Request) async throws -> UserResponse {
        let dto = try req.content.decode(CreateUserDTO.self)
        let user = User(name: dto.name, email: dto.email)
        try await user.save(on: req.db)
        return UserResponse(from: user)
    }

    // GET /users/:id — 获取单个用户
    func show(req: Request) async throws -> UserResponse {
        let id = try req.parameters.require("id", as: UUID.self)
        guard let user = try await User.find(id, on: req.db) else {
            throw Abort(.notFound, reason: "User not found")
        }
        return UserResponse(from: user)
    }

    // PUT /users/:id — 更新用户
    func update(req: Request) async throws -> UserResponse {
        let id  = try req.parameters.require("id", as: UUID.self)
        let dto = try req.content.decode(UpdateUserDTO.self)
        guard let user = try await User.find(id, on: req.db) else {
            throw Abort(.notFound)
        }
        user.name = dto.name ?? user.name
        try await user.save(on: req.db)
        return UserResponse(from: user)
    }

    // DELETE /users/:id — 删除用户
    func delete(req: Request) async throws -> HTTPStatus {
        let id = try req.parameters.require("id", as: UUID.self)
        guard let user = try await User.find(id, on: req.db) else {
            throw Abort(.notFound)
        }
        try await user.delete(on: req.db)
        return .noContent   // 204
    }
}
// routes.swift — 注册所有 RouteCollection
func routes(_ app: Application) throws {
    // API v1 前缀:/api/v1
    let api = app.routes.grouped("api", "v1")

    try api.register(collection: UserController())
    try api.register(collection: PostController())
    try api.register(collection: AuthController())
}
保持路由文件整洁 使用 RouteCollection 后,routes.swift 只负责"注册"各控制器,每个控制器文件只包含自己资源的路由。当你需要找某个路由的处理逻辑时,直接去对应的 Controller 文件,不用在数百行代码中搜索。
RouteCollection 中不要持有可变状态 RouteCollection 实现者(通常是 struct)的实例在 register() 调用后就只是路由注册的媒介,Vapor 不会保留它。如果你将数据库连接池、缓存引用等存储在 RouteCollection 的属性上,会导致意料之外的生命周期问题。路由处理函数需要的服务应通过 req.application(如 req.dbreq.redis)或 Request.Storage 访问,而不是在 Controller 结构体上定义属性。

查询参数的结构化解析

查询参数除了逐个读取,Vapor 还支持将整个查询字符串直接解码为一个 Decodable 结构体,代码更简洁,类型更安全:

// 定义查询参数结构体
struct UserQuery: Content {
    var page:    Int     = 1
    var limit:   Int     = 20
    var search:  String?
    var sortBy:  String  = "createdAt"
}

// GET /users?page=2&limit=10&search=alice&sortBy=name
app.get("users") { req async throws -> [UserResponse] in
    let query = try req.query.decode(UserQuery.self)
    // query.page = 2, query.limit = 10, query.search = "alice"
    var builder = User.query(on: req.db)
    if let search = query.search {
        builder = builder.filter(\.name, .contains, search)
    }
    return try await builder
        .range((query.page - 1) * query.limit ..< query.page * query.limit)
        .all()
        .map { UserResponse(from: $0) }
}

路由分组与版本控制

生产 API 通常需要版本控制(v1、v2)和公共/私有路由的区分。Vapor 的 grouped() 方法支持路径前缀和中间件两个维度的分组,两者可以组合使用:

func routes(_ app: Application) throws {

    // 公开路由(无需认证)
    let publicAPI = app.routes.grouped("api", "v1")
    publicAPI.post("register", use: AuthController().register)
    publicAPI.post("login",    use: AuthController().login)

    // 需要 JWT 认证的路由(组合路径前缀 + 中间件)
    let protected = app.routes
        .grouped("api", "v1")
        .grouped(UserAuthenticator(), User.guardMiddleware())

    try protected.register(collection: UserController())
    try protected.register(collection: PostController())
}

路由调试与列表

开发过程中,有时需要快速查看当前注册了哪些路由(特别是路由冲突、路由找不到时)。Vapor 提供了内置命令和 HTTP 端点来检查路由表:

// configure.swift — 开发环境开启路由列表端点
if app.environment == .development {
    // GET /routes 返回所有注册路由的 JSON 列表
    app.routes.get("routes") { req -> [[String: String]] in
        req.application.routes.all.map {
            ["method": $0.method.rawValue, "path": $0.path.string]
        }
    }
}

// 命令行方式(Vapor CLI)
// $ swift run App routes
// 输出示例:
// +--------+-----------------------+
// | GET    | /api/v1/users         |
// | POST   | /api/v1/users         |
// | GET    | /api/v1/users/:id     |
// | PUT    | /api/v1/users/:id     |
// | DELETE | /api/v1/users/:id     |
// +--------+-----------------------+

参数捕获的高级用法

Vapor 的路径参数不仅支持字符串,还可以直接要求解码为特定 Swift 类型,类型不匹配时自动返回 400:

// 基础参数捕获
app.get("users", ":id") { req async throws -> UserResponse in
    // 要求 id 必须能解析为 UUID,否则自动 400 Bad Request
    let id = try req.parameters.require("id", as: UUID.self)
    ...
}

// 通配符:捕获剩余全部路径(用于反向代理、静态文件服务等场景)
app.get("files", "**") { req async throws -> Response in
    // req.parameters.getCatchall() 返回 [String]
    let path = req.parameters.getCatchall().joined(separator: "/")
    return req.fileio.streamFile(at: "Public/\(path)")
}

// 自定义 LosslessStringConvertible 类型可直接作为路径参数
enum UserRole: String, LosslessStringConvertible {
    case admin, user, moderator

    init?(_ description: String) { self.init(rawValue: description) }
    var description: String { rawValue }
}

// GET /roles/admin/users — role 参数自动解析为 UserRole 枚举
app.get("roles", ":role", "users") { req async throws -> [UserResponse] in
    let role = try req.parameters.require("role", as: UserRole.self)
    return try await User.query(on: req.db)
        .filter(\.$role == role)
        .all()
        .map { UserResponse(from: $0) }
}
路由冲突:静态路径 vs 动态参数GET /users/me(静态路径,返回当前用户)和 GET /users/:id(动态参数)同时注册时,Vapor 的 Trie 树会优先匹配更具体的静态路径。这是 Vapor 有保证的行为:静态节点优先于参数节点,参数节点优先于通配符节点。但如果两条路由的优先级相同(如注册了两个 GET /users/:id),后注册的会覆盖前一个,不会报错——这是需要特别注意的隐患。
本章小结 Trie 树让 Vapor 的路径匹配时间复杂度与路由数量无关(O(路径深度)),是高并发场景下的重要性能保障。RouteCollection 是组织大型项目路由的最佳实践,路由注册集中、处理逻辑分散。查询参数结构化解码(req.query.decode())避免了逐个手动读取的繁琐。路由设计规范:静态路径优先于参数路径;路径参数应使用 .require() 附带类型要求,让框架自动处理类型错误;RouteCollection 本身不应持有可变状态,服务通过 req.application 访问。