Skip to content

Web 主机

WebHost 可以看成是“通用主机的 Web 版入口”。

上一节已经讲过通用主机如何统一配置、日志、依赖注入、生命周期和托管服务;Web 主机在这些基础能力之上,主要做了 4 件事:

  • 启动 HTTP 服务器
  • 集成中间件管道
  • 集成路由与终结点执行
  • 为每个请求创建独立作用域和 HttpContext

如果只看最小示例,Web 主机其实就是这几个动作:

cangjie
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 风格:

cangjie
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,所以顺序应该这样理解:

  • 前置逻辑按注册顺序执行
  • 后置逻辑按注册逆序执行

上面那段代码一次请求的大致顺序会是:

  1. middleware 1 before
  2. middleware 2 before
  3. 终结点处理
  4. middleware 2 after
  5. middleware 1 after

除了 Lambda 方式,也可以把中间件写成实现了 IMiddleware 的类:

cangjie
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
}

如果中间件本身需要依赖注入,也可以使用:

cangjie
app.use<TraceMiddleware>([])

这条路径会通过容器创建中间件实例。

路由与终结点

中间件负责“围绕请求做事情”,终结点负责“真正处理这个请求”。

最简单的注册方式就是 mapGetmapPost 这一类方法:

cangjie
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 当前实现里,如果你已经注册了路由终结点,主机会在构建最终应用时自动补上路由中间件和终结点中间件。

这意味着在大多数场景里,你只需要:

  1. 注册自己的中间件
  2. 注册终结点
  3. 调用 run()

通常不需要在这一层手动调用 useRouting()useEndpoints()

这里需要特别区分一件事:

  • soulsoft_web_hosting 负责把路由与终结点执行集成进主机
  • 路由模式、匹配规则、约束、分组等详细内容,应当看 routing.md

也就是说,这一页只讲“Web 主机把路由和终结点接进来了”,不展开讲路由系统本身。

请求作用域

每次收到 HTTP 请求时,Web 主机都会先创建一个新的 DI scope,然后再进入请求处理管道。

所以在请求处理中:

  • context.services 是当前请求的作用域容器
  • 通过 builder.services.addScoped(...) 注册的服务,可以直接从 context.services 解析
  • context 本身还提供 requestresponseitemsfeaturesuser

一个最小示例如下:

cangjie
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

cangjie
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通用主机环境信息
IWebHostEnvironmentWeb 环境信息,额外提供 webRootPath
IConfiguration合并后的配置对象
ILoggerFactory日志工厂
IHostLifetime主机与外部运行环境之间的生命周期桥接
IHostApplicationLifetime启动和关闭回调
IServerHTTP 服务器抽象

这里有两个边界要注意:

  • IWebHostEnvironment 是 Web 主机额外补进去的
  • IHttpContextAccessor 和路由相关服务都不是默认注册的,需要手动调用 addHttpContextAccessor()addRouting()

监听地址

如果你只是想指定监听地址,最直接的方式是把地址传给 run(urls)

cangjie
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
}

当前实现里,地址解析顺序是:

  1. 如果调用了 run(urls),把这个值写入配置键 urls
  2. 否则读取配置里的 urls
  3. 如果还没有,就使用默认地址

默认地址规则是:

  • 主机名默认取 127.0.0.1
  • 如果环境变量 CANGJIE_RUNNING_IN_CONTAINER=true,主机名改为 0.0.0.0
  • 端口读取配置键 HTTP_PORTS
  • 如果 HTTP_PORTS 也不存在,默认端口是 5000

如果你要进一步修改底层 ServerBuilder,可以使用 configureServer(...)

cangjie
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继承通用主机应用名
webRootPathwwwroot

如果配置中存在 webRootPath,Web 主机会用配置值覆盖默认的 wwwroot

这里还有一个很重要的约束:

  • webRootPathcontentRootPath 都不能以 / 结尾

主机在启动前会检查这两个路径;如果路径以 / 结尾,start() 会直接抛出异常。

默认异常行为

如果请求处理过程中抛出了未捕获异常,Web 主机当前会做两件事:

  • 把响应状态码写成 500
  • 把响应体写成固定文本 Internal Server Error

同时异常会被记录到日志里。

这意味着如果你想返回自定义错误页面、统一 JSON 错误结构,应该在应用自己的中间件里提前处理异常,而不是依赖默认行为。