Day 14 / 共 20 天 · 第 3 周 HTTP 与外部调用

HTTP 外部调用

插件常需要"调个外部服务"——鉴权中心、AI 后端、配置服务。今天拆 HttpCall:用昨天(Day 13)的 Cluster 指定"调谁",异步发起、回调处理,配合 Day 11 学的 ActionPause → Resume 的节奏。这是 Wasm 异步编程的核心,也是明天 Redis 调用(Day 15)的同一套思路。

📍 你在整门课的位置(wasm-go 20 天 · 第 3 周 HTTP 与外部调用)
Cluster D13 HttpCall 异步 D14 Redis D15 日志 D16 Leader D17
L01

为什么要外部调用

💡 本质:异步回调 = 餐厅取餐叫号 你在快餐店点了餐,店员给你一个叫号器(发起 HttpCall)就让你去坐(ActionPause 让出线程)——不会让你堵在柜台前干等。后厨做好了,叫号器一响(回调触发),你再去取餐、开吃(回调里处理 + ResumeHttpRequest)。柜台(worker 线程)因此能同时服务很多人,而不是被你一个人占住。这就是"不能阻塞"逼出来的异步模型。
插件常常不能"就地解决" 很多逻辑需要问别人:鉴权要问鉴权中心"这个 token 有效吗"、AI 网关要调 LLM、限流要问 Redis 当前计数。这些都要在处理请求的过程中调用外部服务。但 Wasm 不能阻塞(Day 03)——所以外部调用是"异步回调式"的:发起后立刻返回 ActionPause 让出线程,结果到了在回调里继续。适应这个思维是写复杂插件的关键。
worker 线程的时间线(横轴 = 时间) 钩子: 发起 HttpCallreturn ActionPause ↩ worker 空出来 → 去服务别的请求 回调触发: 处理结果 + Resume请求从暂停处继续转发 外部服务处理中(Envoy 代发,你不占线程) ⚠️ 忘了 Resume → 请求永远卡在暂停处,直到超时;不 Pause → 结果没到就往下走,拿到空结果。
图注:Pause 期间 worker 不空等——它去服务别人;外部结果到了才回调、Resume,请求接着走。这是"叫号器"模型的时间线版。
L02

ClusterClient

http_wrapper.go:46-86ClusterClient[C Cluster] 包住一个 Cluster,提供 Get/Post/Put/Delete 等方法:

client := wrapper.NewClusterClient(wrapper.FQDNCluster{FQDN: "api.example.com", Port: 443})
client.Post("/v1/check", headers, body, callback, 5000)  // 5s 超时
// Get/Head/Options/Post/Put/Patch/Delete/Connect/Trace/Call 都有(:54-82)
读法:ClusterClient 是对 HttpCall 的友好封装——你不用记 HTTP 方法字符串,直接 .Post(...)。泛型参数 C Cluster 让它接受任意一种 Cluster(Day 13)。这是写插件时发起 HTTP 调用的推荐方式。
L03

http-call 例子

examples/http-call/main.go 完整演示(parseConfig 里建 client,请求头钩子里发起调用):

func parseConfig(json gjson.Result, config *HttpCallConfig) error {
    cluster := wrapper.FQDNCluster{FQDN: json.Get("fqdn").String(), Port: json.Get("port").Int()}
    config.client = wrapper.NewClusterClient(cluster)   // 建好 client 存进配置
    config.requestPath = json.Get("path").String()
    return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config HttpCallConfig) types.Action {
    err := config.client.Post(config.requestPath, headers, body,
        func(statusCode int, responseHeaders http.Header, responseBody []byte) {  // 回调
            proxywasm.AddHttpRequestHeader("X-External-Response", string(responseBody))
            proxywasm.ResumeHttpRequest()   // 结果到了 → 恢复请求
        }, 5000)
    if err != nil { return types.ActionContinue }
    return types.ActionPause   // 先暂停,等回调
}
读法:注意两个关键点:① 请求头钩子返回 ActionPause(先别转发,等外部结果);② 回调里处理完调 ResumeHttpRequest()(恢复请求继续转发)。client 在 parseConfig 阶段建好、存进配置,请求时复用。
L04

Pause / Resume 节奏

钩子发起 HttpCall returnActionPause 网关去处理别的请求 响应到触发回调 回调处理+Resume 网关继续转发
这个节奏一定要记牢 发起异步调用 → 返回 Pause(让出线程)→ 网关空出来干别的 → 你的调用有结果了 → 回调被触发 → 回调里处理结果 + Resume → 请求从暂停处继续。忘了 Resume 请求就永远卡住(超时);不返回 Pause 请求会在结果没到时就往下走(拿不到结果)。Pause 和 Resume 必须成对——这是异步插件最容易出 bug 的地方。
📝 单步走查:一个请求闯"鉴权后端"的全过程 t1 请求头钩子被调 → 发起 client.Post("/check", ...),返回 ActionPause。此刻请求状态 = 暂停
t2 worker 去处理别的请求;本请求的 ctx 挂起等待。
t3 鉴权后端返回 200 {"ok":true} → 回调触发,statusCode=200
t4 回调里判断通过 → proxywasm.ResumeHttpRequest()。请求状态 = 继续
t5 网关把请求转发给真正的后端。✅ 全程 worker 没被"干等"占用。
L05

HttpCall 内部

http_wrapper.go:90-160HttpCall 底层用 proxywasm.DispatchHttpCall

// 先剔除用户传的 :method/:path/:authority 伪头(避免冲突,:91-96)
// 解析 rawURL → authority/path
authority := cluster.HostName()               // 默认用 Cluster 的 HostName
if parsedURL.Host != "" { authority = parsedURL.Host }
// 补上伪头
headers = append(headers, {":method",method}, {":path",path}, {":authority",authority})
// 发起(默认超时 500ms)
proxywasm.DispatchHttpCall(cluster.ClusterName(), headers, body, nil, timeout, func(...) {
    // 响应回调(见 L06)
})
读法:看到 Day 13 的伏笔回收了——cluster.ClusterName() 定"发给谁"、cluster.HostName():authorityDispatchHttpCall 是 proxy-wasm 的宿主调用,把请求交给 Envoy 发出,Envoy 收到响应后触发你的回调。每次调用生成 requestID(uuid)用于日志追踪。
L06

回调解析响应

http_wrapper.go:121-152 回调里解析响应:

respBody, _ := proxywasm.GetHttpCallResponseBody(0, bodySize)
respHeaders, _ := proxywasm.GetHttpCallResponseHeaders()
code := http.StatusBadGateway
for _, h := range respHeaders {
    if h[0] == ":status" { code, _ = strconv.Atoi(h[1]) }  // 从 :status 伪头取状态码
    headers.Add(h[0], h[1])
}
callback(code, headers, respBody)   // 调你注册的回调,交给你处理
SDK 帮你把原始响应"翻译"成好用的形式 底层拿到的是原始头/体,SDK 帮你解析出状态码(从 :status 伪头)、组装成 http.Header,再调你的回调 callback(code, headers, body)你只管在回调里用这三个值做业务(判断成功、读 body、改请求)。注意日志用 log.UnsafeInfof(Day 16 讲)——因为响应体可能含敏感信息。
L07

超时与陷阱

  • 默认超时 500ms:116-119),可传参覆盖(timeoutMillisecond ...uint32)。
  • 超时/失败时回调仍会被调用(code 可能是 502 BadGateway),要处理错误分支。
  • IO 周期陷阱:插件逻辑太重 + 高并发时,回调可能来不及在本 IO 周期处理——用 WithMaxRequestsPerIoCycle(Day 10/19)缓解。
⚠️ 常见误解:小白常以为"回调只在成功时被调用"。其实超时、连接失败也会触发回调,只是 code 是 502 之类。所以别把 Resume 只写在 if code==200 里——否则失败分支的请求会一直悬着直到超时。
别在回调里忘了 Resume 无论成功失败,回调里都要有明确的下一步:成功用结果 + Resume,失败也要 Resume(或 SendHttpResponse 返回错误)——绝不能让请求悬着。常见 bug:只在成功分支 Resume,失败时请求卡到超时。写异步插件的黄金法则:每条路径都要终结请求。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么外部调用必须异步回调?
  • Pause/Resume 的完整节奏?忘了 Resume 会怎样?
  • HttpCall 怎么用 Cluster 的 ClusterName/HostName?
  • 回调里怎么拿状态码和 body?默认超时多少?

✋ 动手

cd /Users/bitmart/work/codes/github/higress-group/wasm-go
cat examples/http-call/main.go
sed -n '90,160p' pkg/wrapper/http_wrapper.go
cat examples/complex-http-call/main.go | head -80
明天预告 · Day 15(第 3 周收官)Redis 调用——RedisClusterClient 怎么 Init、Get/Set/Incr/Eval 等命令、以及为什么它也是异步回调式的。限流、计数类插件的必备能力。
← Day 13 Day 15 · Redis →