gRPC 这项技术真是太棒了,接口约束严格,性能还高,在 k8s 和很多微服务框架中都有应用。

作为一名程序员,学就对了。

之前用 Python 写过一些 gRPC 服务,现在准备用 Go 来感受一下原汁原味的 gRPC 程序开发。

本文的特点是直接用代码说话,通过开箱即用的完整代码,来介绍 gRPC 的各种使用方法。

代码已经上传到 GitHub,下面正式开始。

介绍

gRPC 是 Google 公司基于 Protobuf 开发的跨语言的开源 RPC 框架。gRPC 基于 HTTP/2 协议设计,可以基于一个 HTTP/2 链接提供多个服务,对于移动设备更加友好。

入门

首先来看一个最简单的 gRPC 服务,第一步是定义 proto 文件,因为 gRPC 也是 C/S 架构,这一步相当于明确接口规范。

proto

syntax = "proto3";

package proto;

// The greeting service definition.
service Greeter {
    // Sends a greeting
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
    string name = 1;
}

// The response message containing the greetings
message HelloReply {
    string message = 1;
}

使用 protoc-gen-go 内置的 gRPC 插件生成 gRPC 代码:

protoc --go_out=plugins=grpc:. helloworld.proto

执行完这个命令之后,会在当前目录生成一个 helloworld.pb.go 文件,文件中分别定义了服务端和客户端的接口:

// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
type GreeterClient interface {
	// Sends a greeting
	SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}

// GreeterServer is the server API for Greeter service.
type GreeterServer interface {
	// Sends a greeting
	SayHello(context.Context, *HelloRequest) (*HelloReply, error)
}

接下来就是写服务端和客户端的代码,分别实现对应的接口。

server

package main

