本文中含有需要您注意的重要提示信息,忽略该信息可能对您的业务造成影响,请务必仔细阅读。
开发网关插件可以扩展API网关的核心功能,使其能够满足更加复杂和特定的业务需求。本文介绍如何使用Go语言开发网关插件,并提供了本地开发和调试的指引。
准备工作
安装Golang、TinyGo和Binaryen三个程序。
Golang
官方指引链接(需为1.18版本以上)。
Windows
下载安装文件。
打开下载好的安装文件,双击进行安装。安装成功后,使用键盘上的快捷键Win+R打开运行窗口,在运行窗口中输入
cmd
单击确定即可打开命令窗口,输入命令go version
,成功输出当前安装的版本,表明安装成功。
macOS
下载安装文件。
打开下载好的安装文件双击进行安装,默认会安装到
/usr/local/go
目录。打开终端命令行工具,输入命令
go version
,成功输出当前安装的版本,表明安装成功。
Linux
下载安装文件。
执行下列命令进行安装。
安装Golang。
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.19.linux-amd64.tar.gz
配置环境变量。
export PATH=$PATH:/usr/local/go/bin
执行
go version
,成功输出当前安装的版本,表明安装成功。
TinyGo
官方指引链接(固定为0.28.1版本)。
Windows
下载安装文件。
解压安装文件到指定目录。
配置环境变量。如果安装解压后的目录为
C:\tinygo
,则需要将C:\tinygo\bin
添加到环境变量PATH
中,例如在命令窗口中输入set
命令设置。set PATH=%PATH%;"C:\tinygo\bin";
在命令窗口执行命令
tinygo version
,成功输出当前安装的版本,表明安装成功。
macOS
下载压缩包并解压。
wget https://github.com/tinygo-org/tinygo/releases/download/v0.28.1/tinygo0.28.1.darwin-amd64.tar.gz tar -zxf tinygo0.28.1.darwin-amd64.tar.gz
配置环境变量。如果安装解压后的目录为
/tmp
,则需要将/tmp/tinygo/bin
添加到环境变量PATH
中。export PATH=/tmp/tinygo/bin:$PATH
在终端执行
tinygo version
,成功输出当前安装的版本,表明安装成功。
Linux
以Ubuntu下amd64架构为例,其他系统请参考官方指引链接。
下载并安装DEB文件。
下载文件:
wget https://github.com/tinygo-org/tinygo/releases/download/v0.28.1/tinygo_0.28.1_amd64.deb
安装文件:
sudo dpkg -i tinygo_0.28.1_amd64.deb
配置环境变量:
export PATH=$PATH:/usr/local/bin
在终端执行
tinygo version
,输出当前安装的版本,表明安装成功。
Binaryen
用于优化wasm文件的编译过程。
Windows
下载安装文件。
解压安装文件。将解压文件中的bin\wasm-opt.exe文件拷贝到tinygo所在的bin目录下。
macOS
执行下列命令进行安装。
brew install binaryen
Linux
以Ubuntu为例。执行下列命令进行安装。
apt-get -y install binaryen
编写插件
步骤一:初始化工程目录
新建一个工程目录文件,例如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 get github.com/alibaba/higress/plugins/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/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"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() {
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 wrapper.Log) error {
// 解析出配置,更新到config中
config.mockEnable = json.Get("mockEnable").Bool()
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log wrapper.Log) types.Action {
proxywasm.AddHttpRequestHeader("hello", "world")
if config.mockEnable {
proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
}
return types.ActionContinue
}
HTTP处理挂载点
上文示例代码中通过wrapper.ProcessRequestHeadersBy
将自定义函数onHttpRequestHeaders
用于HTTP请求头处理阶段处理请求。除此之外,还可以通过下面的方式设置其他阶段的自定义处理函数。
HTTP处理阶段 | 触发时机 | 挂载方法 |
HTTP请求头处理阶段 | 网关接收到客户端发送来的请求头数据时 | wrapper.ProcessRequestHeadersBy |
HTTP请求Body处理阶段 | 网关接收到客户端发送来的请求Body数据时 | wrapper.ProcessRequestBodyBy |
HTTP应答头处理阶段 | 网关接收到后端服务响应的应答头数据时 | wrapper.ProcessResponseHeadersBy |
HTTP应答Body处理阶段 | 网关接收到后端服务响应的应答Body数据时 | wrapper.ProcessResponseBodyBy |
工具方法
上文示例代码中的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文件。
go mod tidy
tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer' ./main.go
编译成功会在当前目录下创建文件main.wasm。该文件在下文本地调试的示例中也会被用到。
在使用云原生网关插件市场的自定义插件功能时,直接上传该文件即可。
本地调试
本示例用到的代码和配置,请参见Demo。
工具准备
安装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:v1.4.0-rc.1 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,内容如下:
执行以下命令启动
docker compose
。docker compose up
功能验证
WASM功能验证
使用浏览器直接访问httpbin(http://127.0.0.1:12345/get),可以看到不经过网关时的请求头内容,返回结果如下所示:
使用浏览器通过网关访问httpbin(http://127.0.0.1:10000/get),可以看到经过网关处理后的请求头的内容,返回结果如下所示,说明此时上文编写插件的功能已经生效了,加入了
hello: world
请求头。
插件配置修改验证
修改envoy.yaml,将
mockEnable
配置修改为true
。使用浏览器通过网关访问httpbin(http://127.0.0.1:10000/get),可以看到经过网关处理后的请求头的内容。返回结果如下所示,说明插件配置修改生效,开启了
mock
应答直接返回了hello world
。
示例
无配置插件
插件无需配置时,直接定义空结构体即可。
package main
import (
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
)
func main() {
wrapper.SetCtx(
"hello-world",
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
type MyConfig struct {}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log wrapper.Log) types.Action {
proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
return types.ActionContinue
}
在插件中请求外部服务
目前仅支持HTTP调用,支持访问在网关控制台中设置了服务来源的Nacos、K8s服务,以及固定地址或DNS来源的服务。请注意,无法直接使用net/http
库中的HTTP Client,必须使用如下示例中封装的HTTP Client。示例中,在配置解析阶段解析服务类型,生成对应的HTTP Client,在请求头处理阶段根据配置的请求路径访问对应服务,解析应答头,然后再设置在原始的请求头中。
package main
import (
"errors"
"net/http"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"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() {
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 wrapper.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")
}
serviceSource := json.Get("serviceSource").String()
// 固定地址和dns类型的serviceName,为控制台中创建服务时指定
// nacos和k8s来源的serviceName,即服务注册时指定的原始名称
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if serviceName == "" || servicePort == 0 {
return errors.New("invalid service config")
}
switch serviceSource {
case "k8s":
namespace := json.Get("namespace").String()
config.client = wrapper.NewClusterClient(wrapper.K8sCluster{
ServiceName: serviceName,
Namespace: namespace,
Port: servicePort,
})
return nil
case "nacos":
namespace := json.Get("namespace").String()
config.client = wrapper.NewClusterClient(wrapper.NacosCluster{
ServiceName: serviceName,
NamespaceID: namespace,
Port: servicePort,
})
return nil
case "ip":
config.client = wrapper.NewClusterClient(wrapper.StaticIpCluster{
ServiceName: serviceName,
Port: servicePort,
})
return nil
case "dns":
domain := json.Get("domain").String()
config.client = wrapper.NewClusterClient(wrapper.DnsCluster{
ServiceName: serviceName,
Port: servicePort,
Domain: domain,
})
return nil
default:
return errors.New("unknown service source: " + serviceSource)
}
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log wrapper.Log) types.Action {
// 使用client的Get方法发起HTTP Get调用,此处省略了timeout参数,默认超时时间500毫秒
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)
// SendHttpResponse后请求将自动恢复,不用调用ResumeHttpRequest,这里直接return
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()
})
// 需要等待异步回调完成,返回Pause状态,可以被ResumeHttpRequest恢复
return types.ActionPause
}
在插件中调用Redis
网关支持在插件中调用Redis。在插件配置解析阶段根据插件配置中Redis服务相关信息创建Redis Client,之后可以在请求的不同阶段调用外部Redis服务。关于Redis Client支持的接口,请参见Redis Client接口定义。
请不要在配置解析阶段(例如代码实例parseConfig函数),执行Redis Client中除了Init以外的其他接口。配置解析阶段发起Redis命令将导致一些未定义行为。
以下示例演示如何通过网关Redis插件以及阿里云Redis数据库实现请求限流。代码请参见代码示例。
登录阿里云Redis控制台,创建Redis实例,并设置连接密码。具体操作,请参见快速入门概览。
记录Redis的连接地址,在网关添加服务,配置服务来源为DNS域名。具体操作,请参见添加服务。
使用如下示例代码创建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/alibaba/higress/plugins/wasm-go/pkg/wrapper" ) func main() { 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 wrapper.Log) error { serviceName := json.Get("serviceName").String() servicePort := json.Get("servicePort").Int() domain := json.Get("domain").String() username := json.Get("username").String() password := json.Get("password").String() timeout := json.Get("timeout").Int() qpm := json.Get("qpm").Int() config.qpm = int(qpm) config.client = wrapper.NewRedisClusterClient(wrapper.DnsCluster{ ServiceName: serviceName, Port: servicePort, Domain: domain, }) return config.client.Init(username, password, timeout) } func onHttpRequestHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log wrapper.Log) types.Action { now := time.Now() minuteAligned := now.Truncate(time.Minute) timeStamp := strconv.FormatInt(minuteAligned.Unix(), 10) config.client.Incr(timeStamp, func(status int, response resp.Value) { if status != 0 { log.Errorf("Error occured while calling redis") proxywasm.SendHttpResponse(430, nil, []byte("Error while calling redis"), -1) } else { ctx.SetContext("timeStamp", timeStamp) ctx.SetContext("CallTimeLeft", strconv.Itoa(config.qpm-response.Integer())) if response.Integer() == 1 { config.client.Expire(timeStamp, 60, func(status int, response resp.Value) { if status != 0 { log.Errorf("Error occured while calling redis") } proxywasm.ResumeHttpRequest() }) } else { if response.Integer() > config.qpm { proxywasm.SendHttpResponse(429, [][2]string{{"timeStamp", timeStamp}, {"CallTimeLeft", "0"}}, []byte("Too many requests"), -1) } else { proxywasm.ResumeHttpRequest() } } } }) return types.ActionPause } func onHttpResponseHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log wrapper.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.ActionContinue }
编译插件,上传到插件市场并启用。配置示例如下:
在云原生网关控制台,创建Mock路由,然后访问网关SLB地址,验证Redis限流插件效果。