增强微服务体系结构中的GRPC错误处理
#go #microservices #softwareengineering #grpc

错误处理及其完成方式是软件工程的关键部分。返回的错误和不明智的错误可能会导致难以想象的头痛。在本文中,我将证明我在GRPC错误处理方面面临的问题以及我们可能会采取的措施以改善我们最初不太可用的GRPC错误。

我每天都会犯很多错误,我试图修复它们并向它们学习。如果您注意到一个,如果您愿意纠正我,我会非常感激。

Enhancing gRPC Error Handling in a Microservice Architecture

介绍

首先,让我们从解释我在这里的实际问题开始。服务间的交流(以及完成的方式)可能是微服务体系结构中最重要的问题之一。虽然当使用RPC整体时(可以缓解Netflix Tech Blog的精美描述)时,这是某些问题,但可以否认请求/响应消息和RPCS(通过ProtoBuf)的自我记录性质(通过ProtoBuf)与服务器反射已被证明非常舒适和方便。

GRPC最被忽视的方面之一可能是错误从一个服务返回到另一种服务的方式。在RPC中返回错误的最简单方法可能如下:

func (s *serverImpl) UnaryRPC(ctx context.Context, req *pb.Request) (*pb.Response, error) {
    // do something
    if somethingFailed {
        return nil, errors.New("custom error")
    }

    return resp, nil
}

上面示例的问题是,它向呼叫者提供了几乎零信息,以了解出了什么问题和原因。从呼叫者的角度来看,错误可能是这样处理的:

    reps, err := client.UnaryRPC(ctx, req)
    if err != nil {
      st, ok := status.FromError(err)
      if ok {
        if st.Message() == "custom error" {
          // handle custom error
        } else {
          // handle other errors
        }
      }
    }

希望这已经困扰着您,仅仅是因为我们与另一个服务中的错误字符串紧密相关,这可能随时可以更改,这会使我们的光荣错误处理无用。让我们看一下状态。FromError工作以查看我们是否还有其他有用的信息:

    func FromError(err error) (s *Status, ok bool) {
      if err == nil {
        return nil, true
      }

      // doing some stuff with interface{ GRPCStatus() *Status } type
      // our simple error does not implement any custom interface
      // ...

      return New(codes.Unknown, err.Error()), false
    }

这向我们发出了表明状态的其他属性。Status(例如代码和详细信息)简直毫无意义。代码自动设置为未知,详细信息将被零价值。

  • 您可以安全地使用st:= status.convert(err),它只是跳过了确定的处理零件,因为GRPC guarantees的实现所有RPCS返回状态类型错误。我们还可以通过前往http2_client.go操作方法来确认这一点:
    func (t *http2Client) operateHeaders(frame *http2.MetaHeadersFrame) {
      // ...
      var (
        // ...
        grpcMessage string
        statusGen *status.Status
        rawStatusCode = codes.Unknown
        // ...
      )
      // ...
      for _, hf := range frame.Fields {
        switch hf.Name {
        // ...
        case "grpc-message":
          grpcMessage = decodeGrpcMessage(hf.Value)
        case "grpc-status":
          code, err := strconv.ParseInt(hf.Value, 10, 32)
          handle(err)
          rawStatusCode = codes.Code(uint32(code))
        case "grpc-status-details-bin":
          statusGen, err = decodeGRPCStatusDetails(hf.Value)
          handle(error)
        // ...
        }
        // ...
      } 
      // ...
      if statusGen == nil {
        statusGen = status.New(rawStatusCode, grpcMessage)
      }
      // ...
      t.closeStream(s, io.EOF, rst, http2.ErrCodeNo, statusGen, mdata, true)
    }

让我们礼貌,向我们的呼叫者提供更多信息:

    func (s *serverImpl) UnaryRPC(ctx context.Context, req *pb.Request) (*pb.Response, error) {
      // do something
      if somethingFailed {
        return nil, status.Error(codes.Internal, "internal error")
      }

      return resp, nil
    }

现在,客户还可以对返回的代码做出重要且有用的假设:

    resp, err := client.UnaryRPC(ctx, req)
    if err != nil {
      st := status.Convert(err)
      switch st.Code() {
      case codes.Internal:
        // handle internal error
      default:
        // handle other errors
      }
    }

尽管可能看起来很有帮助和描述性,但该方法缺乏解释和(状态)代码和错误背后的原因。

状态细节

