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

HTTP 客户端

网络编程是 xlang 从"脚本语言"跨入"系统语言"的关键一步。xlang 标准库内置了完整的 HTTP 客户端——不需要任何第三方依赖,一行 http.get 就能发起请求,而底层的连接池、TLS、异步、流式接收、诊断信息全部开箱即用。本章从最简单的 GET 请求开始,逐步深入到连接池复用、异步并发、流式回调和请求取消,让你掌握 xlang HTTP 客户端的全部能力。

本章导读

HTTP 客户端是网络编程的入口。xlang 的 HTTP 客户端设计遵循渐进式复杂度原则:简单场景用快捷函数一行搞定,复杂场景用构建器逐步配置,高并发场景用连接池和异步模型。所有 API 都在 http 命名空间下,无需额外 import。

本章学习目标
  • 掌握 http.get、http.post、http.request 三个快捷函数
  • 熟练使用 HttpClientRequest 构建器配置请求的全部选项
  • 理解 HttpClientResponse 的响应解析:状态码、头、体
  • 使用 HttpClient 连接池复用 TCP 连接
  • 用 executeAsync + await 实现非阻塞并发请求
  • 用 stream 回调逐块接收大响应体
  • 用 task.cancel 取消进行中的请求
  • 读取 HttpDiagnostics 诊断连接复用与错误阶段

43.1 快捷函数:一行发请求

xlang 提供三个快捷函数覆盖最常见的 HTTP 操作:

// GET 请求——返回 HttpClientResponse
HttpClientResponse resp = http.get("http://example.com/api/data", 5000)
if resp.ok() then
    println(resp.body())
end

// POST 请求——指定 body 和 Content-Type
HttpClientResponse postResp = http.post(
    "http://example.com/api/submit",
    "{\"name\":\"xlang\"}",
    "application/json; charset=utf-8",
    5000
)
println(postResp.status().toString())

// 任意方法——PUT / PATCH / DELETE / OPTIONS
HttpClientResponse putResp = http.request("PUT", "http://example.com/api/item/1", "{\"done\":true}", "application/json", 5000)
println(putResp.body())

三个函数的最后一个参数都是超时毫秒数。超时后函数返回的响应对象 ok() 为 false,可通过诊断信息查看具体原因。

何时用快捷函数

快捷函数适合"发一次、拿结果"的场景:调用外部 API、健康检查、简单数据拉取。如果你需要自定义请求头、复用连接、流式接收或异步并发,就需要下面的构建器模式。

43.2 HttpClientRequest 构建器

HttpClientRequest 是 HTTP 客户端的核心类型,采用构建器模式——先创建对象,再链式配置,最后执行:

HttpClientRequest req = HttpClientRequest("http://example.com/api/data")
req.method("POST")                    // 设置 HTTP 方法(默认 GET)
req.header("Authorization", "Bearer token123")  // 设置请求头
req.header("X-Request-Id", "abc-001")
req.timeout(10000)                    // 总超时 10 秒
req.idleTimeout(5000)                 // 空闲超时 5 秒
req.verifyPeer(false)                 // 跳过 TLS 证书验证(仅测试用)
req.text("{\"key\":\"value\"}")        // 设置请求体(字符串)

HttpClientResponse resp = req.execute()
println(resp.status().toString())

构建器方法一览:

方法说明
method(m)设置 HTTP 方法:GET / POST / PUT / PATCH / DELETE 等
header(k, v)设置请求头(同名覆盖)
timeout(ms)请求总超时(毫秒)
idleTimeout(ms)连接空闲超时(毫秒)
verifyPeer(bool)是否验证 TLS 证书(默认 true)
text(body)设置字符串请求体
execute()同步执行,返回 HttpClientResponse
execute(client)通过指定连接池执行
executeAsync(client)异步执行,返回 future<HttpClientResponse>
stream(client, hFn, bFn)流式接收,返回 future<HttpClientResponse>

43.3 响应对象全解析

HttpClientResponse 封装了完整的 HTTP 响应:

HttpClientResponse resp = http.get("http://example.com/api", 5000)

// 状态
int code = resp.status()          // 200
bool ok = resp.ok()               // status 在 200-299 范围
string ver = resp.version()       // "HTTP/1.1"

// 响应体
string body = resp.body()         // 字符串形式
int size = resp.bodySize()        // 字节数
int len = resp.contentLength()    // Content-Length 头的值
Buffer raw = resp.bodyBytes()     // 原始字节(二进制安全)
string text = raw.text()          // 从 Buffer 转字符串

// 响应头
string ct = resp.header("Content-Type")   // 取单个头
int count = resp.headerCount()            // 头的总数
string name = resp.headerName(0)          // 按索引取头名

