Web 主机
WebHost 可以看成是“通用主机的 Web 版入口”。
上一节已经讲过通用主机如何统一配置、日志、依赖注入、生命周期和托管服务;Web 主机在这些基础能力之上,主要做了 4 件事:
- 启动 HTTP 服务器
- 集成中间件管道
- 集成路由与终结点执行
- 为每个请求创建独立作用域和
HttpContext
如果只看最小示例,Web 主机其实就是这几个动作:
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_extensions_injection.*
main(args: Array<String>): Int64 {
// 创建 Web 主机构建器。
let builder = WebHost.createBuilder(args)
// 注册路由相关服务。
builder.services.addRouting()
// 构建 Web 应用。
let app = builder.build()
// 注册一个最简单的中间件。
app.use { context, next =>
println("before handler")
// 继续执行后续中间件或终结点。
next(context)
println("after handler")
}
// 注册 GET /hello 路由。
app.mapGet("/hello") { context =>
context.response.write("Hello, World!")
}
// 启动 Web 应用。
app.run()
return 0
}管道与中间件
Web 主机的请求处理管道,是通过一次次调用 app.use(...) 组装出来的。
也就是说:
- 你每注册一个中间件,就往请求管道里加了一层
- 中间件决定请求在到达终结点前后要做什么
最常见的写法是 Lambda 风格:
app.use { context, next =>
println("middleware 1 before")
// 调用下一个委托。
next(context)
println("middleware 1 after")
}
app.use { context, next =>
println("middleware 2 before")
// 调用下一个委托。
next(context)
println("middleware 2 after")
}当前实现会把已注册中间件反向包起来生成最终的 RequestDelegate,所以顺序应该这样理解:
- 前置逻辑按注册顺序执行
- 后置逻辑按注册逆序执行
上面那段代码一次请求的大致顺序会是:
middleware 1 beforemiddleware 2 before- 终结点处理
middleware 2 aftermiddleware 1 after
除了 Lambda 方式,也可以把中间件写成实现了 IMiddleware 的类:
import soulsoft_web_hosting.*
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_extensions_injection.*
class TraceMiddleware <: IMiddleware {
public func invoke(context: HttpContext, next: RequestDelegate): Unit {
// 进入当前中间件时输出日志。
println("request start")
// 把请求交给管道中的下一个处理者。
next(context)
// 下游执行结束后再输出日志。
println("request end")
}
}
main(args: Array<String>): Int64 {
// 创建 Web 主机构建器。
let builder = WebHost.createBuilder(args)
// 注册路由服务。
builder.services.addRouting()
// 构建应用。
let app = builder.build()
// 直接传入中间件实例。
app.use(TraceMiddleware())
// 注册一个简单终结点。
app.mapGet("/hello") { context =>
context.response.write("Hello, World!")
}
// 启动应用。
app.run()
return 0
}如果中间件本身需要依赖注入,也可以使用:
app.use<TraceMiddleware>([])这条路径会通过容器创建中间件实例。
路由与终结点
中间件负责“围绕请求做事情”,终结点负责“真正处理这个请求”。
最简单的注册方式就是 mapGet、mapPost 这一类方法:
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_extensions_injection.*
main(args: Array<String>): Int64 {
// 创建 Web 主机构建器。
let builder = WebHost.createBuilder(args)
// 注册路由服务。
builder.services.addRouting()
// 构建应用。
let app = builder.build()
// 注册 GET 路由。
app.mapGet("/hello") { context =>
context.response.write("hello")
}
// 注册 POST 路由。
app.mapPost("/echo") { context =>
context.response.write("posted")
}
// 启动应用。
app.run()
return 0
}在 WebHost 当前实现里,如果你已经注册了路由终结点,主机会在构建最终应用时自动补上路由中间件和终结点中间件。
这意味着在大多数场景里,你只需要:
- 注册自己的中间件
- 注册终结点
- 调用
run()
通常不需要在这一层手动调用 useRouting() 和 useEndpoints()。
这里需要特别区分一件事:
soulsoft_web_hosting负责把路由与终结点执行集成进主机- 路由模式、匹配规则、约束、分组等详细内容,应当看 routing.md
也就是说,这一页只讲“Web 主机把路由和终结点接进来了”,不展开讲路由系统本身。
请求作用域
每次收到 HTTP 请求时,Web 主机都会先创建一个新的 DI scope,然后再进入请求处理管道。
所以在请求处理中:
context.services是当前请求的作用域容器- 通过
builder.services.addScoped(...)注册的服务,可以直接从context.services解析 context本身还提供request、response、items、features和user
一个最小示例如下:
import std.sync.*
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_extensions_injection.*
class GreetingService <: Resource {
// 用于观察资源是否已被关闭。
private let _isClosed = AtomicBool(false)
public func message(): String {
return "hello from scoped service"
}
public func isClosed(): Bool {
// 返回当前资源关闭状态。
return _isClosed.load()
}
public func close(): Unit {
// 在作用域结束时标记资源已关闭。
_isClosed.store(true)
}
}
main(args: Array<String>): Int64 {
// 创建 Web 主机构建器。
let builder = WebHost.createBuilder(args)
// 注册路由服务。
builder.services.addRouting()
// 把 GreetingService 注册为 scoped 服务。
builder.services.addScoped<GreetingService, GreetingService>()
// 构建应用。
let app = builder.build()
// 在请求作用域中解析 GreetingService 并返回结果。
app.mapGet("/hello") { context =>
let service = context.services.getOrThrow<GreetingService>()
context.response.write(service.message())
}
// 启动应用。
app.run()
return 0
}这里把 GreetingService 写成 Resource,是为了让它在请求作用域结束时可以自动释放。
如果你的 scoped 服务不持有需要清理的资源,也不一定非要实现 Resource;但只要你希望它跟随请求作用域自动执行清理逻辑,就应该实现它。
如果你希望在普通服务里直接访问当前请求,可以先注册 HttpContextAccessor:
import soulsoft_web_hosting.*
import soulsoft_web_http.*
import soulsoft_web_routing.*
import soulsoft_extensions_injection.*
class CurrentPathService {
// 通过构造函数注入 HttpContextAccessor。
public init(let accessor: IHttpContextAccessor) {}
public func readPath(): String {
// 如果当前请求上下文存在,就读取请求路径。
if (let Some(context) <- accessor.context) {
return context.request.path.value
}
// 没有当前请求时返回空字符串。
return ""
}
}
main(args: Array<String>): Int64 {
// 创建 Web 主机构建器。
let builder = WebHost.createBuilder(args)
// 注册路由服务。
builder.services.addRouting()
// 注册 IHttpContextAccessor,供普通服务访问当前HttpContext。
builder.services.addHttpContextAccessor()
// 注册当前路径服务。
builder.services.addScoped<CurrentPathService, CurrentPathService>()
// 构建应用。
let app = builder.build()
// 调用服务读取当前请求路径。
app.mapGet("/path") { context =>
let service = context.services.getOrThrow<CurrentPathService>()
context.response.write(service.readPath())
}
// 启动应用。
app.run()
return 0
}这里要注意:
- 只有注册了
addHttpContextAccessor(),服务器才会在每次请求开始时把当前HttpContext放进去
主机
除了把请求接进应用,Web 主机本身还有一些运行规则。
默认注册的服务
调用 build() 之后,Web 主机会把这些服务放进容器:
| 服务 | 说明 |
|---|---|
IHostEnvironment | 通用主机环境信息 |
IWebHostEnvironment | Web 环境信息,额外提供 webRootPath |
IConfiguration | 合并后的配置对象 |
ILoggerFactory | 日志工厂 |
IHostLifetime | 主机与外部运行环境之间的生命周期桥接 |
IHostApplicationLifetime | 启动和关闭回调 |
IServer | HTTP 服务器抽象 |
这里有两个边界要注意:
IWebHostEnvironment是 Web 主机额外补进去的IHttpContextAccessor和路由相关服务都不是默认注册的,需要手动调用addHttpContextAccessor()、addRouting()
监听地址
如果你只是想指定监听地址,最直接的方式是把地址传给 run(urls):
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_extensions_injection.*
main(args: Array<String>): Int64 {
// 创建 Web 主机构建器。
let builder = WebHost.createBuilder(args)
// 注册路由服务。
builder.services.addRouting()
// 构建应用。
let app = builder.build()
// 注册一个健康检查接口。
app.mapGet("/health") { context =>
context.response.write("ok")
}
// 指定监听地址并启动应用。
app.run("http://127.0.0.1:8080")
return 0
}当前实现里,地址解析顺序是:
- 如果调用了
run(urls),把这个值写入配置键urls - 否则读取配置里的
urls - 如果还没有,就使用默认地址
默认地址规则是:
- 主机名默认取
127.0.0.1 - 如果环境变量
CANGJIE_RUNNING_IN_CONTAINER=true,主机名改为0.0.0.0 - 端口读取配置键
HTTP_PORTS - 如果
HTTP_PORTS也不存在,默认端口是5000
如果你要进一步修改底层 ServerBuilder,可以使用 configureServer(...):
import soulsoft_web_hosting.*
main(args: Array<String>): Int64 {
// 创建 Web 主机构建器。
let builder = WebHost.createBuilder(args)
// 直接调整底层服务器监听参数。
builder.configureServer { server =>
server.addr("0.0.0.0")
server.port(UInt16(8080))
}
// 构建并运行应用。
let app = builder.build()
app.run()
return 0
}如果你只是改监听地址,优先使用 run(urls) 或配置 urls;只有在确实要改底层服务器参数时,再使用 configureServer(...)。
Web 环境
IWebHostEnvironment 可以看成是在 IHostEnvironment 基础上,多加了一个 webRootPath。
默认行为是:
| 属性 | 默认值 |
|---|---|
environmentName | 继承通用主机环境 |
contentRootPath | 继承通用主机内容根目录 |
applicationName | 继承通用主机应用名 |
webRootPath | wwwroot |
如果配置中存在 webRootPath,Web 主机会用配置值覆盖默认的 wwwroot。
这里还有一个很重要的约束:
webRootPath和contentRootPath都不能以/结尾
主机在启动前会检查这两个路径;如果路径以 / 结尾,start() 会直接抛出异常。
默认异常行为
如果请求处理过程中抛出了未捕获异常,Web 主机当前会做两件事:
- 把响应状态码写成
500 - 把响应体写成固定文本
Internal Server Error
同时异常会被记录到日志里。
这意味着如果你想返回自定义错误页面、统一 JSON 错误结构,应该在应用自己的中间件里提前处理异常,而不是依赖默认行为。