返回状态代码是我们从微服务中返回的描述性错误所带来的最远的。但是,在某些情况下,我们只是只能仅依靠状态代码。为什么,您可能会问?让我解释一下。

假设我们有两种服务,第一个与我们的最终用户交互的服务称为提交,而另一种服务则被视为内部服务,仅与其他服务交谈,而不是与外部客户/客户或最终用户交谈。

考虑以下流程:最终用户试图通过我们的提交服务提交他/她的数据,进而尝试通过将其保存在存储服务中来坚持用户数据(让我们说存储简单地包装一些任意db)。

在特殊的情况下,由于某些业务逻辑,存储否认保存提交提供的数据,并返回如下:

    func (s *storageService) Save(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
      // the actual logic
      if cantSave {
        return nil, status.Error(codes.FailedPrecondition, "the fully described reason here")
      }  
      // ...
    }

假设所描述的原因是特定于存储服务域的特定的,这意味着提交将无法重现确切的描述性原因以向客户展示,让我们探索提交的不同方式可以处理这种情况并将一些错误返回最终用户。

返回误差

    func (s *submitService) Submit(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
      resp, err := storageClient.Save(ctx, req)
      if err != nil {
        st := status.Convert(err)
        switch st.Code() {
        case codes.FailedPrecondition:
          return nil, st.Err()
        default:
          return nil, status.Error(codes.Internal, "oops!")
        }
      }
    }

