Skip to content

Miniapi

本章从一个能跑的 HTTP 服务开始,逐步加上路由分组、参数匹配、约束策略,最后讲清楚整条执行链路是怎么跑的。全程用一个订单管理场景贯穿。

最小示例

先从最简单的开始——一个文件,一个接口,跑起来。

cangjie
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_web_hosting.*
import soulsoft_extensions_injection.*

main(args: Array<String>): Int64 {
    let builder = WebHost.createBuilder(args)
    builder.services.addRouting()

    let app = builder.build()

    app.mapGet("/orders") { ctx =>
        ctx.response.write("订单列表")
    }

    app.run()
    return 0
}

builder.services.addRouting() 注册路由相关的服务(匹配器工厂、端点数据源)。app.mapGet("/orders", handler) 注册一个终结点——但此时只是把终结点塞进数据源,还没把它挂到管道上。

app.run() 启动时发现已经注册了终结点,会自动在管道头部补一个 EndpointRoutingMiddleware、尾部补一个 EndpointMiddleware,不需要你手动调 useRouting()useEndpoints()

启动后访问 http://127.0.0.1:5000/orders,页面显示 订单列表

常用接口

一个真实的服务不会只有一个接口。接下来给订单管理加上增删改查:

cangjie
let app = builder.build()

app.mapGet("/orders") { ctx =>
    ctx.response.write("查询所有订单")
}

app.mapGet("/orders/{id}") { ctx =>
    let id = ctx.request.routeValues.get("id") ?? ""
    ctx.response.write("查询订单: ${id}")
}

app.mapPost("/orders") { ctx =>
    ctx.response.write("创建订单")
}

app.mapPut("/orders/{id}") { ctx =>
    let id = ctx.request.routeValues.get("id") ?? ""
    ctx.response.write("更新订单: ${id}")
}

app.mapDelete("/orders/{id}") { ctx =>
    let id = ctx.request.routeValues.get("id") ?? ""
    ctx.response.write("删除订单: ${id}")
}

app.run()

这里出现了两个新东西:

HTTP 方法约束。 mapGet / mapPost / mapPut / mapDelete 各自给终结点附加上 HttpMethodMetadata,告诉匹配器"只有这个方法才匹配"。如果你需要一个不限制方法的接口(比如健康检查),用 map

cangjie
app.map("/health") { ctx =>
    ctx.response.write("ok")
}

路由参数。 {id} 是一个路由参数——它匹配 /orders/ 之后的下一个路径段。匹配成功后,42 就以 "id" → "42" 的形式存入 routeValues,后续通过 ctx.request.routeValues.get("id") 取值。

到这里,每次请求的执行流程是:

请求到达 → EndpointRoutingMiddleware 匹配 URL + 方法 → 找到 Endpoint
         → (中间没有其他中间件)
         → EndpointMiddleware 取出 Endpoint.delegate 执行 → 响应

路由分组

现在业务变复杂了:订单管理需要一个管理后台 /admin/orders,用户端是 /api/orders,还有移动端 /api/v2/orders。如果每个接口都写完整路径,不仅前缀要重复写,将来想给 /admin 下所有接口统一加权限也很麻烦。

路由分组就是解决这个问题的。 先用最简单的分组把前缀统一管理起来:

cangjie
let app = builder.build()

// 管理后台
let admin = app.mapGroup("/admin")
admin.mapGet("/orders") { ctx =>
    ctx.response.write("管理端:订单列表")
}
admin.mapGet("/users") { ctx =>
    ctx.response.write("管理端:用户列表")
}

// 用户端 API
let api = app.mapGroup("/api")
api.mapGet("/orders") { ctx =>
    ctx.response.write("用户端:我的订单")
}
api.mapPost("/orders") { ctx =>
    ctx.response.write("用户端:下单")
}

app.run()

最终注册的路径是:

  • GET /admin/orders
  • GET /admin/users
  • GET /api/orders
  • POST /api/orders

分组不只是省前缀,更重要的是共享约定。比如 /admin 下全部接口都要管理员权限:

cangjie
let admin = app.mapGroup("/admin")
admin.requireAuthorization(["AdminOnly"])  // 这一行让组内所有终结点自动附带授权元数据

