浅谈Protocol Buffers、GRPC、Buf、GRPC-Gateway

发布时间 2023-05-06 10:55:07作者: lotuslaw

1.Protocol Buffers

  • 什么是proto?

    • Protocol Buffers
  • 如何理解Protocol Buffers?

    • 协议缓冲区
    • 非proto协议如何订立、传播以及维护?
  • 如何理解协议缓冲区?

    • Protocol buffers 提供了一种语言中立、平台中立、可扩展的机制,用于以向前兼容和向后兼容的方式序列化结构化数据。它类似于 JSON,只是它更小更快,并且它生成本地语言绑定。您定义了一次数据的结构化方式,然后您可以使用特殊生成的源代码轻松地将结构化数据写入各种数据流并使用各种语言从中读取结构化数据。

      Protocol buffers 是定义语言(在 .proto文件中创建)、proto 编译器生成的与数据交互的代码、特定于语言的运行时库以及写入文件(或通过网络发送)的数据的序列化格式的组合网络连接)。

      Protocol buffers 为大小高达几兆字节的类型化结构化数据包提供序列化格式。该格式适用于临时网络流量和长期数据存储。可以使用新信息扩展协议缓冲区,而无需使现有数据无效或需要更新代码。

      Protocol buffers 是 Google 最常用的数据格式。它们广泛用于服务器间通信以及磁盘上数据的归档存储。Protocol buffer消息服务由工程师编写的文件描述.proto

  • 目前推荐使用proto3语法,proto3 比 proto2 支持更多语言但 更简洁。去掉了一些复杂的语法和特性,更强调约定而弱化语法。

    • proto文件第一行必须syntax = "proto3";
  • 如何编译proto?

    • 使用proto编译器,一个简单示例如下:

      • 下载https://protobuf.dev/downloads/

      • protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/addressbook.proto
        
      • 有什么问题?编译器可能有版本冲突,不好维护;编译命令繁琐,如何记忆?如果想一次实现复杂的编译过程,要写多行编译命令,容易出问题

    • 使用protobuf管理工具,buf

  • 官方文档

2.GRPC

  • grpc是什么?

    • gRPC 一开始由 google 开发,是一款语言中立、平台中立、开源的远程过程调用(RPC)系统。

    • 在 gRPC 里客户端应用可以像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法,使得您能够更容易地创建分布式应用和服务。与许多 RPC 系统类似,gRPC 也是基于以下理念:定义一个服务,指定其能够被远程调用的方法(包含参数和返回类型)。在服务端实现这个接口,并运行一个 gRPC 服务器来处理客户端调用。在客户端拥有一个存根能够像服务端一样的方法。

    • 回顾通过GRPC连接方式请求tf-serving的过程,需要构建一个stub存根,然后请求服务端

  • grpc的IDL(Interface description language)

    • gRPC 默认使用 protocol buffers,这是 Google 开源的一套成熟的结构数据序列化机制(当然也可以使用其他数据格式如 JSON)。
  • 如何使用?以Python为例

    • 编译proto文件,会生成两个对应的编译文件
      • xx_pb2.py,用来序列化proto中定义的消息类
      • xx_pb2_grpc.py,用来建立server以及stub,xx_pb2_grpc.py包括:
        • proto文件中的消息类
        • proto文件中的抽象类
          • XXServicer, 定义了服务实现的接口
          • XXStub,可以被客户端用来激活服务的RPC
        • 建立server的函数
          • add_XXServicer_to_server
  • 基于HTTP/2

    • HTTP/2 提供了连接多路复用、双向流、服务器推送、请求优先级、首部压缩等机制。可以节省带宽、降低TCP链接次数、节省CPU,帮助移动设备延长电池寿命等。gRPC 的协议设计上使用了HTTP2 现有的语义,请求和响应的数据使用HTTP Body 发送,其他的控制信息则用Header 表示。
    • 问题:如果前端发送http get/post请求,这个默认是http1请求,也就无法请求grpc服务
    • 问题如何解决?
      • 浏览器发送HTTP2请求?
      • 在网关层将http1请求转换为http2请求,grpc-gateway
  • 官方文档