虽然此方法可能简单地解决了我们的问题(最终用户可以看到完全描述性错误)也引入了主要的头痛:调试此错误将是绝对的麻烦。提交实际上是返回此错误,但是祝您在此存储库中找到文本。想象一下,除了存储外,还可以使用其他几个服务来做同样的事情。很快,您会发现自己正在搜索所有客户服务中的某些错误字符串,这些错误字符串似乎会返回,挠头并后悔您一生中做出的每一个决定。 (只想考虑存储返回代码会发生什么。

放下原始错误,然后用我们自己的
替换

    func (s *submitService) Submit(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
      resp, err := storageClient.Save(ctx, req)
      if err != nil {
        st := status.Convert(err)
        switch st.Code() {
        case codes.FailedPrecondition:
          return nil, errors.New("dear end user, you can not do that")
        }
      }
    }

这样,我们至少可以确保提交不需要的错误消息或在提交存储库中没有可用的错误消息,但是返回的错误字符串并不完全是我们希望最终用户看到的。这还不够描述。另外,我们在多个代码中仍然存在相同的问题。

存储服务提供更多错误详细信息

为了在这两种服务之间有效沟通,可能是一个好主意,将错误细节构成原始消息。第一个(可能也是)更明显的方法是扩展存储响应消息并在其中添加额外的错误消息:

    message SaveError {
      int32 first_field = 1;
      bool second_field = 2;
    }

    message SaveResp {
      string first = 1;
      string second = 2;  
      SaveError error = 3;  
    }

    service Storage {
      rpc (SaveRequest) returns (SaveResponse);  
    }

这样,SaveError为提交服务提供了足够的信息来重现描述性错误字符串并最终向最终用户显示。

     func (s *submitService) Submit(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
      resp, err := storageClient.Save(ctx, req)
      if err != nil {
        // some other error
      }
      if resp.Error != nil {
        return nil, createCustomError(resp.Error)  
      }
    }

但是,这种错误处理有些事情。我们应该处理单个RPC的两个错误来源的事实听起来不错,并且可能会引入一些不必要的复杂性和混乱(这表明即使RPC本身成功,我们的客户呼叫仍然可能失败! )。

如果我们希望存储服务提供急需的错误详细信息,但不是完全在响应的正文中怎么办?我们可以以原始消息的形式为我们的状态添加详细信息:

    func (s *storageService) Save(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
      // some logic
      if cantSave {
        st := status.New(codes.FailedPrecondition, "any grpc message")
        st, err := st.WithDetails(&pb.SaveError{...})
        handle(err)  
        return nil, st.Err()  
      }
    }

应为proto.message的实例赋予st.WithDetails()。可以随意传递您自己的自定义原始消息,或使用errdetails package。从呼叫者的角度来看,细节也可以简单地检索:

    func (s *submitService) Submit(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
      resp, err := storageClient.Save(ctx, req)
      if err != nil {
        st := status.Convert(err)
        for _, d := range st.Details() {
          switch detail := d.(type) {
          case *pb.SaveError:  
            return nil, createCustomError(detail)  
          default:
            // other details
          }
        }
      }
    } 

现在,我们不仅可以根据状态代码进行调节,而且还可以依靠已记录的并商定的详细信息,而不会过分依赖任何其他服务。是的!

更多地介绍了Johan Brandhorst的this wonderful article中的状态细节。

引擎盖下的状态

我们看到了客户运输如何在任何GRPC错误中提供状态,因此我们可能对从服务器的角度处理这种情况有一个粗略的了解,例如当我们返回status.Status时,带有代码,消息和详细信息时会发生什么。基本上,如果我们看一下http2_server.go WriteStatus方法,我们可以看到这些GRPC特定的标头是如何编写的:

    func (t *http2Server) WriteStatus(s *Stream, st *status.Status) error {
      // ...
      headerFields := make([]hpack.HeaderField, 0, 2)
      // ...
      headerFields = append(headerFields, hpack.HeaderField{
        Name: "grpc-status", 
        Value: strconv.Itoa(int(st.Code())),
      })
      headerFields = append(headerFields, hpack.HeaderField{
        Name: "grpc-message",
        Value: encodeGrpcMessage(st.Message()),
      })

      if p := st.Proto(); p != nil && len(p.Details) > 0 {
        stBytes, err := proto.Marshal(p)
        handle(err)
        headerFields = append(headerFields, hpack.HeaderField{
          Name: "grpc-status-details-bin",
          Value: encodeBinHeader(stBytes),
        })
      }  
      // use the headerFields ...
    }

GRPC使用拖车来传达其最终状态以及任何消息和/或详细信息,以免发生错误。拖车只是响应主体之后发送的标题子集(与HTTP协议中响应主体发送之前发送的实际响应标头相比)。有关这些拖车的更多信息,请访问Carl Mastrangelo’s “Why does gRPC insist on Trailers?”SoByte’s “Talking about gRPC’s Trailers Design”,这两个我都非常鼓励您查看它们,它们非常非凡。这是后者的几个引号:

所以问题是,为什么GRPC依靠拖车?核心原因是支持流界面。因为它是流媒体接口,所以无法预先确定数据的长度,也无法使用HTTP内容长度的标头。相应的HTTP请求看起来像这样。

    GET /data HTTP/1.1
    Host: example.com

    HTTP/1.1 200 OK
    Server: example.com

    abc123

什么?不确定的长度?卡尔指出,使用块是模棱两可的。他给出以下例子。

    GET /data HTTP/1.1
    Host: example.com

    HTTP/1.1 200 OK
    Server: example.com
    Transfer-Encoding: chunked

    6\r\n
    abc123\r\n
    0\r\n

假设客户端和服务器之前有一个代理。代理接收响应,并开始将数据转发给客户端。第一件事是将标头零件发送到客户端,因此呼叫者确定这次状态代码为200,这是成功的。然后,数据部分按段落转发。如果服务器在代理前向第一个ABC123转发之后,则代理需要发送什么信号?
由于已发送状态代码,因此无法更改200至5xx。您可以直接发送0 \ r \ n来结束分块的传输,以便客户端学习服务器是否异常退出。您唯一可以做的就是直接关闭相应的基础连接,但这将在客户端创建新连接时消耗其他资源。因此,我们需要找到一种方法来将服务器错误通知客户端,同时尽可能多地重复基础连接。 GRPC团队最终决定使用拖车进行运输。

拖车在行动中

想象我们有一个服务器端流式RPC,如下所示。它基本上发送了3条有效的响应消息,然后是一个错误:

    func (s *serverImpl) ServerStream(req *pb.GreetRequest, stream pb.Greeter_ServerStreamServer) error {
      ctx := stream.Context()
      for i := 0; i < 5; i++ {
        select {
        case <-ctx.Done():
          return status.Error(codes.DeadlineExceeded, "deadline exceeded")
        default:
          if i == 3 {
            st := status.New(codes.Internal, "something went wrong")
            st, _ = st.WithDetails(&errdetails.ErrorInfo{
              Reason: "some random reason",
              Domain: "some.random.domain",
              Metadata: map[string]string{
                "first": "something",
                "second": "another thing",
              },
            })
            return st.Err()
          }

          err := stream.Send(&pb.GreetResponse{Greet: fmt.Sprintf("hello %s!", req.Name)})
          if err != nil {
            log.Print("failed to send: ", err)
          }
        }
      }

      return nil
    }

与客户端调用此方法后(我选择了一个简单的GRPC客户端,我们可以看一下Wireshark之​​类的内容(我省略了某些行以减少不必要的混乱状态):

    POST /test.Greeter/ServerStream

    HyperText Transfer Protocol 2
        Stream: DATA, Stream ID: 1, Length 15
            Flags: 0x01
                .... ...1 = End Stream: True

    Protocol Buffers: /test.Greeter/ServerStream,request
        Message: test.GreetRequest
            [Message Name: test.GreetRequest]
            Field(1): name = homayoon (string)

在响应流中,首先我们会收到以下数据包:

    HyperText Transfer Protocol 2
        Stream: HEADERS, Stream ID: 1, Length 14, 200 OK
            Flags: 0x04
                .... ...0 = End Stream: False
                .... .1.. = End Headers: True
            Header: :status: 200 OK
            Header: content-type: application/grpc
        Stream: DATA, Stream ID: 1, Length 22
            Flags: 0x00
                .... ...0 = End Stream: False
            0... .... .... .... .... .... .... .... = Reserved: 0x0
            .000 0000 0000 0000 0000 0000 0000 0001 = Stream Identifier: 1
            [Pad Length: 0]
            DATA payload (22 bytes)

    Protocol Buffers: /test.Greeter/ServerStream,response
        Message: test.GreetResponse
            [Message Name: test.GreetResponse]
            Field(1): greet = hello homayoon! (string)

随后是其他两个没有:状态200 OK标头的数据包(因为它已经在第一个中发送了):

    HyperText Transfer Protocol 2
        Stream: DATA, Stream ID: 1, Length 22
            Flags: 0x00
                .... ...0 = End Stream: False

    Protocol Buffers: /test.Greeter/ServerStream,response
        Message: test.GreetResponse
            [Message Name: test.GreetResponse]
            Field(1): greet = hello homayoon! (string)

最后,我们有一个包含GRPC预告片的最终数据包(请注意终端流:true:

    HyperText Transfer Protocol 2
        Stream: HEADERS, Stream ID: 1, Length 226
            Flags: 0x05
                .... ...1 = End Stream: True
                .... .1.. = End Headers: True
            Header: grpc-status: 13
            Header: grpc-message: something went wrong
            Header: grpc-status-details-bin: CA0SFHNvbWV0aGluZyB3ZW50IHdyb25nGoEBCih0eXBlLmdvb2dsZWFwaXMuY29tL2dvb2dsZS5ycGMuRXJyb3JJbmZvElUKEnNvbWUgcmFuZG9tIHJlYXNvbhISc29tZS5yYW5kb20uZG9tYWluGhIKBWZpcnN0Eglzb21ldGhpbmcaFwoGc2Vjb25kEg1hbm90aGVyIHRoaW

不必说,我们很容易在grpc-status-details-bin中检索原始细节,让我们以困难的方式进行操作,只需复制上面的base64编码字符串(而不是检索它在GRPC客户端):

    import (
       spb "google.golang.org/genproto/googleapis/rpc/status"
    )

    d := "CA0SFHNvbWV0aGluZyB3ZW50IHdyb25nGoEBCih0eXBlLmdvb2dsZWFwaXMuY29tL2dvb2dsZS5ycGMuRXJyb3JJbmZvElUKEnNvbWUgcmFuZG9tIHJlYXNvbhISc29tZS5yYW5kb20uZG9tYWluGhIKBWZpcnN0Eglzb21ldGhpbmcaFwoGc2Vjb25kEg1hbm90aGVyIHRoaW5n"
    b, _ := base64.StdEncoding.DecodeString(d)
    st := &spb.Status{}
    proto.Unmarshal(b, st)
    for _, dt := range st.Details {
      errD := &errdetails.ErrorInfo{}
      dt.UnmarshalTo(errD)
      b, _ := json.MarshalIndent(errD, "", "  ")
      fmt.Println(string(b))
    }

中提琴!

    {
      "reason": "some random reason",
      "domain": "some.random.domain",
      "metadata": {
        "first": "something",
        "second": "another thing"
      }
    }

结论

在本文中,我们探讨了如何在通过GRPC通信的GRPC微服务体系结构中提供描述性和信息性错误。我们还看了看如何在引擎盖下处理不同的GRPC拖车。最后,我们讨论了GRPC预告片,并尝试了模拟服务器,以更好地了解这些标头/拖车的传递方式以及何时通过。我希望您发现这篇文章很有帮助。如果您愿意并分享这篇文章,我会很感激。