admin.mapGet("/orders") { ctx =>
    ctx.response.write("管理端:订单列表")
}
admin.mapPost("/users") { ctx =>
    ctx.response.write("管理端:创建用户")
}

requireAuthorization 本质是在每个终结点的 metadata 里追加一条 IAuthorizeData。后面的授权中间件读到这条元数据,就知道"这个终结点需要 AdminOnly 策略"。

分组还支持嵌套:

cangjie
let api = app.mapGroup("/api")
let v1 = api.mapGroup("/v1")
let v2 = api.mapGroup("/v2")

v1.mapGet("/orders") { ctx => ctx.response.write("v1 订单") }
v2.mapGet("/orders") { ctx => ctx.response.write("v2 订单") }

最终路径:/api/v1/orders/api/v2/orders

分组约定的执行顺序是:外层组约定 → 内层组约定 → 终结点自身约定。离终结点越近的约定优先级越高。

除了 requireAuthorizationrequireCors 这些现成方法,分组还支持任意元数据注入:

cangjie
let admin = app.mapGroup("/admin")
admin.add { builder =>
    builder.metadata.add("audit-log-enabled")
}

这个 add 里的闭包会在组内每个终结点构建时执行一次,builder 就是正在构建的 EndpointBuilder

执行流程

前面一直在注册终结点,但请求到底是怎么走的?两个中间件,一个管匹配,一个管执行。

中间件做什么对 HttpContext 的操作
EndpointRoutingMiddleware匹配 URL → 找到 Endpointcontext.setEndpoint(endpoint),然后 next()
EndpointMiddleware执行已匹配的 Endpointcontext.getEndpoint()?.delegate.invoke(context)

关键细节:EndpointRoutingMiddleware 匹配成功只挂 Endpoint,不执行 delegate。 真正执行的是管道最后的 EndpointMiddleware

GET /admin/orders


EndpointRoutingMiddleware
  ├── 匹配 URL + HTTP 方法 + 约束
  ├── context.setEndpoint(endpoint)
  └── next(context)


      空档 —— 授权、CORS 等中间件在此
        │     读 context.getEndpoint().metadata → 放行 / 拦截

EndpointMiddleware
  └── endpoint.delegate.invoke(context) ──▶ HTTP Response

这个空档是刻意留的。路由只管"找到谁",不负责"立刻执行"。推迟执行,中间就能插入任意中间件——读 metadata,决定放行还是短路。

前面没写 useRouting() 是交给 app.run() 自动补的。现在要在路由和终结点之间插自定义中间件,就得显式调用 app.useRouting(),把管道控制权拿到手里。

下面把验证和短路合成一个例子。在路由之后插一个中间件,既打印匹配到的 Endpoint 信息,也检查是否需要鉴权:

cangjie
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_web_hosting.*
import soulsoft_web_routing.http.*
import soulsoft_extensions_injection.*

main(args: Array<String>): Int64 {
    let builder = WebHost.createBuilder(args)
    builder.services.addRouting()

    let app = builder.build()

    app.useRouting()

    // 验证 + 鉴权:路由之后插一个中间件,既能看匹配结果,也能按需短路
    app.use {
        context: HttpContext, next: RequestDelegate =>
            print(">>> 匹配结果: ")
            match (context.getEndpoint()) {
                case Some(ep) =>
                    print(" 终结点=${ep.displayName}")
                    if (let Some(re) <- Some(ep).flatMap {e => e as RouteEndpoint}) {
                        print(", 模板=${re.routePattern.rawText}")
                    }
                    if (let Some(m) <- ep.metadata.getMetadata<IHttpMethodMetadata>()) {
                        print(", 方法=${m.httpMethods.toArray()}")
                    }
                    println()

                    // 检查是否需要鉴权:有 "need-auth" 标记就验 Header
                    let authTag = ep.metadata.getMetadata<String>()
                    match (authTag) {
                        case Some(tag) =>
                            if (tag == "need-auth") {
                                let token = context.request.headers.get("Authorization")
                                if (token.isNone()) {
                                    context.response.statusCode = 403
                                    context.response.write("缺少鉴权头")
                                    return // ← 短路,不调 next()
                                }
                            }
                        case None => ()
                    }
                case None => println("未匹配")
            }
            next(context)
    }

    app.mapGet("/orders") {
        ctx => ctx.response.write("订单列表")
    }

    app.mapGet("/admin/orders") {
        ctx => ctx.response.write("管理:全部订单")
    }.add {
        // 附加元数据标记,中间件读到后要求鉴权
        builder => builder.metadata.add("need-auth")
    }

    app.run()
    return 0
}