3.Buf

  • 什么是buf?

    • Buf 是管理 Protobuf 模式的有用工具。它提供各种功能,包括代码生成、重大更改检测、linting 和格式化,以协助 Protobuf 开发和维护。Buf 旨在与您现有的工作流程集成,以简化模式驱动的开发,无论项目大小如何。
    • 功能有很多,这里不详细介绍,只关注如何编译proto。
  • 如何编译proto?

    • 编写好buf.gen.yaml,只需一句命令buf generate即可,后续无论如何改变proto,都只用一句命令

      • version: v1
        plugins:
            - plugin: go
              out: gen/go
              opt: paths=source_relative
              path: custom-gen-go
              strategy: directory
            - plugin: java
              out: gen/java
            - plugin: buf.build/protocolbuffers/python:v21.9
              out: gen/python
        
  • 使用本地插件 OR 使用远程插件?

    • 官方强烈建议使用远程插件,个人使用起来也觉得远程插件非常方便,因为本地插件的安装配置有时候也可能有问题,比如某些特定场景下需要另一个版本的编译插件,这时候再次安装或者卸载重装就有可能出问题,而使用远程插件则不需要关注这些问题。

      • version: v1
        plugins:
        
        -   - plugin: go
        
        +   - plugin: buf.build/protocolbuffers/go:v1.28.1
        
              out: gen/go
              opt: paths=source_relative
        
        -   - plugin: go-grpc
        
        +   - plugin: buf.build/grpc/go:v1.2.0
        
              out: gen/go
              opt:
              - paths=source_relative
              - require_unimplemented_servers=false
        
  • 官方文档

4.GRPC-Gateway

  • 什么是grpc-gateway?

    • gRPC-Gateway 是protoc的一个插件。它读取gRPC服务定义并生成将 RESTful JSON API 转换为 gRPC 的反向代理服务器。该服务器是根据您的 gRPC 定义中的自定义选项生成的。
  • 如何工作?

  • 如何使用?

    • 见实操章节。
  • 官方文档

