gRPC实现gateway的教程

准备

使用 go mod 建立工程和新增api目录

.
├── api
│   ├── gen      # protoc或buf编译*.proto的代码
│   ├── google   # gRPC或googleapis提供的*.proto文件
│   └── v1       # 业务相关的*.proto
├── build
├── cmd
├── configs
├── internal
├── pkg
├── go.mod       # go mod 初始化建立
└── go.sum       # go mod 初始化建立

第三方.proto文件

googleapis github.com/googleapis/… HTTP映射gRPC规则
protocolbuffers github.com/protocolbuf… protobuf源码的类型

在编写自定义.proto时,必要时需要引入其它预定义好的数据结构,避免重复编码,而HTTP映射gRPC需要引入googleapi中的http.proto注解,这里先把可能用到的数据结构copy进来

├── google
│   ├── api
│   │   ├── annotations.proto
│   │   └── http.proto
│   └── protobuf
│       ├── any.proto
│       ├── api.proto
│       ├── descriptor.proto
│       ├── duration.proto
│       ├── empty.proto
│       ├── field_mask.proto
│       ├── plugin.proto
│       ├── source_context.proto
│       ├── struct.proto
│       ├── timestamp.proto
│       ├── type.proto
│       └── wrappers.proto

又或者可以参考buf中BSR仓库 Docs · googleapis/googleapis 引入,按照这个仓库的说法,存放的30+个文件足够覆盖99.999%开发者的使用需求。

编码自定义业务

还是以最简单的Echo服务,按照 上一节 介绍的编写业务代码的流程

1.编写.proto描述服务

proto语法请参考 Language Guide (proto 3) | Protocol Buffers Documentation ,在 /api/v1 下新建 echo.proto

syntax = "proto3";

package api.v1;

option go_package = "api.v1;pb";

message EchoRequest {
  string value = 1;
}

message EchoResponse {
  string value = 1;
}

service EchoService {
  rpc Echo(EchoRequest) returns (EchoResponse) {}
}

2.引入http.rule

当你浏览 grpc-ecosystem/grpc-gateway 会发现映射规则是这样写的:

import "google/api/annotations.proto";

service EchoService {
  rpc Echo(EchoRequest) returns (EchoResponse) {
    option (google.api.http) = {
      post: "/v1/example/echo"
      body: "*"
    }
  }
}

老实说并不是很喜欢这种写法,因为对于服务与服务之间的调用通常是通过RPC的,这样HTTP的映射规则显得有点多余,这样会污染proto的代码。

通过buf配置映射HTTP Rule

除了通过 protoc 编译 *.proto,还可以通过 Buf 配置编译,以echo服务为例,在 /api/v1 下新建 gateway.yaml 指定转换规则,同样可以达到在echo.proto引入注解的效果

type: google.api.Service
config_version: 3

http:
  rules:
    - selector: api.v1.EchoService.Echo
      post: /v1/example/echo
      body: "*"

3.HTTP Rule与gRPC的参数转换

我们知道一个HTTP请求包含请求头和请求体,当发出一个请求时,通常会在几个地方携带请求参数

  • Request Header 中的自定义字段

  • URL携带的查询参数

  • Request Body 中请求体

http.proto就规定了参数如何转换成.proto定义的请求参数,这里主要是关于URL查询参数和Body数据如何转换

3.1 URL path

service Messaging {
  rpc GetMessage(GetMessageRequest) returns (Message) {
    option (google.api.http) = {
        get: "/v1/{name=messages/*}"
    };
  }
}
message GetMessageRequest {
  string name = 1; // Mapped to URL path.
}
message Message {
  string text = 1; // The resource content.
}

HTTP gRPC
GET /v1/messages/123456 GetMessage(name: “messages/123456”)

3.2 查询参数

service Messaging {
  rpc GetMessage(GetMessageRequest) returns (Message) {
    option (google.api.http) = {
        get:"/v1/messages/{message_id}"
    };
  }
}
message GetMessageRequest {
  message SubMessage {
    string subfield = 1;
  }
  string message_id = 1; // Mapped to URL path.
  int64 revision = 2;    // Mapped to URL query parameter `revision`.
  SubMessage sub = 3;    // Mapped to URL query parameter `sub.subfield`.
}

HTTP gRPC
GET /v1/messages/123456?revision=2&sub.subfield=foo GetMessage(message_id: “123456” revision: 2 sub: SubMessage(subfield: “foo”))

3.3 Body

