使用Go语言开发网关插件

重要

本文中含有需要您注意的重要提示信息,忽略该信息可能对您的业务造成影响,请务必仔细阅读。

开发网关插件可以扩展API网关的核心功能,使其能够满足更加复杂和特定的业务需求。本文介绍如何使用Go语言开发网关插件,并提供了本地开发和调试的指引。

准备工作

安装Golang、TinyGo和Binaryen三个程序。

Golang

官方指引链接(需为1.18版本以上)。

  • Windows

    1. 下载安装文件

    2. 打开下载好的安装文件,双击进行安装。安装成功后,使用键盘上的快捷键Win+R打开运行窗口,在运行窗口中输入cmd单击确定即可打开命令窗口,输入命令go version,成功输出当前安装的版本,表明安装成功。

  • macOS

    1. 下载安装文件

    2. 打开下载好的安装文件双击进行安装,默认会安装到/usr/local/go目录。

    3. 打开终端命令行工具,输入命令go version,成功输出当前安装的版本,表明安装成功。

  • Linux

    1. 下载安装文件

    2. 执行下列命令进行安装。

      1. 安装Golang。

        rm -rf /usr/local/go && tar -C /usr/local -xzf go1.19.linux-amd64.tar.gz
      2. 配置环境变量。

        export PATH=$PATH:/usr/local/go/bin
      3. 执行go version,成功输出当前安装的版本,表明安装成功。

TinyGo

官方指引链接(固定为0.28.1版本)。

  • Windows

    1. 下载安装文件

    2. 解压安装文件到指定目录。

    3. 配置环境变量。如果安装解压后的目录为C:\tinygo,则需要将C:\tinygo\bin添加到环境变量PATH中,例如在命令窗口中输入set命令设置。

      set PATH=%PATH%;"C:\tinygo\bin";
    4. 在命令窗口执行命令tinygo version,成功输出当前安装的版本,表明安装成功。

  • macOS

    1. 下载压缩包并解压。

      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
    2. 配置环境变量。如果安装解压后的目录为/tmp,则需要将/tmp/tinygo/bin添加到环境变量PATH中。

      export PATH=/tmp/tinygo/bin:$PATH
    3. 在终端执行tinygo version,成功输出当前安装的版本,表明安装成功。

  • Linux

    以Ubuntu下amd64架构为例,其他系统请参考官方指引链接。

    1. 下载并安装DEB文件。

      1. 下载文件:

        wget https://github.com/tinygo-org/tinygo/releases/download/v0.28.1/tinygo_0.28.1_amd64.deb
      2. 安装文件:

        sudo dpkg -i tinygo_0.28.1_amd64.deb
      3. 配置环境变量:

        export PATH=$PATH:/usr/local/bin
    2. 在终端执行tinygo version,输出当前安装的版本,表明安装成功。

Binaryen

用于优化wasm文件的编译过程。

  • Windows

    1. 下载安装文件

    2. 解压安装文件。将解压文件中的bin\wasm-opt.exe文件拷贝到tinygo所在的bin目录下。

  • macOS

    执行下列命令进行安装。

    brew install binaryen
  • Linux

    以Ubuntu为例。执行下列命令进行安装。

    apt-get -y install binaryen

编写插件

步骤一:初始化工程目录

  1. 新建一个工程目录文件,例如wasm-demo-go。

  2. 在所建目录下执行以下命令,进行Go工程初始化。

    go mod init wasm-demo-go
  3. 设置下载依赖包的代理。

    go env -w GOPROXY=https://proxy.golang.com.cn,direct
  4. 下载构建插件的依赖。

    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.AddHttpRequestHeaderproxywasm.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启动验证

  1. 进入在编写插件时创建的目录,例如wasm-demo目录,确认该目录下已经编译生成了main.wasm文件。

  2. 在目录下创建文件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: {}
  3. 继续在该目录下创建文件envoy.yaml,内容如下:

    展开查看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
      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
  4. 执行以下命令启动docker compose

    docker compose up

功能验证

WASM功能验证

  1. 使用浏览器直接访问httpbin(http://127.0.0.1:12345/get),可以看到不经过网关时的请求头内容,返回结果如下所示:1

  2. 使用浏览器通过网关访问httpbin(http://127.0.0.1:10000/get),可以看到经过网关处理后的请求头的内容,返回结果如下所示,说明此时上文编写插件的功能已经生效了,加入了hello: world请求头。2

插件配置修改验证

  1. 修改envoy.yaml,将mockEnable配置修改为true4

  2. 使用浏览器通过网关访问httpbin(http://127.0.0.1:10000/get),可以看到经过网关处理后的请求头的内容。返回结果如下所示,说明插件配置修改生效,开启了mock应答直接返回了hello world3

示例

无配置插件

插件无需配置时,直接定义空结构体即可。

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数据库实现请求限流。代码请参见代码示例

  1. 登录阿里云Redis控制台,创建Redis实例,并设置连接密码。具体操作,请参见快速入门概览

    image

  2. 记录Redis的连接地址,在网关添加服务,配置服务来源为DNS域名。具体操作,请参见添加服务

    image

    image

  3. 使用如下示例代码创建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
    }
  4. 编译插件,上传到插件市场并启用。配置示例如下:

    image

  5. 云原生网关控制台,创建Mock路由,然后访问网关SLB地址,验证Redis限流插件效果。

    image

    image.png