当前位置:   article > 正文

为你的 Envoy 构建首个 WebAssembly 插件

envoy插件开发

原文链接:https://juejin.cn/post/7303798788719493157

WebAssembly (缩写为 Wasm) 是一种开放标准的二进制指令集,用于在 Web 浏览器中执行高性能的跨平台代码。它旨在成为一种通用的虚拟机,可以在各种环境中运行,不仅限于 Web 浏览器。WebAssembly 最初是为了提高 Web 应用程序的性能而设计的,但它已经扩展到其他领域,例如服务器端应用程序、嵌入式系统和桌面应用程序。本文主要介绍如何快速入门 Wasm。

介绍

借助一张网图大致了解下 Wasm,可以看到大部分主流语言都是能编译成 Wasm,然后借助 Wasm 的虚拟机 (运行环境) 在 x86 或 ARM 架构的系统上运行。

2cd517be9efff4d736dab71f3e994bcc.jpeg

下图下介绍 Wasm 能做什么以及 envoy filter 调用 Wasm 的时机:

  • 图上方:可以看到过滤器的介入是在 Wasm 的 OnStartOnConfigureOnHeaders 等 hook 中介入;

  • 图左侧:在 Wasm 编码中可以调用的 rpc 接口,当然不同的语言对于 rpc 的代码兼容也是有待完善,后面会讲述到这个内容;

  • 图右侧:wasm 提供的内置状态 API 及打印日志的方法;

  • 图下方:wasm 提供的获取请求头,请求内容,及设置返回内容的方法;

  • 图中间:wasm 默认使用的沙箱环境 chrome v8 引擎执行 Wasm 的代码。

f940b3c0f45b383fa4ef9a9b4f8038be.png

学会安装服务器服务器

wasmer 提供基于 WebAssembly 的超轻量级容器,其可以在任何地方运行:从桌面到云、以及 IoT 设备,并且也能嵌入到任何编程语言中 (它是 RUST 写的,所以对 Rust 支持是最好的,没有之一)。

wasmer 提供自动安装方式和手动方式安装,自动方式参考线上文档即可自动安装[1]

由于我们使用 golang 开发 Wasm 插件,所以还需要一个 tinygo 编译器将 golang 变异成 Wasm。

安装 Wasmer

下载安装包:

  1. $ cd /usr/local/opt
  2. $ wget https://github.com/wasmerio/wasmer/releases/download/v3.3.0/wasmer-darwin-arm64.tar.gz

安装:

  1. $ tar -xvf wasmer-darwin-arm64.tar.gz
  2. $ mv wasmer-darwin-arm64 wasmer
  3. $ echo 'export PATH=/usr/local/opt/wasmer/bin:$PATH' >> ~/.bash_profile
  4. $ source !$

检查版本号:

$ wasmer --version

安装 tinygo

下载安装包:

  1. $ cd /usr/local/opt
  2. $ wget https://github.com/tinygo-org/tinygo/releases

安装:

  1. $ mv tinygo /usr/local/opt/tinygo/
  2. $ echo 'export PATH=/usr/local/opt/tinygo/bin:$PATH' >> ~/.bash_profile
  3. $ echo 'export TINYGOROOT=/usr/local/opt/tinygo' >> ~/.bash_profile
  4. $ source !$

验证:

  1. $ tinygo version
  2. tinygo version 0.27.0 darwin/amd64 (using go version go1.19.4 and LLVM version 15.0.0)

安装优化工具链

Binaryen 是用 C + + 编写的 WebAssembly 编译器和工具链基础结构库。它的目标是使 WebAssembly 的编译变得简单、快速和有效:

下载:

$ wget https://github.com/WebAssembly/binaryen/releases/

安装:

  1. $ cp -av binaryen /usr/local/opt/
  2. $ echo 'export PATH=/usr/local/opt/binaryen/bin:$PATH' >> ~/.bash_profile

测试编译与运行

main.go

  1. package main
  2. import "fmt"
  3. func main()  {
  4.         fmt.Println("hello wasm.")
  5. }

build Wasm

$ tinygo build  -target=wasi  -o main.wasm main.go