service Messaging {
  rpc UpdateMessage(UpdateMessageRequest) returns (Message) {
    option (google.api.http) = {
      patch: "/v1/messages/{message_id}"
      body: "message"
    };
  }
}
message UpdateMessageRequest {
  string message_id = 1; // mapped to the URL
  Message message = 2;   // mapped to the body
}

HTTP gRPC
PATCH /v1/messages/123456 { “text”: “Hi!” } UpdateMessage(message_id: “123456” message { text: “Hi!” })

3.4 Body = “*”

service Messaging {
  rpc UpdateMessage(Message) returns (Message) {
    option (google.api.http) = {
      patch: "/v1/messages/{message_id}"
      body: "*"
    };
  }
}
message Message {
  string message_id = 1;
  string text = 2;
}

HTTP gRPC
PATCH /v1/messages/123456 { “text”: “Hi!” } UpdateMessage(message_id: “123456” text: “Hi!”)

3.5 多个HTTP-URL绑定一个服务

service Messaging {
  rpc GetMessage(GetMessageRequest) returns (Message) {
    option (google.api.http) = {
      get: "/v1/messages/{message_id}"
      additional_bindings {
        get: "/v1/users/{user_id}/messages/{message_id}"
      }
    };
  }
}
message GetMessageRequest {
  string message_id = 1;
  string user_id = 2;
}

HTTP gRPC
GET /v1/messages/123456 GetMessage(message_id: “123456”)
GET /v1/users/me/messages/123456 GetMessage(user_id: “me” message_id: “123456”)

更多的可以参考 http.proto 中的注释,以上例子均出自此文件用例

4.编译.proto文件生成接口层

现在已经完成了 echo.proto 服务定义和 gateway.yaml 对HTTP Rule的映射。

避免使用 protoc 繁琐的命令行操作,这里使用 buf 工具对其编译生成 HTTP 和 gRPC 的接口层代码

在编译之前, buf 工具需要指向被编译的 *.proto 的目录,在 /api 下新建 buf.yaml

version: v2
# The v2 buf.yaml file specifies a local workspace, which consists of at least one module.
# The buf.yaml file should be placed at the root directory of the workspace, which
# should generally be the root of your source control repository.
modules:
  # Each module entry defines a path, which must be relative to the directory where the
  # buf.yaml is located. You can also specify directories to exclude from a module.

  # A module can have its own lint and breaking configuration, which overrides the default
  # lint and breaking configuration in its entirety for that module. All values from the
  # default configuration are overridden and no rules are merged.
  - path: v1
    lint:
      use:
        - DEFAULT
      except:
        - PACKAGE_DIRECTORY_MATCH
        - RPC_REQUEST_RESPONSE_UNIQUE
        - RPC_REQUEST_STANDARD_NAME
        - RPC_RESPONSE_STANDARD_NAME
    breaking:
      use:
        - FILE

并且,编译过程要指定哪些插件参与编译,编译代码输出目录,在 /api 下新建 buf.gen.yaml

version: v2
clean: true
plugins:
  # protoc-gen-go needs to be installed, generate go files based on proto files
  - local: protoc-gen-go
    out: gen/v1
    opt:
      - paths=source_relative
  # protoc-gen-go-grpc needs to be installed, generate grpc go files based on proto files
  - local: protoc-gen-go-grpc
    out: gen/v1
    opt:
      - paths=source_relative
      - require_unimplemented_servers=false
  # protoc-gen-grpc-gateway needs to be installed, generate grpc-gateway go files based on proto files
  - local: protoc-gen-grpc-gateway
    out: gen/v1
    opt:
      - paths=source_relative
      - grpc_api_configuration=v1/gateway.yaml
  # protoc-gen-openapiv2 needs to be installed, generate swagger config files based on proto files
  - local: protoc-gen-openapiv2
    out: gen/v1
    opt:
      - grpc_api_configuration=v1/gateway.yaml

> protoc-gen-go --version
protoc-gen-go v1.36.5

> protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.5.1

> protoc-gen-grpc-gateway --version
Version v2.26.1, commit unknown, built at unknown

> protoc-gen-openapiv2 --version
Version v2.26.1, commit unknown, built at unknown

> buf --version
1.50.0

> cd api/
> buf generate

编译成功后会在 api/gen 下生成请求参数,返回响应,HTTP和gRPC的接口层的代码