访问 GET /orders

  • 控制台打印 >>> 匹配结果: 终结点=/orders, 模板=orders, 方法=[GET]
  • 没有 need-auth 标记,直接放行 → 200

访问 GET /admin/orders 不带 Authorization 头:

  • 控制台打印 >>> 匹配结果: 终结点=admin/orders, 模板=admin/orders, 方法=[GET]
  • 读到 need-auth 标记,但没 Header → 403,next() 不会被调用

带正确的头则正常 200。

这就是"匹配"和"执行"分成两个中间件的全部意义:匹配完了不执行,让中间的中间件有机会读 Endpoint.metadata,决定该不该让它过。 如果合在一起,授权中间件就无从知道当前请求需要什么权限——它要么自己再解析一遍路由,要么依赖全局配置,做不到同一个路径不同权限这种精细控制。

路由和终结点是什么关系

上面的流程跑完,你应该已经感觉到了:

路由是匹配过程,终结点是匹配结果。

  • RoutePattern(路由模式)是匹配规则,比如 "orders/{id:int}"——这是输入
  • Endpoint(终结点)是匹配成功后产出的对象,里面装着两样东西:谁来处理(delegate)和关于它的描述(metadata)

类比:RoutePattern 是电话本里的名字,Endpoint 是你找到的那一行——有电话号码(delegate)也有备注标签(metadata)。

Endpoint 里面装了什么

cangjie
public abstract class Endpoint {
    prop delegate: RequestDelegate           // 真正干活的函数
    prop metadata: EndpointMetadataCollection  // 元数据 = 标注的集合
    prop displayName: ?String                // 可读名称
}

delegate 就是你传给 mapGet 的那个闭包。metadata 是一个只读列表,里面可以放任意对象——框架和扩展方法往里塞的东西包括:

元数据类型谁塞进去的下游谁在消费
HttpMethodMetadatamapGet / mapPost路由匹配器(按 HTTP 方法过滤)
IAuthorizeData.requireAuthorization()授权中间件
IAllowAnonymous.allowAnonymous()授权中间件
ICorsPolicyMetadata.requireCors()CORS 中间件

框架里真正使用的是 RouteEndpoint——它继承 Endpoint,多了一个 routePattern 字段,记录匹配时用的那条路由模板,供生成 URL 时反查。

一句话:RoutePattern 决定"谁被选中",Endpoint 是"被选中的那个东西"。选出来之后挂在 HttpContext 上,任何中间件都能读它、根据它的 metadata 做决策,最后再执行它。

RoutePattern 详解

RoutePatternFactory.parse("orders/{id:int}") 不会把它留在字符串里。它会拆成一个结构化的类型树。这里不需要死记,看一遍拆解就懂了。

三层模型

RoutePattern
  ├── pathSegments: List<RoutePatternPathSegment>
  │     └── parts: List<RoutePatternPart>
  │           ├── RoutePatternLiteralPart    (isLiteral, content)
  │           ├── RoutePatternParameterPart  (isParameter, name, parameterKind, parameterPolicies)
  │           └── RoutePatternSeparatorPart  (isSeparator, content)
  ├── parameters: List<RoutePatternParameterPart>
  └── parameterPolicies: Map<String, List<RoutePatternParameterPolicyReference>>

"api/orders/{id:int}" 为例:

RoutePattern
  rawText: "api/orders/{id:int}"
  pathSegments:
    [0] parts: [LiteralPart { content = "api" }]
    [1] parts: [LiteralPart { content = "orders" }]
    [2] parts: [ParameterPart {
                  name = "id"
                  parameterKind = Standard
                  parameterPolicies = [PolicyRef("int")]
                  isOptional = false
                  isCatchAll = false
                }]
  parameters: [ParameterPart { name = "id", ... }]
  parameterPolicies: {"id" → [PolicyRef("int")]}

