Dive into gRPC(2):实现一个服务
在上一篇文章中,我们介绍了什么是RPC,以及gRPC,并介绍了gRPC的安装方法,还有一个简单的例子了解了gRPC的使用,但并没有涉及到具体的含义。在这里,我们将从零开始介绍,如何使用gRPC开发一个简单的服务。
这里我们仅仅使用Go语言实现一个简单的client/server系统,这篇文章假设读者具有基本的Go语言基础,并且在你的机器上已经安装好了基础环境(Go、grpc、protobuf
包,以及protoc
工具)。
这次我们打算实现一个简单的服务,提供一些简单的数学函数,比如求解最大公约数等。这个服务我们叫做SimpleMath,接下来,我们从零开始实现。
首先在$GOPATH/src
下创建一个目录,simplemath
,然后在里面再创建三个子目录:client
、api
以及server
。此时,目录结构如下:
$GOPATH // your $GOPATH folder
|__src // the source folder
|__simplemath // the simplemath project folder
|__client // the client folder stores the client codes
|__api // the api folder stores the .proto and generated .pb.go files
|__server // the server folder stores the server codes
...
其中,client
存放的是客户端的代码,server
存放的是服务器的代码,而api
存放的是接口的定义文件以及protobuf
自动生成的代码。
1. API定义
之前我们提过,在RPC中,需要使用一定的接口定义语言来定义接口API,使得通信双方能够正常通信。在gRPC中,我们使用的是protocol
。
protocol
允许我们定义两类内容:message
和service
。其中message
就是客户端向服务器发送的请求数据的格式,以及服务器给客户端的响应数据的格式;而service
是protocol
定义的接口,规定了服务器提供什么样的服务,也限制了客户端能够从服务器获取到的服务。
简单地说,message
就是对象,objects,而service
就是动作或行为,actions。
1.1 统一一下接口
在api
目录中新建文件simplemath.proto
:
syntax = "proto3";
package api;
// this is the request data for GreatCommonDivisor
message GCDRequest {
int32 first = 1; // the first number
int32 second = 2; // the second number
}
// this is the response data for GreatCommonDivisor
message GCDResponse {
int32 result = 1; // the result
}
// this is the service for simplemath
service SimpleMath {
// this is the interface for greatest common divisor
rpc GreatCommonDivisor (GCDRequest) returns (GCDResponse) {}
}
此时,整个项目的结构如下:
$GOPATH // your $GOPATH folder
|__src // the source folder
|__simplemath // the simplemath project folder
|__client // the client folder stores the client codes
|__api // folder that stores .proto and .pb.go files
| |__simplemath.proto // file defines the messages and services
|__server // the server folder stores the server codes
...
1.2 我们做了什么?
.proto
文件的开头都是先指定一个语法版本,在这里是使用的proto3
。然后指定包名,这个包名就是文件所在的目录名。
如同上面说的,在这个文件中,我们定义了两部分的内容:两个message
分别对应请求GCDRequest
与响应GCDResponse
数据,还有一个service
,其中定义了一个rpc接口GreatCommonDivisor
,用来求解两个数的最大公约数。这个接口接收一个GCDRequest
,经过远程调用计算之后返回一个GCDReponse
。
在message
中,我们定义了需要的参数,前面表明了每个参数的类型,在protobuf
中,int32
对应Go语言的int32
类型。注意在protobuf
中,没有int
这种类型。
1.3 编译一下才能用
这个文件仅仅定义了客户端与服务器之间的接口,但是通信并不是使用这个文件定义的内容。为了使定义起作用,我们需要对这个文件进行编译,生成所选择的目标语言对应的代码,然后才能在客户端和服务器中使用。
protoc
就是用来编译.proto
文件的程序,进入.proto
文件所在的目录,执行如下命令进行编译:
$ protoc -I. --go_out=plugins=grpc:. simplemath.proto
如果提示没有找到程序:
protoc-gen-go: program not found or is not executable
这说明程序没有在$PATH
路径中,将其添加到路径中即可:
$ export PATH=$PATH:$GOPATH/bin
没有错误的话,会在目录下生成一个文件simplemath.pb.go
,这个就是编译生成的代码。此时,项目的结构如下:
$GOPATH // your $GOPATH folder
|__src // the source folder
|__simplemath // the simplemath project folder
|__client // the client folder stores the client codes
|__api // folder that stores the .proto and .pb.go files
| |__simplemath.proto // file defines the messages and services
| |__simplemath.pb.go // file generated by protoc
|__server // the server folder stores the server codes
...
可见,生成的代码的后缀是.pb.go
。
1.4 发生了什么?
打开这个文件,可以看到下面内容:
// this is the GCDRequest struct
type GCDRequest struct {
First int32 `protobuf:"varint,1,opt,name=first,proto3" json:"first,omitempty"`
Second int32 `protobuf:"varint,2,opt,name=second,proto3" json:"second,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
// SimpleMathClient is the client API for SimpleMath service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
type SimpleMathClient interface {
GreatCommonDivisor(ctx context.Context, in *GCDRequest, opts ...grpc.CallOption) (*GCDResponse, error)
}
type simpleMathClient struct {
cc *grpc.ClientConn
}
func NewSimpleMathClient(cc *grpc.ClientConn) SimpleMathClient {
return &simpleMathClient{cc}
}
func (c *simpleMathClient) GreatCommonDivisor(ctx context.Context, in *GCDRequest, opts ...grpc.CallOption) (*GCDResponse, error) {
out := new(GCDResponse)
err := c.cc.Invoke(ctx, "/api.SimpleMath/GreatCommonDivisor", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// SimpleMathServer is the server API for SimpleMath service.
type SimpleMathServer interface {
GreatCommonDivisor(context.Context, *GCDRequest) (*GCDResponse, error)
}
func RegisterSimpleMathServer(s *grpc.Server, srv SimpleMathServer) {
s.RegisterService(&_SimpleMath_serviceDesc, srv)
}
篇幅原因,这里仅展示部分内容。其中第一部分就是我们在simplemath.proto
中定义的一个message
,编译之后变成了一个结构体。需要注意的是,在Go中,首字母大写表明这是导出的,即使我们在simplemath.proto
中定义的时候使用的是小写的。因此,我们后续使用相应字段的时候,注意大小写的问题。
第二部分就是和客户端有关的内容,生成了一个客户端的结构体以及工厂函数,客户端中根据相应的接口定义生成了相应的函数,观察函数内部,可以看到这里使用了Invoke
函数进行远程调用,深入的细节这里不表。
注意这里这个接口的参数以及返回数据,参数是context.Context
和*GCDRequest
以及一系列选项,返回*GCDResponse
和error
。在我们的simplemath.proto
定义中,函数的定义是这样的:
// this is the service for simplemath
service SimpleMath {
// this is the interface for greatest common divisor
rpc GreatCommonDivisor (GCDRequest) returns (GCDResponse) {}
}
函数的参数和返回值只有一个,这说明编译后自动添加了原来没有的数据。这就规定了我们在客户端中调用时候的调用形式以及在服务器中实现服务时候的形式,主要注意一下。
第三部分就是和服务器相关的代码。同样也生成了一个服务器接口以及注册函数。这部分内容使得服务器在这个RPC调用期间都可用。同理,函数的参数以及返回值和客户端的一样。
还有就是,在生成的接口SimpleMathServer
中,这个名字是和我们定义的service
的名字相关的,我们的service
叫做SimpleMath
,那么这个接口就叫做SimpleMathServer
,而生成的注册函数就叫RegisterSimpleMathServer
,这个在之后会用到。
接下来,就开始定义服务器的行为了。
2. 构造服务
在上面,我们使用simplemath.proto
定义了服务接口,并使用protoc
生成了可用的代码。接下来,我们需要使用生成的代码来定义服务器的行为,来提供真正的服务。
2.1 将服务逻辑剥离
在上一篇中,我们的Greeter
服务器中的逻辑代码是直接写在主程序main中的,这使得逻辑混乱,将来服务复杂的时候不容易维护。这里,我们在server
中新建一个目录rpcimpl
,用来存放服务的实现代码,而在主程序main.go
中,仅仅处理服务的启动等逻辑:
$GOPATH // your $GOPATH folder
|__src // the source folder
|__simplemath // the simplemath project folder
|__client // the client folder stores the client codes
|__api // folder that stores .proto and .pb.go files
| |__simplemath.proto // file that defines messages and services
| |__simplemath.pb.go // file generated by protoc
|__server // the server folder stores the server codes
|__rpcimpl // the rpcimpl folder stores the logic codes
...
进入rpcimpl
目录,新建文件simplemath.go
,作为服务的实现代码:
package rpcimpl
import (
"golang.org/x/net/context"
pb "simplemath/api"
)
type SimpleMathServer struct{}
func (sms *SimpleMathServer) GreatCommonDivisor(ctx context.Context, in *pb.GCDRequest) (*pb.GCDResponse, error) {
first := in.First
second := in.Second
for second != 0 {
first, second = second, first%second
}
return &pb.GCDResponse{Result: first}, nil
}
2.2 我们做了什么?
这里,我们将导入的"simplemath/api"
命名为pb
,算是习惯用法吧。注意导入的具体路径,是相对于$PATH/src
的,从上面的目录结构可以看出来。
之后我们定义了一个结构体,叫做SimpleMathServer
,并实现了在simplemath.pb.go
中定义的接口:
// SimpleMathServer is the server API for SimpleMath service.
type SimpleMathServer interface {
GreatCommonDivisor(context.Context, *GCDRequest) (*GCDResponse, error)
}
在函数中,我们实现了最大公约数的逻辑,计算完结果后,将结果构造成一个GCDResponse
然后返回。
这里没有错误处理,在实际中,我们需要严格进行错误处理,防止程序意外崩溃。
2.3 编写主函数
然后,我们需要注册服务以及启动服务,在server
目录中,我们新建文件main.go
:
package main
import (
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"log"
"net"
pb "simplemath/api"
"simplemath/server/rpcimpl"
)
// the address to bind
const (
port = ":50051"
)
// main start a gRPC server and waits for connection
func main() {
// create a listener on TCP port 50051
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// create a gRPC server object
s := grpc.NewServer()
// attach the server instance to ther gRPC server
pb.RegisterSimpleMathServer(s, &rpcimpl.SimpleMathServer{})
reflection.Register(s)
// start the server
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
2.4 主函数的含义
在这里,我们指定了服务器绑定的地址(port=":50051"
),创建一个连接(lis
)后,需要使用grpc.NewServer()
来创建一个服务器server,之后,用我们的注册函数将我们实现的服务器server和前面创建的绑定起来。
需要注意的是,在绑定gRPC server
和我们自己的server时,使用的是pb.RegisterSimpleMathServer
,这个函数就是我们simplemath.pb.go
中生成的函数。这个函数名字的由来在前面已经讲过了,提出这个名字由来的原因是,当我们在调用这个函数的时候,不需要去simplemath.pb.go
文件中查看相应的函数的名字,直接根据我们定义的simplemath.proto
中service
的名字(SimpleMath
),就可以直接得到("Register"+"SimpleMath"+"Server"
)。
接下来的代码也很直观,我们使用前面创建的gRPC服务器,在前面建立的连接lis
上启动,这样,我们的服务器就可以提供服务了。
此时,项目的目录结构如下:
$GOPATH // your $GOPATH folder
|__src // the source folder
|__simplemath // the simplemath project folder
|__client // the client folder stores the client codes
|__api // folder that stores .proto and .pb.go files
| |__simplemath.proto // file defines the messages and services
| |__simplemath.pb.go // file generated by protoc
|__server // the server folder stores the server codes
|__rpcimpl // the rpcimpl folder stores the logic codes
| |__simplemath.go // the logic code related to simplemath
|__main.go // server program goes from here
...
2.5 生成可执行程序
在上一篇文章中,我们使用的是go run
命令直接运行,这里我是先使用go build
命令编译,生成一个可执行的程序,那么这个程序在任何地方就可以执行了。
进入server
目录,执行go build
命令,即可生成可执行文件server
,此时,目录结构如下:
$GOPATH // your $GOPATH folder
|__src // the source folder
|__simplemath // the simplemath project folder
|__client // the client folder stores the client codes
|__api // folder that stores .proto and .pb.go files
| |__simplemath.proto // file defines the messages and services
| |__simplemath.pb.go // file generated by protoc
|__server // the server folder stores the server codes
|__rpcimpl // the rpcimpl folder stores the logic codes
| |__simplemath.go // the logic code related to simplemath
|__main.go // server program goes from here
|__server // the executable file
...
接下来,让我们实现一个简单的客户端。
3. 使用服务
接口定义完了,服务也实现了,那么我们现在就开始使用上面定义与实现的服务。这里,我们构造一个简单的客户端。
3.1 逻辑剥离
和构造服务时一致,我们这里将在客户端中远程调用服务器的代码剥离出来。在client
目录下新建目录rpc
,并在rpc
目录下新建文件simplemath.go
:
package rpc
import (
"golang.org/x/net/context"
"google.golang.org/grpc"
pb "simplemath/api"
"strconv"
"log"
"time"
)
const (
address = "localhost:50051"
)
func GreatCommonDivisor(first, second string) {
// get a connection
conn, err := grpc.Dial(address, grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
// create a client
c := pb.NewSimpleMathClient(conn)
a, _ := strconv.ParseInt(first, 10, 32)
b, _ := strconv.ParseInt(second, 10, 32)
// create a ctx
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// remote call
r, err := c.GreatCommonDivisor(ctx, &pb.GCDRequest{First: int32(a), Second: int32(b)})
if err != nil {
log.Fatalf("could not compute: %v", err)
}
log.Printf("The Greatest Common Divisor of %d and %d is %d", a, b, r.Result)
}
这里同样导入了simplemath/api
包,然后指定了建立连接的地址,使用grpc.Dial
创建一个连接,并用这个连接创建一个客户端,然后我们就可以远程调用了。
3.2 编写主函数
在client
中,我们新建一个主函数main.go
:
package main
import (
"fmt"
"os"
"simplemath/client/rpc"
)
func main() {
if len(os.Args) == 1 {
usage()
os.Exit(1)
}
method := os.Args[1]
switch method {
case "gcd":
if len(os.Args) < 4 {
usage()
os.Exit(1)
}
rpc.GreatCommonDivisor(os.Args[2], os.Args[3])
default:
usage()
os.Exit(1)
}
}
func usage() {
fmt.Println("Welcome to Simple Math Client")
fmt.Println("Usage:")
fmt.Println("gcd num1 num2")
fmt.Println("Enjoy")
}
这就是我们的主程序,现在只有计算最大公约数的服务,如果后序继续添加服务,那么我们就可以很方便地进行扩展。
在client
中,我们同样使用go build
进行编译。编译完成后,项目的目录结构如下:
$GOPATH // your $GOPATH folder
|__src // the source folder
|__simplemath // the simplemath project folder
|__client // the client folder stores the client codes
| |__rpc // the rpc folder stores the call function
| | |__simplemath.go // the logic code for remote call
| |__main.go // client program goes from here
| |__client // the executeable client file
|__api // folder that stores .proto and .pb.go files
| |__simplemath.proto // file defines the messages and services
| |__simplemath.pb.go // file generated by protoc
|__server // the server folder stores the server codes
|__rpcimpl // the rpcimpl folder stores the logic codes
| |__simplemath.go // the logic code related to simplemath
|__main.go // server program goes from here
|__server // the executable server file
...
这样,所有的工作都做完了,让它们开始工作吧。
3.3 Let them talk
在一个shell里执行./server &
,后面的&
表示程序在后台执行,然后执行./client gcd 12 15
,结果如下:
2018/09/20 10:40:26 The Greatest Common Divisor of 12 and 15 is 3
我们的simplemath服务运行成功了。在这篇文章中,我们从零开始构造了一个简单的服务,梳理了使用gRPC构造服务的一般流程。不过这只是一个简单的例子,随着服务的复杂化,项目的整体结构也会不断变化。但归根结底,项目的基本结构还是我们simplemath的结构。
To Be Continued~