.
├── api
│   ├── buf.gen.yaml                # 指定哪些插件参与编译
│   ├── buf.yaml                    # 指定*.proto目录
│   ├── gen
│   │   └── v1
│   │       ├── echo.pb.go          # 请求参数,返回响应数据结构
│   │       ├── echo.pb.gw.go       # http伪装gRPC代码
│   │       ├── echo.swagger.json   # api文档
│   │       └── echo_grpc.pb.go     # grpc服务
│   ├── google
│   │   ├── api
│   │   │   ├── annotations.proto
│   │   │   ├── http.proto
│   │   ├── protobuf
│   │   │   ├── any.proto
│   │   │   ├── api.proto
│   │   │   ├── descriptor.proto
│   │   │   ├── duration.proto
│   │   │   ├── empty.proto
│   │   │   ├── field_mask.proto
│   │   │   ├── plugin.proto
│   │   │   ├── source_context.proto
│   │   │   ├── struct.proto
│   │   │   ├── timestamp.proto
│   │   │   ├── type.proto
│   │   │   └── wrappers.proto
│   └── v1
│       ├── echo.proto            # 自定义业务服务
│       └── gateway.yaml          # HTTP Rule映射
├── build
├── cmd
├── configs
├── internal
├── pkg
├── go.mod
└── go.sum

5.实现业务逻辑

对于自动生成的代码中并不会包含业务逻辑,所以在 internal/ 目录内新建 service.go

├── internal
│   └── app
│       └── echo
│           └── service
│               └── service.go    # 新增

在编写 echo.proto 时指定编译之后的包名

option go_package = "api.v1;pb";  // 导入包名定义为pb,编码时编辑器能够自动补全

简单实现 echo 业务服务:返回输入字符串

package service

import (
    "context"

    pb "go-web/api/gen/v1"
)

var _ pb.EchoServiceServer = (*EchoService)(nil)

type EchoService struct{}

func NewEchoService() *EchoService {
    return &EchoService{}
}

func (srv *EchoService) Echo(ctx context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) {
    return &pb.EchoResponse{Value: req.Value}, nil
}

实例化gRPC服务

interal/ 目录下新建 grpc.go

├── internal
│   └── app
│       └── health
│           ├── server
│           │   └── grpc.go        # 新增
│           └── service
│               └── service.go

gRPC服务与业务服务之间究竟存在着怎样的关系呢?

让我们先来回顾一下RPC的调用格式:(服务器地址+服务名+API+请求参数)-> 服务响应。当RPC服务接收到请求参数后,会依据参数中给出的服务名和API进行查找。以echo示例而言,服务名便是在proto文件中定义的EchoService,而API则是rpc Echo(EchoRequest) returns (EchoResponse) {},随后便会展开调用。

然而,在gRPC生成的代码里,并不包含自定义业务逻辑的实现部分。这就意味着,开发者需要自行实现一个与API签名完全一致的业务方法。完成业务方法的编写后,还需将其绑定(注入)到gRPC服务当中。只有经过这一步骤,gRPC服务才具备调用该业务方法的能力,从而实现业务逻辑与服务框架的有效衔接,达成完整的服务功能。

package server

import (
    "net"

    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"

    pb "go-web/api/gen/v1"
    "go-web/internal/app/health/service"
)

const GRPC_ADDR = ":58081"

func NewGRPC(srv *service.EchoService) (grpcSvr *grpc.Server, err error) {
    // 实例化gRPC服务
    grpcSvr = grpc.NewServer()

    // 这里就是用自定义业务服务绑定(注入)到gRPC服务当中,gRPC能够调用业务服务,因为它们有相同的函数签名
    pb.RegisterEchoServiceServer(grpcSvr, srv)
    // 注册反射服务,方便我们用外部工具调试
    reflection.Register(grpcSvr)

    go func() {
        // 绑定并监听IP和Port
        listener, err := net.Listen("tcp", GRPC_ADDR)
        if err != nil {
            panic(err)
        }
        if err = grpcSvr.Serve(listener); err != nil {
            panic(err)
        }
    }()

    return
}

实例化HTTP服务

interal/ 目录下新建 http.go

├── internal
│   └── app
│       └── health
│           ├── server
│           │   ├── grpc.go
│           │   └── http.go        # 新增
│           └── service
│               └── service.go

实例化HTTP服务:

package server

import (
    "context"
    "errors"
    "net/http"

    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"

    pb "go-web/api/gen/v1"
)

const APP_ADDR = ":8081"

func NewHTTP() (httpSvr *http.Server, err error) {
    // HTTP路由
    mux := runtime.NewServeMux(runtime.WithErrorHandler(errorHandler))
    // HTTP服务同时也是gRPC客户端,客户端以非安全模式发出请求时
    opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
    // 客户端指向gRPC服务地址,并向HTTP路由写入URL => gRPC服务的映射
    err = pb.RegisterEchoServiceHandlerFromEndpoint(context.Background(), mux, APP_ADDR, opts)
    if err != nil {
        panic(err)
    }
    // 启动HTTP服务
    httpSvr = &http.Server{Addr: APP_ADDR, Handler: mux}
    go func() {
        if err = httpSvr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            panic(err)
        }
    }()
    return
}

