Web 主机
在通用主机之上扩展 HTTP 服务器和中间件管道——读完本页,你将掌握 Web 应用的请求处理模型和中间件扩展方式。
适用场景
- 需要 HTTP 服务端能力的 Web 应用、API 服务
- 已经在用通用主机,需要加上请求处理和中间件管道
- 希望通过统一的中间件模型扩展 Web 能力(日志、认证、静态文件等)
如果只需要后台服务、定时任务,不需要 HTTP,直接用通用主机即可。
通用主机 解决了配置、日志、DI、生命周期这些所有程序共享的启动问题。Web 主机在它之上加了 HTTP 服务器和中间件管道——通用主机负责"跑起来",Web 主机负责"接请求"。WebHost.createBuilder() 内部与 Host.createBuilder() 执行完全相同的初始化流程,再额外组装 HTTP 层,通用主机的能力全盘继承。
快速开始
引入 soulsoft_web_hosting 及其依赖后,三步跑起一个 Web 应用:
import soulsoft_web_http.*
import soulsoft_web_hosting.*
main(args: Array<String>): Int64 {
// 1. 创建构建器
let builder = WebHost.createBuilder(args)
// 2. 构建
let app = builder.build()
// 3. 添加中间件 + 启动
app.use { context, next =>
context.response.write("Hello, World!")
next(context)
}
app.run()
return 0
}访问 http://localhost:5000,返回 Hello, World!。按 Ctrl+C,主机会走完整关闭流程——停止 HTTP 服务器、停止托管服务、释放 DI 容器。
整个流程只有三个角色:
| 步骤 | 做什么 | 用的类型 |
|---|---|---|
| 创建构建器 | 自动加载环境、配置、日志,准备 DI 容器 | WebHost.createBuilder(args) |
| 配置服务与管道 | 注册服务、添加中间件 | builder.services / builder.configureServer() |
| 构建并运行 | 固化容器,启动 HTTP 服务器并阻塞 | build() → run() |
核心概念
中间件管道
Web 主机启动后,每个 HTTP 请求进入一条请求管道。管道由一组中间件首尾串联——请求从一端进入,逐个穿过每个中间件,最终以响应的形式从另一端返回。
用一个现实中的东西来想:一根 U 型水管。
请求从左管流入,沿管道向下,经过底部 U 型弯折返,沿右管向上,变成响应流出。同一股水,方向相反。
请求(入水口) 响应(出水口)
│ ▲
▼ │
╔═════╧══════╗ ╔═════╤══════╗
║ 日志段 ║ ║ 日志段 ║
║ 入水侧: ╞═════ next() ════════╡ 出水侧: ║
║ 记录方法、 ║ ║ 记录状态码 ║
║ 路径 ║ ║ ║
╚═════╤══════╝ ╚═════╤══════╝
│ ▲
│ next() │
▼ │
╔═════╧══════╗ ╔═════╤══════╗
║ 认证段 ║ ║ 认证段 ║
║ 入水侧: ╞═════ next() ════════╡ 出水侧: ║
║ Token有效 ║ ║ (无操作) ║
║ → 放行 ║ ║ ║
║ Token无效 ║ ║ ║
║ → 关阀门 ║ ║ ║
╚═════╤══════╝ ╚═════╤══════╝
│ ▲
│ ╔═══════════════╗ │
└═══════╣ U 型弯(终端)╠════════════┘
║ 未命中 → 404 ║
╚═══════════════╝水管上的每一段就是一个中间件。中间件像一个环套在 U 型管上——环同时横跨左管和右管,因此有两个切入点:左管上的入水侧(next() 之前)和右管上的出水侧(next() 之后)。日志中间件的这个环,入水侧记录请求信息,出水侧记录响应状态码——环还是那个环,只是水经过 U 型弯后方向反了,所以环的两边各碰一次。
认证中间件相当于装了一个阀门:Token 有效就开闸放水,让它流到下一段;Token 无效就关阀门——水从这里直接折返,后面的管道段根本没机会碰到水。
关键规则只有三条:
- 每个中间件拿到请求后,决定是否调用
next()。调用——请求继续往后传;不调用——管道在此处短路,响应原路返回。 next()之前是"请求阶段",next()之后是"响应阶段"。同一个中间件,两段逻辑分别作用在请求的"来"和"回"上。- 响应沿调用栈逆序返回。中间件 1 调了中间件 2,中间件 2 调了中间件 3,返回顺序就是 3 → 2 → 1。
对照 U 型水管图,把每一段翻译成代码:
import soulsoft_web_http.*
import soulsoft_web_hosting.*
main(args: Array<String>): Int64 {
let app = WebHost.createBuilder(args).build()
// ═══════════ 日志段 ═══════════
app.use { context, next =>
// ↓ 入水侧:记录请求方法、路径
println("→ ${context.request.method} ${context.request.path}")
next(context) // ═══ 水流穿过 next(),进入认证段 ═══
// ↓ 出水侧:记录响应状态码
println("← ${context.response.statusCode}")
}
// ═══════════ 认证段 ═══════════
app.use { context, next =>
if (context.request.headers.get("Authorization").isNone()) {
// ↓ 入水侧关阀门 → 水流从这里折返,不进入下一段
context.response.statusCode = StatusCodes.Unauthorized
context.response.contentType = "text/plain; charset=utf-8"
context.response.write("未提供认证令牌")
return // ← 不调用 next()
}
next(context) // ═══ 开闸放水 ═══
// 出水侧:(无操作)
}
app.run()
return 0
}短路
中间件不调用 next(),管道就断了。两种请求两种结果:
- 带 Token 的请求 → 日志入水 → 认证入水(开闸)→ 终端(U 型弯)→ 认证出水 → 日志出水 → 404
- 不带 Token 的请求 → 日志入水 → 认证入水(关阀门)→ 水流直接从认证段折返,终端被跳过 → 日志出水 → 401
顺序决定行为
中间件按 use() 的调用顺序执行。同样的中间件集合,顺序不同,行为完全不同:
正确:日志 → 认证 → 业务逻辑
│ │
│ └── 认证失败就短路,日志照样记
│
错误:认证 → 日志 → 业务逻辑
│ │
└── 认证失败直接返回,日志中间件根本收不到这个请求注册顺序的铁律:越基础、越需要覆盖所有请求的中间件,越要放在前面。日志、异常处理应该最先注册;认证次之;业务相关的放在最后。
HTTP 上下文
每个请求到达时,Web 主机创建一个 HttpContext,其中包含请求和响应的全部信息。
读取请求数据:
app.use { context, next =>
let req = context.request
// 查询字符串:?page=1&size=10
let page = req.query.get("page") ?? "1"
// POST 表单
let username = req.form.get("username") ?? ""
let password = req.form.get("password") ?? ""
// Cookie
if (let Some(token) <- req.cookies.get("session")) {
println("会话 Token: ${token}")
}
// 请求头
let userAgent = req.headers.get("User-Agent")
next(context)
}HttpRequest 常用属性一览:
| 属性 | 类型 | 说明 |
|---|---|---|
method | String | HTTP 方法(GET/POST 等) |
path | PathString | 请求路径(可改写,用于路径重写) |
query | IQueryCollection | 查询参数集合 |
queryString | QueryString | 原始查询字符串 |
form | IFormCollection | 表单数据(自动判断 URL 编码 / multipart) |
body | InputStream | 请求体流 |
headers | IHeaderDictionary | 请求头集合 |
cookies | IRequestCookieCollection | Cookie 集合 |
contentType | ?String | 请求内容类型 |
contentLength | ?Int64 | 请求体长度 |
host | HostString | 请求主机名 |
scheme | String | 协议(http/https) |
routeValues | RouteValueDictionary | 路由参数值 |
写入响应:
app.use { context, next =>
let resp = context.response
resp.statusCode = StatusCodes.Ok
resp.contentType = "text/plain; charset=utf-8"
resp.headers.add("X-Custom-Header", "value")
resp.write("响应正文")
next(context)
}HttpResponse 常用成员一览:
| 成员 | 类型 | 说明 |
|---|---|---|
statusCode | UInt16 | 响应状态码 |
contentType | ?String | 响应内容类型 |
contentLength | ?Int64 | 响应内容长度 |
write(text) | 方法 | 写入字符串到响应体 |
write(bytes) | 方法 | 写入字节数组到响应体 |
writeAsJson(data) | 方法 | 写入 JSON 并自动设置 Content-Type |
redirect(location) | 方法 | 重定向(302)到指定地址 |
headers | IHeaderDictionary | 响应头集合 |
cookies | IResponseCookies | 响应 Cookie 集合 |
onStarting(callback) | 方法 | 响应头发送前的回调 |
onCompleted(callback) | 方法 | 响应发送完毕后的回调 |
hasStarted | Bool | 响应是否已开始发送 |
警告
响应一旦开始写入(write() 被调用),statusCode 和 headers 不可再修改。需要设置状态码和头的逻辑必须放在 write() 之前,或者注册到 onStarting() 回调中。
与通用主机的关系
WebHost 继承自 EndpointRouteBuilder(后者继承 ApplicationBuilder),WebHost.createBuilder() 与 Host.createBuilder() 执行完全相同的初始化流程(环境识别、配置加载、日志注册),再额外组装 HTTP 服务器和中间件管道。
完全相同的部分(直接引用通用主机文档,这里不再展开):
| 能力 | 参考文档 |
|---|---|
环境识别(IHostEnvironment) | 通用主机 - 环境 |
| 配置加载链(JSON → 环境变量 → 命令行) | 通用主机 - 配置 |
| 日志系统(控制台 + 配置文件) | 通用主机 - 配置 |
DI 容器(ServiceCollection) | 通用主机 - 快速开始 |
托管服务(BackgroundService / IHostedService) | 通用主机 - 托管服务 |
生命周期回调(onStarted / onStopping / onStopped) | 通用主机 - 生命周期 |
| 优雅关闭流程 | 通用主机 - 启动流程 |
Web 主机额外增加的部分(本文重点):
| 新增能力 | 说明 |
|---|---|
| 中间件管道 | app.use() 串联请求处理逻辑,所有 Web 能力的扩展入口 |
| HTTP 上下文 | HttpContext 封装请求/响应,提供 Cookie、查询字符串、表单等完整解析 |
| HttpContextAccessor | 在中间件之外的代码中获取当前请求上下文 |
| 服务器配置 | builder.configureServer() 自定义超时、请求体大小等底层参数 |
| IWebHostEnvironment | 在 IHostEnvironment 基础上增加 webRootPath |
IWebHostEnvironment
IWebHostEnvironment 继承 IHostEnvironment,额外增加一个字段:
| 属性 | 类型 | 说明 | 默认值 |
|---|---|---|---|
webRootPath | String | 静态文件根目录 | "wwwroot" |
environmentName | String | 环境名称(继承) | "Production" |
contentRootPath | String | 内容根路径(继承) | 当前工作目录 |
applicationName | String | 应用名称(继承) | 空字符串 |
通过 cangjie_ 前缀的环境变量或 --webRootPath 命令行参数覆盖 webRootPath。环境判断(isDevelopment() / isProduction() 等)与通用主机完全一致。
常见用法
use() 的四种形式
use() 有四个重载,本质上是同一种模型的四种表达方式。
形式一:(RequestDelegate) -> RequestDelegate
app.use {
next => { // ───── ① 外层闭包:build() 时执行一次 ─────
let logger = resolveLogger() // 初始化逻辑,只跑一次(例如解析单例服务)
return { context => // ───── ② 内层闭包:每个请求执行 ─────
logger.info("${context.request.path}")
next(context)
}
}
}两层闭包,用 ① ② 锚点对应:
- ① 外层(
next => { ... }):build()组装管道时执行一次。在这里做初始化——解析单例服务、预计算配置值、创建对象池。这些事只跑一次,结果被 ② 内层闭包捕获复用。 - ② 内层(
context => { ... }):每个请求到来时执行。这里写每个请求不同的逻辑——读请求头、判断条件、调用next。
形式二:(HttpContext, RequestDelegate) -> Unit
app.use { context, next =>
// 全部代码都在 ② 那层——每个请求都执行,没有 ① 的概念
next(context)
}context 和 next 平级传入,写起来更直白。内部等价于形式一——源码中形式二就是展开成形式一后调用的。
区别不在嵌套深浅,在执行次数。 形式一有 ① ② 两层,初始化与请求处理分离;形式二只有 ② 那层,所有代码每个请求都跑。
什么时候用形式一:需要在管道构建阶段做一次性初始化——解析单例服务、读取配置值、创建可复用的对象。这些事如果放在形式二里,每个请求都要重复做一遍。
什么时候用形式二:每个请求独立判断、不需要初始化——比如检查请求头、读写 Cookie。代码更短更直白。
形式三:IMiddleware 实例
class AuthMiddleware <: IMiddleware {
public func invoke(context: HttpContext, next: RequestDelegate): Unit {
if (context.request.headers.get("Authorization").isNone()) {
context.response.statusCode = StatusCodes.Unauthorized
return
}
next(context)
}
}
// 直接传实例
app.use(AuthMiddleware())可复用的中间件类,支持通过构造函数注入依赖。适合逻辑复杂、需要单元测试、或者要在多个项目中复用的中间件。
形式四:泛型 use<TMiddleware>(args)
// 让 DI 容器创建中间件实例,自动解析构造函数依赖
app.use<AuthMiddleware>([])通过 ActivatorUtilities 自动从 DI 容器解析中间件的构造函数依赖,args 作为额外参数传入。适合中间件构造函数依赖其他服务时使用——不需要手动从容器取出依赖再 new。
HttpContextAccessor
中间件的 context 参数只在管道内有效。如果你在中间件之外的代码中需要获取当前请求上下文(比如在 BackgroundService 调用的业务层),通过 IHttpContextAccessor:
import soulsoft_web_http.*
import soulsoft_web_hosting.*
import soulsoft_extensions_injection.*
class OrderService {
private let contextAccessor: IHttpContextAccessor
public init(contextAccessor: IHttpContextAccessor) {
this.contextAccessor = contextAccessor
}
public func getCurrentUser(): ?String {
if (let Some(ctx) <- contextAccessor.context) {
return ctx.request.headers.get("X-Username")
}
return None
}
}
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
// 注册 HttpContextAccessor(Scoped 生命周期)
builder.services.addHttpContextAccessor()
builder.services.addScoped<OrderService>()
let app = builder.build()
app.use { context, next =>
let service = context.services.getOrThrow<OrderService>()
let user = service.getCurrentUser() ?? "未知用户"
context.response.contentType = "text/plain; charset=utf-8"
context.response.write("当前用户: ${user}")
next(context)
}
app.run()
return 0
}addHttpContextAccessor() 将 IHttpContextAccessor 注册为 Scoped,这意味着:
- 每个请求有独立的
HttpContext实例,互不干扰 - 只能在 Scoped 或 Transient 服务中注入,不能注入 Singleton——详见通用主机 - 常见问题
服务器配置
监听地址和端口——默认监听 http://127.0.0.1:5000。改地址和端口有四种方式:
// 方式一:run() 参数(最常用)
app.run("http://0.0.0.0:8080")
// 方式二:urls 环境变量 / 命令行参数
// cangjie_urls=http://0.0.0.0:8080
app.run()
// 方式三:容器环境自动切换默认地址
// CANGJIE_RUNNING_IN_CONTAINER=true → 默认 host 从 127.0.0.1 变为 0.0.0.0
// 方式四:只改端口不改地址
// cangjie_HTTP_PORTS=8080 → 默认变为 http://127.0.0.1:8080框架内部解析逻辑:urls 配置键 → 容器检测(CANGJIE_RUNNING_IN_CONTAINER)→ HTTP_PORTS 配置键。
警告
不要在 configureServer() 里设地址和端口——addr、port、afterBind、distributor 会在配置回调之后被框架强制覆盖。
configureServer()——用于配置不会被框架覆盖的底层参数:超时、请求体大小限制、TLS、协程池等:
import soulsoft_web_hosting.*
import stdx.net.http.ServerBuilder
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
builder.configureServer { server: ServerBuilder =>
server.maxRequestBodySize(10 * 1024 * 1024) // 限制请求体最大 10MB
server.readTimeout(Duration.second * 30) // 读取超时 30 秒
}
let app = builder.build()
app.run()
return 0
}configureServer 回调中可放心设置的有:readTimeout、writeTimeout、maxRequestBodySize、maxRequestHeaderSize、tlsConfig、servicePoolConfig 等。
内置服务
WebHost.createBuilder().build() 除了继承通用主机的全部内置服务,额外注册以下 Web 专用服务:
| 服务接口 | 实现类 | 生命周期 | 说明 |
|---|---|---|---|
IServer | HttpServer | Singleton(tryAdd) | HTTP 服务器,监听请求并驱动管道 |
IWebHostEnvironment | WebHostEnvironment | Singleton | 在 IHostEnvironment 基础上增加 webRootPath |
全部内置服务汇总(含继承):
| 服务接口 | 来源 | 生命周期 | 说明 |
|---|---|---|---|
IHostEnvironment | 通用主机 | Singleton | 环境名、内容根路径、应用名 |
IWebHostEnvironment | Web 新增 | Singleton | 继承 IHostEnvironment,增加 webRootPath(默认 "wwwroot") |
IConfiguration | 通用主机 | Singleton | 合并后的完整配置 |
IServer | Web 新增 | Singleton(tryAdd) | HTTP 服务器 |
IHostApplicationLifetime | 通用主机 | Singleton | 生命周期回调 + stopApplication() |
IHostLifetime | 通用主机 | Singleton(tryAdd) | 平台信号处理 |
HostedServiceExecutor | 通用主机 | Singleton | 启动/停止所有 IHostedService |
ILoggerFactory | 通用主机 | Singleton | 日志工厂(控制台 + 从 logging 配置节读取过滤规则) |
IServiceProvider | 通用主机 | Singleton | DI 容器自身,build() 后固化 |
标了 tryAdd 的表示你可以提前注册自己的实现覆盖默认值——比如替换 IServer 做测试。未标 tryAdd 的用 addSingleton 注册,重复注册会抛异常。
内置环境变量
Web 主机继承通用主机的全部环境变量,额外增加以下:
| 环境变量 | 写入字段 | 默认值 | 等效命令行参数 |
|---|---|---|---|
cangjie_webRootPath | webRootPath | "wwwroot" | --webRootPath=public |
cangjie_urls | urls(配置键) | — | --urls=http://0.0.0.0:8080 |
cangjie_HTTP_PORTS | HTTP_PORTS(配置键) | "5000" | --HTTP_PORTS=8080 |
CANGJIE_RUNNING_IN_CONTAINER | 仅影响默认 host | — | — |
cangjie_environment | environmentName | "Production" | --environment=Staging |
cangjie_contentRootPath | contentRootPath | 当前工作目录 | --contentRootPath=/app |
cangjie_applicationName | applicationName | 空字符串 | --applicationName=myapp |
cangjie_* | IConfiguration[key] | — | — |
cangjie_urls 设置完整的监听地址(含协议和端口)。cangjie_HTTP_PORTS 只改端口号。CANGJIE_RUNNING_IN_CONTAINER=true 时默认 host 从 127.0.0.1 切换为 0.0.0.0(容器必须监听所有网卡接口)。三者优先级:urls > 容器检测 > HTTP_PORTS。
最佳实践
以一个静态文件中间件为例——演示 Web 主机开发的完整模式:选项分层、接口抽象、IMiddleware 实现、extend 封装注册 API。
选项分层
选项分两层,服务和中间件各自只依赖自己需要的:
package issue
import std.fs.*
import std.collection.*
import soulsoft_web_http.*
import soulsoft_web_hosting.*
import soulsoft_extensions_options.*
import soulsoft_extensions_injection.*
// 1. 服务选项:控制文件从哪里读(进 DI 容器)
class StaticFileOptions {
public var rootPath: String = "wwwroot"
public var defaultFile: String = "index.html"
}
// 2. 中间件选项:控制文件怎么响应(不进容器,直接传中间件)
class StaticFileMiddlewareOptions {
public var mappings: HashMap<String, String> = HashMap<String, String>(
[
("html", "text/html; charset=utf-8"),
("css", "text/css; charset=utf-8"),
("js", "application/javascript; charset=utf-8"),
("json", "application/json; charset=utf-8"),
("png", "image/png"),
("jpg", "image/jpeg"),
("svg", "image/svg+xml"),
("ico", "image/x-icon"),
("txt", "text/plain; charset=utf-8")
]
)
}服务与中间件实现
服务和中间件的实现——服务依赖接口和选项,中间件只依赖抽象:
// 3. 抽象服务接口
interface IFileService {
func read(path: String): ?Array<Byte>
}
// 4. 服务实现:依赖服务选项(通过 IOptions<T> 注入)
class FileService <: IFileService {
private let rootPath: String
public init(options: IOptions<StaticFileOptions>) {
rootPath = options.value.rootPath
}
public func read(path: String): ?Array<Byte> {
var cleanPath = path
while (cleanPath.startsWith("/")) {
cleanPath = cleanPath[1..]
}
let fullPath = "${rootPath}/${cleanPath}"
try {
return File.readFrom(fullPath)
} catch (_: Exception) {
return None
}
}
}
// 5. 中间件:依赖服务接口 + 中间件选项直接传入
class StaticFileMiddleware <: IMiddleware {
private let fileService: IFileService
private let mappings: HashMap<String, String>
public init(fileService: IFileService, options: StaticFileMiddlewareOptions) {
this.fileService = fileService
this.mappings = options.mappings
}
public func invoke(context: HttpContext, next: RequestDelegate): Unit {
var path = context.request.path.toString()
if (path.endsWith("/")) {
path = "${path}index.html"
}
if (let Some(content) <- fileService.read(path)) {
let ext = resolveExt(path)
context.response.contentType = mappings.get(ext) ?? "application/octet-stream"
context.response.write(content)
return
}
next(context)
}
private func resolveExt(path: String): String {
let parts = path.split(".")
if (parts.size > 1) {
return parts[parts.size - 1]
}
return ""
}
}注册封装
注册封装和启动入口——extend 让调用方一行搞定注册:
// 6. extend 扩展:服务选项进容器,中间件选项直接传
extend ServiceCollection {
public func addStaticFiles(options: (StaticFileOptions) -> Unit): ServiceCollection {
this.configure(options)
this.addSingleton<IFileService, FileService>()
return this
}
}
extend ApplicationBuilder {
public func useStaticFiles(configure: (StaticFileMiddlewareOptions) -> Unit) {
// 推荐扩展 ApplicationBuilder 而非 WebHost——适用范围更广,不耦合具体主机类型
let options = StaticFileMiddlewareOptions()
configure(options)
this.use<StaticFileMiddleware>(options)
}
}
// 7. 启动入口
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
builder.services.addStaticFiles {
opt => opt.rootPath = "./wwwroot"
}
let app = builder.build()
app.useStaticFiles { options =>
// 全部使用默认映射,也可以覆盖: options.mappings["txt"] = "text/plain; charset=gbk"
}
app.run()
return 0
}这个例子演示了 Web 主机开发的完整模式:
- 选项分层:
StaticFileOptions控制"从哪里读"(进容器,FileService通过IOptions<T>注入),StaticFileMiddlewareOptions控制"怎么响应"(不进容器,直接传给中间件构造函数)。两者职责清晰,互不污染。 - 中间件用
IMiddleware:构造函数注入服务接口 + 直接传选项。use<StaticFileMiddleware>(options)用ActivatorUtilities自动解析 DI 中的依赖,options作为额外参数传入。 - 服务用接口抽象:
IFileService定义契约,FileService具体实现,中间件只依赖接口——可替换、可测试。 - 注册用
extend:addStaticFiles()一行搞定选项配置 + 服务注册,useStaticFiles()一行挂载中间件。扩展ApplicationBuilder而非WebHost——ApplicationBuilder是中间件管道的抽象基类,扩展它意味着你的中间件不绑定特定主机类型,适用面更广。 - 封装细节:调用方只需
app.useStaticFiles { options => ... },不需要知道内部注册了IFileService还是StaticFileMiddleware。
三条原则:选项分层(服务选项进容器、中间件选项直接传)、一个中间件只做一件事(静态文件不管认证)、用 extend 封装注册(调用方不需要知道内部注册了哪些服务)。
常见问题
run() 和 start() 有什么区别?
run() = start() + waitForShutdown() + close()。如果需要在启动后、阻塞前做一些事(比如给监控发送就绪信号),用 start() + waitForShutdown():
let app = WebHost.createBuilder(args).build()
app.start()
println("服务已就绪,可接收请求")
app.waitForShutdown()怎么在 BackgroundService 中访问数据库?
注册数据库连接为 Scoped,在 BackgroundService 中注入 IServiceScopeFactory,每次循环创建作用域——这个模式与通用主机完全一致,详见 通用主机 - 常见问题。
中间件怎么返回 JSON?
用 context.response.writeAsJson(data),自动序列化并设置 Content-Type: application/json。
关于通用主机的常见问题?
环境切换、多个 BackgroundService 的启动顺序、Scoped 服务在 Singleton 中的使用、主动退出程序等——这些问题在 通用主机 - 常见问题 中已覆盖,这里不再重复。