本章导读
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() 返回 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
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.errorNone | 0 | 成功 |
http.errorInvalidArgument | 1 | 参数无效 |
http.errorOutOfMemory | 2 | 内存不足 |
http.errorInvalidUrl | 3 | URL 格式错误 |
http.errorConnect | 4 | 连接失败 |
http.errorWrite | 5 | 写入失败 |
http.errorProtocol | 6 | 协议错误 |
http.errorCallbackAbort | 7 | 回调中止 |
http.errorTimeoutTotal | 8 | 总超时 |
http.errorTimeoutIdle | 9 | 空闲超时 |
http.errorCancelled | 10 | 已取消 |
http.errorConnectionClosed | 11 | 连接被对端关闭 |
http.errorTransport | 12 | 传输层错误 |
阶段码全集:
| 常量 | 值 | 含义 |
|---|---|---|
http.phaseNone | 0 | 未开始 |
http.phasePrepare | 1 | 准备阶段 |
http.phaseConnect | 2 | 连接阶段 |
http.phaseWrite | 3 | 写入请求 |
http.phaseResponseHeaders | 4 | 接收响应头 |
http.phaseResponseBody | 5 | 接收响应体 |
http.phaseComplete | 6 | 完成 |
诊断信息的典型用法——区分"连不上"和"连上了但超时":
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
深入细节
HTTP/1.1 默认启用 keep-alive:服务器在响应后不关闭 TCP 连接,客户端将连接放回池中。下次对同一 host:port 发请求时直接复用。响应的 flags() 中 http.responseKeepAlive 位为 1 表示连接被保留。如果服务器返回 Connection: close,连接用完即关,池中不留。诊断中 reusedConnection == true 表示本次请求确实复用了之前的连接。
stream 的回调在网络 I/O 线程上执行,不在你的主线程上。这意味着:① 回调中不能访问非线程安全的共享状态;② 回调应尽量轻量(复制数据、累加计数),重计算应放到 await 之后;③ http.copyChunk 在回调线程上分配内存,该内存由 XRT 引用计数管理,回调返回后仍有效。
task.cancel(f) 是协作式取消:它设置一个取消标志,底层 I/O 操作在下一个检查点发现标志后中止。如果 future 已经完成(响应已收到),cancel 返回 false 且无效果。取消后 await 抛出的异常携带 http.errorCancelled 错误码。注意:取消不保证底层 TCP 连接立即关闭——连接可能被放回池中供后续使用。
⚠️ 常见错误
http.get 即使服务器返回 404 或 500 也不会抛异常——它正常返回响应对象。必须先用 resp.ok() 或 resp.status() 检查,否则你可能在解析一个错误页面的 HTML。
func onBody(point data, int size) 中的 data 是内部缓冲区指针,回调返回后即失效。必须在回调内调用 http.copyChunk(data, size) 复制数据。保存原始指针会导致悬垂引用。
在 for 循环里对同一主机发 100 次请求,每次都用 http.get(无连接池),会产生 100 次 TCP 握手。正确做法:创建一个 HttpClient,所有请求通过 req.execute(client) 走连接池。
禁用证书验证等于告诉攻击者"请随意伪造服务器"。仅在本地测试时临时使用,且加注释标明原因。生产环境必须保持默认的 true。
取消后 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 |
复习题
- http.get 的第二个参数是什么?如果不传会怎样?
- HttpClientRequest 的 timeout 和 idleTimeout 有什么区别?各在什么场景下触发?
- resp.ok() 在什么条件下返回 true?服务器返回 404 时 ok() 是什么值?
- 如何获取响应中所有 Set-Cookie 头的值?为什么不能只用 resp.header("Set-Cookie")?
- HttpClient 连接池复用的前提条件是什么?什么情况下连接不会被复用?
- executeAsync 返回什么类型?如何并发等待三个异步请求全部完成?
- 流式回调中为什么必须调用 http.copyChunk?不调用会怎样?
- task.cancel 返回 false 意味着什么?此时 await 会抛异常吗?
- HttpDiagnostics 的 phaseCode 为 http.phaseConnect 且 errorCode 为 http.errorConnect,说明什么问题?
- verifyPeer(false) 的安全风险是什么?什么场景下可以临时使用?
编程练习
编写程序,接受一组 URL(硬编码或从命令行读取),对每个 URL 发起 GET 请求,打印:URL、状态码、响应时间(用 time 模块计时)、是否成功。要求使用连接池复用连接,并设置 5 秒超时。
参考思路:创建 HttpClient,循环中构建 HttpClientRequest,记录 time.now() 差值,最后 closeIdle()。
给定 5 个 URL,使用 executeAsync 并发发起请求,等待全部完成后打印每个响应的 bodySize。比较并发耗时与串行耗时(用 time 模块测量),验证并发确实更快。
参考思路:先发起所有 executeAsync 收集 future 数组,再逐一 await;用 time.now() 测量总耗时。
对一个较大的 URL 使用 stream 接收,在 body 回调中累加已接收字节数,每收到一块打印当前进度(已接收字节 / Content-Length)。如果 Content-Length 未知,只打印已接收字节数。
参考思路:在 headers 回调中记录 Content-Length;在 body 回调中累加 size 并 println 进度。