// 错误处理,将gRPC返回结果封装成HTTP的响应,以JSON格式返回
func errorHandler(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, writer http.ResponseWriter, request *http.Request, err error) {
    runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, writer, request, err)
}

测试一下

此时把 EchoService、GrpcServer、HTTPServer 封装成应用程序,在 internal/ 内新建 app.go

├── internal
│   └── app
│       └── health
│           ├── di
│           │   └── app.go        # 新增
│           ├── server
│           │   ├── grpc.go
│           │   └── http.go
│           └── service
│               └── service.go

package di

import (
    "context"
    "net/http"
    "time"

    "google.golang.org/grpc"

    "go-web/internal/app/health/service"
)

type App struct {
    srv     *service.EchoService
    httpSvr *http.Server
    grpcSvr *grpc.Server
}

func NewApp(srv *service.EchoService, httpSvr *http.Server, grpcSvr *grpc.Server) (*App, func(), error) {
    app := &App{
        srv:     srv,
        httpSvr: httpSvr,
        grpcSvr: grpcSvr,
    }

    // 清理函数,关闭HTTP和gRPC服务
    closeFunc := func() {
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        app.grpcSvr.Stop()
        _ = app.httpSvr.Shutdown(ctx)
    }
    return app, closeFunc, nil
}

接着,在 main 函数中实例化 App 并注入相关依赖,在 cmd 中新建 echo.go

├── cmd
│   └── echo.go

package main

import (
    "os"
    "os/signal"
    "syscall"

    "go-web/internal/app/health/di"
    "go-web/internal/app/health/server"
    "go-web/internal/app/health/service"
)

func main() {
    echoSrv := service.NewEchoService()
    grpcSvr, _ := server.NewGRPC(echoSrv)
    httpSvr, _ := server.NewHTTP()
    _, closeFunc, _ := di.NewApp(echoSrv, httpSvr, grpcSvr)

    // Wait for interrupt signal to gracefully shutdown the server
    quit := make(chan os.Signal, 1)
    // kill (no param) default send syscall.SIGTERM
    // kill -2 is syscall.SIGINT
    // kill -9 is syscall.SIGKILL but can't be catch, so don't need add it
    signal.Notify(quit, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGTERM)
    for {
        signal := <-quit
        switch signal {
        case syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
            closeFunc()
            return
        case syscall.SIGHUP:
        default:
            return
        }
    }
}

最后,启动 cmd/echo.go 并测试:

go run cmd/echo.go

使用 grpcurl 工具测试gRPC服务

grpcurl -d '{"value": "Hello, World!"}' -plaintext localhost:58081 api.v1.EchoService.Echo

可以看到回显输入字符

{
  "value": "Hello, World!"
}

使用 curl 测试HTTP服务,

curl -X POST \
  -d '{"value": "Hello, World!"}' \
  -H 'Content-Type: application/json' \
  http://localhost:8081/v1/example/echo

同样可以看到回显输入字符

{"value":"Hello, World!"}%

gRPC对HTTP的映射

  • How gRPC error codes map to HTTP status codes in the response.
  • HTTP request source IP is added as X-Forwarded-For gRPC request header.
  • HTTP request host is added as X-Forwarded-Host gRPC request header.
  • HTTP Authorization header is added as authorization gRPC request header.
  • Remaining Permanent HTTP header keys (as specified by the IANA here) are prefixed with grpcgateway- and added with their values to gRPC request header.
  • HTTP headers that start with ‘Grpc-Metadata-’ are mapped to gRPC metadata (prefixed with grpcgateway-).
  • While configurable, the default {un,}marshaling uses protojson.
  • The path template used to map gRPC service methods to HTTP endpoints supports the google.api.http path template syntax. For example, /api/v1/{name=projects/*/topics/*} or /prefix/{path=organizations/**}.

可以看到针对 HTTP HeaderIP、host、Authorization 有特殊映射外,其余字段会默认添加 grpcgateway- 前缀,如果需要在请求时带上自定义头,务必在HTTP自定义请求头加上 Grpc-Metadata- 前缀,gateway框架会帮你映射到gRPC的 metadata 上面,方便在业务逻辑上提取。

文章来源: https://study.disign.me/article/202508/11.grpc-gateway.md

发布时间: 2025-02-20

作者: 技术书栈编辑