Skip to content

Web 主机

在通用主机之上扩展 HTTP 服务器和中间件管道——读完本页,你将掌握 Web 应用的请求处理模型和中间件扩展方式。

适用场景

  • 需要 HTTP 服务端能力的 Web 应用、API 服务
  • 已经在用通用主机,需要加上请求处理和中间件管道
  • 希望通过统一的中间件模型扩展 Web 能力(日志、认证、静态文件等)

如果只需要后台服务、定时任务,不需要 HTTP,直接用通用主机即可。

通用主机 解决了配置、日志、DI、生命周期这些所有程序共享的启动问题。Web 主机在它之上加了 HTTP 服务器和中间件管道——通用主机负责"跑起来",Web 主机负责"接请求"。WebHost.createBuilder() 内部与 Host.createBuilder() 执行完全相同的初始化流程,再额外组装 HTTP 层,通用主机的能力全盘继承。

快速开始

引入 soulsoft_web_hosting 及其依赖后,三步跑起一个 Web 应用:

cangjie
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 无效就关阀门——水从这里直接折返,后面的管道段根本没机会碰到水。

关键规则只有三条:

  1. 每个中间件拿到请求后,决定是否调用 next()。调用——请求继续往后传;不调用——管道在此处短路,响应原路返回。
  2. next() 之前是"请求阶段",next() 之后是"响应阶段"。同一个中间件,两段逻辑分别作用在请求的"来"和"回"上。
  3. 响应沿调用栈逆序返回。中间件 1 调了中间件 2,中间件 2 调了中间件 3,返回顺序就是 3 → 2 → 1。

对照 U 型水管图,把每一段翻译成代码:

cangjie
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,其中包含请求和响应的全部信息。

读取请求数据:

cangjie
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 常用属性一览:

属性类型说明
methodStringHTTP 方法(GET/POST 等)
pathPathString请求路径(可改写,用于路径重写)
queryIQueryCollection查询参数集合
queryStringQueryString原始查询字符串
formIFormCollection表单数据(自动判断 URL 编码 / multipart)
bodyInputStream请求体流
headersIHeaderDictionary请求头集合
cookiesIRequestCookieCollectionCookie 集合
contentType?String请求内容类型
contentLength?Int64请求体长度
hostHostString请求主机名
schemeString协议(http/https)
routeValuesRouteValueDictionary路由参数值

写入响应:

cangjie
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 常用成员一览:

成员类型说明
statusCodeUInt16响应状态码
contentType?String响应内容类型
contentLength?Int64响应内容长度
write(text)方法写入字符串到响应体
write(bytes)方法写入字节数组到响应体
writeAsJson(data)方法写入 JSON 并自动设置 Content-Type
redirect(location)方法重定向(302)到指定地址
headersIHeaderDictionary响应头集合
cookiesIResponseCookies响应 Cookie 集合
onStarting(callback)方法响应头发送前的回调
onCompleted(callback)方法响应发送完毕后的回调
hasStartedBool响应是否已开始发送

警告

响应一旦开始写入(write() 被调用),statusCodeheaders 不可再修改。需要设置状态码和头的逻辑必须放在 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() 自定义超时、请求体大小等底层参数
IWebHostEnvironmentIHostEnvironment 基础上增加 webRootPath

IWebHostEnvironment

IWebHostEnvironment 继承 IHostEnvironment,额外增加一个字段:

属性类型说明默认值
webRootPathString静态文件根目录"wwwroot"
environmentNameString环境名称(继承)"Production"
contentRootPathString内容根路径(继承)当前工作目录
applicationNameString应用名称(继承)空字符串

通过 cangjie_ 前缀的环境变量或 --webRootPath 命令行参数覆盖 webRootPath。环境判断(isDevelopment() / isProduction() 等)与通用主机完全一致。

常见用法

use() 的四种形式

use() 有四个重载,本质上是同一种模型的四种表达方式。

形式一:(RequestDelegate) -> RequestDelegate

cangjie
app.use {
    next => {                               // ───── ① 外层闭包:build() 时执行一次 ─────
        let logger = resolveLogger()        //   初始化逻辑,只跑一次(例如解析单例服务)
        return { context =>                 // ───── ② 内层闭包:每个请求执行 ─────
            logger.info("${context.request.path}")
            next(context)
        }
    }
}

