详解gRPC框架的错误处理

科技   2024-09-06 08:52   北京  

基本错误处理

首先回顾下gRPC的pb文件和生成出来的client与server端的接口

service OrderManagement {
rpc getOrder(google.protobuf.StringValue) returns (Order);
}
type OrderManagementClient interface {
 GetOrder(ctx context.Context, 
           in *wrapperspb.StringValue, opts ...grpc.CallOption) (*Order, error)
}
type OrderManagementServer interface {
 GetOrder(context.Context, *wrapperspb.StringValue) (*Order, error)
 mustEmbedUnimplementedOrderManagementServer()
}

可以看到,虽然我们没有在pb文件中的接口定义设置error返回值,但生成出来的go代码是包含error返回值的

这非常符合Go语言的使用习惯:通常情况下我们定义多个error变量,并且在函数内返回,调用方可以使用errors.Is()或者errors.As()对函数的error进行判断

var (
 ParamsErr = errors.New("params err")
 BizErr    = errors.New("biz err")
)

func Invoke(i bool) error {
 if i {
  return ParamsErr
 } else {
  return BizErr
 }
}

func main() {
 err := Invoke(true)

 if err != nil {
  switch {
  case errors.Is(err, ParamsErr):
   log.Println("params error")
  case errors.Is(err, BizErr):
   log.Println("biz error")
  }
 }
}

🌿 但,在RPC场景下,我们还能进行error的值判断么?

// common/errors.go
var ParamsErr = errors.New("params is not valid")
// server/main.go
func (s *OrderManagementImpl) GetOrder(ctx context.Context, orderId *wrapperspb.StringValue) (*pb.Order, error) {
 return nil, common.ParamsErr
}
// client/main.go
retrievedOrder, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: "101"})

if err != nil && errors.Is(err, common.ParamsErr) {
  // 不会走到这里,因为err和common.ParamsErr不相等
  panic(err)
}

很明显,serverclient并不在同一个进程甚至都不在同一个台机器上,所以errors.Is()或者errors.As()是没有办法做判断的

业务错误码

那么如何做?在http的服务中,我们会使用错误码的方式来区分不同错误,通过判断errno来区分不同错误

{
    "errno"0,
    "msg""ok",
    "data": {}
}

{
    "errno"1000,
    "msg""params error",
    "data": {}
}

类似的,我们调整下我们pb定义:在返回值里携带错误信息

service OrderManagement {
rpc getOrder(google.protobuf.StringValue) returns (GetOrderResp);
}

message GetOrderResp{
BizErrno errno = 1;
string msg = 2;
Order data = 3;
}

enum BizErrno {
Ok = 0;
ParamsErr = 1;
BizErr = 2;
}

message Order {
string id = 1;
repeated string items = 2;
string description = 3;
float price = 4;
string destination = 5;
}

于是在服务端实现的时候,我们可以返回对应数据或者错误状态码

func (s *OrderManagementImpl) GetOrder(ctx context.Context, orderId *wrapperspb.StringValue) (*pb.GetOrderResp, error) {
 ord, exists := orders[orderId.Value]
 if exists {
  return &pb.GetOrderResp{
   Errno: pb.BizErrno_Ok,
   Msg:   "Ok",
   Data:  &ord,
  }, nil
 }

 return &pb.GetOrderResp{
  Errno: pb.BizErrno_ParamsErr,
  Msg:   "Order does not exist",
 }, nil
}

在客户端可以判断返回值的错误码来区分错误,这是我们在常规RPC的常见做法

// Get Order
resp, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: ""})
if err != nil {
  panic(err)
}

if resp.Errno != pb.BizErrno_Ok {
  panic(resp.Msg)
}

log.Print("GetOrder Response -> : ", resp.Data)

🌿 但,这么做有什么问题么?

很明显,对于clinet侧来说,本身就可能遇到网络失败等错误,所以返回值(*GetOrderResp, error)包含error并不会非常突兀

但再看一眼server侧的实现,我们把错误枚举放在GetOrderResp中,此时返回的另一个error就变得非常尴尬了,该继续返回一个error呢,还是直接都返回nil呢?两者的功能极度重合

那有什么办法既能利用上error这个返回值,又能让client端枚举出不同错误么?一个非常直观的想法:让error里记录枚举值就可以了!

但我们都知道Go里的error是只有一个string的,可以携带的信息相当有限,如何传递足够多的信息呢?gRPC官方提供了google.golang.org/grpc/status的解决方案

使用 Status处理错误

gRPC 提供了google.golang.org/grpc/status来表示错误,这个结构包含了 code 和 message 两个字段

🌲 code是类似于http status code的一系列错误类型的枚举,所有语言 sdk 都会内置这个枚举列表

虽然总共预定义了16个code,但gRPC框架并不是用到了每一个code,有些code仅提供给业务逻辑使用

CodeNumberDescription
OK0成功
CANCELLED1调用取消
UNKNOWN2未知错误
.........

🌲 message就是服务端需要告知客户端的一些错误详情信息

package main

import (
 "errors"
 "fmt"
 "log"

 "google.golang.org/grpc/codes"
 "google.golang.org/grpc/status"
)

func Invoke() {
 ok := status.New(codes.OK, "ok")
 fmt.Println(ok)

 invalidArgument := status.New(codes.InvalidArgument, "invalid args")
 fmt.Println(invalidArgument)
}

Status 和语言 Error 的互转

上文提到无论是serverclient返回的都是error,如果我们返回Status那肯定是不行的

但 Status 提供了和Error互转的方法

所以在服务端可以利用.Err()Status转换成error并返回

或者直接创建一个Statuserrorstatus.Errorf(codes.InvalidArgument, "invalid args")返回

func (s *OrderManagementImpl) GetOrder(ctx context.Context, orderId *wrapperspb.StringValue) (*pb.Order, error) {
 ord, exists := orders[orderId.Value]
 if exists {
  return &ord, status.New(codes.OK, "ok").Err()
 }

 return nil, status.New(codes.InvalidArgument,
  "Order does not exist. order id: "+orderId.Value).Err()
}

到客户端这里我们再利用status.FromError(err)error转回Status

order, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: ""})
if err != nil {
  // 转换有可能失败
  st, ok := status.FromError(err)
  if ok && st.Code() == codes.InvalidArgument {
    log.Println(st.Code(), st.Message())
  } else {
    log.Println(err)
  }

  return
}

log.Print("GetOrder Response -> : ", order)

🌿 但,status真的够用么?

类似于HTTP 状态码code的个数也是有限的。有个很大的问题就是 表达能力非常有限

所以我们需要一个能够额外传递业务错误信息字段的功能

Richer error model

Google 基于自身业务, 有了一套错误扩展 https://cloud.google.com/apis/design/errors#error_model

// The `Status` type defines a logical error model that is suitable for
// different programming environments, including REST APIs and RPC APIs.
message Status {
// A simple error code that can be easily handled by the client. The
// actual error code is defined by `google.rpc.Code`.
int32 code = 1;

// A developer-facing human-readable error message in English. It should
// both explain the error and offer an actionable resolution to it.
string message = 2;

// Additional error information that the client code can use to handle
// the error, such as retry info or a help link.
repeated google.protobuf.Any details = 3;
}

可以看到比标准错误多了一个 details 数组字段, 而且这个字段是 Any 类型, 支持我们自行扩展

使用示例

由于 Golang 支持了这个扩展, 所以可以看到 Status 直接就是有 details 字段的.

所以使用 WithDetails 附加自己扩展的错误类型, 该方法会自动将我们的扩展类型转换为 Any 类型

WithDetails 返回一个新的 Status 其包含我们提供的details

WithDetails 如果遇到错误会返回nil 和第一个错误

