运行一个 gRPC 服务

3.4 运行一个 gRPC 服务

在了解了 gRPC 和 Protobuf 的具体使用和情况后,我们将结合常见的应用场景,完成一个 gRPC 服务。而为了防止重复用工,这一个 gRPC 服务将会直接通过 HTTP 调用我们上一章节的博客后端服务,以此来获得标签列表的业务数据,我们只需要把主要的精力集中在 gRPC 服务相关联的知识上就可以了,同时后续的数个章节知识点的开展都会围绕着这个服务来进行。

3.4.1 初始化项目

$ mkdir -p $HOME/go-programming-tour-book/tag-service
$ cd $HOME/go-programming-tour-book/tag-service
$ go mod init github.com/go-programming-tour-book/tag-service

并创建以下子级目录,便于后续章节的使用,最终的目录结构如下:

tag-service
├── main.go
├── go.mod
├── go.sum
├── pkg
├── internal
├── proto
├── server
└── third_party

完成项目基础目录的创建后,在项目根目录执行 grpc 的安装命令。

3.4.2 编译和生成 proto 文件

在正式的开始编写服务前,我们需要先编写对应的 RPC 方法所需的 proto 文件,这是我们日常要先做的事情之一,因此接下来我们开始进行公共 proto 的编写,在项目的 proto 目录下新建 common.proto 文件,写入如下代码:

syntax = "proto3";

package proto;

message Pager {
    int64 page = 1;
    int64 page_size = 2;
    int64 total_rows = 3;
}

接着再编写获取标签列表的 RPC 方法,我们继续新建 tag.proto 文件,写入如下代码:

syntax = "proto3";

package proto;

import "proto/common.proto";

service TagService {
    rpc GetTagList (GetTagListRequest) returns (GetTagListReply) {}
}

message GetTagListRequest {
    string name = 1;
    uint32 state = 2;
}

message Tag {
    int64 id = 1;
    string name = 2;
    uint32 state = 3;
}

message GetTagListReply {
    repeated Tag list = 1;
    Pager pager = 2;
}

在上述 proto 代码中,我们引入了公共文件 common.proto,并依据先前博客后端服务一致的数据结构定义了 RPC 方法,完成后我们就可以编译和生成 proto 文件,在项目根目录下执行如下命令:

$ protoc --go_out=plugins=grpc:. ./proto/*.proto 

需要注意的一点是,我们在 tag.proto 文件中 import 了 common.proto,因此在执行 protoc 命令生成时,如果你只执行命令 protoc --go_out=plugins=grpc:. ./proto/tag.proto 是会存在问题的。

因此建议若所需生成的 proto 文件和所依赖的 proto 文件都在同一目录下,可以直接执行 ./proto/*.proto 命令来解决,又或是指定所有含关联的 proto 引用 ./proto/common.proto ./proto/tag.proto ,这样子就可以成功生成.pb.go 文件,并且避免了很多的编译麻烦。

但若实在是存在多层级目录的情况,可以利用 protoc 命令的 -IM 指令来进行特定处理。

3.4.3 编写 gRPC 方法

3.4.3.1 获取博客 API 的数据

由于我们的数据源是第二章节的博客后端,因此我们需要编写一个最简单的 API SDK 去进行调用,我们在项目的 pkg 目录新建 bapi 目录,并创建 api.go 文件,写入如下代码:

const (
	APP_KEY    = "eddycjy"
	APP_SECRET = "go-programming-tour-book"
)

type AccessToken struct {
	Token string `json:"token"`
}

func (a *API) getAccessToken(ctx context.Context) (string, error) {
	body, err := a.httpGet(ctx, fmt.Sprintf("%s?app_key=%s&app_secret=%s", "auth", APP_KEY, APP_SECRET))
	if err != nil {
		return "", err
	}

	var accessToken AccessToken
	_ = json.Unmarshal(body, &accessToken)
	return accessToken.Token, nil
}

func (a *API) httpGet(ctx context.Context, path string) ([]byte, error) {
	resp, err := http.Get(fmt.Sprintf("%s/%s", a.URL, path))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	body, _ := ioutil.ReadAll(resp.Body)
	return body, nil
}

首先我们编写了两个主要方法,分别是 API SDK 统一的 HTTP GET 的请求方法,以及所有 API 请求都需要带上的 AccessToken 的获取,接下来就是具体的获取标签列表的方法编写,如下:

type API struct {
	URL string
}

func NewAPI(url string) *API {
	return &API{URL: url}
}

func (a *API) GetTagList(ctx context.Context, name string) ([]byte, error) {
	token, err := a.getAccessToken(ctx)
	if err != nil {
		return nil, err
	}

	body, err := a.httpGet(ctx, fmt.Sprintf("%s?token=%s&name=%s", "api/v1/tags", token, name))
	if err != nil {
		return nil, err
	}

	return body, nil
}

上述代码主要是实现从第二章的博客后端中获取 AccessToken 和完成各类数据源的接口编写,并不是本章节的重点,因此只进行了简单实现,若有兴趣可以进一步的实现 AccessToken 的缓存和刷新,以及多 HTTP Method 的接口调用等等。

3.4.3.2 编写 gRPC Server

在完成了 API SDK 的编写后,我们在项目的 server 目录下创建 tag.go 文件,针对获取标签列表的接口逻辑进行编写,如下:

import (
	pb "github.com/go-programming-tour-book/tag-service/proto"
	...
)

type TagServer struct {}

func NewTagServer() *TagServer {
	return &TagServer{}
}

func (t *TagServer) GetTagList(ctx context.Context, r *pb.GetTagListRequest) (*pb.GetTagListReply, error) {
	api := bapi.NewAPI("http://127.0.0.1:8000")
	body, err := api.GetTagList(ctx, r.GetName())
	if err != nil {
		return nil, err
	}

	tagList := pb.GetTagListReply{}
	err = json.Unmarshal(body, &tagList)
	if err != nil {
		return nil, errcode.TogRPCError(errcode.Fail)
	}

	return &tagList, nil
}

在上述代码中,我们主要是指定了博客后端的服务地址(http://127.0.0.1:8000),然后调用 GetTagList 方法的 API,通过 HTTP 调用到第二章节所编写的博客后端服务获取标签列表数据,然后利用 json.Unmarshal 的特性,将其直接转换,并返回。

3.4.4 编写启动文件

我们在项目根目录下创建 main.go 文件,写入如下启动逻辑:

func main() {
	s := grpc.NewServer()
	pb.RegisterTagServiceServer(s, server.NewTagServer())

	lis, err := net.Listen("tcp", ":"+port)
	if err != nil {
		log.Fatalf("net.Listen err: %v", err)
	}

	err = s.Serve(lis)
	if err != nil {
		log.Fatalf("server.Serve err: %v", err)
	}
}

至此,我们一个简单的标签服务就完成了,它将承担我们整个篇章的研讨功能。接下来我们在项目根目录下执行 go run main.go 命令,启动这个服务,检查是否一切是否正常,

3.4.5 调试 gRPC 接口

在服务启动后,我们除了要验证服务是否正常运行,还要调试或验证 RPC 方法是否运行正常,而 gRPC 是基于 HTTP/2 协议的,因此不像普通的 HTTP/1.1 接口可以直接通过 postman 或普通的 curl 进行调用。但目前开源社区也有一些方案,例如像 grpcurl,grpcurl 是一个命令行工具,可让你与 gRPC 服务器进行交互,安装命令如下:

$ go get github.com/fullstorydev/grpcurl
$ go install github.com/fullstorydev/grpcurl/cmd/grpcurl

但使用该工具的前提是 gRPC Server 已经注册了反射服务,因此我们需要修改上述服务的启动文件,如下:

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

func main() {
	s := grpc.NewServer()
	pb.RegisterTagServiceServer(s, server.NewTagServer())
	reflection.Register(s)
	...
}

reflection 包是 gRPC 官方所提供的反射服务,我们在启动文件新增了 reflection.Register 方法的调用后,我们需要重新启动服务,反射服务才可用。

接下来我们就可以借助 grpcurl 工具进行调试了,一般我们可以首先执行下述 list 命令:

$ grpcurl -plaintext localhost:8001 list
grpc.reflection.v1alpha.ServerReflection
proto.TagService

$ grpcurl -plaintext localhost:8001 list proto.TagService
proto.TagService.GetTagList

我们一共指定了三个选项,分别是:

  • plaintext:grpcurl 工具默认使用 TLS 认证(可通过 -cert 和 -key 参数设置公钥和密钥),但由于我们的服务是非 TLS 认证的,因此我们需要通过指定这个选项来忽略 TLS 认证。
  • localhost:8001:指定我们运行的服务 HOST。
  • list:指定所执行的命令,list 子命令可获取该服务的 RPC 方法列表信息。例如上述的输出结果,一共有两个方法,一个是注册的反射方法,一个是我们自定义的 RPC Service 方法,因此可以更进一步的执行命令 grpcurl -plaintext localhost:8001 list proto.TagService 查看其子类的 RPC 方法信息。

在了解该服务具体有什么 RPC 方法后,我们可以执行下述命令去调用 RPC 方法:

$ grpcurl -plaintext -d '{"name":"Go"}' localhost:8001 proto.TagService.GetTagList  
{
  "list": [
    {
      "id": "1",
      "name": "Go",
      "state": 1
    }
  ],
  "pager": {
    "page": "1",
    "pageSize": "10",
    "totalRows": "1"
  }
}

在这里我们使用到了 grpcurl 工具的-d 选项,其输入的内容必须为 JSON 格式,该内容将被解析,最终以 protobuf 二进制格式传输到 gRPC Server,你可以简单理解为 RPC 方法的入参信息,也可以不传,不指定-d 选项即可。

3.4.6 gRPC 错误处理

在项目的实际运行中,常常会有各种奇奇怪怪的问题触发,也就是要返回错误的情况,在这里我们可以将我们的数据源,也就是博客的后端服务停掉,再利用工具重新请求,看看报什么错误,如下:

$ grpcurl -plaintext localhost:8001 proto.TagService.GetTagList 
ERROR:
  Code: Unknown
  Message: Get http://127.0.0.1:8000/api/v1/tags?name=: dial tcp 127.0.0.1:8000: connect: connection refused

你会发现其返回的字段分为两个,一个是 Code,另外一个是 Message,也就是对应着我们前文提到 grpc-statusgrpc-message 两个字段,它们共同代表着我们 gRPC 的整体调用情况。

3.4.6.1 gRPC 状态码

那我们更细致来看,这些 gRPC 的内部状态又分别有哪些呢,目前官方给出的全部状态响应码如下:

Code Status Notes
0 OK 成功
1 CANCELLED 该操作被调用方取消
2 UNKNOWN 未知错误
3 INVALID_ARGUMENT 无效的参数
4 DEADLINE_EXCEEDED 在操作完成之前超过了约定的最后期限。
5 NOT_FOUND 找不到
6 ALREADY_EXISTS 已经存在
7 PERMISSION_DENIED 权限不足
8 RESOURCE_EXHAUSTED 资源耗尽
9 FAILED_PRECONDITION 该操作被拒绝,因为未处于执行该操作所需的状态
10 ABORTED 该操作被中止
11 OUT_OF_RANGE 超出范围,尝试执行的操作超出了约定的有效范围
12 UNIMPLEMENTED 未实现
13 INTERNAL 内部错误
14 UNAVAILABLE 该服务当前不可用。
15 DATA_LOSS 不可恢复的数据丢失或损坏。

那么对应在我们刚刚的调用结果,状态码是 UNKNOWN,这是为什么呢,我们可以查看底层的处理源码,如下:

func FromError(err error) (s *Status, ok bool) {
	...
	if se, ok := err.(interface {
		GRPCStatus() *Status
	}); ok {
		return se.GRPCStatus(), true
	}
	return New(codes.Unknown, err.Error()), false
}

我们可以看到,实际上若不是 GRPCStatus 类型的方法,都是默认返回 codes.Unknown,也就是未知。而我们目前的报错,实际上是直接返回 return err 的,那么 gRPC 内部当然不认得,那我们可以怎么处理呢,实际上只要跟着内部规范实现即可。

3.4.6.2 错误码处理

我们在项目的 pkg 目录下新建 errcode 目录,并创建 errcode.go 文件,写入如下方法:

type Error struct {
	code int
	msg  string
}

var _codes = map[int]string{}

func NewError(code int, msg string) *Error {
	if _, ok := _codes[code]; ok {
		panic(fmt.Sprintf("错误码 %d 已经存在,请更换一个", code))
	}
	_codes[code] = msg
	return &Error{code: code, msg: msg}
}

func (e *Error) Error() string {
	return fmt.Sprintf("错误码:%d, 错误信息::%s", e.Code(), e.Msg())
}

func (e *Error) Code() int {
	return e.code
}

func (e *Error) Msg() string {
	return e.msg
}

接下来继续在目录下新建 common_error.go 文件,写入如下公共错误码:

var (
	Success          = NewError(0, "成功")
	Fail             = NewError(10000000, "内部错误")
	InvalidParams    = NewError(10000001, "无效参数")
	Unauthorized     = NewError(10000002, "认证错误")
	NotFound         = NewError(10000003, "没有找到")
	Unknown          = NewError(10000004, "未知")
	DeadlineExceeded = NewError(10000005, "超出最后截止期限")
	AccessDenied     = NewError(10000006, "访问被拒绝")
	LimitExceed      = NewError(10000007, "访问限制")
	MethodNotAllowed = NewError(10000008, "不支持该方法")
)

继续在目录下新建 rpc_error.go 文件,写入如下 RPC 相关的处理方法:

func TogRPCError(err *Error) error {
	s := status.New(ToRPCCode(err.Code()), err.Msg())
	return s.Err()
}

func ToRPCCode(code int) codes.Code {
	var statusCode codes.Code
	switch code {
	case Fail.Code():
		statusCode = codes.Internal
	case InvalidParams.Code():
		statusCode = codes.InvalidArgument
	case Unauthorized.Code():
		statusCode = codes.Unauthenticated
	case AccessDenied.Code():
		statusCode = codes.PermissionDenied
	case DeadlineExceeded.Code():
		statusCode = codes.DeadlineExceeded
	case NotFound.Code():
		statusCode = codes.NotFound
	case LimitExceed.Code():
		statusCode = codes.ResourceExhausted
	case MethodNotAllowed.Code():
		statusCode = codes.Unimplemented
	default:
		statusCode = codes.Unknown
	}

	return statusCode
}

3.4.6.3 业务错误码

这个时候你会发现,你返回的错误最后都会被转换为 RPC 的错误信息,那原始的业务错误码,我们可以放在哪里呢,因为没有业务错误码,怎么知道错在具体哪个业务板块,面向用户的客户端又如何特殊处理呢?

那么实际上,在 gRPC 的状态消息中其一共包含三个属性,分别是错误代码、错误消息、错误详细信息,因此我们可以通过错误详细信息这个字段来实现这个功能,其 googleapis 的 status.pb.go 原型如下:

type Status struct {
	Code      int32 `protobuf:"..."`
	Message   string `protobuf:"..."`
	Details   []*any.Any `protobuf:"..."`
	...
}

因此我们只需要对应其下层属性,让其与我们的应用程序的错误码机制产生映射关系即可,首先我们在 common.proto 中增加 any.proto 文件的引入和消息体 Error 的定义,将其作为我们应用程序的错误码原型,如下:

import "google/protobuf/any.proto";

package proto;

message Pager {...}

message Error {
    int32 code = 1;
    string message = 2;
    google.protobuf.Any detail = 3;
}

接着重新执行编译命令 protoc --go_out=plugins=grpc:. ./proto/*.proto ,再打开刚刚编写的 rpc_error.go 文件,修改 TogRPCError 方法,新增 Details 属性,如下:

func TogRPCError(err *Error) error {
	s, _ := status.New(ToRPCCode(err.Code()), err.Msg()).WithDetails(&pb.Error{Code: int32(err.Code()), Message: err.Msg()})
	return s.Err()
}

这时候又有新的问题了,那就是服务自身,在处理 err 时,如何能够获取到错误类型呢,我们可以通过新增 FromError 方法,代码如下:

type Status struct {
	*status.Status
}

func FromError(err error) *Status {
	s, _ := status.FromError(err)
	return &Status{s}
}

而针对有的应用程序,除了希望把业务错误码放进 Details 中,还希望把其它信息也放进去的话,我们继续新增下述方法:

func ToRPCStatus(code int, msg string) *Status {
	s, _ := status.New(ToRPCCode(code), msg).WithDetails(&pb.Error{Code: int32(code), Message: msg})
	return &Status{s}
}

3.4.6.4 验证

我们在项目的 errcode 目录下新建 module_error.go 文件,写入模块的业务错误码,如下:

var (
	ErrorGetTagListFail = NewError(20010001, "获取标签列表失败")
)

接下来我们修改 server 目录下的 tag.go 文件中的 GetTagList 方法,将业务错误码填入(同时建议记录日志,可参见第二章节),如下:

func (t *TagServer) GetTagList(ctx context.Context, r *pb.GetTagListRequest) (*pb.GetTagListReply, error) {
	resp, err := http.Get("http://127.0.0.1:8000/api/v1/tags?name=" + r.GetName())
	if err != nil {
		return nil, errcode.TogRPCError(errcode.ErrorGetTagListFail)
	}
	...
}

这个时候我们还是保持该 RPC 服务的数据源(博客服务)的停止运行,并在添加完业务错误码后重新运行 RPC 服务,然后利用 grpcurl 工具查看错误码是否达到我们的预期结果,如下:

$ grpcurl -plaintext localhost:8001 proto.TagService.GetTagList  
ERROR:
  Code: Unknown
  Message: 获取标签列表失败
  Details:
  1)	{
    	  "@type": "type.googleapis.com/proto.Error",
    	  "code": 20010001,
    	  "message": "获取标签列表失败"
    	}

那么外部客户端可通过 Details 属性知道业务错误码了,那内部客户端要如何使用呢,如下:

err := errcode.TogRPCError(errcode.ErrorGetTagListFail)
sts := errcode.FromError(err)
details := sts.Details()

最终错误信息是以我们 RPC 所返回的 err 进行传递的,因此我们只需要利用先前编写的 FromError 方法,解析为 status,接着调用 Details 方法,进行 Error 的断言,就可以精确的获取到业务错误码了。

3.4.7. 为什么,是什么

在上文中我们分别有提到一些特殊的操作,虽然你已经用了,但你可能会不理解,本着知其然知其所以然的思想,我们继续深究,看看都是什么。

3.4.7.1 为什么可以转换

在 3.4.3 小节中,我们编写 RPC 方法时,可以直接用 json 和 protobuf 所生成出来的结构体互相转换,这是为什么呢,为什么可以这么做,我们可以一起看看所生成的.pb.go 文件内容,如下:

type GetTagListReply struct {
	List                 []*Tag   `protobuf:"... json:"list,omitempty"`
	Pager                *Pager   `protobuf:"... json:"pager,omitempty"`
}

实际上在 protoc 生成.pb.go 文件时,会在所生成的结构体上打入 JSON Tag,其默认的规则就是下划线命名,因此可以通过该方式进行转换,而若是出现字段刚好不兼容的情况,我们也可以通过结构体转结构体的方式,最后达到这种效果。

3.4.7.2 为什么零值不展示

在实际的运行调用中,有一个问题常常被初学者所问到,占比非常高,那就是为什么在调用过程中,有的数据没有展示出来,例如:name 为空字符串、state 为 0 的话,就不会在 RPC 返回的数据中展示出来,你会发现其实是有规律的,他们都是零值,你可以看到所生成的.pb.go 文件内容,如下:

type Tag struct {
	Id                   int64    `protobuf:"... json:"id,omitempty"`
	Name                 string   `protobuf:"... json:"name,omitempty"`
	State                uint32   `protobuf:"... json:"state,omitempty"`
	...
}

在上小节也有提到,实际上所生成的结构体是有打 JSON Tag 的,它在所有的字段中都标明了 omitempty 属性,也就是当值为该类型的零值时将不会序列化该字段。

那么紧跟这个问题,就会出现第二个最常见的被提问的疑惑,那就是能不能解决这个”问题“,实际上这个并不是”问题“,因为这是 Protobuf 的规范,在官方手册的 JSON Mapping 小节明确指出,如果字段在 Protobuf 中具有默认值,则默认情况下会在 JSON 编码数据中将其省略以节省空间。

3.4.7.3 googleapis 是什么

googleapis 代指 Google API 的公共接口定义,在 Github 上搜索 googleapis 就可以找到对应的仓库了,不过需要注意的是由于 Go 具有不同的目录结构,因此很难在原始的 googleapis 存储库存储和生成 Go gRPC 源代码,因此 Go gRPC 实际使用的是 go-genproto 仓库,该仓库有如下两个主要使用来源:

  1. google/protobuf:protobuf 和 ptypes 子目录中的代码是均从存储库派生的, protobuf 中的消息体用于描述 Protobuf 本身。 ptypes 下的消息体定义了常见的常见类型。
  2. googleapis/googleapis:专门用于与 Google API 进行交互的类型。


本图书由 煎鱼 ©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。