
protobuf是谷歌开发的一款跨平台跨语言强扩展性的用于序列化数据的协议。
它主要由C++编写,用户按照相应的接口描述语言(Interface description language, IDL)可以批量生成对应语言的代码模板,就像人们常用的xml、json一样。
而grpc是使用protobuf协议实现的一个RPC框架,由谷歌开发。
本文通过一个小例子演示创建grpc的go服务端以及php和node的客户端进行通信,并为go服务端启用grpc gateway使之支持http访问。
注意:本文只适用于Linux或者macOS。
环境安装
安装protoc
protoc是protobuf的编译器。就像其他编程语言,用户编写代码,编译器将其编译成其他后端语言,protoc可以将用户编写的IDL编译成其他后端语言。具体编译成什么语言则根据稍后安装的语言插件以及用户操作而定。
直接从protocolbuffers/protobuf[1]下载编译安装。
# 下载
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[2]。
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-go
和protoc-gen-grpc-gateway
)从$GOPATH/bin/
移动到/usr/local/go/bin
下才能在使用时自动寻找到。
安装php插件
如果你关注的不是php客户端的部分,可以跳过这一节。
安装php的插件是三种语言中最麻烦的一个步骤。
首先确认你的php环境包含pecl
,然后安装grpc-1.34.0
和protobuf
的扩展:
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[3],并编译安装:
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
文件拥有官方语法参考手册[4],这里简单解释些基本概念。
首先,在文件开头,需要声明采用的语法版本为proto3,否则默认为proto2。
package
关键字可以用于定义代码生成后的包名、命名空间等。
编写message
一次通讯过程中会有请求和响应体,在protobuf中,被定义为message
关键字。写起来有点像定义结构体那样:
syntax = "proto3";
package "greeter"
message HelloRequest {
string name = 1;
enum Corpus {
UNIVERSAL = 0;
WEB = 1;
}
Corpus corpus = 1;
}
复制
在这个作为请求体的名为HelloRequest
的message
结构中又定义了一些字段,通过类型 字段名 = 数字标识
的方式编写:
•所有类型都是标量类型,支持的类型有string
、bool
、double
、float
、int32
、int64
等,可以参考官方手册[5]获得;•注意这里面还有一个特殊的类型,enum
(枚举),用于限定某个字段的侯选值范围。•同一个message
的每个字段的数字标识必须不重复,这是用于在protobuf压缩成的二进制中识别使用的标记。支持范围从1到229 - 1(536_870_911),但是注意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
)。注意.
不要忽略,它表示当前目录。
结果将会在当前目录生成两个文件夹GPBMetadata
和Greeter
,分别包含了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[6],将http请求反向代理转发给grpc服务器,进行同步处理。使用者要做的则是在已有的proto
文件上,添加一些关于http请求的定义描述,就可以生成支持grpc-gateway
的代码了。
根据这个库,我们得知:grpc-gateway
根据proto
文件中使用google.api.http
的annotations
定义的规则生成RESTFUL的http请求反向代理服务。
更新proto
因此我们需要在proto
文件中引入google/api/annotations.proto
,这个文件可以在googleapis/googleapis[7]获得。
将其下载下来放置在./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[8]。•第五行引入了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[9]找到并根据README步骤安装和运行。推荐使用Github提供的Codespaces[10]在线编辑器进行安装运行,节省环境适配时间。
引用链接
[1]
protocolbuffers/protobuf: https://github.com/protocolbuffers/protobuf[2]
protoc-gen-go: https://github.com/golang/protobuf/tree/master/protoc-gen-go[3]
grpc/grpc: https://github.com/grpc/grpc[4]
官方语法参考手册: https://developers.google.com/protocol-buffers/docs/proto3[5]
官方手册: https://developers.google.com/protocol-buffers/docs/proto3#scalar[6]
grpc-ecosystem/grpc-gateway: https://github.com/grpc-ecosystem/grpc-gateway[7]
googleapis/googleapis: https://github.com/googleapis/googleapis[8]
google/protobuf/descriptor.proto: https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/descriptor.proto[9]
yuchanns/grpc-practise: https://github.com/yuchanns/grpc-practise[10]
Codespaces: https://github.com/codespaces