import (
	"context"
	"fmt"
	"grpc-server/proto"
	"log"
	"net"

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

type greeter struct {
}

func (*greeter) SayHello(ctx context.Context, req *proto.HelloRequest) (*proto.HelloReply, error) {
	fmt.Println(req)
	reply := &proto.HelloReply{Message: "hello"}
	return reply, nil
}

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	server := grpc.NewServer()
	// 注册 grpcurl 所需的 reflection 服务
	reflection.Register(server)
	// 注册业务服务
	proto.RegisterGreeterServer(server, &greeter{})

	fmt.Println("grpc server start ...")
	if err := server.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

client

package main

import (
	"context"
	"fmt"
	"grpc-client/proto"
	"log"

	"google.golang.org/grpc"
)

func main() {
	conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure())
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	client := proto.NewGreeterClient(conn)
	reply, err := client.SayHello(context.Background(), &proto.HelloRequest{Name: "zhangsan"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(reply.Message)
}

这样就完成了最基础的 gRPC 服务的开发,接下来我们就在这个「基础模板」上不断丰富,学习更多特性。

流方式

接下来看看流的方式,顾名思义,数据可以源源不断的发送和接收。

流的话分单向流和双向流,这里我们直接通过双向流来举例。

proto

service Greeter {
    // Sends a greeting
    rpc SayHello (HelloRequest) returns (HelloReply) {}
    // Sends stream message
    rpc SayHelloStream (stream HelloRequest) returns (stream HelloReply) {}
}

增加一个流函数 SayHelloStream,通过 stream 关键词来指定流特性。

需要重新生成 helloworld.pb.go 文件,这里不再多说。

server

func (*greeter) SayHelloStream(stream proto.Greeter_SayHelloStreamServer) error {
	for {
		args, err := stream.Recv()
		if err != nil {
			if err == io.EOF {
				return nil
			}
			return err
		}

		fmt.Println("Recv: " + args.Name)
		reply := &proto.HelloReply{Message: "hi " + args.Name}

		err = stream.Send(reply)
		if err != nil {
			return err
		}
	}
}

在「基础模板」上增加 SayHelloStream 函数,其他都不需要变。

client

client := proto.NewGreeterClient(conn)

// 流处理
stream, err := client.SayHelloStream(context.Background())
if err != nil {
	log.Fatal(err)
}

// 发送消息
go func() {
	for {
		if err := stream.Send(&proto.HelloRequest{Name: "zhangsan"}); err != nil {
			log.Fatal(err)
		}
		time.Sleep(time.Second)
	}
}()

// 接收消息
for {
	reply, err := stream.Recv()
	if err != nil {
		if err == io.EOF {
			break
		}
		log.Fatal(err)
	}
	fmt.Println(reply.Message)
}

通过一个 goroutine 发送消息,主程序的 for 循环接收消息。

执行程序会发现,服务端和客户端都不断有打印输出。

验证器

接下来是验证器,这个需求是很自然会想到的,因为涉及到接口之间的请求,那么对参数进行适当的校验是很有必要的。

在这里我们使用 protoc-gen-govalidators 和 go-grpc-middleware 来实现。

先安装:

go get github.com/mwitkow/go-proto-validators/protoc-gen-govalidators

go get github.com/grpc-ecosystem/go-grpc-middleware

接下来修改 proto 文件:

proto

import "github.com/mwitkow/go-proto-validators@v0.3.2/validator.proto";

message HelloRequest {
    string name = 1 [
        (validator.field) = {regex: "^[z]{2,5}$"}
    ];
}

在这里对 name 参数进行校验,需要符合正则的要求才可以正常请求。

还有其他验证规则,比如对数字大小进行验证等,这里不做过多介绍。

接下来生成 *.pb.go 文件:

protoc  \
    --proto_path=${GOPATH}/pkg/mod \
    --proto_path=${GOPATH}/pkg/mod/github.com/gogo/protobuf@v1.3.2 \
    --proto_path=. \
    --govalidators_out=. --go_out=plugins=grpc:.\
    *.proto

执行成功之后,目录下会多一个 helloworld.validator.pb.go 文件。

这里需要特别注意一下,使用之前的简单命令是不行的,需要使用多个 proto_path 参数指定导入 proto 文件的目录。

官方给了两种依赖情况,一个是 google protobuf,一个是 gogo protobuf。我这里使用的是第二种。

即使使用上面的命令,也有可能会遇到这个报错:

Import "github.com/mwitkow/go-proto-validators/validator.proto" was not found or had errors

但不要慌,大概率是引用路径的问题,一定要看好自己的安装版本,以及在 GOPATH 中的具体路径。

最后是服务端代码改造:

引入包:

grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
grpc_validator "github.com/grpc-ecosystem/go-grpc-middleware/validator"

然后在初始化的时候增加验证器功能:

server := grpc.NewServer(
	grpc.UnaryInterceptor(
		grpc_middleware.ChainUnaryServer(
			grpc_validator.UnaryServerInterceptor(),
		),
	),
	grpc.StreamInterceptor(
		grpc_middleware.ChainStreamServer(
			grpc_validator.StreamServerInterceptor(),
		),
	),
)

启动程序之后,我们再用之前的客户端代码来请求,会收到报错:

2021/10/11 18:32:59 rpc error: code = InvalidArgument desc = invalid field Name: value 'zhangsan' must be a string conforming to regex "^[z]{2,5}$"
exit status 1

因为 name: zhangsan 是不符合服务端正则要求的,但是如果传参 name: zzz,就可以正常返回了。

Token 认证

终于到认证环节了,先看 Token 认证方式,然后再介绍证书认证。

先改造服务端,有了上文验证器的经验,那么可以采用同样的方式,写一个拦截器,然后在初始化 server 时候注入。

认证函数:

func Auth(ctx context.Context) error {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return fmt.Errorf("missing credentials")
	}

	var user string
	var password string

	if val, ok := md["user"]; ok {
		user = val[0]
	}
	if val, ok := md["password"]; ok {
		password = val[0]
	}

	if user != "admin" || password != "admin" {
		return grpc.Errorf(codes.Unauthenticated, "invalid token")
	}

	return nil
}

metadata.FromIncomingContext 从上下文读取用户名和密码,然后和实际数据进行比较,判断是否通过认证。

拦截器:

var authInterceptor grpc.UnaryServerInterceptor
authInterceptor = func(
	ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (resp interface{}, err error) {
	//拦截普通方法请求,验证 Token
	err = Auth(ctx)
	if err != nil {
		return
	}
	// 继续处理请求
	return handler(ctx, req)
}

初始化:

server := grpc.NewServer(
	grpc.UnaryInterceptor(
		grpc_middleware.ChainUnaryServer(
			authInterceptor,
			grpc_validator.UnaryServerInterceptor(),
		),
	),
	grpc.StreamInterceptor(
		grpc_middleware.ChainStreamServer(
			grpc_validator.StreamServerInterceptor(),
		),
	),
)

除了上文的验证器,又多了 Token 认证拦截器 authInterceptor

最后是客户端改造,客户端需要实现 PerRPCCredentials 接口。

type PerRPCCredentials interface {
    // GetRequestMetadata gets the current request metadata, refreshing
    // tokens if required. This should be called by the transport layer on
    // each request, and the data should be populated in headers or other
    // context. If a status code is returned, it will be used as the status
    // for the RPC. uri is the URI of the entry point for the request.
    // When supported by the underlying implementation, ctx can be used for
    // timeout and cancellation.
    // TODO(zhaoq): Define the set of the qualified keys instead of leaving
    // it as an arbitrary string.
    GetRequestMetadata(ctx context.Context, uri ...string) (
        map[string]string,    error,
    )
    // RequireTransportSecurity indicates whether the credentials requires
    // transport security.
    RequireTransportSecurity() bool
}

GetRequestMetadata 方法返回认证需要的必要信息,RequireTransportSecurity 方法表示是否启用安全链接,在生产环境中,一般都是启用的,但为了测试方便,暂时这里不启用了。

实现接口:

type Authentication struct {
	User     string
	Password string
}

func (a *Authentication) GetRequestMetadata(context.Context, ...string) (
	map[string]string, error,
) {
	return map[string]string{"user": a.User, "password": a.Password}, nil
}

func (a *Authentication) RequireTransportSecurity() bool {
	return false
}

连接:

conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure(), grpc.WithPerRPCCredentials(&auth))

好了,现在我们的服务就有 Token 认证功能了。如果用户名或密码错误,客户端就会收到:

2021/10/11 20:39:35 rpc error: code = Unauthenticated desc = invalid token
exit status 1

如果用户名和密码正确,则可以正常返回。

单向证书认证

证书认证分两种方式:

  1. 单向认证
  2. 双向认证

先看一下单向认证方式:

生成证书

首先通过 openssl 工具生成自签名的 SSL 证书。

1、生成私钥:

openssl genrsa -des3 -out server.pass.key 2048

2、去除私钥中密码:

openssl rsa -in server.pass.key -out server.key

3、生成 csr 文件:

openssl req -new -key server.key -out server.csr -subj "/C=CN/ST=beijing/L=beijing/O=grpcdev/OU=grpcdev/CN=example.grpcdev.cn"

4、生成证书:

openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt

再多说一句,分别介绍一下 X.509 证书包含的三个文件:key,csr 和 crt。

  • key: 服务器上的私钥文件,用于对发送给客户端数据的加密,以及对从客户端接收到数据的解密。
  • csr: 证书签名请求文件,用于提交给证书颁发机构(CA)对证书签名。
  • crt: 由证书颁发机构(CA)签名后的证书,或者是开发者自签名的证书,包含证书持有人的信息,持有人的公钥,以及签署者的签名等信息。
gRPC 代码

证书有了之后,剩下的就是改造程序了,首先是服务端代码。

// 证书认证-单向认证
creds, err := credentials.NewServerTLSFromFile("keys/server.crt", "keys/server.key")
if err != nil {
	log.Fatal(err)
	return
}

server := grpc.NewServer(grpc.Creds(creds))

只有几行代码需要修改,很简单,接下来是客户端。

由于是单向认证,不需要为客户端单独生成证书,只需要把服务端的 crt 文件拷贝到客户端对应目录下即可。

// 证书认证-单向认证
creds, err := credentials.NewClientTLSFromFile("keys/server.crt", "example.grpcdev.cn")
if err != nil {
	log.Fatal(err)
	return
}
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(creds))