5.实操步骤

  • 安装go1.18

  • 在go的GOPATH下新建src,src下新建目录test_grpc_gateway

  • cd到目录test_grpc_gateway下,go mod init test_grpc_gateway

    • go mod init test_grpc_gateway
      
  • 切换国内源go env -w GOPROXY=https://goproxy.cn

    • go env -w GOPROXY=https://goproxy.cn
      
  • 打开go的GO111MODULE,配置Windows编译环境

    • go env -w GO111MODULE=on
      SET CGO_ENABLED=1
      SET GOOS=windows
      
    • 使用go env命令查看配置是否成功

  • 新建tools目录,tools目录下新建tools.go文件,按照官方文档,导入依赖,执行go mod tidy(这一步其实可以省略了,因为已经用buf来编译了,这里先暂时放这,参照官方的文档

    • // +build tools
      
      package tools
      
      import (
          _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
          _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
          _ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
          _ "google.golang.org/protobuf/cmd/protoc-gen-go"
      )
      
    • go mod tidy
      
  • 新建proto文件夹,文件夹下新建test_calc.proto文件,定义proto

    • syntax = "proto3";
      
      import "google/api/annotations.proto";
      
      package test_calc.v1;
      
      option go_package = "grpc_gateway/gen/pb";
      
      service TestCal {
          rpc Add(AddRequest) returns (ResultReply) {
              option (google.api.http) = {
                  post: "/v1/test_calc/add"
                  body: "*"
              };
          }
      
          rpc Multiply(MultiplyRequest) returns (ResultReply) {
              option (google.api.http) = {
                  post: "/v1/test_calc/multiply"
                  body: "*"
              };
          }
      }
      
      
      message AddRequest {
          int32 num1 = 1;
          int32 num2 = 2;
      }
      
      message MultiplyRequest {
          int32 num1 = 1;
          int32 num2 = 2;
      }
      
      message ResultReply {
          int32 num1 = 1;
      }
      
  • 下载buf(Windows版本),存入指定文件夹,这里不放到$PATH下

  • 在proto文件夹下新建buf.gen.yaml,使用buf的远程插件管理功能,其中go相关的插件配置按照grpc-gateway官方文档配置,这里再额外给出Python的配置,插件从Remote plugins (buf.build)中查找

    • Buf

    • version: v1
      plugins:
        # generate go structs for protocol buffer defination
        - remote: buf.build/library/plugins/go:v1.27.1-1
          out: ../gen/go
          opt:
            - paths=source_relative
        # generate gRPC stubs in golang
        - remote: buf.build/library/plugins/go-grpc:v1.1.0-2
          out: ../gen/go
          opt:
            - paths=source_relative
        # generate reverse proxy from protocol definations
        - remote: buf.build/grpc-ecosystem/plugins/grpc-gateway:v2.6.0-1
          out: ../gen/go
          opt:
            - paths=source_relative
        # generate openapi documentation for api
        - remote: buf.build/grpc-ecosystem/plugins/openapiv2:v2.6.0-1
          out: ../gen/openapiv2
        # Base types for Python. Generates message and enum types.
        - remote: buf.build/protocolbuffers/plugins/python:v21.1.0-1
          out: ../gen/python
        # Generates Python client and server stubs for the gRPC framework.
        - remote: buf.build/grpc/plugins/python:v1.46.3-1
          out: ../gen/python
      
  • 在proto文件夹下执行buf mod init

    • cd proto
      buf mod init
      
  • 在生成的buf.yaml配置中增加googleapis依赖,执行buf mod update

    • version: v1
      breaking:
        use:
          - FILE
      lint:
        use:
          - DEFAULT
      deps:
        - buf.build/googleapis/googleapis
      
    • buf mod update
      
  • 为了方便后续代码的编写,这里手动用buf命令编译一次proto文件,后续直接使用Makefile编译即可,进入proto文件夹下,执行buf generate编译

    • cd proto
      # 注意,这里是绝对路径,因为没有加入到$PATH
      D:/software/buf/buf-Windows-x86_64.exe generate
      
  • 下载安装Windows版本的gcc编译器Download TDM-GCC Compiler (sourceforge.net),最好在Linux环境下操作,这里主要为了方便演示

  • 编写Makefile文件

    • .PHONY: all pb app_calc app_grpcgw
      all: pb app_calc app_grpcgw
      
      pb:
      	cd proto && D:/software/buf/buf-Windows-x86_64.exe generate --debug
      
      app_calc:
      	go build -o output/app_calc.exe ./main_calc/serving_calc/main.go
      
      app_grpcgw:
      	go build -o output/app_grpcgw.exe ./main_calc/serving_grpcgw/main.go
      
  • 编写代码

    • 公共变量定义,新建common目录,common下新建三个文件consts.go,flags.go,vars.go

      • package common
        
        const (
        	GRPCEndPoint = "localhost:60066"
        )
        
      • package common
        
        import (
        	"flag"
        	"log"
        	"os"
        )
        
        func ParseArgs() {
        	log.SetFlags(log.LstdFlags | log.Lshortfile)
        	verbose := flag.Bool("v", false, "display version and revision")
        	flag.StringVar(&Port, "p", "6066", "grpc gateway addr to listen")
        	flag.Parse()
        	if *verbose {
        		log.Printf("Version:%s, Revision:%s\n", Version, Revision)
        		os.Exit(0)
        	}
        	if Port == "" {
        		log.Printf("listen port should not be empty\n")
        		os.Exit(0)
        	}
        }
        
      • package common
        
        var (
        	Version  = ""
        	Revision = ""
        	Port     = ""
        )
        
    • 编写grpc服务,新建serving文件夹,serving下新建app_calc,app_calc下新建serving.go

      • package app_calc
        
        import (
        	"context"
        	"encoding/json"
        	"test_grpc_gateway/common"
        	"test_grpc_gateway/gen/go"
        	"log"
        	"net"
        	"time"
        	"google.golang.org/grpc"
        )
        
        func Run() {
        	server := grpc.NewServer()
        	pb.RegisterTestCalServer(server, &expTestCalServer{})
        	if l, err := net.Listen("tcp", common.GRPCEndPoint); err != nil {
        		panic(err)
        	} else {
        		time.AfterFunc(100*time.Millisecond, func() {
        			log.Printf("grpc service listen on %s successfully", common.GRPCEndPoint)
        			log.Println("ready to service ....")
        		})
        		if err = server.Serve(l); err != nil {
        			panic(err)
        		}
        	}
        }
        
        type expTestCalServer struct {
        	pb.UnimplementedTestCalServer
        }
        
        func (e expTestCalServer) Add(ctx context.Context, request *pb.AddRequest) (*pb.ResultReply, error) {
        	log.Printf("[Add] receive request:%s\n", toString(request))
        	_return := &pb.ResultReply{
        		Num1: request.Num1 + request.Num2,
        	}
        	return _return, nil
        }
        
        func (e expTestCalServer) Multiply(ctx context.Context, request *pb.MultiplyRequest) (*pb.ResultReply, error) {
        	log.Printf("[Multiply] receive request:%s\n", toString(request))
        	_return := &pb.ResultReply{
        		Num1: request.Num1 * request.Num2,
        	}
        	return _return, nil
        }
        
        func toString(obj interface{}) string {
        	bts, _ := json.Marshal(obj)
        	return string(bts)
        }
        
    • 编写grpc-gateway服务,serving下新建app_grpcgw,app_grpcgw下新建serving.go

      • package app_grpcgw
        
        import (
        	"context"
        	"test_grpc_gateway/common"
        	"test_grpc_gateway/gen/go"
        	"log"
        	"net"
        	"net/http"
        	"time"
        
        	"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
        	"google.golang.org/grpc"
        )
        
        func Run() {
        	common.ParseArgs()
        	mux := runtime.NewServeMux()
        	err := pb.RegisterTestCalHandlerFromEndpoint(context.Background(), mux, common.GRPCEndPoint, []grpc.DialOption{grpc.WithInsecure()})
        	if err != nil {
        		panic(err)
        	}
        	server := http.Server{
        		Handler: corsMux(mux),
        	}
        	l, err := net.Listen("tcp", ":" + common.Port)
        	if err != nil {
        		panic(err)
        	}
        	time.AfterFunc(200*time.Millisecond, func() {
        		log.Printf("grpc gateway listen on :%s successfully\n", common.Port)
        		log.Println("ready to service ....")
        	})
        	err = server.Serve(l)
        	if err != nil {
        		panic(err)
        	}
        }
        
        func corsMux(h http.Handler) http.Handler {
        	return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        		rw.Header().Set("Access-Control-Allow-Origin", req.Header.Get("Origin"))
        		rw.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE")
        		rw.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType")
        		if req.Method == "OPTIONS" {
        			return
        		}
        		h.ServeHTTP(rw, req)
        	})
        }
        
    • 注意导入新包要执行go mod tidy

      • go mod tidy
        
    • 编写grpc服务入口文件,新建main_calc文件夹,main_calc下新建serving_calc,serving_calc下新建main.go

      • package main
        
        import "test_grpc_gateway/serving/app_calc"
        
        func main() {
        	app_calc.Run()
        }
        
    • 编写grpc-gateway服务入口文件,main_calc下新建serving_calc,serving_grpcgw下新建main.go

      • package main
        
        import "test_grpc_gateway/serving/app_grpcgw"
        
        func main() {
        	app_grpcgw.Run()
        }
        
    • 编写Python版本的grpc服务与client,新建py_server_client文件夹,为了方便导包,这里将buf编译生成的两个python版本编译文件copy到当前目录,新建client.py,server.py,需要提前安装好依赖

      • pip install grpcio
        pip install protobuf
        pip install --upgrade google-api-python-client
        
      • import grpc
        import test_calc_pb2
        import test_calc_pb2_grpc
        
        
        def run(n, m):
            channel = grpc.insecure_channel("localhost:60066")  # 连接上grpc服务端
            stub = test_calc_pb2_grpc.TestCalStub(channel)
            response = stub.Add(test_calc_pb2.AddRequest(num1=n, num2=m))
            print(f'{n} + {m} = {response}')
            response2 = stub.Multiply(test_calc_pb2.MultiplyRequest(num1=n, num2=m))
            print(f'{n} * {m} = {response2}')
        
        
        if __name__ == '__main__':
            run(1, 2)
        
      • from concurrent import futures
        import grpc
        import test_calc_pb2
        import test_calc_pb2_grpc
        
        
        class TestCalServicer(test_calc_pb2_grpc.TestCal):
            def Add(self, request, context):
                print("Add function called")
                print("num1:", request.num1, ", num2:", request.num2)
                return test_calc_pb2.ResultReply(num1=request.num1+request.num2)
        
            def Multiply(self, request, context):
                print("Multiply function called")
                print("num1:", request.num1, ", num2:", request.num2)
                return test_calc_pb2.ResultReply(num1=request.num1*request.num2)
        
        def serve():
            server = grpc.server(futures.ThreadPoolExecutor(max_workers=5))
            test_calc_pb2_grpc.add_TestCalServicer_to_server(TestCalServicer(), server)
            server.add_insecure_port("[::]:60066")
            server.start()
            print("grpc sercer start...")
            server.wait_for_termination()
        
        
        if __name__ == '__main__':
            serve()
        
    • 编写前端测试文件,这里方便期间,直接在浏览器控制台测试,编写一个index.html,导入jQuery即可。

      • <!DOCTYPE html>
        <html>
            <head>
                <meta charset="utf-8">
                <title>grpc_gateway测试</title>
                <script src="https://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js"></script>
                <style>
                </style>
            </head>
            <body>
                
            </body>
            <script>
                // data = {}
                // data['num1'] = 1
                // data['num2'] = 2
                // $.ajax({
                //     url: "http://127.0.0.1:6066/v1/test_calc/add",
                //     type: "post",
                //     data: JSON.stringify(data),
                //     success: function(data) {
                //         data_response = data;
                //         isPredictFirst = true;
                //         console.log(data);
                //         alert("INFO:预测成功!");
                //     }
                // });
                // $.ajax({
                //     url: "http://127.0.0.1:6066/v1/test_calc/multiply",
                //     type: "post",
                //     data: JSON.stringify(data),
                //     success: function(data) {
                //         data_response = data;
                //         isPredictFirst = true;
                //         console.log(data);
                //         alert("INFO:预测成功!");
                //     }
                // });
            </script>
        </html>
        
  • 进入项目根目录,进行编译

    • mingw32-make
      
  • 至此所有代码编写完毕,项目目录树如下:

    • C:\USERS\PS\GO\SRC\TEST_GRPC_GATEWAY
      │  go.mod
      │  go.sum
      │  index.html
      │  Makefile
      │  
      ├─common
      │      consts.go
      │      flags.go
      │      vars.go
      │
      ├─doc
      │      grpc-gateway分享.md
      │
      ├─gen
      │  ├─go
      │  │      test_calc.pb.go
      │  │      test_calc.pb.gw.go
      │  │      test_calc_grpc.pb.go
      │  │
      │  ├─openapiv2
      │  │      test_calc.swagger.json
      │  │
      │  └─python
      │          test_calc_pb2.py
      │          test_calc_pb2_grpc.py
      │
      ├─main_calc
      │  ├─serving_calc
      │  │      main.go
      │  │
      │  └─serving_grpcgw
      │          main.go
      │
      ├─output
      │      app_calc
      │      app_calc.exe
      │      app_grpcgw
      │      app_grpcgw.exe
      │
      ├─proto
      │      buf.gen.yaml
      │      buf.lock
      │      buf.yaml
      │      test_calc.proto
      │
      ├─py_server_client
      │  │  client.py
      │  │  server.py
      │  │  test_calc_pb2.py
      │  │  test_calc_pb2_grpc.py
      │  │
      │  └─__pycache__
      │          test_calc_pb2.cpython-38.pyc
      │          test_calc_pb2_grpc.cpython-38.pyc
      │
      ├─serving
      │  ├─app_calc
      │  │      serving.go
      │  │
      │  └─app_grpcgw
      │          serving.go
      │
      └─tools
              tools.go
      
  • 测试1:grpc的client调用grpc的client

    • 启动go编译完成的app_calc.exe

      • 2023/05/06 07:58:54 grpc service listen on localhost:60066 successfully
        2023/05/06 07:58:54 ready to service ...
        
      • 由于没有编写go版本的client,这里使用python版本的client,从而也验证grpc的跨语言

        • # client
          (python38) C:\Users\ps\go\src\test_grpc_gateway\py_server_client>python client.py
          1 + 2 = num1: 3
          
          1 * 2 = num1: 2
          
          # server
          2023/05/06 08:00:17 [Add] receive request:{"num1":1,"num2":2}
          2023/05/06 08:00:17 [Multiply] receive request:{"num1":1,"num2":2}
          
    • 关闭go编译完成的app_calc.exe,启动python版本的server,并用client调用

      • # server
        grpc sercer start...
        Add function called
        num1: 1 , num2: 2
        Multiply function called
        num1: 1 , num2: 2
        
        # client
        1 + 2 = num1: 3
        
        1 * 2 = num1: 2
        
  • 测试2:直接使用浏览器调用grpc的server,启动编译完成的app_calc.exe,vscode打开index.html,找到控制台,发送Ajax请求

    • data = {}
      data['num1'] = 1
      data['num2'] = 2
      $.ajax({
              url: "http://127.0.0.1:60066/v1/test_calc/multiply",
              type: "post",
              data: JSON.stringify(data),
              success: function(data) {
                  data_response = data;
                  isPredictFirst = true;
                  console.log(data);
                  alert("INFO:预测成功!");
              }
          });
      
    • # 返回
      POST http://127.0.0.1:60066/v1/test_calc/multiply net::ERR_CONNECTION_RESET
      
    • 使用python的grpc server也是同理

  • 测试3:打开grpc-gateway,启动编译完成的app_grpcgw.exe,启动编译完成的app_calc.exe,再次发送请求

    • data = {}
      data['num1'] = 1
      data['num2'] = 2
      $.ajax({
              url: "http://127.0.0.1:60066/v1/test_calc/multiply",
              type: "post",
              data: JSON.stringify(data),
              success: function(data) {
                  data_response = data;
                  isPredictFirst = true;
                  console.log(data);
                  alert("INFO:预测成功!");
              }
          });
      
    • {num1: 2}
      
    • $.ajax({
              url: "http://127.0.0.1:6066/v1/test_calc/add",
              type: "post",
              data: JSON.stringify(data),
              success: function(data) {
                  data_response = data;
                  isPredictFirst = true;
                  console.log(data);
                  alert("INFO:预测成功!");
              }
          });
      
    • {num1: 3}
      
    • 使用python的server也是同理