開發網關外掛程式可以擴充API Gateway的核心功能,使其能夠滿足更加複雜和特定的業務需求。本文介紹如何使用Go語言開發網關外掛程式,並提供了本地開發和調試的指引。
Higress 已完成從 TinyGo 0.29 + Go 1.20 編譯方案遷移至 Go 1.24 原生支援的 Wasm 編譯。Go 1.24 版本現已原生支援 Wasm 檔案編譯。
對於此前使用 TinyGo 編譯外掛程式的使用者,若需遷移到 Go 1.24 編譯模式,除調整 go.mod 中的依賴外,還需將外掛程式初始化邏輯由 main 函數移至 init 函數。具體樣本請參考後續文檔內容。
針對原有 TinyGo 實現的外掛程式,還需注意以下適配事項:
1. 若在 Header 處理階段調用外部服務並返回 type.ActionPause,需改為返回 types.HeaderStopAllIterationAndWatermark。相關實現可參考後文提供的外掛程式調用外部服務樣本。
2. 若因 TinyGo 對標準庫 regexp 支援不完整而使用了 go-re2 庫,現應替換為 Go 官方 regexp 包。
工具準備
需要先安裝 Golang。
Golang
官方指引連結(需為1.24版本以上)。
使用 Go 1.24 編譯的外掛程式需要雲原生API Gateway版本不低於2.1.5,低版本網關參考使用 GO 語言開發 WASM 外掛程式。
Windows
下載安裝檔案。
開啟下載好的安裝檔案直接安裝,預設會安裝到
Program Files或Program Files (x86)目錄。安裝成功後,使用鍵盤上的快速鍵Win+R開啟運行視窗,在運行視窗中輸入
cmd單擊確定即可開啟命令視窗,輸入命令go version,成功輸出當前安裝的版本,表明安裝成功。
MacOS
下載安裝檔案。
開啟下載好的安裝檔案雙擊進行安裝,預設會安裝到
/usr/local/go目錄。開啟終端命令列工具,輸入命令
go version,成功輸出當前安裝的版本,表明安裝成功。
Linux
下載安裝檔案。
執行下列命令進行安裝。
安裝Golang。
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gz配置環境變數。
export PATH=$PATH:/usr/local/go/bin執行
go version,成功輸出當前安裝的版本,表明安裝成功。
編寫外掛程式
初始化工程目錄
建立一個工程目錄檔案,例如
wasm-demo-go。在所建目錄下執行以下命令,進行 Go 工程初始化。
go mod init wasm-demo-go國內環境可能需要設定下載依賴包的代理。
go env -w GOPROXY=https://proxy.golang.com.cn,direct下載構建外掛程式的依賴。
go get github.com/higress-group/proxy-wasm-go-sdk@go-1.24 go get github.com/higress-group/wasm-go@main go get github.com/tidwall/gjson
編寫 main.go 檔案
下面是一個簡單樣本,實現了在外掛程式配置mockEnable: true時直接返回hello world應答;未做外掛程式配置,或者設定mockEnable: false時給原始請求添加 hello: world要求標頭。更多例子請參考本文第四節。
注意:在網關控制台中的外掛程式配置為 yaml 格式,下發給外掛程式時將自動轉換為 json 格式,所以例子中的 parseConfig 可以直接從 json 中解析配置。
package main
import (
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
func main() {}
func init() {
wrapper.SetCtx(
// 外掛程式名稱
"my-plugin",
// 為解析外掛程式配置,設定自訂函數
wrapper.ParseConfigBy(parseConfig),
// 為處理要求標頭,設定自訂函數
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
// 自訂外掛程式配置
type MyConfig struct {
mockEnable bool
}
// 在控制台外掛程式配置中填寫的yaml配置會自動轉換為json,此處直接從json這個參數裡解析配置即可
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
// 解析出配置,更新到config中
config.mockEnable = json.Get("mockEnable").Bool()
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
proxywasm.AddHttpRequestHeader("hello", "world")
if config.mockEnable {
proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
}
return types.HeaderContinue
}HTTP 處理掛載點
上面範例程式碼中通過 wrapper.ProcessRequestHeadersBy將自訂函數 onHttpRequestHeaders用於HTTP 要求頭處理階段處理請求。除此之外,還可以通過下面方式,設定其他階段的自訂處理函數。
HTTP 處理階段 | 觸發時機 | 掛載方法 |
HTTP 要求頭處理階段 | 網關接收到用戶端發送來的要求標頭資料時 |
|
HTTP 要求 Body 處理階段 | 網關接收到用戶端發送來的請求 Body 資料時 |
|
HTTP 應答頭處理階段 | 網關接收到後端服務響應的應答頭資料時 |
|
HTTP 應答 Body 處理階段 | 網關接收到後端服務響應的應答 Body 資料時 |
|
工具方法
上面範例程式碼中的 proxywasm.AddHttpRequestHeader 和 proxywasm.SendHttpResponse是外掛程式 SDK 提供的兩個工具方法,主要的工具方法見下表。
分類 | 方法名稱 | 用途 | 可以生效的 HTTP 處理階段 |
要求標頭處理 | GetHttpRequestHeaders | 擷取用戶端請求的全部要求標頭 | HTTP 要求頭處理階段 |
ReplaceHttpRequestHeaders | 替換用戶端請求的全部要求標頭 | HTTP 要求頭處理階段 | |
GetHttpRequestHeader | 擷取用戶端請求的指定要求標頭 | HTTP 要求頭處理階段 | |
RemoveHttpRequestHeader | 移除用戶端請求的指定要求標頭 | HTTP 要求頭處理階段 | |
ReplaceHttpRequestHeader | 替換用戶端請求的指定要求標頭 | HTTP 要求頭處理階段 | |
AddHttpRequestHeader | 新增一個用戶端要求標頭 | HTTP 要求頭處理階段 | |
請求 Body 處理 | GetHttpRequestBody | 擷取用戶端請求 Body | HTTP 要求 Body 處理階段 |
AppendHttpRequestBody | 將指定的位元組串附加到用戶端請求 Body 末尾 | HTTP 要求 Body 處理階段 | |
PrependHttpRequestBody | 將指定的位元組串附加到用戶端請求 Body 的開頭 | HTTP 要求 Body 處理階段 | |
ReplaceHttpRequestBody | 替換用戶端請求 Body | HTTP 要求 Body 處理階段 | |
應答頭處理 | GetHttpResponseHeaders | 擷取後端響應的全部應答頭 | HTTP 應答頭處理階段 |
ReplaceHttpResponseHeaders | 替換後端響應的全部應答頭 | HTTP 應答頭處理階段 | |
GetHttpResponseHeader | 擷取後端響應的指定應答頭 | HTTP 應答頭處理階段 | |
RemoveHttpResponseHeader | 移除後端響應的指定應答頭 | HTTP 應答頭處理階段 | |
ReplaceHttpResponseHeader | 替換後端響應的指定應答頭 | HTTP 應答頭處理階段 | |
AddHttpResponseHeader | 新增一個後端回應標頭 | HTTP 應答頭處理階段 | |
應答 Body 處理 | GetHttpResponseBody | 擷取用戶端請求 Body | HTTP 應答 Body 處理階段 |
AppendHttpResponseBody | 將指定的位元組串附加到後端響應 Body 末尾 | HTTP 應答 Body 處理階段 | |
PrependHttpResponseBody | 將指定的位元組串附加到後端響應 Body 的開頭 | HTTP 應答 Body 處理階段 | |
ReplaceHttpResponseBody | 替換後端響應 Body | HTTP 應答 Body 處理階段 | |
HTTP 調用 | DispatchHttpCall | 發送一個 HTTP 要求 | - |
GetHttpCallResponseHeaders | 擷取 DispatchHttpCall 請求響應的應答頭 | - | |
GetHttpCallResponseBody | 擷取 DispatchHttpCall 請求響應的應答 Body | - | |
GetHttpCallResponseTrailers | 擷取 DispatchHttpCall 請求響應的應答 Trailer | - | |
直接響應 | SendHttpResponse | 直接返回一個特定的 HTTP 應答 | - |
流程恢複 | ResumeHttpRequest | 恢複先前被暫停請求處理流程 | - |
ResumeHttpResponse | 恢複先前被暫停應答處理流程 | - |
請不要在請求/響應未處於Pause狀態時,調用ResumeHttpRequest或調用ResumeHttpResponse。尤其注意在SendHttpResponse之後,Pause狀態的請求/響應將自動回復,若再調用ResumeHttpRequest或ResumeHttpResponse將導致未定義的行為。
編譯產生 WASM 檔案
本地編譯 wasm 檔案
如果使用自訂初始化的目錄,執行以下命令來編譯 wasm 檔案。
go mod tidy
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm ./成功編譯後,會產生一個名為 main.wasm 的新檔案。該檔案將在後續的本地調試樣本中使用。當需要在雲原生網關市場中使用自訂外掛程式功能時,上傳此檔案即可。
Header 的狀態管理說明
Header | 說明 |
| 表示當前 filter 已經處理完畢,可以繼續交給下一個 filter 處理。 |
| 表示 header 還不能繼續交給下一個 filter 進行處理。 但並不停止從串連讀資料,繼續觸發 body data 的處理。 這樣可以在 body data 處理階段更新 Http 要求標頭內容。 如果 body data 需要交給下一個 filter 處理, 此時 header 也會被一起交給下一個 filter 處理。 說明 返回該狀態時,要求必須有 body,如果沒有 body,請求/響應將被一直阻塞。 判斷是否存在請求 body 可以使用 HasRequestBody()。 |
| 表示 header 可以繼續交給下一個 filter 處理,但下一個 filter 收到的 |
| 停止所有迭代,表示 header 不能繼續交給下一個 filter,並且當前 filter 也不能收到 body data。 並對當前過濾器及後續過濾器的頭部、資料和尾部進行緩衝。如果緩衝大小超過了 buffer limit,在要求階段就直接返回 413,響應階段就直接返回 500。 同時需要調用 |
| 與 |
關於 types.HeaderStopIteration 和 HeaderStopAllIterationAndWatermark 的使用情境可以參考 Higress 官方提供 ai-transformer 外掛程式和 ai-quota 外掛程式。
要在 Higress 中配合 Wasmplugin CRD 或者 Console 的 UI 互動配置該外掛程式,需要將該 wasm 檔案打包成 oci 或者 docker 鏡像,可以參考《自訂外掛程式》。
本地調試
工具準備
安裝Docker。
使用docker compose啟動驗證
進入在編寫外掛程式時建立的目錄,例如wasm-demo目錄,確認該目錄下已經編譯產生main.wasm檔案。
在目錄下建立檔案docker-compose.yaml,內容如下:
version: '3.7' services: envoy: image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.1.5 entrypoint: /usr/local/bin/envoy # 注意這裡對wasm開啟了debug層級日誌,正式部署時則預設info層級 command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug depends_on: - httpbin networks: - wasmtest ports: - "10000:10000" volumes: - ./envoy.yaml:/etc/envoy/envoy.yaml - ./main.wasm:/etc/envoy/main.wasm httpbin: image: kennethreitz/httpbin:latest networks: - wasmtest ports: - "12345:80" networks: wasmtest: {}繼續在該目錄下建立檔案envoy.yaml,內容如下:
admin: address: socket_address: protocol: TCP address: 0.0.0.0 port_value: 9901 static_resources: listeners: - name: listener_0 address: socket_address: protocol: TCP address: 0.0.0.0 port_value: 10000 filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager scheme_header_transformation: scheme_to_overwrite: https stat_prefix: ingress_http route_config: name: local_route virtual_hosts: - name: local_service domains: ["*"] routes: - match: prefix: "/" route: cluster: httpbin http_filters: - name: wasmdemo typed_config: "@type": type.googleapis.com/udpa.type.v1.TypedStruct type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm value: config: name: wasmdemo vm_config: runtime: envoy.wasm.runtime.v8 code: local: filename: /etc/envoy/main.wasm configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { "mockEnable": false } - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: httpbin connect_timeout: 30s type: LOGICAL_DNS # Comment out the following line to test on v6 networks dns_lookup_family: V4_ONLY lb_policy: ROUND_ROBIN load_assignment: cluster_name: httpbin endpoints: - lb_endpoints: - endpoint: address: socket_address: address: httpbin port_value: 80執行以下命令啟動docker compose。
docker compose up
功能驗證
WASM功能驗證
使用curl直接存取httpbin,可以看到不經過網關時的要求標頭內容,如下。
curl http://127.0.0.1:12345/get { "args": {}, "headers": { "Accept": "*/*", "Host": "127.0.0.1:12345", "User-Agent": "curl/7.79.1" }, "origin": "172.18.0.1", "url": "http://127.0.0.1:12345/get" }使用curl通過網關訪問httpbin,可以看到經過網關處理後的要求標頭的內容,如下。
curl http://127.0.0.1:10000/get { "args": {}, "headers": { "Accept": "*/*", "Hello": "world", "Host": "127.0.0.1:10000", "Original-Host": "127.0.0.1:10000", "Req-Start-Time": "1681269273896", "User-Agent": "curl/7.79.1", "X-Envoy-Expected-Rq-Timeout-Ms": "15000" }, "origin": "172.18.0.3", "url": "https://127.0.0.1:10000/get" }
此時上文編寫外掛程式的功能已生效,加入了hello: world要求標頭。
外掛程式配置修改驗證
修改envoy.yaml,將
mockEnable配置修改為true。configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { "mockEnable": true }使用curl通過網關訪問httpbin,可以看到經過網關處理後的要求標頭的內容,如下。
curl http://127.0.0.1:10000/get hello world
說明外掛程式配置修改生效,開啟了mock應答直接返回了hello world。
更多樣本
無配置外掛程式
外掛程式無需配置時,直接定義空結構體即可。
package main
import (
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
)
func main() {}
func init() {
wrapper.SetCtx(
"hello-world",
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
type MyConfig struct {}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
return types.HeaderContinue
}在外掛程式中請求外部服務
目前僅支援 http 調用,支援訪問在網關控制台中設定了服務來源的 Nacos、K8s 服務,以及固定地址或 DNS 來源的服務。請注意,無法直接使用net/http庫中的 HTTP client,必須使用如下例中封裝的 HTTP client。
下面例子中,在配置解析階段解析服務類型,產生對應的 HTTP client ;在要求標頭處理階段根據配置的請求路徑訪問對應服務,解析應答頭,然後再設定在原始的要求標頭中。
package main
import (
"errors"
"net/http"
"strings"
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
func main() {}
func init() {
wrapper.SetCtx(
"http-call",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
type MyConfig struct {
// 用於發起HTTP調用client
client wrapper.HttpClient
// 請求url
requestPath string
// 根據這個key取出調用服務的應答頭對應欄位,再設定到原始請求的要求標頭,key為此配置項
tokenHeader string
}
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
config.tokenHeader = json.Get("tokenHeader").String()
if config.tokenHeader == "" {
return errors.New("missing tokenHeader in config")
}
config.requestPath = json.Get("requestPath").String()
if config.requestPath == "" {
return errors.New("missing requestPath in config")
}
// 帶服務類型的完整 FQDN 名稱,例如 my-svc.dns, my-svc.static, service-provider.DEFAULT-GROUP.public.nacos, httpbin.my-ns.svc.cluster.local
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// 靜態IP類型服務的邏輯連接埠是80
servicePort = 80
}
}
config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: servicePort,
})
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
// 使用client的Get方法發起HTTP Get調用,此處省略了timeout參數,預設逾時時間500毫秒
err := config.client.Get(config.requestPath, nil,
// 回呼函數,將在響應非同步返回時被執行
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
// 請求沒有返回200狀態代碼,進行處理
if statusCode != http.StatusOK {
log.Errorf("http call failed, status: %d", statusCode)
proxywasm.SendHttpResponse(http.StatusInternalServerError, nil,
[]byte("http call failed"), -1)
return
}
// 列印響應的HTTP狀態代碼和應答body
log.Infof("get status: %d, response body: %s", statusCode, responseBody)
// 從應答頭中解析token欄位設定到原始要求標頭中
token := responseHeaders.Get(config.tokenHeader)
if token != "" {
proxywasm.AddHttpRequestHeader(config.tokenHeader, token)
}
// 恢複原始請求流程,繼續往下處理,才能正常轉寄給後端服務
proxywasm.ResumeHttpRequest()
})
if err != nil {
// 由於調用外部服務失敗,允許存取請求,記錄日誌
log.Errorf("Error occured while calling http, it seems cannot find the service cluster.")
return types.ActionContinue
} else {
// 需要等待非同步回調完成,返回HeaderStopAllIterationAndWatermark狀態,可以被ResumeHttpRequest恢複
return types.HeaderStopAllIterationAndWatermark
}
}在外掛程式中調用Redis
使用如下範例程式碼實現Redis限流外掛程式。
package main
import (
"strconv"
"time"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
"github.com/tidwall/resp"
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
)
func main() {}
func init() {
wrapper.SetCtx(
"redis-demo",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
)
}
type RedisCallConfig struct {
client wrapper.RedisClient
qpm int
}
func parseConfig(json gjson.Result, config *RedisCallConfig, log logs.Log) error {
// 帶服務類型的完整 FQDN 名稱,例如 my-redis.dns、redis.my-ns.svc.cluster.local
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// 靜態IP類型服務的邏輯連接埠是80
servicePort = 80
} else {
servicePort = 6379
}
}
username := json.Get("username").String()
password := json.Get("password").String()
// 單位是毫秒
timeout := json.Get("timeout").Int()
if timeout == 0 {
timeout = 1000
}
qpm := json.Get("qpm").Int()
config.qpm = int(qpm)
config.client = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: servicePort,
})
return config.client.Init(username, password, timeout)
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log logs.Log) types.Action {
now := time.Now()
minuteAligned := now.Truncate(time.Minute)
timeStamp := strconv.FormatInt(minuteAligned.Unix(), 10)
// 如果 redis api 返回的 err != nil,一般是由於網關找不到 redis 後端服務,請檢查是否誤刪除了 redis 後端服務
err := config.client.Incr(timeStamp, func(response resp.Value) {
if response.Error() != nil {
log.Errorf("call redis error: %v", response.Error())
proxywasm.ResumeHttpRequest()
} else {
ctx.SetContext("timeStamp", timeStamp)
ctx.SetContext("callTimeLeft", strconv.Itoa(config.qpm-response.Integer()))
if response.Integer() == 1 {
err := config.client.Expire(timeStamp, 60, func(response resp.Value) {
if response.Error() != nil {
log.Errorf("call redis error: %v", response.Error())
}
proxywasm.ResumeHttpRequest()
})
if err != nil {
log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
proxywasm.ResumeHttpRequest()
}
} else {
if response.Integer() > config.qpm {
proxywasm.SendHttpResponse(429, [][2]string{{"timeStamp", timeStamp}, {"callTimeLeft", "0"}}, []byte("Too many requests\n"), -1)
} else {
proxywasm.ResumeHttpRequest()
}
}
}
})
if err != nil {
// 由於調用redis失敗,允許存取請求,記錄日誌
log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
return types.HeaderContinue
} else {
// 請求hold住,等待redis調用完成
return types.HeaderStopAllIterationAndWatermark
}
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log logs.Log) types.Action {
if ctx.GetContext("timeStamp") != nil {
proxywasm.AddHttpResponseHeader("timeStamp", ctx.GetContext("timeStamp").(string))
}
if ctx.GetContext("callTimeLeft") != nil {
proxywasm.AddHttpResponseHeader("callTimeLeft", ctx.GetContext("callTimeLeft").(string))
}
return types.HeaderContinue
}