Miniapi
本章从一个能跑的 HTTP 服务开始,逐步加上路由分组、参数匹配、约束策略,最后讲清楚整条执行链路是怎么跑的。全程用一个订单管理场景贯穿。
最小示例
先从最简单的开始——一个文件,一个接口,跑起来。
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,页面显示 订单列表。
常用接口
一个真实的服务不会只有一个接口。接下来给订单管理加上增删改查:
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:
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 下所有接口统一加权限也很麻烦。
路由分组就是解决这个问题的。 先用最简单的分组把前缀统一管理起来:
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/ordersGET /admin/usersGET /api/ordersPOST /api/orders
分组不只是省前缀,更重要的是共享约定。比如 /admin 下全部接口都要管理员权限:
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 策略"。
分组还支持嵌套:
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。
分组约定的执行顺序是:外层组约定 → 内层组约定 → 终结点自身约定。离终结点越近的约定优先级越高。
除了 requireAuthorization、requireCors 这些现成方法,分组还支持任意元数据注入:
let admin = app.mapGroup("/admin")
admin.add { builder =>
builder.metadata.add("audit-log-enabled")
}这个 add 里的闭包会在组内每个终结点构建时执行一次,builder 就是正在构建的 EndpointBuilder。
执行流程
前面一直在注册终结点,但请求到底是怎么走的?两个中间件,一个管匹配,一个管执行。
| 中间件 | 做什么 | 对 HttpContext 的操作 |
|---|---|---|
EndpointRoutingMiddleware | 匹配 URL → 找到 Endpoint | context.setEndpoint(endpoint),然后 next() |
EndpointMiddleware | 执行已匹配的 Endpoint | context.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 信息,也检查是否需要鉴权:
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 里面装了什么
public abstract class Endpoint {
prop delegate: RequestDelegate // 真正干活的函数
prop metadata: EndpointMetadataCollection // 元数据 = 标注的集合
prop displayName: ?String // 可读名称
}delegate 就是你传给 mapGet 的那个闭包。metadata 是一个只读列表,里面可以放任意对象——框架和扩展方法往里塞的东西包括:
| 元数据类型 | 谁塞进去的 | 下游谁在消费 |
|---|---|---|
HttpMethodMetadata | mapGet / 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: String | orders、api |
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" }。框架内置了 IntRouteConstraint、FloatRouteConstraint、BoolRouteConstraint、RegexRouteConstraint:
| 写法 | 含义 |
|---|---|
{id:int} | 整数 |
{id:int:regex(^\\d+$)} | 整数 + 正则 |
{v:range(1,100)} | 取值 1~100 |
{id:int=1} | 整数,默认值 1 |
动手验证
写一段代码把 RoutePattern 拆开看看,确认上面讲的模型:
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+$)]常用模板速查
| 模板 | 匹配示例 | 说明 |
|---|---|---|
/orders | GET /orders | 字面量 |
/orders/{id} | GET /orders/42 | 标准参数 |
/orders/{id?} | GET /orders、GET /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}.html | GET /report.html | 后缀字面量 |
/{name}.{ext?} | GET /data.json、GET /data | 可选扩展名 |
身份认证
前面的流程跟踪已经把管道空档说透了——路由只负责匹配,执行推迟到最后,中间留给认证和授权。这一节聚焦终结点层面:怎么给终结点挂认证策略、分组怎么继承、个别接口怎么豁免。
管道的组装和前面讲的一样——路由在前,认证在后,最后执行:
let host = builder.build()
host.useRouting() // ① 匹配 URL,挂 Endpoint
host.useAuthentication() // ② 验 JWT → 解析出 User
host.useAuthorization() // ③ 读 Endpoint.metadata + User → 放行或 401/403给终结点加认证
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。
对比一下不加认证的接口:
host.mapGet("/health") { ctx =>
ctx.response.write("ok")
}
// 没有 .requireAuthorization() → metadata 里没有授权标记 → 授权中间件直接 next()分组认证
一个接口一个接口地写 .requireAuthorization() 很啰嗦。mapGroup 可以把认证策略统一挂到分组上,组内所有终结点自动继承:
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() 豁免:
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() 而意外暴露。
小结
.requireAuthorization()和.allowAnonymous()操作的都是 Endpoint.metadata——这就是为什么授权中间件必须在路由之后:没有路由匹配,Endpoint 就不存在,metadata 也无从读起。- 分组约定会被继承——在
mapGroup上调一次requireAuthorization,组内所有终结点自动带上,不用逐一手动加。 - 匿名豁免优先级最高——
allowAnonymous在授权中间件里最先被检查,一旦发现就跳过所有后续策略。
常见问题
mapGet 和 map 的区别?
mapGet 给终结点附加 HttpMethodMetadata,只匹配 GET 请求。map 不附加任何方法约束,所有 HTTP 方法都能匹配。同理 mapPost、mapPut、mapDelete 各约束一个方法。
路由参数取不到值?
检查两点:参数名拼写是否和模板一致({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。