好了,现在我们的服务就支持单向证书认证了。

但是还没完,这里可能会遇到一个问题:

2021/10/11 21:32:37 rpc error: code = Unavailable desc = connection error: desc = "transport: authentication handshake failed: x509: certificate relies on legacy Common Name field, use SANs or temporarily enable Common Name matching with GODEBUG=x509ignoreCN=0"
exit status 1

原因是 Go 1.15 开始废弃了 CommonName,推荐使用 SAN 证书。如果想要兼容之前的方式,可以通过设置环境变量的方式支持,如下:

export GODEBUG="x509ignoreCN=0"

但是需要注意,从 Go 1.17 开始,环境变量就不再生效了,必须通过 SAN 方式才行。所以,为了后续的 Go 版本升级,还是早日支持为好。

双向证书认证

最后来看看双向证书认证。

生成带 SAN 的证书

还是先生成证书,但这次有一点不一样,我们需要生成带 SAN 扩展的证书。

什么是 SAN?

SAN(Subject Alternative Name)是 SSL 标准 x509 中定义的一个扩展。使用了 SAN 字段的 SSL 证书,可以扩展此证书支持的域名,使得一个证书可以支持多个不同域名的解析。

将默认的 OpenSSL 配置文件拷贝到当前目录。

Linux 系统在:

