第 44 章 / 共 58 章 卷九 网络编程

HTTP 服务器

上一章讲了如何"发"请求,本章讲如何"收"请求。xlang 标准库内置了一个功能完整的 HTTP 服务器:路由匹配、路径参数、查询解析、Cookie、表单、multipart 文件上传、静态文件、TLS/SNI 多证书、流式响应、流式上传、多应用隔离、虚拟主机、优雅停机——全部零依赖,一个 http.server.create 即可启动。本章按"创建→路由→请求→响应→高级特性→生命周期"的顺序逐一展开。

本章导读

xlang 的 HTTP 服务器是嵌入式的——它运行在你的 xlang 进程内,不需要 Nginx 或 Apache 做前置代理。这让它非常适合:微服务、API 网关、开发工具后端、IoT 设备 Web 管理界面、以及 xServer 这类将 HTTP 服务器与脚本引擎合一的场景。

本章学习目标
  • 用 http.server.create 创建并启动 HTTP 服务器
  • 注册 GET/POST/PUT/任意方法路由,理解路径参数 {id}
  • 从 HttpRequest 提取路径、查询、Cookie、请求头、请求体
  • 用 HttpResponse 返回文本、JSON、文件、重定向、Cookie、流式数据
  • 解析 URL-encoded 表单和 multipart 文件上传
  • 配置静态文件服务和 TLS/SNI
  • 使用 App 隔离和虚拟主机托管多个应用
  • 掌握 drain/stop/destroy 优雅停机流程

44.1 创建与启动服务器

一个最小的 HTTP 服务器只需四步:创建、注册路由、启动、停止。

// 路由处理函数
func route(point reqHandle, point respHandle) as bool
    HttpRequest req = HttpRequest(reqHandle)
    HttpResponse resp = HttpResponse(respHandle)
    return resp.text("Hello from xlang!")
end

// 声明函数值(路由注册需要函数值类型)
func(point, point) as bool handler = route

// 创建服务器:端口 0 = 自动分配,绑定 127.0.0.1
point server = http.server.create(0, "127.0.0.1", 128, 1048576, 1048576, 100, 65536, 1)

// 注册路由
http.server.getRequest(server, "/hello", handler)

// 启动
assert http.server.start(server), "服务器启动失败"
int port = http.server.port(server)
println("服务器运行在 http://127.0.0.1:" + port.toString())

// 阻塞等待(生产环境中保持运行)
time.sleep(60000)

// 停止并销毁
http.server.stop(server)
http.server.destroy(server)

http.server.create 的参数:

参数含义示例值
port监听端口(0 = 系统自动分配)8080
host绑定地址"0.0.0.0"
backlog连接等待队列长度128
maxBody最大请求体字节1048576 (1MB)
maxResponse最大响应体字节1048576
timeout连接超时(秒)100
maxHeader最大请求头字节65536
threads工作线程数1

44.2 路由注册

xlang HTTP 服务器支持多种路由注册方式:

// 按方法注册——Request 风格(推荐)
http.server.getRequest(server, "/users", handler)       // 仅 GET
http.server.postRequest(server, "/users", handler)      // 仅 POST
http.server.anyRequest(server, "/health", handler)      // 任意方法

// 多方法路由——用 | 分隔
http.server.routeRequest(server, "PUT|PATCH|DELETE", "/users/{id}", handler)

// 路径参数——用 {name} 占位
http.server.getRequest(server, "/users/{id}", handler)
// 请求 /users/42 时,req.param("id") == "42"

路由匹配规则:

  • 精确路径优先于参数路径:/users/me 优先于 /users/{id}
  • 方法必须匹配:getRequest 只响应 GET
  • anyRequest 响应所有 HTTP 方法
  • 未匹配的路径返回 404(或触发自定义错误处理器)
两种处理函数签名

Request 风格 func(point, point) as bool 是推荐方式,功能完整。Legacy 风格 func(string, string, string, string) as string 只接收 method/path/query/body 四个字符串,适合极简场景。

44.3 HttpRequest 请求解析

HttpRequest 封装了入站请求的全部信息:

func route(point reqHandle, point respHandle) as bool
    HttpRequest req = HttpRequest(reqHandle)
    HttpResponse resp = HttpResponse(respHandle)

    // 基本信息
    string method = req.method()        // "GET" / "POST" / ...
    string path = req.path()            // "/users/42"
    bool open = req.connectionOpen()    // 连接是否仍然打开

    // 路径参数(来自 {id} 占位符)
    string userId = req.param("id")     // "42"
    string pName = req.paramName(0)     // "id"
    string pVal = req.paramValue(0)     // "42"

    // 查询参数(?name=xlang&page=1)
    string name = req.queryValue("name")       // "xlang"
    string qName = req.queryName(0)            // "name"
    string qVal = req.queryValueAt(0)          // "xlang"

    // 请求头
    string auth = req.header("Authorization")
    string hName = req.headerName(0)

    // Cookie
    string sid = req.cookie("sid")
    string cName = req.cookieName(0)
    string cVal = req.cookieValueAt(0)

    // 请求体
    string body = req.body()

    return resp.text("ok")