executing in local envrioment

$ wasmer main.wasm

proxy-wasm-go-sdk

proxy-wasm-go-sdk ` 是一个遵循 ABI 规范的 WebAssembly 开发工具,专为 L4/L7 代理而设计。该工具依赖于 Envoy 和 TinyGo。具体而言,它是为 Golang 开发的 Envoy WASM 插件提供支持的工具。

安装 golang 库:

go get github.com/tetratelabs/proxy-wasm-go-sdk@v0.18.0

入门实战

代码量不是特别多就不上传 github 了,也不想水多几篇就一篇写完了,懒~~~;

简单构建 Wasm

当前目录为 filter1

整体的代码构建类似于代码框架的周期 hook,下面是代码目录:

  1. |____filter1
  2. | |____httpcontext.go
  3. | |____pluginctx.go
  4. | |____vm.go
  5. |____main.go

httpcontext.go 关键函数 OnHttpRequestHeadersOnHttpResponseHeaders,代码中给出 user 参数如果不等于 shadow 就会暂停往后端传递请求。

  1. package filter1
  2. import (
  3.         "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
  4.         "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
  5.         "net/url"
  6. )
  7. const (
  8.   // 注意 proxywasm 获取 请求路径的方式
  9.         HttpPath = ":path"
  10. )
  11. type MyHttpContext struct {
  12.         types.DefaultHttpContext
  13. }
  14. func NewMyHttpContext() *MyHttpContext {
  15.         return &MyHttpContext{}
  16. }
  17. func (this *MyHttpContext) OnHttpRequestHeaders(intbool) types.Action {
  18.         // 通过 header 获取request path
  19.         hp, err := proxywasm.GetHttpRequestHeader(HttpPath)
  20.         if err != nil {
  21.                 proxywasm.LogErrorf("get http path error: %s", err.Error())
  22.         }
  23.         proxywasm.LogInfof("request path = %s", hp)
  24.         urlParser, err := url.Parse(hp)
  25.         if err != nil {
  26.                 proxywasm.LogError(err.Error())
  27.         }
  28.         proxywasm.LogInfof("host = %s", urlParser.Host)
  29.         proxywasm.LogInfof("uri = %s", urlParser.Path)
  30.         proxywasm.LogInfof("params = %s", urlParser.RawQuery)
  31.         // send response
  32.         if user := urlParser.Query().Get("user");  user != "shadow" {
  33.                 _ = proxywasm.SendHttpResponse(401,
  34.                         [][2]string{
  35.                         {"content-type""application/json; charset=utf-8"},
  36.                         },
  37.                         []byte("用户没有权限或缺少参数"),
  38.                 -1)
  39.              // 表示不可继续
  40.                 return types.ActionPause
  41.         }
  42.    //表示正常action
  43.         return types.ActionContinue
  44. }
  45. func (this *MyHttpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action {
  46.         err := proxywasm.AddHttpResponseHeader("hello""world")
  47.         if err != nil {
  48.                 proxywasm.LogErrorf("add header error: %s", err.Error())
  49.         }
  50.         return types.ActionContinue
  51. }

pluginctx.go OnPluginStart

  1. package filter1
  2. import (
  3.         "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
  4.         "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
  5. )
  6. var (
  7.         pluginStartCnt = 0
  8. )
  9. type HttpPluginContext struct {
  10.         types.DefaultPluginContext
  11. }
  12. func NewHttpPluginContext() *HttpPluginContext {
  13.         return &HttpPluginContext{}
  14. }
  15. func (this *HttpPluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus {
  16.         pluginStartCnt++
  17.         proxywasm.LogInfof("pluginStartCnt: %d", pluginStartCnt)
  18.         return types.OnPluginStartStatusOK
  19. }
  20. func (this *HttpPluginContext) NewHttpContext(contextID uint32) types.HttpContext {
  21.         return NewMyHttpContext()
  22. }

vm.go OnVMStart

  1. package filter1
  2. import (
  3.         "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
  4.         "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
  5. )
  6. type MyVM struct {
  7.         types.DefaultVMContext
  8. }
  9. func NewMyVM() *MyVM {
  10.         return &MyVM{}
  11. }
  12. func (this *MyVM)  OnVMStart(vmConfigurationSize int) types.OnVMStartStatus  {
  13.         proxywasm.LogInfo("vm start filter 1")
  14.         return types.OnVMStartStatusOK
  15. }
  16. func (this *MyVM) NewPluginContext(contextID uint32) types.PluginContext {
  17.         return NewHttpPluginContext()
  18. }

main.go 到现在为止代码都没什么难度,就没有解析代码,简单来说就有点像框架的生命周期。

  1. package main
  2. import (
  3.         "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
  4.         "study-wasm/2_wasm/filter1"
  5. )
  6. func main() {
  7.         proxywasm.SetVMContext(filter1.NewMyVM())
  8. }

把以上代码编译成 Wasm:

  1. $ cd study-wasm/2_wasm
  2. $ tinygo build  -target=wasi  -o myfilter1.wasm  study-wasm/2_wasm/main.go

接下来需要启动 envoy,我们将会使用 docker 启动 envoy,在此之前需要先配置 envoy.yaml,主要留意 http_filters wasm 的配置即可。

  1. admin:
  2.   address:
  3.     socket_address: { address: 0.0.0.0, port_value: 9901 }
  4. static_resources:
  5.   listeners:
  6.     - name: listener_0
  7.       address:
  8.         socket_address:  { address: 0.0.0.0, port_value: 8080 }
  9.       listener_filters:
  10.         - name: "envoy.filters.listener.http_inspector"
  11.       filter_chains:
  12.         - filters:
  13.             - name: envoy.filters.network.http_connection_manager
  14.               typed_config:
  15.                 "@type"type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
  16.                 stat_prefix: ingress_http
  17.                 codec_type: AUTO
  18.                 route_config:
  19.                   name: shadow-route
  20.                   virtual_hosts:
  21.                     - name: myhost
  22.                       domains: ["*"]
  23.                       routes:
  24.                         - match: {prefix: "/"}
  25.                           route:
  26.                             cluster: shadow_cluster_config
  27.                 http_filters:
  28.                   - name: envoy.filters.http.wasm
  29.                     typed_config:
  30.                       "@type"type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
  31.                       config:
  32.                         name: "study-wasm"
  33.                         # 这个root_id 随意就好
  34.                         root_id: "test-filter"
  35.                         vm_config:
  36.                           runtime: "envoy.wasm.runtime.v8"
  37.                           # vm_id 可以用来共享 vm 后面会说到
  38.                           vm_id: "f1"
  39.                           # 代码方式用本地挂载, 如果是生产环境可以配置 http url 的方式, 请自行查阅 envoy 文档
  40.                           code:
  41.                             local:
  42.                               filename: "/filters/wasm/myfilter1.wasm"
  43.                   - name: envoy.filters.http.router
  44.   clusters:
  45.   # 上游配置的是 nginx 服务器
  46.     - name: shadow_cluster_config
  47.       connect_timeout: 1s
  48.       type: Static
  49.       dns_lookup_family: V4_ONLY
  50.       lb_policy: ROUND_ROBIN
  51.       load_assignment:
  52.         cluster_name: shadow_cluster
  53.         endpoints:
  54.           - lb_endpoints:
  55.               - endpoint:
  56.                   address:
  57.                     socket_address:
  58.                       address: 172.17.0.5
  59.                       port_value: 80

使用 docker 启动 envoy,envoy 配置需要放置在 /opt/envoy/envoy.yaml,编译好的 Wasm 文件需要放置在 /opt/envoy/filters/wasm 目录下。

  1. $ docker run --name=envoy -d \
  2.   -p 9901:9901 \
  3.   -p 8080:8080 \
  4.   -v /opt/envoy/envoy.yaml:/etc/envoy/envoy.yaml  \
  5.   -v /opt/envoy/filters/wasm:/filters/wasm \
  6.   envoyproxy/envoy-alpine:v1.21.0

启动后验证,不带参数访问会产生 401 错误,带正确参数访问成功获取数据。到此我们已经简单的实现了 Wasm 的拦截请求功能,后续我们在上面代码的基础上进行部分修改以演示 Wasm 支持的不同功能。

  1. $ curl http://127.0.0.1:8080
  2. 用户没有权限或缺少参数
  3. $ curl http://127.0.0.1:8080?user=shadow
  4. v1

Wasm 读取配置

这次演示的是 Wasm 如何读取配置,方式有很多下面演示如何读取 envoy 中配置,下面只放出变化部分的配置或代码。

envoy.yaml 主要配置了 configuration

  1. ...
  2. ...
  3.                 http_filters:
  4.                   - name: envoy.filters.http.wasm
  5.                     typed_config:
  6.                       "@type"type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
  7.                       config:
  8.                         name: "study-wasm"
  9.                         root_id: "test-filter"
  10.                         configuration:
  11.                           "@type"type.googleapis.com/google.protobuf.StringValue
  12.                           value: |
  13.                             {
  14.                               "welcome_content""欢迎登陆 xxx.com"
  15.                             }
  16.                         vm_config:
  17.                           runtime: "envoy.wasm.runtime.v8"
  18.                           vm_id: "f1"
  19.                           code:
  20.                             local:
  21.                               filename: "/filters/wasm/myfilter1.wasm"
  22.                   - name: envoy.filters.http.router
  23. ...
  24. ...

增加获取配置代码,以下是在 pluginctx.go OnPluginStart

  1. func (this *HttpPluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus {
  2.         // 获取 plugin 传递的 config, 对于配置插件 type.googleapis.com/google.protobuf.StringValue
  3.         cfg, err := proxywasm.GetPluginConfiguration()
  4.         if err != nil {
  5.                 proxywasm.LogErrorf("get plugin config error: %s", err.Error())
  6.         }
  7.         pluginStartCnt++
  8.         proxywasm.LogInfof("pluginStartCnt: %d", pluginStartCnt)
  9.         proxywasm.LogInfof("get plugin config: %s"string(cfg))
  10. }

需要重新编译 Wasm 插件及重启 envoy (重要)。

  1. # 查看envoy 日志是否打印 插件配置
  2. $ docker logs -f envoy

多个 Wasm 插件共享虚拟机

下图 (左) 可以看出 Wasm vm 并不运行在主线程上,所以它并不会阻碍主线程的运行;下图 (右) 可以看出多个 Wasm 服务运行在同一个 Wasm 虚拟机中,并不一定需要每个 Wasm 启动一个虚拟机。

b8009b99ced053b62291e70aeae23612.jpeg

从上图可以看出,主要标记相同的 vm_id 可以共享虚拟机。下面提供了 envoy.yaml 需要修改的部分。在配置中创建了两个 Wasm 插件,一个是 filter1,另一个是 filter2,而这两个插件配置的 vm_id 是相同的。

  1. ...
  2. ...
  3.                 http_filters:
  4.                   - name: envoy.filters.http.wasm
  5.                     typed_config:
  6.                       "@type"type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
  7.                       config:
  8.                         name: "study-wasm-1"
  9.                         root_id: "test-filter-1"
  10.                         vm_config:
  11.                           runtime: "envoy.wasm.runtime.v8"
  12.                           # 需要指定 vm_id
  13.                           vm_id: "f1"
  14.                           code:
  15.                             local:
  16.                               filename: "/filters/wasm/myfilter1.wasm"
  17.                   - name: envoy.filters.http.wasm
  18.                     typed_config:
  19.                       "@type"type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
  20.                       config:
  21.                         name: "study-wasm-2"
  22.                         root_id: "test-filter-2"
  23.                         vm_config:
  24.                           runtime: "envoy.wasm.runtime.v8"
  25.                           # 需要指定 vm_id
  26.                           vm_id: "f1"
  27.                           code:
  28.                             local:
  29.                               filename: "/filters/wasm/myfilter2.wasm"
  30.                   - name: envoy.filters.http.router
  31. ...
  32. ...

多个 Wasm 插件共共享存储

上述描述阐述了多个 Wasm 插件共享同一个虚拟机 (VM) 的主要目的。这种共享虚拟机的设计旨在实现资源的更有效利用,而其中最为重要的优势之一是能够共享存储。

313afbd773a534c2aa4219be06557439.jpeg

延用 3.3 的 envoy 配置,我们将在插件 filter1 中存储数据,然后在 filter2 中获取数据;(filter1 和 filter2 是两套代码)

filter1 启动时设置共享数据,vm.go OnVMStart

  1. func (this *MyVM) OnVMStart(vmConfigurationSize int) types.OnVMStartStatus {
  2.         proxywasm.LogInfo("vm start filter 1")
  3.         // cas 是一个保证线程安全的值, 它会由 share-data 内部维护
  4.         if err := proxywasm.SetSharedData("my_name", []byte("shadow"), 1); err != nil && err != types.ErrorStatusCasMismatch {
  5.                 proxywasm.LogErrorf("on vm start error: %s", err.Error())
  6.         }
  7.         return types.OnVMStartStatusOK
  8. }

filter2 将在返回响应时从共享存储中获取数据并返回到客户端。

  1. func (this *MyHttpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action {
  2.         // 如果需要重新设置 sharedata 则需要重新传入 cas, 让它单调递增
  3.         //v, cas, err := proxywasm.GetSharedData("my_name")
  4.         // 跨 vm 获取share-data
  5.         v, _, err := proxywasm.GetSharedData("my_name")
  6.         if err != nil {
  7.                 proxywasm.LogError(err.Error())
  8.                 return types.ActionContinue
  9.         }
  10.         if err := proxywasm.AddHttpResponseHeader("my_name"string(v)); err != nil {
  11.                 proxywasm.LogError(err.Error())
  12.         }
  13.         return types.ActionContinue
  14. }

重新编译 2 个 Wasm 插件及替换 envoy.yaml 并重启 envoy,访问代理。

  1. # 可以看到 header 中存在 my_name: shadow
  2. $ curl -v http://127.0.0.1:8080?user=shadow

对外请求

由于我们实际是通过 tinygo 进行代码编译,而 tinygo 仅支持 golang 的 net 包,而不支持 net/http 包,如果我们使用 net 包就构建 http 那就会比较复杂了,而且会有很多错误;所以我们将使用之前提到过的内置请求函数。

查看 tinygo 支持的包:

https://tinygo.org/docs/reference/lang-support/stdlib/

b7d533fcd619ec719e93bda9b61a70de.jpeg
编写异步请求

在通常情况下,我们了解到整个请求过程需要极低的延迟,而请求本身是一个网络的 IO。因此,SDK 为我们提供了一个异步的 RPC 请求方法,并且默认情况下不主动等待返回结果。

虽然 SDK 为我们提供了内置的 RPC 请求方式,但是并不允许我们直接访问外部 IP,而是只是放我们配置的上游服务,所以我们需要在 envoy 中增加一个上游服务。

  1. clusters:
  2.     ....
  3.     ....
  4.     - name: shadow_cluster_v2
  5.       connect_timeout: 1s
  6.       type: Static
  7.       dns_lookup_family: V4_ONLY
  8.       lb_policy: ROUND_ROBIN
  9.       load_assignment:
  10.         cluster_name: shadow_cluster_v2
  11.         endpoints:
  12.           - lb_endpoints:
  13.               - endpoint:
  14.                   address:
  15.                     socket_address:
  16.                       address: 172.17.0.7
  17.                       port_value: 80

httpcontext.go OnHttpRequestHeaders 在请求到来时,访问 shadow_cluster_v2,留意下代码注释的细节。

  1. func (this *MyHttpContext) OnHttpRequestHeaders(intbool) types.Action {
  2.     headers := [][2]string{
  3.                     {":method""GET"},
  4.                     {":path""/"},
  5.                     // 这里由于没有域名解析所以使用地址
  6.                     //{":authority", "172.17.0.7"},
  7.                     //{"Host", "172.17.0.7"},
  8.                     {"Host""shadow_cluster_v2"},
  9.                     {"accept""*/*"},
  10.                     {":scheme""http"},
  11.             }
  12.             // 由于 golang 不支持 net/http 包在 Wasm 中使用, 所以这里使用 Wasm 的http call, 所以需要在配置中设置上游地址
  13.             _, callerr := proxywasm.DispatchHttpCall("shadow_cluster_v2", headers,
  14.                     nilnil1000func(numHeaders, bodySize, numTrailers int) {
  15.                             b, err := proxywasm.GetHttpCallResponseBody(0, bodySize)
  16.                             if err != nil {
  17.                                     proxywasm.LogError("http 调用出错" + err.Error())
  18.                             } else {
  19.                                     proxywasm.LogInfo("得到http请求内容: " + string(b))
  20.                             }
  21.                     })
  22.             if callerr != nil {
  23.                     proxywasm.LogError(callerr.Error())
  24.             }
  25.             return types.ActionContinue
  26. }

重新编译 Wasm 插件及替换 envoy.yaml 并重启 envoy,通过终端访问代理后查看 envoy 的日志中是否打印 http 请求的内容。

编写同步请求

在某些情境下,我们可能需要进行同步等待请求返回,比如在权限验证等情况下。同步请求意味着我们需要主动暂停主流程,等待请求返回后再恢复主流程。

httpcontext.go OnHttpRequestHeaders,原来的 types.ActionContinue 需要改成 types.ActionPause 以暂停主流程。如果出发需要直接返回终端请求,否则恢复请求。

  1. func (this *MyHttpContext) OnHttpRequestHeaders(intbool) types.Action {
  2.         // 在默认情况下 DispatchHttpCall 是异步请求,主线程不会等待我们完成就进行下一步操作;
  3.         // 现在我们需要通过主线程Pause不再传递请求,直到我们完成并执行恢复函数;
  4.         headers := [][2]string{
  5.                 {":method""GET"},
  6.                 {":path""/"},
  7.                 // 这里由于没有域名解析所以使用地址
  8.                 //{":authority", "172.17.0.7"},
  9.                 //{"Host", "172.17.0.7"},
  10.                 {"Host""shadow_cluster_v2"},
  11.                 {"accept""*/*"},
  12.                 {":scheme""http"},
  13.         }
  14.         _, callerr := proxywasm.DispatchHttpCall("shadow_cluster_v2", headers,
  15.                 nilnil1000func(numHeaders, bodySize, numTrailers int) {
  16.                         b, err := proxywasm.GetHttpCallResponseBody(0, bodySize)
  17.                         if err != nil {
  18.                                 proxywasm.LogError("http 调用出错" + err.Error())
  19.                                 // 调用出错
  20.                                 _ = proxywasm.SendHttpResponse(500, [][2]string{
  21.                                         {"content-type""application/json; charset=utf-8"},
  22.                                 }, []byte(fmt.Sprint("call shadow_cluster_v2 error: %s", err.Error())), -1)
  23.                         } else {
  24.                                 proxywasm.LogInfo("得到http请求内容: " + string(b))
  25.                                 // 恢复请求
  26.                                 if err := proxywasm.ResumeHttpRequest(); err != nil {
  27.                                         proxywasm.LogErrorf("恢复请求错误, err:%s", err.Error())
  28.                                 }
  29.                         }
  30.                 })
  31.         if callerr != nil {
  32.                 proxywasm.LogError(callerr.Error())
  33.         }
  34.         return types.ActionPause
  35.         //return types.ActionContinue
  36. }

重新编译 Wasm 插件及替换 envoy.yaml 并重启 envoy,通过终端访问代理后查看 envoy 的日志中是否打印 http 请求的内容。

写在最后

Wasm 的基本入门编码方式就到这结束了,我们在生产上可以用作 istio gateway 的分流 (通过判断 header)、用户认证等场景。

引用链接

[1]

自动安装: https://github.com/wasmerio/wasmer-install

c3eb36155192033566a45c937eeb97c1.png

加入 Sealos 开源社区

体验像个人电脑一样简单的云操作系统

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/知新_RL/article/detail/784704
推荐阅读
相关标签