三种 Part 类型

类型它是什么关键字段例子
RoutePatternLiteralPart固定文本content: Stringordersapi
RoutePatternParameterPart路径参数name, parameterKind, parameterPolicies{id}{name?}{*path}
RoutePatternSeparatorPart段内分隔符content: String复合段中的 .-

大部分路径的每个段只有一个 part(isSimple = true)。但 "report-{date}.pdf" 这种就属于复合段——一个段里混合了字面量和参数:

Segment { isSimple = false }
  parts: [LiteralPart("report-"), ParameterPart("date"), SeparatorPart("."), LiteralPart("pdf")]

参数的三种模式

模式写法匹配行为
Standard{id}必须出现,匹配一个完整段
Optional{id?}可以不出现;复合段中只能在 . 后面
CatchAll{*path} / {**path}捕获剩余全部路径,必须位于最后一个段

限制规则:

  • CatchAll 必须在最后一个段,且该段只能有它一个 part
  • 同一个段不能有连续参数 {a}{b}
  • 同一个模板不能有重名参数

参数约束

{id:int} 里的 int 就是一个约束——被解析为 RoutePatternParameterPolicyReference{ content = "int" }。框架内置了 IntRouteConstraintFloatRouteConstraintBoolRouteConstraintRegexRouteConstraint

写法含义
{id:int}整数
{id:int:regex(^\\d+$)}整数 + 正则
{v:range(1,100)}取值 1~100
{id:int=1}整数,默认值 1

动手验证

写一段代码把 RoutePattern 拆开看看,确认上面讲的模型:

cangjie
import soulsoft_web_routing.patterns.*

let pattern = RoutePatternFactory.parse("orders/{id:int:regex(^\\d+$)}")

for (seg in pattern.pathSegments) {
    for (part in seg.parts) {
        match (part) {
            case lp: RoutePatternLiteralPart =>
                println("字面量: ${lp.content}")
            case pp: RoutePatternParameterPart =>
                let policies = pp.parameterPolicies.map {v => v.content ?? ""}
                println("参数: ${pp.name}, 可选: ${pp.isOptional}, CatchAll: ${pp.isCatchAll}, 策略: ${policies}")
            case _ => ()
        }
    }
}

输出:

字面量: orders
参数: id, 可选: false, CatchAll: false, 策略: [int, regex(^\d+$)]

常用模板速查

模板匹配示例说明
/ordersGET /orders字面量
/orders/{id}GET /orders/42标准参数
/orders/{id?}GET /ordersGET /orders/42可选参数
/orders/{id:int}GET /orders/42整数约束
/files/{*path}GET /files/a/b/c单星 CatchAll
/{controller=Home}/{action=Index}GET /Products/List内联默认值
/{a}-{b}GET /foo-bar复合段
/{name}.htmlGET /report.html后缀字面量
/{name}.{ext?}GET /data.jsonGET /data可选扩展名

身份认证

前面的流程跟踪已经把管道空档说透了——路由只负责匹配,执行推迟到最后,中间留给认证和授权。这一节聚焦终结点层面:怎么给终结点挂认证策略、分组怎么继承、个别接口怎么豁免。

管道的组装和前面讲的一样——路由在前,认证在后,最后执行:

cangjie
let host = builder.build()

host.useRouting()          // ① 匹配 URL,挂 Endpoint
host.useAuthentication()   // ② 验 JWT → 解析出 User
host.useAuthorization()    // ③ 读 Endpoint.metadata + User → 放行或 401/403

给终结点加认证

cangjie
host.mapGet("/orders") { ctx =>
    ctx.response.write("订单列表")
}.requireAuthorization("default")  // ← 这个终结点需要 "default" 策略

host.mapGet("/admin/orders") { ctx =>
    ctx.response.write("管理:全部订单")
}.requireAuthorization("default")  // ← 同样需要登录

host.run()

requireAuthorization("default") 往 Endpoint 的 metadata 里塞一条 IAuthorizeData。第 ③ 步的授权中间件读到这条元数据后,检查 context.user 是否满足 "default" 策略——不满足就短路返回 401。

对比一下不加认证的接口:

cangjie
host.mapGet("/health") { ctx =>
    ctx.response.write("ok")
}
// 没有 .requireAuthorization() → metadata 里没有授权标记 → 授权中间件直接 next()

分组认证

一个接口一个接口地写 .requireAuthorization() 很啰嗦。mapGroup 可以把认证策略统一挂到分组上,组内所有终结点自动继承:

cangjie
let api = host.mapGroup("/api")

// 需要登录的分组
let auth = api.mapGroup("/user")
auth.requireAuthorization("default")   // ← 组约定:组内所有终结点都要登录

auth.mapGet("/profile") { ctx =>
    ctx.response.write("用户资料")    // 自动继承 requireAuthorization
}

auth.mapPost("/avatar") { ctx =>
    ctx.response.write("上传头像")    // 自动继承 requireAuthorization
}

// 公开分组:不加 requireAuthorization
let pub = api.mapGroup("/blog")
pub.mapGet("/posts") { ctx =>
    ctx.response.write("文章列表")    // 无需登录
}

分组约定的执行顺序是 外层组 → 内层组 → 终结点自身,离终结点越近优先级越高。这意味着分组虽然加了认证,个别终结点仍然可以覆盖。

允许匿名

认证分组里偶尔有一两个接口不需要登录(比如获取公开列表)。用 .allowAnonymous() 豁免:

cangjie
let admin = host.mapGroup("/admin")
admin.requireAuthorization("default")

admin.mapGet("/dashboard") { ctx =>
    ctx.response.write("管理面板")    // 需要登录
}

admin.mapGet("/notice") { ctx =>
    ctx.response.write("公告")        // ← 虽然是 /admin 下的,但不需要登录
}.allowAnonymous()

allowAnonymous() 往 Endpoint.metadata 里塞一条 IAllowAnonymous。授权中间件先检查有没有这条元数据——有就直接 next(),跳过所有策略检查。优先级比 requireAuthorization 高。

整个权限模型用不到十个字就能概括:

分组 requireAuthorization  →  默认全锁
终结点 allowAnonymous       →  个别开门

这比"默认公开、逐个加锁"安全得多:新增接口时不会因为忘了写 .requireAuthorization() 而意外暴露。

小结

  1. .requireAuthorization().allowAnonymous() 操作的都是 Endpoint.metadata——这就是为什么授权中间件必须在路由之后:没有路由匹配,Endpoint 就不存在,metadata 也无从读起。
  2. 分组约定会被继承——在 mapGroup 上调一次 requireAuthorization,组内所有终结点自动带上,不用逐一手动加。
  3. 匿名豁免优先级最高——allowAnonymous 在授权中间件里最先被检查,一旦发现就跳过所有后续策略。

常见问题

mapGetmap 的区别?

mapGet 给终结点附加 HttpMethodMetadata,只匹配 GET 请求。map 不附加任何方法约束,所有 HTTP 方法都能匹配。同理 mapPostmapPutmapDelete 各约束一个方法。

路由参数取不到值?

检查两点:参数名拼写是否和模板一致({id} 对应 routeValues.get("id"));模板是否有约束导致匹配失败(比如 {id:int} 不会匹配 /orders/abc)。

没有匹配的终结点会怎样?

返回 HTTP 404。这是框架的默认行为——EndpointRoutingMiddleware 匹配失败后不挂 Endpoint,EndpointMiddleware 发现没有 Endpoint 就不执行,管道末端的 fallback 返回 404。

app.run() 自动补了中间件,为什么还要学 useRouting() / useEndpoints()

自动补的前提是"两个中间件之间没有别的东西"。一旦你需要插入鉴权、日志、短路等自定义逻辑,就必须显式调用,拿回管道控制权。学这两个 API 就是学"什么时候需要拿回控制权"。

{*path}{**path} 的区别?

单星 {*path} 捕获剩余路径,匹配内容不会再次解码。双星 {**path} 捕获后额外做一次 URL 解码。大多数场景用单星就够了。

为什么在 useRouting() 之前读不到 Endpoint?

因为 Endpoint 就是 EndpointRoutingMiddleware 匹配成功后才挂到 HttpContext 上的。在它之前,context.getEndpoint() 一定返回 None