/etc/pki/tls/openssl.cnf

Mac 系统在:

/System/Library/OpenSSL/openssl.cnf

修改临时配置文件,找到 [ req ] 段落,然后将下面语句的注释去掉。

req_extensions = v3_req # The extensions to add to a certificate request

接着添加以下配置:

[ v3_req ]
# Extensions to add to a certificate request

basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names

[ alt_names ]
DNS.1 = www.example.grpcdev.cn

[ alt_names ] 位置可以配置多个域名,比如:

[ alt_names ]
DNS.1 = www.example.grpcdev.cn
DNS.2 = www.test.grpcdev.cn

为了测试方便,这里只配置一个域名。

1、生成 ca 证书:

openssl genrsa -out ca.key 2048

openssl req -x509 -new -nodes -key ca.key -subj "/CN=example.grpcdev.com" -days 5000 -out ca.pem

2、生成服务端证书:

# 生成证书
openssl req -new -nodes \
    -subj "/C=CN/ST=Beijing/L=Beijing/O=grpcdev/OU=grpcdev/CN=www.example.grpcdev.cn" \
    -config <(cat openssl.cnf \
        <(printf "[SAN]\nsubjectAltName=DNS:www.example.grpcdev.cn")) \
    -keyout server.key \
    -out server.csr
    
# 签名证书
openssl x509 -req -days 365000 \
    -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
    -extfile <(printf "subjectAltName=DNS:www.example.grpcdev.cn") \
    -out server.pem

3、生成客户端证书:

# 生成证书
openssl req -new -nodes \
    -subj "/C=CN/ST=Beijing/L=Beijing/O=grpcdev/OU=grpcdev/CN=www.example.grpcdev.cn" \
    -config <(cat openssl.cnf \
        <(printf "[SAN]\nsubjectAltName=DNS:www.example.grpcdev.cn")) \
    -keyout client.key \
    -out client.csr

# 签名证书
openssl x509 -req -days 365000 \
    -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
    -extfile <(printf "subjectAltName=DNS:www.example.grpcdev.cn") \
    -out client.pem
gRPC 代码

接下来开始修改代码,先看服务端:

// 证书认证-双向认证
// 从证书相关文件中读取和解析信息,得到证书公钥、密钥对
cert, _ := tls.LoadX509KeyPair("cert/server.pem", "cert/server.key")
// 创建一个新的、空的 CertPool
certPool := x509.NewCertPool()
ca, _ := ioutil.ReadFile("cert/ca.pem")
// 尝试解析所传入的 PEM 编码的证书。如果解析成功会将其加到 CertPool 中,便于后面的使用
certPool.AppendCertsFromPEM(ca)
// 构建基于 TLS 的 TransportCredentials 选项
creds := credentials.NewTLS(&tls.Config{
	// 设置证书链,允许包含一个或多个
	Certificates: []tls.Certificate{cert},
	// 要求必须校验客户端的证书。可以根据实际情况选用以下参数
	ClientAuth: tls.RequireAndVerifyClientCert,
	// 设置根证书的集合,校验方式使用 ClientAuth 中设定的模式
	ClientCAs: certPool,
})

再看客户端:

// 证书认证-双向认证
// 从证书相关文件中读取和解析信息,得到证书公钥、密钥对
cert, _ := tls.LoadX509KeyPair("cert/client.pem", "cert/client.key")
// 创建一个新的、空的 CertPool
certPool := x509.NewCertPool()
ca, _ := ioutil.ReadFile("cert/ca.pem")
// 尝试解析所传入的 PEM 编码的证书。如果解析成功会将其加到 CertPool 中,便于后面的使用
certPool.AppendCertsFromPEM(ca)
// 构建基于 TLS 的 TransportCredentials 选项
creds := credentials.NewTLS(&tls.Config{
	// 设置证书链,允许包含一个或多个
	Certificates: []tls.Certificate{cert},
	// 要求必须校验客户端的证书。可以根据实际情况选用以下参数
	ServerName: "www.example.grpcdev.cn",
	RootCAs:    certPool,
})

大功告成。

Python 客户端

前面已经说了,gRPC 是跨语言的,那么,本文最后我们用 Python 写一个客户端,来请求 Go 服务端。

使用最简单的方式来实现:

proto 文件就使用最开始的「基础模板」的 proto 文件:

syntax = "proto3";

package proto;

// The greeting service definition.
service Greeter {
    // Sends a greeting
    rpc SayHello (HelloRequest) returns (HelloReply) {}
    // Sends stream message
    rpc SayHelloStream (stream HelloRequest) returns (stream HelloReply) {}
}

// The request message containing the user's name.
 message HelloRequest {
    string name = 1;
}

// The response message containing the greetings
message HelloReply {
    string message = 1;
}

同样的,也需要通过命令行的方式生成 pb.py 文件:

python3 -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. ./*.proto

执行成功之后会在目录下生成 helloworld_pb2.py 和 helloworld_pb2_grpc.py 两个文件。

这个过程也可能会报错:

ModuleNotFoundError: No module named 'grpc_tools'

别慌,是缺少包,安装就好:

pip3 install grpcio
pip3 install grpcio-tools

最后看一下 Python 客户端代码:

import grpc

import helloworld_pb2
import helloworld_pb2_grpc


def main():
    channel = grpc.insecure_channel("127.0.0.1:50051")
    stub = helloworld_pb2_grpc.GreeterStub(channel)
    response = stub.SayHello(helloworld_pb2.HelloRequest(name="zhangsan"))
    print(response.message)


if __name__ == '__main__':
    main()

这样,就可以通过 Python 客户端请求 Go 启的服务端服务了。

总结

本文通过实战角度出发,直接用代码说话,来说明 gRPC 的一些应用。

内容包括简单的 gRPC 服务,流处理模式,验证器,Token 认证和证书认证。

除此之外,还有其他值得研究的内容,比如超时控制,REST 接口和负载均衡等。以后还会抽时间继续完善剩下这部分内容。

本文中的代码都经过测试验证,可以直接执行,并且已经上传到 GitHub,小伙伴们可以一遍看源码,一遍对照文章内容来学习。


源码地址:

参考文章: