如何扩展Server端返回的Error

在gRPC的Server端返回Error时,虽然接口的返回值中声明的是标准库的Error,但gRPC内部会判断Error是否为特定的类型,如果不是,则会统一返回Code为“Unknown”的Error。因此,多数情况下我们需要用专门的方法构造一个Error,比如:

        import "google.golang.org/gRPC/status"
        ...
        return status.Error(codes.Aborted,  "aborted")

实际上,这个Error的类型是“google.golang.org/genproto/googleapis/rpc/status” 包里面的“Status”,定义如下:

        type Status struct {
              Code int32
              Message string
              Details []*google_protobuf.Any
        }

除了Details字段外,只有Code和Message,十分简洁,但如果我们有更复杂的业务需求,这些字段是不能满足的,因此我们需要扩展Error。

方案零:不要扩展gRPC预定义的Status Code

前面的例子中,“codes.Aborted”是gRPC预定义的一些Code之一,自然而然,我们会想到是否可以扩展这个Code列表。

我曾经尝试过扩展这个列表,在Go语言的gRPC客户端中是可以拿到自定义的Code的,但官方的开发人员在一些问答中表示不推荐这么做,而且一些其他语言的gRPC客户端中,一旦发现Code不在预定义的列表中,有可能直接替换成预定义的“Unknown”错误,甚至直接抛出异常,因此不要这么做。

方案一:在Response中添加err属性

这也是一种显而易见的方案,既然已有的Error不能满足需求,那就在Response对象中加入一个“err”属性,而它的类型是自己定义的,大概是这个样子:

        type MyResponse struct {
          err MyErr
          ...
        }

可以满足需求,但很不优雅,同意么?这么做直接违反了gRPC的错误处理机制,甚至不符合Go语言的规范,所以也不推荐这么做。

方案二:通过Metadata传递

首先要说,这才是一个靠谱的方案,也是官方曾经推荐的方案。

先不说“曾经”是什么意思,我们来看看这个方案是怎么玩的。

gRPC的Client端和Server端之间,可以借助名为“Metadata”的数据结构来传递额外的信息,而我们自己扩展的Error信息就属于这个“额外信息”。

详细的用法可以参考在GitHub上关于“gRPC-metadata”的文档:

https://github.com/gRPC/gRPC-go/blob/master/Documentation/gRPC-metadata.md

此处附上其中的一些代码示例,以便让大家快速地获得一个直观的印象。

以从Server端向Client端传递Metadata为例,首先在Server端将数据准备好:

        func (s *server) SomeRPC(ctx context.Context,  in *pb.someRequest)
        (*pb.someResponse,  error) {
                  // 创建并发送Header
                  header := metadata.Pairs("header-key",  "val")
              gRPC.SendHeader(ctx,  header)
              // 创建并发送Trailer
              trailer := metadata.Pairs("trailer-key",  "val")
              gRPC.SetTrailer(ctx,  trailer)
        }

接着在Client端接收数据:

        var header,  trailer metadata.MD // 用来保存header和trailer的变量
        r,  err := client.SomeRPC(
            ctx,
            someRequest,
            gRPC.Header(&header),     // 将会接收header
            gRPC.Trailer(&trailer),   // 将会接收trailer
        )

        // 按需求对header和trailer做处理

方案三:通过Status中的Details属性传递

最后出场的是我们实际采用的方案。

我在前面将扩展的Error信息称作“额外信息”,其实准确的说应该是“额外的Error信息”,因此,直觉上最自然的方式还是在Error对象内部携带这个信息。

你一定注意到了“Status”对象里的那个“Details”属性,它是一个“Any”对象的数组,字面上看似乎是“可以保存任何类型对象的数组”的意思,确实是这样。

gRPC最近刚刚添加了两个工具方法,使得“Details”属性变得非常易用:

        // WithDetails返回一个新创建的Status对象,其中附加了参数details传入的
        Message列表,
        // 如果有error发生,将返回nil和第一个遇到的error。
        func (s *Status) WithDetails(details ...proto.Message) (*Status,
        error)

        // Details返回Status中Details携带的Message列表,
        // 如果decode某个Message时发生错误,那这个错误会被添加到结果列表中返回。
        func (s *Status) Details() []interface{}

实际使用时很方便。

附加Details:

        s,  _ := status.New(codes.Aborted,  "message").WithDetails(d1,  d2)
        return nil,  s.Err()

获取Details:

        details,  _ := s.Details()
        for _,  d := range details {
          m := d.(YourType)
          // ...
        }

好了,以上就是我们在使用gRPC过程中遇到的两个问题和相应的思考,也许并不是最优的方案,但希望能给你带来一些提示。

Go语言作为一种快速发展变化中的语言,相应的技术生态还不是十分健全,包括gRPC和由此衍生的gRPC-Gateway等项目,仍然有不少提升空间。中国是目前Go语言应用人数最多、气氛最火热的国家,我们希望能看到越来越多的国内开发者参与到开源项目的发展中,也希望有越来越多的优秀项目出现。