两层闭包,用 ① ② 锚点对应:

  • ① 外层(next => { ... }build() 组装管道时执行一次。在这里做初始化——解析单例服务、预计算配置值、创建对象池。这些事只跑一次,结果被 ② 内层闭包捕获复用。
  • ② 内层(context => { ... }:每个请求到来时执行。这里写每个请求不同的逻辑——读请求头、判断条件、调用 next

形式二:(HttpContext, RequestDelegate) -> Unit

cangjie
app.use { context, next =>
    // 全部代码都在 ② 那层——每个请求都执行,没有 ① 的概念
    next(context)
}

contextnext 平级传入,写起来更直白。内部等价于形式一——源码中形式二就是展开成形式一后调用的。

区别不在嵌套深浅,在执行次数。 形式一有 ① ② 两层,初始化与请求处理分离;形式二只有 ② 那层,所有代码每个请求都跑。

什么时候用形式一:需要在管道构建阶段做一次性初始化——解析单例服务、读取配置值、创建可复用的对象。这些事如果放在形式二里,每个请求都要重复做一遍。

什么时候用形式二:每个请求独立判断、不需要初始化——比如检查请求头、读写 Cookie。代码更短更直白。

形式三:IMiddleware 实例

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

cangjie
// 让 DI 容器创建中间件实例,自动解析构造函数依赖
app.use<AuthMiddleware>([])

通过 ActivatorUtilities 自动从 DI 容器解析中间件的构造函数依赖,args 作为额外参数传入。适合中间件构造函数依赖其他服务时使用——不需要手动从容器取出依赖再 new

HttpContextAccessor

中间件的 context 参数只在管道内有效。如果你在中间件之外的代码中需要获取当前请求上下文(比如在 BackgroundService 调用的业务层),通过 IHttpContextAccessor

cangjie
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。改地址和端口有四种方式:

cangjie
// 方式一: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() 里设地址和端口——addrportafterBinddistributor 会在配置回调之后被框架强制覆盖。

configureServer()——用于配置不会被框架覆盖的底层参数:超时、请求体大小限制、TLS、协程池等:

cangjie
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 回调中可放心设置的有:readTimeoutwriteTimeoutmaxRequestBodySizemaxRequestHeaderSizetlsConfigservicePoolConfig 等。

内置服务

WebHost.createBuilder().build() 除了继承通用主机的全部内置服务,额外注册以下 Web 专用服务:

服务接口实现类生命周期说明
IServerHttpServerSingleton(tryAddHTTP 服务器,监听请求并驱动管道
IWebHostEnvironmentWebHostEnvironmentSingletonIHostEnvironment 基础上增加 webRootPath

全部内置服务汇总(含继承):

服务接口来源生命周期说明
IHostEnvironment通用主机Singleton环境名、内容根路径、应用名
IWebHostEnvironmentWeb 新增Singleton继承 IHostEnvironment,增加 webRootPath(默认 "wwwroot"
IConfiguration通用主机Singleton合并后的完整配置
IServerWeb 新增Singleton(tryAddHTTP 服务器
IHostApplicationLifetime通用主机Singleton生命周期回调 + stopApplication()
IHostLifetime通用主机Singleton(tryAdd平台信号处理
HostedServiceExecutor通用主机Singleton启动/停止所有 IHostedService
ILoggerFactory通用主机Singleton日志工厂(控制台 + 从 logging 配置节读取过滤规则)
IServiceProvider通用主机SingletonDI 容器自身,build() 后固化

标了 tryAdd 的表示你可以提前注册自己的实现覆盖默认值——比如替换 IServer 做测试。未标 tryAdd 的用 addSingleton 注册,重复注册会抛异常。

内置环境变量

Web 主机继承通用主机的全部环境变量,额外增加以下:

环境变量写入字段默认值等效命令行参数
cangjie_webRootPathwebRootPath"wwwroot"--webRootPath=public
cangjie_urlsurls(配置键)--urls=http://0.0.0.0:8080
cangjie_HTTP_PORTSHTTP_PORTS(配置键)"5000"--HTTP_PORTS=8080
CANGJIE_RUNNING_IN_CONTAINER仅影响默认 host
cangjie_environmentenvironmentName"Production"--environment=Staging
cangjie_contentRootPathcontentRootPath当前工作目录--contentRootPath=/app
cangjie_applicationNameapplicationName空字符串--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

选项分层

选项分两层,服务和中间件各自只依赖自己需要的:

cangjie
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")
        ]
    )
}

服务与中间件实现

服务和中间件的实现——服务依赖接口和选项,中间件只依赖抽象:

cangjie
// 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 让调用方一行搞定注册:

cangjie
// 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 具体实现,中间件只依赖接口——可替换、可测试。
  • 注册用 extendaddStaticFiles() 一行搞定选项配置 + 服务注册,useStaticFiles() 一行挂载中间件。扩展 ApplicationBuilder 而非 WebHost——ApplicationBuilder 是中间件管道的抽象基类,扩展它意味着你的中间件不绑定特定主机类型,适用面更广。
  • 封装细节:调用方只需 app.useStaticFiles { options => ... },不需要知道内部注册了 IFileService 还是 StaticFileMiddleware

三条原则:选项分层(服务选项进容器、中间件选项直接传)、一个中间件只做一件事(静态文件不管认证)、extend 封装注册(调用方不需要知道内部注册了哪些服务)。

常见问题

run() 和 start() 有什么区别?

run() = start() + waitForShutdown() + close()。如果需要在启动后、阻塞前做一些事(比如给监控发送就绪信号),用 start() + waitForShutdown()

cangjie
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 中的使用、主动退出程序等——这些问题在 通用主机 - 常见问题 中已覆盖,这里不再重复。

相关专题