260×260

科学搜查官yuchanns

追寻计算机炼金术的贤者之石
  • Shenzhen, China
  • 后端开发工程师
Posted a month ago

protobuf的使用

protobuf是谷歌开发的一款跨平台跨语言强扩展性的用于序列化数据的协议,就像人们常用的xml、json一样。它主要由C++编写,用户按照相应的接口描述语言(Interface description language, IDL)可以批量生成对应语言的代码模板,用于诸如微服务rpc交换数据之类的通信。

而grpc是使用protobuf协议实现的一个RPC框架,由谷歌开发。

本文通过一个小例子演示创建grpc的go服务端以及php和node的客户端进行通信,并为go服务端启用grpc gateway使之支持http访问。

注意:本文只适用于Linux或者macOS。

protobuf

环境安装

  • 安装protoc
  • 安装go插件
  • 安装php插件
  • 安装node插件

安装protoc

protoc是protobuf的编译器。就像其他编程语言,用户编写代码,编译器将其编译成其他后端语言,protoc可以将用户编写的IDL编译成其他后端语言。具体编译成什么语言则根据稍后安装的语言插件以及用户操作而定。

直接从protocolbuffers/protobuf下载编译安装。

# 下载 wget https://github.com/protocolbuffers/protobuf/releases/download/v3.14.0/protobuf-all-3.14.0.tar.gz # 解压 tar -zxvf protobuf-all-3.14.0.tar.gz # 进入并编译安装 cd protobuf-3.14.0 ./configure # 这里根据相应的cpu提高make速度 make -j6 make install # 检查安装是否成功 protoc --version # libprotoc 3.14.0

注意,用Linux内核的操作系统安装protoc时,很高概率的情况下还需执行ldconfig才能成功执行protoc --version

安装go插件

如果你关注的不是go服务端的部分,可以跳过这一节。

注意,这里笔者使用的是v1.3版本的插件。v1.4版本在一些语法和命令上有所不同,会出现不兼容的情况,请锁好版本。

比如,生成代码不同。v1.3中rpc代码和grpc代码是合在一个文件上一起生成的;而v1.4会分成两个文件。

v1.3对gatewayc的支持也和v1.4不同,语法上也有所不同。

首先安装生成go语言代码的插件v1.3版本protoc-gen-go

GO111MODULE=on GOPROXY=https://goproxy.cn go get github.com/golang/protobuf/protoc-gen-go@v1.3

然后安装grpc gateway插件,这里我们使用v1插件,理由同上,避免不兼容情况。

GO111MODULE=on GOPROXY=https://goproxy.cn go get github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway@v1

注意,如果操作系统是macOS,还需要把两个插件(protoc-gen-goprotoc-gen-grpc-gateway)从$GOPATH/bin/移动到/usr/local/go/bin下才能在使用时自动寻找到。

安装php插件

如果你关注的不是php客户端的部分,可以跳过这一节。

安装php的插件是三种语言中最麻烦的一个步骤。

首先确认你的php环境包含pecl,然后安装grpc-1.34.0protobuf的扩展:

pecl install grpc-1.34.0 protobuf ## 找到php.ini的位置 php -i | grep php.ini ## 往php.ini中添加扩展 echo 'extension=grpc.so' >> php.ini echo 'extension=protobuf.so' >> php.ini ## 查看扩展是否已经安装 php -m | egrep 'grpc|protobuf'

接下来编译安装适用于protoc的grpc_php_plugin

由于插件编译已经废弃了make方式,所以这里采用bazel进行编译安装,首先需要安装bazel:

bazel是Google开发的一款代码构建工具,可以处理大规模构建,解决环境问题。

sudo apt install curl gnupg curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor > bazel.gpg sudo mv bazel.gpg /etc/apt/trusted.gpg.d/ echo "deb [arch=amd64] https://storage.googleapis.com/bazel-apt stable jdk1.8" | sudo tee /etc/apt/sources.list.d/bazel.list apt update apt install bazel

如果使用macOS则直接使用Homebrew安装brew install bazel

然后Clone grpc/grpc,并编译安装:

git clone git@github.com:grpc/grpc.git cd grpc bazel build @com_google_protobuf//:protoc bazel build src/compiler:grpc_php_plugin