func InvokRPC() error {
 st := status.New(codes.InvalidArgument, "invalid args")

 if details, err := st.WithDetails(&pb.BizError{}); err == nil {
  return details.Err()
 }

 return st.Err()
}

前面提到details 数组字段, 而且这个字段是 Any 类型, 支持我们自行扩展。

同时,Google API 为错误详细信息定义了一组标准错误负载,您可在 google/rpc/error_details.proto 中找到这些错误负载

它们涵盖了对于 API 错误的最常见需求,例如配额失败和无效参数。与错误代码一样,开发者应尽可能使用这些标准载荷

下面是一些示例 error_details 载荷:

  • ErrorInfo 提供既稳定可扩展的结构化错误信息。
  • RetryInfo:描述客户端何时可以重试失败的请求,这些内容可能在以下方法中返回:Code.UNAVAILABLE 或 Code.ABORTED
  • QuotaFailure:描述配额检查失败的方式,这些内容可能在以下方法中返回:Code.RESOURCE_EXHAUSTED
  • BadRequest:描述客户端请求中的违规行为,这些内容可能在以下方法中返回:Code.INVALID_ARGUMENT

服务端

package main

import (
 "fmt"

 pb "github.com/liangwt/note/grpc/error_handling/error"
 epb "google.golang.org/genproto/googleapis/rpc/errdetails"
 "google.golang.org/grpc/codes"
 "google.golang.org/grpc/status"
)

func (s *OrderManagementImpl) GetOrder(ctx context.Context, orderId *wrapperspb.StringValue) (*pb.Order, error) {
 ord, exists := orders[orderId.Value]
 if exists {
  return &ord, status.New(codes.OK, "ok").Err()
 }

 st := status.New(codes.InvalidArgument,
  "Order does not exist. order id: "+orderId.Value)

 details, err := st.WithDetails(
  &epb.BadRequest_FieldViolation{
   Field:       "ID",
   Description: fmt.Sprintf("Order ID received is not valid"),
  },
 )
 if err == nil {
  return nil, details.Err()
 }

 return nil, st.Err()
}

客户端

// Get Order
order, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: ""})
if err != nil {
 st, ok := status.FromError(err)
 if !ok {
  log.Println(err)
   return
 }

 switch st.Code() {
 case codes.InvalidArgument:
  for _, d := range st.Details() {
   switch info := d.(type) {
   case *epb.BadRequest_FieldViolation:
    log.Printf("Request Field Invalid: %s", info)
   default:
    log.Printf("Unexpected error type: %s", info)
   }
  }
 default:
  log.Printf("Unhandled error : %s ", st.String())
 }

 return
}

log.Print("GetOrder Response -> : ", order)

引申问题

如何传递这个非标准的错误扩展消息呢?或许可以在下一章可以找到答案。

总结

我们先介绍了gRPC最基本的错误处理方式:返回error

之后我们又介绍了一种能够携带更多错误信息的方式:Status,它包含codemessagedetails等信息,通过Statuserror的互相转换,利用error来传输错误

相关阅读

写给go开发者的gRPC教程-protobuf基础

写给go开发者的gRPC教程-通信模式

👇 欢迎关注 👇

最后真心推荐一下我前段时间写的付费专栏,该专栏主要用一些实际的案例讲解项目业务需求分析、技术实现分析、模块划分和分层的方法论,讲明白这些底层逻辑后再教大家怎么用 UML 等工具把它们图形化表达出来。详细内容请可扫码观看,或访问:独家原创--程序员的全能画图课

另外现在我在做的一个Go实战项目的专栏,整个项目也采用了这里讲的知识来实际地一步步做分析和开发的,大家可以先把这些方法论学会,后面跟着新专栏学习,看项目和代码的实践能明显提高自己做项目的架构水平。

网管叨bi叨
分享软件开发和系统架构设计基础、Go 语言和Kubernetes。
 最新文章