天天看點

gRPC,爆贊

原文連結: gRPC,爆贊

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 服務的開發,接下來我們就在這個「基礎模闆」上不斷豐富,學習更多特性。

流方式

接下來看看流的方式,顧名思義,資料可以源源不斷的發送和接收。

流的話分單向流和雙向流,這裡我們直接通過雙向流來舉例。

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 檔案,這裡不再多說。

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 := 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 檔案:

import "github.com/mwitkow/[email protected]/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/[email protected] \
    --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
           

接下來開始修改代碼,先看服務端:

// 證書認證-雙向認證
// 從證書相關檔案中讀取和解析資訊,得到證書公鑰、密鑰對
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,小夥伴們可以一遍看源碼,一遍對照文章内容來學習。

源碼位址:

  • https://github.com/yongxinz/go-example/tree/main/grpc-example
  • https://github.com/yongxinz/gopher/tree/main/blog

往期文章:

  • 推薦三個實用的 Go 開發工具
  • 被 Docker 日志坑慘了
  • 使用 grpcurl 通過指令行通路 gRPC 服務
  • 這個 TCP 問題你得懂:Cannot assign requested address

參考文章:

  • https://github.com/mwitkow/go-proto-validators
  • https://github.com/Bingjian-Zhu/go-grpc-example
  • http://gaodongfei.com/archives/start-grpc
  • https://liaoph.com/openssl-san/
  • https://www.cnblogs.com/jackluo/p/13841286.html