完成之后会在grpc/bazel-bin/src/compiler下生成一个grpc_php_plugin,供后续使用。

安装node插件

如果你关注的不是node客户端的部分,可以跳过这一节。

Node安装gprc是最简单的。直接在项目根目录(package.json所在的目录)安装两个插件就可以了:

yarn add grpc @grpc/proto-loader

编写proto

安装完相应的插件,我们就可以编写proto文件,并生成相应的grpc代码了。

proto文件拥有官方语法参考手册,这里简单解释些基本概念。

首先,在文件开头,需要声明采用的语法版本为proto3,否则默认为proto2

package关键字可以用于定义代码生成后的包名、命名空间等。

编写message

一次通讯过程中会有请求和响应体,在protobuf中,被定义为message关键字。写起来有点像定义结构体那样:

syntax = "proto3"; package "greeter" message HelloRequest { string name = 1; enum Corpus { UNIVERSAL = 0; WEB = 1; } Corpus corpus = 1; }

在这个作为请求体的名为HelloRequestmessage结构中又定义了一些字段,通过类型 字段名 = 数字标识的方式编写:

  • 所有类型都是标量类型,支持的类型有stringbooldoublefloatint32int64等,可以参考官方手册获得;
  • 注意这里面还有一个特殊的类型,enum(枚举),用于限定某个字段的侯选值范围。
  • 同一个message的每个字段的数字标识必须不重复,这是用于在protobuf压缩成的二进制中识别使用的标记。支持范围从1到229 - 1(536870911),但是注意19000~19999是protobuf内置的标识,也无法使用,使用时会被编译器提示警告;
  • 字段名也不能重复。

同样,我们可以再定义一个HelloResponse,作为响应体:

/* 这里可以添加注释 * 可以是多行注释 */ message HelloResponse { string msg = 1; // 也可以用这种方式添加注释 }

如果对message做出了更新,删除字段或数字标识等操作,需要避免后来人重用这些字段或数字标识造成的问题,这时候使用reserved关键字指定保留字段和数字标识。一旦这些字段或标识被使用,编译器将会提示:

message Foo { reserved 2, 15, 9 to 11; reserved "foo", "bar"; }

如果希望message中某个字段可以重复数次,可以在字段前面加上repeated关键字。

message之间也可以嵌套使用,如:

message SearchResponse { repeated Result results = 1; } message Result { string url = 1; string title = 2; repeated string snippets = 3; }

以及可以通过import关键字引入其他proto文件(在编译时需要指定所有文件所在的路径):

import other "myproject/other_protos.proto";

如果你希望一个message中两个字段二选一,可以使用oneof关键字:

message SampleMessage { oneof test_oneof { string name = 4; SubMessage sub_message = 9; } }

定义一个字典:

message SampleMessage { map<key_type, value_type> map_field = N; } 注意字典不可使用`repeated`关键字。

编写service

有了请求和响应,接下来就是定义通信服务。

使用service关键字可以定义一个通信服务的接口;然后通过rpc关键字定义路由(接口的方法),这一步骤将会用上前面定义的message结构体,将他们组合起来,表达请求和响应的内容结构:

service Greeter { rpc SayHello(HelloRequest) returns (HelloResponse); }

生成对应语言的代码

编写完proto文件,我们就可以对其进行编译了,请确保环境安装环节没有缺漏,否则会失败。

生成go服务端代码

使用安装好的protobuf编译器protoc,它具有一个flag参数-I,表示Import Path,以及对应语言的--lang_out参数。

protoc -I . --go_out=plugins=grpc:. *.proto

这段命令表达的意思是,使用当前路径(-I .),在当前目录生成go代码并且使用grpc插件(--go_out=plugins=grpc:.),编译源为当前目录下的所有proto文件(*.proto)。注意.不要忽略,它表示当前目录。

于是我们可以在当前目录找到一个greeter.pb.go的文件。

在这个文件中,提供了RegisterGreeterServer方法,接受一个*grpc.Server和一个实现了GreeterServer接口的结构体指针。

我们只要在代码中实现对应的接口,在实现中编写具体的业务逻辑,然后通过RegisterGreeterServer注册到grpc.Server,接着启动,就实现了go grpc服务端的编写:

package main import ( "context" "fmt" "log" "net" "github.com/yuchanns/grpc-practise/proto/greeter" "google.golang.org/grpc" "google.golang.org/grpc/reflection" ) func main() { l, err := net.Listen("tcp", ":9090") if err != nil { log.Fatalf("failed to create listener: %s", err) } srv := grpc.NewServer() greeterServer := &GreeterServer{} greeter.RegisterGreeterServer(srv, greeterServer) reflection.Register(srv) log.Println("start at :9090") if err := srv.Serve(l); err != nil { log.Fatalf("failed to serve: %s", err) } } // GreeterServer implements greeter.GreeterServer type GreeterServer struct{} // SayHello returns a grpc response func (s *GreeterServer) SayHello(c context.Context, req *greeter.HelloRequest) (*greeter.HelloResponse, error) { return &greeter.HelloResponse{ Msg: fmt.Sprintf("hello, %s", req.Name), }, nil }

运行代码,启动grpc服务端。

生成PHP端代码

protoc -I. --php_out=. --grpc_out=. --plugin=protoc-gen-grpc=./grpc/bazel-bin/src/compiler/grpc_php_plugin *.proto

这段命令表达的意思是,使用当前路径(-I .),在当前目录生成php代码(--php_out=.),grpc代码生成在当前目录(--grpc_out=.),指定插件路径(--plugin=protoc-gen-grpc=./grpc/bazel-bin/src/compiler/grpc_php_plugin),编译源为当前目录下的所有proto文件(*.proto)。注意.不要忽略,它表示当前目录。

结果将会在当前目录生成两个文件夹GPBMetadataGreeter,分别包含了message和gprc服务代码。

然后我们通过composer安装grpc/grpc:1.34的代码库,引用生成的代码编写客户端请求代码:

<?php require __DIR__ . "/vendor/autoload.php"; require __DIR__ . "/proto/GPBMetadata/Greeter.php"; require __DIR__ . "/proto/Greeter/GreeterClient.php"; require __DIR__ . "/proto/Greeter/HelloRequest.php"; require __DIR__ . "/proto/Greeter/HelloResponse.php"; $client = new Greeter\GreeterClient('localhost:9090', [ 'credentials' => Grpc\ChannelCredentials::createInsecure(), ]); $request = new Greeter\HelloRequest(); $name = "php"; $request->setName($name); list($reply, $status) = $client->SayHello($request)->wait(); $msg = $reply->getMsg(); echo $msg,PHP_EOL;

运行脚本,即可成功请求grpc服务器和获取返回信息。

生成node端代码

node端是使用起来最简单的。无需生成代码,直接引用原始proto文件就可以使用:

const PROTO_PATH = __dirname + '/greeter.proto' const grpc = require('grpc') const protoLoader = require('@grpc/proto-loader') const packageDefinition = protoLoader.loadSync(PROTO_PATH) const greeter = grpc.loadPackageDefinition(packageDefinition).greeter const client = new greeter.Greeter("localhost:9090", grpc.credentials.createInsecure()) client.SayHello({name: "node"}, (error, resp) => { if (error) { console.log(error) return } console.log(resp) })

运行脚本,即可成功请求grpc服务器和获取返回信息。

使用Grpc Gateway转发http请求

接下来是扩展话题——往往我们写一个服务端不仅仅是接收服务间的rpc调用,有时候还需要使用RESTFUL提供给外部http请求访问,如果再写一遍支持http请求的服务,未免造成了重复编码浪费时间,一旦接口出现变更,可能还要维护着两套代码。

幸好grpc-ecosystem(grpc生态)团队提供了一个grpc-ecosystem/grpc-gateway,将http请求反向代理转发给grpc服务器,进行同步处理。使用者要做的则是在已有的proto文件上,添加一些关于http请求的定义描述,就可以生成支持grpc-gateway的代码了。

根据这个库,我们得知:grpc-gateway根据proto文件中使用google.api.httpannotations定义的规则生成RESTFUL的http请求反向代理服务。

更新proto

因此我们需要在proto文件中引入google/api/annotations.proto,这个文件可以在googleapis/googleapis获得。

将其下载下来放置在./googleapis/google/api下,然后对proto文件进行修改:

syntax = "proto3"; package greeter; option go_package="greeter"; import "google/api/annotations.proto"; service Greeter { rpc SayHello(HelloRequest) returns (HelloResponse) { option (google.api.http) = { post: "/api/greeter/say_hello" body: "*" }; } } message HelloRequest { string name = 1; } message HelloResponse { string message = 1; }

可以发现,主要是三处地方做了改动。

  • 在第三行添加了一个options go_package="greeter";。这是protobuf提供的选项功能,用于添加一些额外的处理,完整的可用选项可以参考google/protobuf/descriptor.proto
  • 第五行引入了google/api/annotations.proto,编译的时候需要指定该文件所在路径。
  • 在rpc方法体中,添加了option (google.api.http),它内部有两个字段,分别是RESTFUL请求方法: "路由"body: "*"

编译go服务端代码

然后重新进行编译,这次一并生成grpc gateway的代码:

protoc -I. -I./googleapis --go_out=plugins=grpc:. *.proto protoc -I. -I./googleapis --grpc-gateway_out=:. *.proto

-I这个flag是可以重复使用的,可以看到这次额外指定了一个./googleapis,因为上面的import "google/api/annotations.proto";查找需要用到。另外还使用了--grpc-gateway_out=:.表明在当前目录生成grpc-gateway相关的代码。

稍后在当前目录可以看到生成了两个文件,分别带*.pb.go*.pb.gw.go后缀。

然后我们在原来的grpc server基础上添加一个*runtime.ServeMux,使用*.pb.gw.go提供的RegisterGreeterHandlerFromEndpoint方法将实现了GreeterServer接口的结构体指针注册到runtime.ServeMux,然后再次启动服务:

package main import ( "context" "fmt" "log" "net" "net/http" "github.com/grpc-ecosystem/grpc-gateway/runtime" "github.com/yuchanns/grpc-practise/proto/greeter" "google.golang.org/grpc" "google.golang.org/grpc/reflection" ) func main() { endpoint := ":9090" addr := ":8080" // grpc l, err := net.Listen("tcp", endpoint) if err != nil { log.Fatalf("failed to create listener: %s", err) } srv := grpc.NewServer() greeterServer := &GreeterServer{} greeter.RegisterGreeterServer(srv, greeterServer) reflection.Register(srv) // grpc-gateway mux := runtime.NewServeMux() greeter.RegisterGreeterHandlerFromEndpoint(context.Background(), mux, endpoint, []grpc.DialOption{ grpc.WithInsecure(), }) log.Printf("grpc-server start at %s and grpc-gateway start at %s\n", endpoint, addr) go func() { if err := http.ListenAndServe(addr, mux); err != nil { log.Fatalf("failed to start grpc gateway: %+v", err) } }() if err := srv.Serve(l); err != nil { log.Fatalf("failed to serve: %s", err) } } // GreeterServer implements greeter.GreeterServer type GreeterServer struct{} // SayHello returns a grpc response func (s *GreeterServer) SayHello(c context.Context, req *greeter.HelloRequest) (*greeter.HelloResponse, error) { return &greeter.HelloResponse{ Msg: fmt.Sprintf("hello, %s", req.Name), }, nil }

尝试通过curl发出一个post请求到路由/api/greeter/say_hello:

curl -X POST -d '{"name": "curl"}' localhost:8080/api/greeter/say_hello ## {"msg":"hello, curl"}

RESTFUL请求成功。

PHP客户端的变动

需要注意,对proto文件的变更,也对PHP端生成代码有两个影响:

  • composer需要添加一个新的依赖composer require google/common-protos,否则会找不到annotation相关的代码
  • 生成PHP代码时记得带上googleapis路径,避免找不到报错
protoc -I. -I./googleapis --php_out=. --grpc_out=. --plugin=protoc-gen-grpc=./grpc/bazel-bin/src/compiler/grpc_php_plugin *.proto

Node客户端的变动

对Node没有影响。

结尾

本文所有代码均可在yuchanns/grpc-practise找到并根据README步骤安装和运行。推荐使用Github提供的Codespaces在线编辑器进行安装运行,节省环境适配时间。