// 协议标志
int flags = resp.flags()
bool keepAlive = (flags & http.responseKeepAlive) != 0
bool chunked = (flags & http.responseChunked) != 0
body() vs bodyBytes()

body() 返回 string,适合文本响应;bodyBytes() 返回 Buffer,适合二进制数据(图片、protobuf)。对于纯文本,两者内容相同,但 Buffer 避免了编码转换开销。

43.4 请求头与响应头

HTTP 头是元数据传递的核心机制。xlang 中设置和读取头都非常直观:

// 设置请求头
HttpClientRequest req = HttpClientRequest("http://example.com/api")
req.header("Accept", "application/json")
req.header("X-Custom", "value1")

HttpClientResponse resp = req.execute()

// 读取响应头——单个值
string server = resp.header("Server")

// 遍历所有响应头(处理重复头如 Set-Cookie)
int i = 0
while i < resp.headerCount()
    string name = resp.headerName(i)
    if name == "Set-Cookie" then
        println("Cookie: " + name)
    end
    i += 1
end

注意:HTTP 允许同一个头名出现多次(典型如 Set-Cookie)。header(name) 只返回第一个匹配值;要获取全部,需要遍历 headerCount() 并用 headerName(i) 逐一检查。

43.5 超时与 TLS 配置

网络请求必须设置超时,否则可能无限阻塞。xlang 提供两级超时:

HttpClientRequest req = HttpClientRequest("https://api.example.com/data")
req.timeout(30000)       // 总超时:从发起到收到完整响应的最大时间
req.idleTimeout(10000)   // 空闲超时:连接建立后,两次数据之间的最大等待
超时类型含义典型值
timeout整个请求的最大耗时5000–60000 ms
idleTimeout连接空闲(无数据传输)的最大等待3000–30000 ms

TLS 相关配置:

// 生产环境:默认验证证书(不要改)
req.verifyPeer(true)   // 默认值,可省略

// 仅开发/测试:跳过证书验证
req.verifyPeer(false)  // ⚠️ 不要在生产代码中使用
⚠️ 安全警告

verifyPeer(false) 会禁用证书链验证,使连接容易受到中间人攻击。仅在本地开发、自签名证书测试时临时使用,生产代码必须保持 true。

43.6 连接池与 HttpClient

每次请求都新建 TCP 连接(三次握手 + TLS 握手)代价高昂。HttpClient 提供连接池,复用已建立的连接:

// 创建连接池
HttpClient client = http.client()

// 多次请求复用同一连接
HttpClientRequest req1 = HttpClientRequest("http://example.com/api/1")
HttpClientResponse resp1 = req1.execute(client)

HttpClientRequest req2 = HttpClientRequest("http://example.com/api/2")
HttpClientResponse resp2 = req2.execute(client)

// 检查连接是否被复用
HttpDiagnostics diag = resp2.diagnostics()
println("连接复用: " + diag.reusedConnection.toString())

// 清理空闲连接
client.closeIdle()

连接池的工作原理:第一次请求建立 TCP 连接后,如果响应头包含 Connection: keep-alive(HTTP/1.1 默认),连接不会关闭而是放回池中。后续对同一主机的请求直接从池中取出连接,省去握手开销。

何时用连接池

当你对同一主机发起多次请求时(API 轮询、批量数据拉取、微服务间调用),连接池能显著降低延迟。单次请求用快捷函数即可,无需显式创建连接池。

43.7 异步请求与 await

同步请求会阻塞当前线程。当你需要并发发起多个请求时,使用 executeAsync + await:

HttpClient client = http.client()

// 发起异步请求——不阻塞
HttpClientRequest req = HttpClientRequest("http://example.com/api/data")
HttpClientResponse resp = await req.executeAsync(client)
println(resp.body())

// 并发多个请求
HttpClientRequest r1 = HttpClientRequest("http://example.com/api/a")
HttpClientRequest r2 = HttpClientRequest("http://example.com/api/b")
HttpClientRequest r3 = HttpClientRequest("http://example.com/api/c")

future<HttpClientResponse> f1 = r1.executeAsync(client)
future<HttpClientResponse> f2 = r2.executeAsync(client)
future<HttpClientResponse> f3 = r3.executeAsync(client)

// 等待全部完成
HttpClientResponse resp1 = await f1
HttpClientResponse resp2 = await f2
HttpClientResponse resp3 = await f3

println("a=" + resp1.body())
println("b=" + resp2.body())
println("c=" + resp3.body())

三个请求同时发出,总耗时约等于最慢的那个请求,而非三者之和。这是异步 I/O 的核心优势。

43.8 流式接收

对于大文件下载或 SSE(Server-Sent Events)等长响应,一次性读入内存不现实。stream 方法通过回调逐块接收:

// 定义头回调——收到响应头时调用,返回 false 中止
func onHeaders(int status, string reason) as bool
    println("状态: " + status.toString() + " " + reason)
    return true   // 继续接收
end

// 定义体回调——每收到一块数据调用,返回 false 中止
func onBody(point data, int size) as bool
    Buffer chunk = http.copyChunk(data, size)
    println("收到 " + chunk.len().toString() + " 字节")
    // 处理 chunk...
    return true   // 继续接收
end

HttpClient client = http.client()
func(int, string) as bool headersFn = onHeaders
func(point, int) as bool bodyFn = onBody

HttpClientRequest req = HttpClientRequest("http://example.com/large-file")
HttpClientResponse resp = await req.stream(client, headersFn, bodyFn)

关键要点:

  • http.copyChunk(data, size) 将原始指针数据复制为安全的 Buffer——回调返回后原始指针即失效
  • 回调返回 false 会中止接收,触发 http.errorCallbackAbort 异常
  • 流式接收仍然是异步的,需要 await
⚠️ 回调中不要持有 data 指针

point data 指向的内部缓冲区在回调返回后即被回收。如果你需要保存数据,必须在回调内调用 http.copyChunk 复制出来。

43.9 请求取消

异步请求发出后,你可能需要取消它(用户关闭页面、超时策略、条件竞争)。xlang 通过 task.cancel 取消 future:

HttpClient client = http.client()
HttpClientRequest req = HttpClientRequest("http://example.com/slow-api")

// 发起异步请求
future<HttpClientResponse> f = req.executeAsync(client)

// 在另一个线程/条件满足时取消
bool cancelled = task.cancel(f)
println("取消已接受: " + cancelled.toString())

// await 已取消的 future 会抛异常
bool caught = false
try
    HttpClientResponse resp = await f
catch e
    caught = true
    println("请求已取消")
end
assert caught

task.cancel 返回 true 表示取消请求已被接受(future 尚未完成);返回 false 表示 future 已经完成,无法取消。取消后 await 会抛出异常,诊断信息中的错误码为 http.errorCancelled。

43.10 诊断信息与错误码

每个响应对象都携带 HttpDiagnostics,记录请求的完整生命周期:

HttpClientResponse resp = req.execute(client)
HttpDiagnostics diag = resp.diagnostics()

// 错误码——0 表示成功
println("错误码: " + diag.errorCode.toString())

// 阶段码——请求停在哪个阶段
println("阶段码: " + diag.phaseCode.toString())

// 连接是否复用
println("复用连接: " + diag.reusedConnection.toString())

// 响应体字节数
println("体大小: " + diag.responseBodyBytes.toString())

错误码全集:

常量值含义
http.errorNone0成功
http.errorInvalidArgument1参数无效
http.errorOutOfMemory2内存不足
http.errorInvalidUrl3URL 格式错误
http.errorConnect4连接失败
http.errorWrite5写入失败
http.errorProtocol6协议错误
http.errorCallbackAbort7回调中止
http.errorTimeoutTotal8总超时
http.errorTimeoutIdle9空闲超时
http.errorCancelled10已取消
http.errorConnectionClosed11连接被对端关闭
http.errorTransport12传输层错误

阶段码全集:

常量值含义
http.phaseNone0未开始
http.phasePrepare1准备阶段
http.phaseConnect2连接阶段
http.phaseWrite3写入请求
http.phaseResponseHeaders4接收响应头
http.phaseResponseBody5接收响应体
http.phaseComplete6完成

诊断信息的典型用法——区分"连不上"和"连上了但超时":

HttpClientResponse resp = http.get("http://unreachable.host/api", 3000)
if not resp.ok() then
    HttpDiagnostics diag = resp.diagnostics()
    if diag.errorCode == http.errorConnect then
        println("无法连接到服务器")
    elif diag.errorCode == http.errorTimeoutTotal then
        println("连接超时,服务器可能过载")
    elif diag.errorCode == http.errorTimeoutIdle then
        println("服务器响应太慢")
    else
        println("错误码: " + diag.errorCode.toString())
    end
end

深入细节

深入 1:连接池的 keep-alive 机制

HTTP/1.1 默认启用 keep-alive:服务器在响应后不关闭 TCP 连接,客户端将连接放回池中。下次对同一 host:port 发请求时直接复用。响应的 flags() 中 http.responseKeepAlive 位为 1 表示连接被保留。如果服务器返回 Connection: close,连接用完即关,池中不留。诊断中 reusedConnection == true 表示本次请求确实复用了之前的连接。

深入 2:流式回调的线程模型

stream 的回调在网络 I/O 线程上执行,不在你的主线程上。这意味着:① 回调中不能访问非线程安全的共享状态;② 回调应尽量轻量(复制数据、累加计数),重计算应放到 await 之后;③ http.copyChunk 在回调线程上分配内存,该内存由 XRT 引用计数管理,回调返回后仍有效。

深入 3:future 取消的语义

task.cancel(f) 是协作式取消:它设置一个取消标志,底层 I/O 操作在下一个检查点发现标志后中止。如果 future 已经完成(响应已收到),cancel 返回 false 且无效果。取消后 await 抛出的异常携带 http.errorCancelled 错误码。注意:取消不保证底层 TCP 连接立即关闭——连接可能被放回池中供后续使用。

⚠️ 常见错误

❌ 错误 1:不检查响应状态就使用 body

http.get 即使服务器返回 404 或 500 也不会抛异常——它正常返回响应对象。必须先用 resp.ok() 或 resp.status() 检查,否则你可能在解析一个错误页面的 HTML。

❌ 错误 2:在流式回调中保存 data 指针

func onBody(point data, int size) 中的 data 是内部缓冲区指针,回调返回后即失效。必须在回调内调用 http.copyChunk(data, size) 复制数据。保存原始指针会导致悬垂引用。

❌ 错误 3:循环中每次创建新连接

在 for 循环里对同一主机发 100 次请求,每次都用 http.get(无连接池),会产生 100 次 TCP 握手。正确做法:创建一个 HttpClient,所有请求通过 req.execute(client) 走连接池。

❌ 错误 4:生产代码中 verifyPeer(false)

禁用证书验证等于告诉攻击者"请随意伪造服务器"。仅在本地测试时临时使用,且加注释标明原因。生产环境必须保持默认的 true。

❌ 错误 5:await 已取消的 future 不 catch

取消后 await 会抛异常。如果不 try/catch,异常会向上传播导致程序崩溃。取消是正常控制流,必须处理。

本章小结

主题核心要点
快捷函数http.get / http.post / http.request 一行发请求,最后参数为超时毫秒
构建器HttpClientRequest 链式配置 method/header/timeout/body,execute() 执行
响应status()/ok()/body()/bodyBytes()/header()/headerCount()/flags()
头操作req.header(k,v) 设置;resp.header(k) 取首个;遍历用 headerCount+headerName
超时timeout 总超时 + idleTimeout 空闲超时;生产环境必须设置
连接池http.client() 创建;req.execute(client) 复用连接;closeIdle() 清理
异步executeAsync 返回 future;await 获取结果;多个 future 并发等待
流式stream(client, hFn, bFn) 逐块回调;http.copyChunk 复制数据;返回 false 中止
取消task.cancel(f) 协作取消;await 已取消 future 抛异常;必须 try/catch
诊断resp.diagnostics() 获取 errorCode/phaseCode/reusedConnection/responseBodyBytes

复习题

  1. http.get 的第二个参数是什么?如果不传会怎样?
  2. HttpClientRequest 的 timeout 和 idleTimeout 有什么区别?各在什么场景下触发?
  3. resp.ok() 在什么条件下返回 true?服务器返回 404 时 ok() 是什么值?
  4. 如何获取响应中所有 Set-Cookie 头的值?为什么不能只用 resp.header("Set-Cookie")?
  5. HttpClient 连接池复用的前提条件是什么?什么情况下连接不会被复用?
  6. executeAsync 返回什么类型?如何并发等待三个异步请求全部完成?
  7. 流式回调中为什么必须调用 http.copyChunk?不调用会怎样?
  8. task.cancel 返回 false 意味着什么?此时 await 会抛异常吗?
  9. HttpDiagnostics 的 phaseCode 为 http.phaseConnect 且 errorCode 为 http.errorConnect,说明什么问题?
  10. verifyPeer(false) 的安全风险是什么?什么场景下可以临时使用?

编程练习

练习 1:API 状态检查器

编写程序,接受一组 URL(硬编码或从命令行读取),对每个 URL 发起 GET 请求,打印:URL、状态码、响应时间(用 time 模块计时)、是否成功。要求使用连接池复用连接,并设置 5 秒超时。

参考思路:创建 HttpClient,循环中构建 HttpClientRequest,记录 time.now() 差值,最后 closeIdle()。

练习 2:并发下载器

给定 5 个 URL,使用 executeAsync 并发发起请求,等待全部完成后打印每个响应的 bodySize。比较并发耗时与串行耗时(用 time 模块测量),验证并发确实更快。

参考思路:先发起所有 executeAsync 收集 future 数组,再逐一 await;用 time.now() 测量总耗时。

练习 3:流式下载进度条

对一个较大的 URL 使用 stream 接收,在 body 回调中累加已接收字节数,每收到一块打印当前进度(已接收字节 / Content-Length)。如果 Content-Length 未知,只打印已接收字节数。

参考思路:在 headers 回调中记录 Content-Length;在 body 回调中累加 size 并 println 进度。