end

44.4 HttpResponse 响应构建

HttpResponse 提供丰富的响应构建方法:

func route(point reqHandle, point respHandle) as bool
    HttpRequest req = HttpRequest(reqHandle)
    HttpResponse resp = HttpResponse(respHandle)

    // 设置状态码(默认 200)
    resp.status(201, "Created")

    // 设置响应头
    resp.header("X-Server", "xlang")
    resp.addHeader("Set-Cookie", "a=1")   // 可重复添加同名头

    // 返回文本
    return resp.text("Hello World")

    // 返回 JSON
    // return resp.jsonText("{\"ok\":true}")

    // 返回文件
    // return resp.file("static/index.html")

    // 返回文件的一部分(Range 请求)
    // resp.status(206, "Partial Content")
    // return resp.fileRange("video.mp4", 0, 1024)

    // 重定向
    // return resp.redirect("/new-location", 302)

    // 设置 Cookie
    // resp.cookie("sid", "abc123", "/", 3600, http.server.sameSiteLax, http.server.cookieHttpOnly)
    // return resp.text("cookie set")

    // 返回二进制数据
    // Buffer data = ...
    // return resp.body(data.data(), c_size(data.len()), "application/octet-stream")
end

响应方法速查:

方法说明
text(s)返回 text/plain 文本
jsonText(s)返回 application/json
file(path)返回文件内容(自动推断 MIME)
fileRange(path, off, len)返回文件片段
redirect(url, code)302/301 重定向
body(ptr, size, mime)返回二进制数据
cookie(...)设置 Set-Cookie 头
start() / write() / finish()流式响应(见 44.7)
reserveBody(n) / appendText(s)分块构建响应体

44.5 表单与文件上传

xlang 服务器自动解析两种表单格式:

// URL-encoded 表单(Content-Type: application/x-www-form-urlencoded)
if req.isFormUrlEncoded() then
    string name = req.formValue("name")       // 按名取值
    string first = req.formName(0)            // 第一个字段名
    string val = req.formValueAt(0)           // 第一个字段值
end

// Multipart 表单(Content-Type: multipart/form-data)
if req.isMultipartForm() then
    string title = req.multipartValue("title")       // 普通字段
    string partName = req.multipartName(0)           // 第 0 部分字段名
    string text = req.multipartText(0)               // 第 0 部分文本内容

    if req.multipartIsFile(1) then                   // 第 1 部分是文件
        string fileName = req.multipartFileName(1)   // 原始文件名
        Buffer data = req.multipartBytes(1)          // 文件内容
        println("上传文件: " + fileName + ", 大小: " + data.len().toString())
    end
end
大文件上传用流式

multipart 解析会将整个文件读入内存。对于大文件(视频、镜像),应使用流式上传(见 44.7),数据逐块到达,边收边写磁盘,内存占用恒定。

44.6 静态文件服务

将 URL 路径映射到磁盘目录:

// 基本静态服务:/public/* → ./static/ 目录
http.server.static(server, "/public", "./static")
// 请求 /public/css/style.css → 读取 ./static/css/style.css

// 高级静态服务:自定义默认文件、缓存、标志
int flags = http.server.allowDotfiles + http.server.allowBackslash
http.server.staticEx(
    server,
    "/assets",              // URL 前缀
    "./static",             // 磁盘目录
    "index.html",           // 默认文件(访问 /assets/ 时返回)
    "max-age=3600",         // Cache-Control 头
    65536,                  // 最大文件大小
    flags                   // 标志位
)

MIME 类型由文件扩展名自动推断:

// 查询 MIME 类型
string mime = http.server.mimeByPath("data.json")
// → "application/json; charset=utf-8"

44.7 流式响应与流式上传

流式响应——适合 SSE、大文件下载、实时日志推送:

if req.path() == "/stream" then
    resp.header("Content-Type", "text/event-stream")
    if not resp.start() then
        return false    // 发送头失败
    end
    resp.write("data: hello\n\n")
    resp.write("data: world\n\n")
    return resp.finish()
end

流式上传——适合大文件接收,内存占用恒定:

// 三个回调:开始、数据块、结束
func uploadBegin(point reqHandle) as bool
    HttpRequest req = HttpRequest(reqHandle)
    string name = req.param("name")
    return file.writeText("upload_" + name + ".tmp", "")
end

func uploadChunk(point reqHandle, point data, int size) as bool
    HttpRequest req = HttpRequest(reqHandle)
    string name = req.param("name")
    string text = http.server.chunkText(data, size)
    return file.appendText("upload_" + name + ".tmp", text)
end

func uploadEnd(point reqHandle, point respHandle) as bool
    HttpRequest req = HttpRequest(reqHandle)
    HttpResponse resp = HttpResponse(respHandle)
    string name = req.param("name")
    string content = file.readText("upload_" + name + ".tmp")
    return resp.text("上传完成: " + name + ", 大小: " + content.len().toString())
end

// 声明函数值
func(point) as bool beginFn = uploadBegin
func(point, point, int) as bool chunkFn = uploadChunk
func(point, point) as bool endFn = uploadEnd

// 注册流式上传路由
http.server.postStreamRequest(server, "/upload/{name}", beginFn, chunkFn, endFn)

44.8 TLS 与 SNI 多证书

创建 HTTPS 服务器:

// 创建 TLS 服务器(指定证书和私钥)
point server = http.server.createTls("cert.pem", "key.pem", 443)

// SNI 多证书——同一端口为不同域名使用不同证书
http.server.tlsHost(server, "api.example.com", "api_cert.pem", "api_key.pem")
http.server.tlsHost(server, "web.example.com", "web_cert.pem", "web_key.pem")

// 移除 SNI 证书
http.server.removeTlsHost(server, "api.example.com")

SNI(Server Name Indication)允许一个 IP:端口 为多个域名提供不同的 TLS 证书。客户端在 TLS 握手时告知目标域名,服务器选择对应证书。这是现代 CDN 和虚拟主机的基础。

44.9 App 多应用与虚拟主机

一个服务器可以托管多个独立应用,彼此路由隔离:

// 创建应用
point app1 = http.server.app()
point app2 = http.server.app()

// 为 app1 注册路由
http.server.appGet(app1, "/", firstHandler)
http.server.appPost(app1, "/api/data", dataHandler)
http.server.appStatic(app1, "/public", "./app1_static")

// 为 app2 注册路由
http.server.appGet(app2, "/", secondHandler)
http.server.appError(app2, errorHandler)

// 将 app2 设为默认应用
http.server.setApp(server, app2)

// 热切换应用(零停机)
http.server.reloadApp(server, app1)

// 虚拟主机——按域名分发
point vhost = http.server.app()
http.server.appGet(vhost, "/", hostHandler)
http.server.host(server, "admin.example.com", vhost)

// 移除虚拟主机
http.server.removeHost(server, "admin.example.com")

// 销毁应用
http.server.destroyApp(app1)
http.server.destroyApp(app2)
http.server.destroyApp(vhost)

App 的典型用途:同一进程中运行管理后台 + 用户 API + 静态资源服务,彼此路由不干扰,且可以独立热更新。

44.10 生命周期与优雅停机

服务器的完整生命周期:

// 1. 创建
point server = http.server.create(8080, "0.0.0.0", 128, 1048576, 1048576, 100, 65536, 4)

// 2. 注册路由...

// 3. 启动
assert http.server.start(server)

// 4. 运行中——查询状态
int port = http.server.port(server)
int reqs = http.server.requestCount(server)
int conns = http.server.connectionCount(server)

// 5. 优雅停机
// drain:停止接受新连接,等待已有请求完成(最多等 timeout 毫秒)
bool drained = http.server.drain(server, 5000)

// stop:强制停止
http.server.stop(server)

// 6. 销毁(释放资源)
http.server.destroy(server)

停机顺序很重要:

  • drain(server, timeout):停止监听新连接,等待进行中的请求完成。适合滚动更新。
  • stop(server):立即停止,不等待。适合紧急关闭。
  • destroy(server):释放服务器占用的所有资源。必须在 stop 之后调用。
生产环境停机流程

推荐顺序:① 从负载均衡器摘除本节点;② drain(server, 30000) 等待存量请求完成;③ stop(server);④ destroy(server);⑤ 进程退出。这保证了零请求丢失。

深入细节

深入 1:路由处理函数的返回值语义

处理函数返回 true 表示"响应已构建完毕,可以发送给客户端";返回 false 表示"处理失败,关闭连接"。注意:即使你调用了 resp.status(404),只要最后 return true,404 响应也会正常发送。return false 不会发送任何响应,直接断开连接。

深入 2:错误处理器

通过 http.server.error(server, errorHandler) 注册全局错误处理器。当路由未匹配或内部错误时,错误处理器被调用,签名为 func(int, string, point, point) as bool——接收状态码、错误消息、请求和响应句柄。这让你可以返回自定义 404 页面或 JSON 错误格式。

深入 3:req.closeConnection() 的用途

调用 req.closeConnection() 会在响应发送后关闭 TCP 连接(发送 Connection: close 头)。适用场景:响应后不再需要连接(如某些安全端点)、检测到异常客户端、或实现简单的限流(迫使客户端重新握手)。

⚠️ 常见错误

❌ 错误 1:忘记声明函数值类型

路由注册需要函数值(func(point, point) as bool),不能直接传函数名。必须先 func(point, point) as bool handler = route,再传 handler。直接传函数名会编译报错。

❌ 错误 2:流式响应忘记调用 finish()

resp.start() 发送头,resp.write() 写数据块,但如果忘记 resp.finish(),客户端会一直等待响应结束(或超时断开)。三者必须配套使用。

❌ 错误 3:destroy 前没有 stop

直接调用 destroy 而不先 stop,可能导致进行中的请求被强制中断、资源未正确释放。正确顺序:drain → stop → destroy。

❌ 错误 4:静态文件路径穿越

默认情况下 http.server.static 会阻止 ../ 路径穿越。但如果你设置了 http.server.allowBackslash 标志,在 Windows 上可能引入反斜杠穿越风险。生产环境不要随意开启此标志。

本章小结

主题核心要点
创建http.server.create(port, host, backlog, ...) 或 createTls 创建 HTTPS
路由getRequest/postRequest/anyRequest/routeRequest;{id} 路径参数
请求method/path/param/queryValue/cookie/header/body/formValue/multipart*
响应text/jsonText/file/fileRange/redirect/body/cookie/status/header
表单isFormUrlEncoded + formValue;isMultipartForm + multipartValue/multipartBytes
静态static(prefix, dir);staticEx 支持默认文件/缓存/标志
流式响应:start/write/finish;上传:postStreamRequest 三回调
TLScreateTls 创建;tlsHost 添加 SNI 证书;removeTlsHost 移除
Appapp() 创建隔离应用;setApp/reloadApp 切换;host() 虚拟主机
生命周期start → drain(优雅) → stop(强制) → destroy(释放)

复习题

  1. http.server.create 的 port 参数传 0 有什么效果?什么场景下有用?
  2. getRequest 和 anyRequest 的区别是什么?routeRequest 的 "PUT|PATCH" 语法表示什么?
  3. 路径 /users/{id} 匹配请求 /users/42 时,如何获取 "42"?
  4. resp.text() 和 resp.jsonText() 设置的 Content-Type 有什么不同?
  5. isFormUrlEncoded 和 isMultipartForm 分别对应什么 Content-Type?
  6. 流式响应的三个步骤是什么?忘记 finish() 会怎样?
  7. SNI 解决什么问题?没有 SNI 时一个 IP 只能有一个 HTTPS 站点吗?
  8. reloadApp 和 setApp 的区别是什么?哪个支持零停机切换?
  9. drain(server, 5000) 的 5000 是什么含义?drain 返回 false 说明什么?
  10. 为什么路由注册需要函数值而不能直接传函数名?

编程练习

练习 1:RESTful TODO API

实现一个内存 TODO 列表 API:GET /todos 返回 JSON 数组;POST /todos 添加项(从 body 解析 JSON);PUT /todos/{id} 更新;DELETE /todos/{id} 删除。用 list 存储,json 模块序列化。启动后用 http.get/http.request 自测。

参考思路:定义 Todo class,用 list<Todo> 存储;路由中按 method+path 分发;json.stringify 序列化响应。

练习 2:文件上传服务

实现 POST /upload/{name} 流式上传端点:begin 回调创建文件,chunk 回调追加写入,end 回调返回文件大小。再实现 GET /files/{name} 用 resp.file 返回已上传的文件。测试:用 http.post 发送文本,再用 http.get 下载验证。

参考思路:postStreamRequest 注册三回调;file.writeText/appendText/readText 操作磁盘。

练习 3:多应用虚拟主机

创建一个服务器,托管两个 App:默认 App 返回 "main site",虚拟主机 "admin.local" 返回 "admin panel"。再为默认 App 添加 /public 静态文件服务。用 http.get 配合 Host 头测试虚拟主机分发。

参考思路:http.server.app() 创建两个应用;http.server.host(server, "admin.local", vhost) 绑定域名。