grpc服务设计最佳实践

grpc服务设计最佳实践

该文章转载于 grpc服务设计最佳实践

grpc是Google开源的一款基于http2传输协议的高性能通用rpc框架。作为Google内部使用的Stubby RPC的开源版本,grpc目前是很多公司(尤其是采用Go语言技术栈的公司)开发微服务的首选rpc框架。本文内容主要整理自Google工程师Doug Fawley在2017年的一次技术分享,同时也包含了一些笔者对于分享内容的理解以及grpc最新功能的补充。Doug Fawley是gRPC-Go项目核心贡献者,这次分享的视频链接为 Best Practices for (Go) gRPC Services,虽然主要是面向Go语言的,但其中关于服务设计的一些最佳实践对于所有语言的开发者都具有很好的指导意义。

大纲

  • 接口设计
  • 错误处理
  • 超时控制
  • 限流
  • 重试
  • 内存管理
  • 日志
  • 监控

1 接口设计

1.1 幂等性

任何对于业务数据产生修改的接口,都需要保证幂等性。网络抖动、服务节点故障、负载短时间过高等问题都可能导致某次接口调用超时,而接口调用方在超时后往往会进行重试。因此,在做服务的接口设计和实现时,需要保证任何RPC接口请求都可以放心的重试而无需知道上一次请求是否已经处理过。
这里以转账来说明如何让接口更好的支持幂等,下面的proto描述了一个非常简单的转账接口设计,请求业务参数包括转账方、转账目的方和转账金额,响应仅包含一个转账结果码。我们可以看出第一个接口设计显然是无法支持幂等的,同一笔转账在发生接口重试的时候会导致转账多次,这明显是有问题的。第二个接口设计在请求中添加了用来标识一笔转账的交易token(或者是一个全局唯一ID),同一笔转账无论重试多少次都会携带相同的交易token,这样转账服务就能根据token来判断这笔转账是否已经处理过并返回相同的处理结果。

// 无法支持幂等的接口设计
message Request {    
    string from = 1;
    string to = 2;
    float amount = 3;
 }

 message Response {    
    int64 confirmation = 1;
  }
  
 // 支持幂等的接口设计
 message Request {    
    string from = 1;    
    string to = 2;    
    float amount = 3;    
    int64 token = 4; // 本次转账交易token 或者 全局唯一ID
    }

 message Response {    
    // 重复请求返回相同的结果    
    int64 confirmation = 1;
}

1.2 性能

接口设计除了考虑能够保证业务逻辑的正确实现外,还需要考虑接口性能。那么在设计接口时如何保证性能呢?

1.2.1 重复字段

为了提高接口处理效率,我们有时会设计一些批量处理数据的接口,调用方可以一次性传递多个待处理的数据对象。对于这类接口请求,我们需要对重复字段设置长度限制。因为这类接口中的重复字段往往可能对应数据库中的多条记录,如果不设限制,就会导致长耗时的SQL,更严重的可能会直接超出了数据库支持的最大SQL长度。对于grpc来说,我们可以使用go-proto-validators或者protoc-gen-validate来自动实现接口请求的字段校验。

message BatchRequest {    
    message Item {        
        int64 id = 1;        
        string name = 2;    
        }

        repeated Item item_list = 1 [(validator.field) = {repeated_count_max: 1000}];
}

除了接口请求会有重复字段外,接口响应也会有重复字段的问题,比如一个查询接口可能会返回多条数据库中记录,一旦记录数过大通过接口一次性返回就不那么现实了。所以对于接口响应的重复字段,我们在做接口设计时需要支持分页。

1.2.2 避免长耗时操作

接口需要尽量避免长耗时操作,一次RPC接口调用的耗时越长,越可能因为超时导致重试。任何耗时可能长达几十秒的接口都推荐调整为在后台异步执行耗时操作,执行结果既可以通过回调或发送PubSub消息通知调用者,也可以在接口的响应中返回一个用于查询结果的token,这样调用者可以通过轮询的方式查询执行结果。

1.2.3 默认值

通过protobuf定义的接口请求或响应,某个字段如果未赋值,其默认值就是该字段类型的零值。虽然大部分时候我们都可以采用零值作为默认值,但有时也可以根据业务定义更合理的默认值。比如对于一个查询数据库记录的接口,将升序作为查询排序顺序的默认值可能就是更好的选择。对于protobuf中的枚举类型,一般推荐使用UNKNOWNUNSPECIFIED或者UNSET作为默认值的名称。

在定义默认值时除了需要考虑合理性外,还需要考虑能够向后兼容。考虑一个用户注册的接口,可能在第一版中不需要提供性别这个参数,所有注册用户默认性别为男性。在第二版接口中如果添加一个性别字段,默认值为UNKNOWN,那么老版本的调用方在调用新版接口时因为不会提供性别参数,就会导致注册用户的性别使用默认值UNKNOWN。这明显与之前默认性别为男性的逻辑不符,因此在新增字段时我们一定要保证默认值的设置能够向后兼容,不能影响老版本的调用方。做过客户端开发的人都知道,即使你能同时控制客户端和服务端的发版,双端也不可能同时完成新版本的更新,一款iOS应用从提交审核到真正放出的时间极不可控,所以在做接口设计时必须考虑向后兼容问题。

1.2.4 错误返回

grpc接口调用的返回值包含两部分:接口响应和代表接口调用是否出错的error。接口的返回值应该尽量遵循如下规则:要么返回正常的请求响应,要么就返回错误。对于grpc错误的返回和处理,可以参考grpc-errors,这个项目库给出了不同语言发送和处理grpc错误的代码示例。当接口处理发生错误时不要把错误信息包含在接口的响应体里面,否则当错误发生时调用方必需解析响应消息才能知道到底哪里出了问题。笔者对这一点颇有感触,目前所在团队的几个服务因为历史遗留问题都是把错误信息(包括错误码、错误描述等)包含在接口的响应体里面,grpc接口一律不返回error,这就导致像重试、熔断和降级等通用的服务治理功能没法使用,因为在网关层看来所有接口调用都是成功的。

另外,应该避免在一个RPC接口里面实现多个彼此没有关联的操作,这样设计会让错误处理变得很麻烦,因为一次接口调用可能某个操作成功了,某个操作失败了,而不同操作的错误处理可能完全不同。对于这种场景可以使用grpc的流式接口或者干脆拆分成多个接口,因为发起一次grpc的调用代价很低,拆分成多次接口调用没有太大问题。

2 错误处理

在接口服务内部,发生错误是不避免的,如何优雅的处理错误十分重要。

2.1 不要随意panic

不要随意使用panic,panic只适用于服务内部无法解决的错误,如内存耗尽、数据损坏、数据库操作失败等,其余错误全部返回给调用方去处理。另外在服务开发过程中要尽量避免空指针问题,这几乎是导致panic发生的主要原因。一个有用的技巧是在处理proto相关的结构时最好使用proto自动生成的get函数去获取字段值,这些get函数是空指针安全的。

2.2 错误传播

当错误发生时,不要盲目的把标准库或第三方库以及依赖的外部服务的错误直接返回给调用方。因为调用方在收到这些错误会很困惑,不知道应该如何处理,也很难做debug。最好的处理方式是在确定错误原因后对错误进行适当的翻译,然后再返回给调用方。

res, err := client.Call(...)if err != nil {

    // 错误发生后先探明错误原因    
    s, ok := status.FromError(err)

    if !ok {
        return status.Errorf(codes.Internal, "client.Call: unknow error: %v", err)
    }

    // 根据错误类型对错误进行描述    

    switch s.Code() {
        case codes.InvalidArgument:
            return status.Errorf(codes.InvalidArgument, "client.Call: invalid arguments error: %v", err)
            ...
     }}

3 超时控制

在调用第三方服务接口时,因各种原因导致的超时总是不可避免的。为此,给接口调用设置超时时间就显得非常有意义,这可以让客户端和服务端都知道应该在何时终止一次超出预期处理时间的操作。因此,一定要设置超时,而且要由调用接口的客户端来设置这个超时。

3.1 设置超时

在Go语言中通过context来设置超时是最常用的方式,我们既可以用时长也可以用绝对时间来实现超时控制。

// 通过时长设置超时:5秒后超时
ctx, cancel := context.WithTimeout(cotext.Background(), 5 * time.Second)defer cancel()

res, err := client.Call(ctx, req)

// 通过绝对时间设置超时:1496352600这个时间戳后超时
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(1496352600, 0))
defer cancel()
res, err := client.Call(ctx, req)

虽然超时时间由调用服务接口的客户端来设置,但这并不是说服务端就可以不关心超时了。客户端传递过来的超时如果设置的太短,那么服务端就没有足够时间去执行业务操作,而如果设置的太长又可能会占用服务端太多的资源,所以服务端需要根据具体的业务场景对接口超时的合理性做一些校验。

// 客户端请求不仅要设置超时,而且需要在5秒~30秒之间
func (s *Server) MyRequestHandler(ctx context.Context, ...) (*Res, error) {
    d, ok := ctx.Deadline()
    if !ok {
          return status.Error(codes.InvalidArgument, "no deadline specified in request")
     }
    timeout := d.Sub(time.Now())
    if timeout < 5 * time.Second || timeout > 30 * time.Second {
          return status.Error(codes.InvalidArgument, "deadline must be 5-30 seconds in future; was %v", timeout)
     }
}

3.2 超时传递

很多时候一次业务操作的处理可能会涉及多个不同的服务,在这种链式服务调用的场景下,你的服务端也会是调用了一个或多个其他服务的客户端。此时就会涉及到一个问题,我们应该如何在调用链中传递超时?在实际开发中我们有三种可选的方式,每种方式各有利弊。

3.3 直接使用上游传递过来的超时

这是一种简单且有效的方式,能够保证一次业务操作的整个调用链不会超过客户端设置的超时时间,示例代码如下:

func (s *Server) MyRequestHandler(ctx context.Context, ...) (*Res, error) {
    client1.Call(ctx, ...)
    client2.Call(ctx, ...)
    return client3.Call(ctx, ...)
    }

但这种方式有两个潜在的问题:

    1. 如果下游服务出现异常,可能需要更长的时间才能终止掉整个请求
      以上面的代码为例,假定客户端设置20秒超时,当前服务正常情况下执行client1.Call只需要1秒就能返回,但是因为负载过高导致client1对应的依赖服务暂时无法处理请求,所以在足足等了20秒后client1.Call超时返回错误了。在这种情况下当前服务和客户端都需要等20秒才能终止这次操作,而实际上client1.Call正常情况下1秒就能返回,超过5秒基本可以认定有问题,完全没必要等20秒后才终止调用。要知道这无意义的等待不仅占用了当前服务的资源,而且可能会让使用客户端的用户不耐烦。
    1. 如果下游服务调用超时后还需要额外的操作,那么其实已经没有时间去执行额外操作了
      同样以上面的代码为例,假定在业务逻辑上无论client1.Call成功还是失败,client2.Call都要执行,那么当client1.Call在20秒超时返回错误后,再去执行client2.Call,则client2.Call肯定会立即失败,因为ctx已经超时了。笔者目前所做的业务在线上就遇到过类似问题,支付服务在处理一笔交易的扣款时需要调用第三方钱包的接口,在业务上无论第三方接口调用成功还是失败都需要根据结果来更新交易的状态。有一次第三方钱包的接口服务出现问题导致扣款超时失败,在去更新数据库中交易的状态时,因为ctx已经超时了所以写数据库失败,正常的业务逻辑走不下去了。

3.4 对每一个下游调用使用明确的超时

这种方法在调用下游服务时不再默认传递上游的超时,而是会为每一个接口调用设置一个明确的超时时间,示例代码如下:

func (s *Server) MyRequestHandler(ctx context.Context, ...) (*Res, error) {
    ctx1, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
    defer cancel()
    client1.Call(ctx1, ...)
    ctx2, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
    defer cancel()
    client2.Call(ctx2, ...)
    ctx3, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
    defer cancel()
    return client3.Call(ctx3, ...)}

这种方法的好处是当下游服务出现问题时能够快速检测异常并及早终止整个处理流程,可以避免第一种超时传递方法潜在的第一个问题。当然缺点也同样明显,其本质上是一种悲观的处理策略,即认为某次调用如果在某个时间内无法响应就肯定是出了问题,比如client1.Call没能在100毫秒内返回就代表在20秒内也不会返回,这其实会导致过早的终止整个流程,万一它可以在120毫秒内返回呢?这种方法的另外一个缺点是如何指定不同服务甚至是相同服务不同接口的超时时长,常用的方法可能是通过采集和统计不同接口的响应时长(比如99分位的耗时)然后计算出一个较为合理的值。
根据余下操作需要预留的时间为每一个下游调用设定一个最晚响应时间
与第二种超时传递方法不同,这种方法在调用下游服务时不是设置一个超时时长,而是设置一个超时的最晚响应时间,示例代码如下:

func (s *Server) MyRequestHandler(ctx context.Context, ...) (*Res, error) {
    d, _ := ctx.Deadline()
    ctx1, cancel := context.WithDeadline(ctx, d.Add(-150*time.Millisecond))
    defer cancel()
    client1.Call(ctx1, ...)
    ctx2, cancel := context.WithDeadline(ctx, d.Add(-100*time.Millisecond))
    defer cancel()
    client2.Call(ctx2, ...)
    return client3.Call(ctx, ...)}

这种方法既可以在一定程度上检测下游服务的异常,又能够保证在等待足够久之后依然有时间完成接下来的业务处理,可以认为是针对上面两个潜在问题的折中。其本质是一种乐观的处理策略,即不管目前已完成的操作已经花费了多少时间,只要还有指定的剩余时间那么就继续往下处理,本人也是使用这种方法来处理上面提到的那个线上问题的。这种方法的缺点是如何在调用下游服务时合理的计算出余下操作需要预留的时间,这比第二种超时传递方法指定接口的超时时长更加复杂,也更难做自动化。

4 限流

在应对流量短时间内暴涨或者恶意用户疯狂调用等情况时,限流是能够保证服务稳定的重要手段,因此grpc服务器端需要实现本地的限流。gRPC-Go提供了一种称为tap handle的机制,其原理是在创建grpc服务器时可以定义一个ServerInHandle类型的函数,该函数作用在gRPC-Go的传输层,在每个新的grpc stream建立前执行,如果函数返回错误那么grpc stream就不再创建了。根据官网文档的描述,tap handle主要用于判断服务器是否应该消耗系统资源接受新的grpc stream,所以几乎就是为限流量身打造的。这里需要提醒大家,tap handle目前是实验性的,在后续版本中可能会修改或删除。

type ServerInHandle func(ctx context.Context, info *Info) (context.Context, error)

以下是基于tap handle机制和google的RateLimiter实现的服务端限流,其中rateLimiter就是一个类型为ServerInHandle的函数。该函数从context中获取用户信息,然后为该用户创建一个限流器并注册到全局的map中,这样每次收到该用户的请求时都去检查一下限流器,如果超过限流的阈值就直接返回错误拒绝这次请求。

import "golang.org/x/time/rate"

s := grpc.NewServer(grpc.InTapHandle(rateLimiter))
func rateLimiter(ctx context.Context, info *tap.Info) (context.Context, error) {
    if m[user] == nil { 
        // 假定user来自context中的oauth元数据            
        m[user] = rate.NewLimiter(5, 1) // 允许的最大QPS    
    }
    if !m[user].Allow() {
        return nil, status.Errorf(codes.ResourceExhausted, "client exceeded rate limit")
    }
    return ctx, nil}

限流通常来说都是服务器端的工作,但是作为一种好的接口调用实践,客户端本身也可以实现本地的限流策略。以下同样是基于google的RateLimiter实现的客户端限流代码示例,这里需要注意的是客户端和服务器端的限流策略最好保持一致。

import "golang.org/x/time/rate"
var limiter = rate.NewLimiter(5, 1) // 允许的最大QPS...func MyHandler(ctx, context.Context, req Request) (Response, err) {
  if err := limiter.Wait(ctx); err != nil {
    return nil, err
  }
  return c.Call(ctx, req)}

5 重试

在真实的线上环境中,服务间的调用会出现各种不可思议的错误,其中某些错误可能是明确需要调用方重试的,比如网络的异常抖动导致连接错误,或者下游服务因为负载异常出现短时间的不可用等等。那么grpc服务间的调用应该如何实现重试呢?

5.1 基于wrapper函数的重试

基于wrapper函数的重试是一种更加灵活的重试实现机制,其思想就是每个对外的grpc接口调用都通过一个带有重试功能的封装函数来完成,以下代码示例实现了一个简单的封装函数。它基于配置好的最大重试次数进行重试,当grpc调用返回可重试错误时,只要尚未超过最大重试次数且未触发限流,就会再次发起接口调用。

func (c *client) ChildRPC(ctx context.Context, name string, f func(context.Context) error) error {
    for attempts := 1; attempts <= c.maxAttempts; attempts++ {
        if err := c.limiter.Wait(ctx); err != nil {
            return c.limiterErr(name, err, attempts)
    }
 
    if err := f(ctx); err == nil {
        return nil
    } else if !c.retry(err) {
            return c.convertErr(name, err, attempts)
    }
  }
 
  return c.tooManyAttemptsErr(name)}
var res Responseerr := c.ChildRPC(ctx, "SendMoney", func(ctx context.Context) (err error) {
    res, err = sendMoneyClient.SendMoney(ctx, req)
    return})

5.2 基于拦截器的重试

相较于wrapper函数,基于client端的拦截器来实现重试是一种对开发者更为透明的方式。go-grpc-middleware提供了自动重试的拦截器实现grpc_retry,它可以根据grpc的响应状态进行自动重试。以下代码展示了通过grpc_retry定义的重试拦截器,该拦截器在grpc响应状态为NotFound或Aborted时会自动触发最多3次重试,重试间隔采用线性退避,首次间隔100毫秒,每次重试最多3秒超时。

opts := []grpc_retry.CallOption{
    grpc_retry.WithMax(3), // 最多重试3次    grpc_retry.WithPerRetryTimeout(3 * time.Second) // 每次重试3秒超时    grpc_retry.WithBackoff(grpc_retry.BackoffLinear(100 * time.Millisecond)), // 线性退避重试,首次重试间隔100毫秒    grpc_retry.WithCodes(codes.NotFound, codes.Aborted), // 仅当grpc响应状态码是NotFound或Aborted时才重试}
grpc.DialContext(ctx, "myservice.example.com",
    grpc.WithStreamInterceptor(grpc_retry.StreamClientInterceptor(opts...)),
    grpc.WithUnaryInterceptor(grpc_retry.UnaryClientInterceptor(opts...)),)

5.3 基于grpc内置的重试

grpc支持通过service config的方式让client端来对服务调用进行参数设置,可配置的服务参数可参考官方的service_config.proto,在这些参数中就包括重试策略。目前grpc内置了两种不同的重试策略:RetryPolicy和HedgingPolicy,两种重试策略的参数描述如下。

// RPC接口调用重试策略
message RetryPolicy {    
    // 最大重试次数,包括首次调用本身,该字段的值必须大于1,超过5会当做5处理。    
    uint32 max_attempts = 1;        // 指数退避参数,首次重试会在  random(0, initial_backoff)时间后触发,        
    // 之后第n次重试会在random(0, min(initial_backoff*backoff_multiplier**(n-1), max_backoff))时间后触发    
    google.protobuf.Duration initial_backoff = 2;    
    google.protobuf.Duration max_backoff = 3;    
    float backoff_multiplier = 4;    // 会触发重试的RPC响应状态码    
    repeated google.rpc.Code retryable_status_codes = 5;  }    
    // RPC接口调用对冲策略,只能对幂等接口使用对冲策略。  
    message HedgingPolicy {    
        // 对冲策略最多会发起的调用次数,包括首次调用本身,该字段的值必须大于1,超过5会当做5处理。    
        uint32 max_attempts = 1;    // 首次RPC调用会立即发起,但是接下来的max_attempts-1次调用会以hedging_delay的时间间隔依次发起,    
        // 如果把这个值设置为0,那么所有max_attempts次调用都会立即发起。    
        google.protobuf.Duration hedging_delay = 2;    
        // 对冲的接口调用可以继续发起的非致命RPC响应状态码,即如果服务端返回了非致命状态码,对冲的接口会继续发起,    
        // 否则所有对冲的接口调用都会取消并向client应用层返回错误。    
        repeated google.rpc.Code non_fatal_status_codes = 3;  }

RetryPolicy比较好理解,而HedgingPolicy是指在不等待响应的情况下主动发送单次调用的多个请求。如果一个方法使用HedgingPolicy,那么首先会像正常的RPC调用一样发送第一次请求,如果在hedging_delay时间内没有响应,那么直接发送第二次请求,以此类推,直到发送了max_attempts次。所以HedgingPolicy在超过指定时间没有响应就会直接发起请求,而RetryPolicy必须要服务端响应后才会再次发起请求。正因如此,对冲策略只能对支持幂等的接口使用。以下代码展示了如何使用service config,该配置对于MyService服务的Hello方法定义了重试策略,该策略在接口调用返回Unavailable错误码时最多发起4次接口调用。

var retryPolicy = `{       
     "methodConfig": [{          
        "name": [{"service": "MyService","method":"Foo"}],          
        "retryPolicy": {              
            "MaxAttempts": 4,              
            "InitialBackoff": ".01s",              
            "MaxBackoff": ".05s",              
            "BackoffMultiplier": 2.0,              
            "RetryableStatusCodes": [ "UNAVAILABLE" ]          
            }        
            }]}`
            
grpc.DialContext(ctx, "myservice.example.com", grpc.WithDefaultServiceConfig(retryPolicy))

6 内存管理

grpc-go目前没有很好的机制实现服务器端的内存管理,导致服务器端内存使用暴涨的一个常见原因是短时间内并发请求过多。grpc服务器在处理一次接口调用的过程中至少需要创建3个goroutine,虽然goroutine的系统资源开销较小,但因为我们不能限制goroutine的个数,那么短时间内goroutine的增长还是有可能导致OOM。

  • 当Accept接收到一个新连接时会启用一个goroutine用来处理认证及HTTP/2相关的初始化
  • 接着会启用一个goroutine用来接收HTTP/2协议的数据
  • 接收到一个完整的请求包时会再启用一个goroutine用来处理请求包

6.1 限制连接数

为了应对因连接数过大导致的内存增长,可以考虑如下三种方式对单个服务的连接数进行限制。

6.1.1 设置listener连接限制和并发流限制

通过网络工具库netutil我们可以设置服务器端listener的最大连接数,而grpc本身可以设置最大并发流。

import "golang.org/x/net/netutil"
listener = netutil.LimitListener(listener, connectionLimit) // 设置listener最大连接数
grpc.NewServer(grpc.MaxConcurrentStreams(streamsLimit)) // 设置最大并发流

6.1.2 tap handle限流

通过使用tap handle机制,我们可以检查当前正在处理的请求数或者当前系统的内存使用量来决定是否对一次请求进行限流,这也能起到控制并发请求数的效果。

6.1.3 负载均衡分流

通过负载均衡器,我们可以根据服务器负载将流量均匀分配到多个服务节点上,避免流量集中在某些节点上导致出现性能瓶颈。

6.2 设置最大请求体长度

除了大量请求可能导致内存占用过多外,少量请求体超大的请求同样可能导致服务器端发生OOM,针对这种情况我们可以设置最大请求体长度。
grpc.NewServer(grpc.MaxRecvMsgSize(4096)) // 设置服务器端可接收的最大请求体长度为4KB

6.3 控制接口响应大小

有时候很小的接口请求可能导致超大的接口响应,比如一个简单的查询接口可能会返回多条数据库中的记录。这种情况其实在接口设计阶段就可以规避,对这种情况要么使用流式响应,要么就支持分页。

7 日志

没有人会去读日志,所以别指望有人会注意到日志文件里打印的一行error日志。如果你希望在终端用户之前发现问题,那么应该使用监控而不是日志。

func (s *Server) MyRequestHandler(...) (*Res, error) {
    res, err := client.Call(...)
    if err != nil {
        log.Error("Unexpected client.Call error")
        return err
    }}

多数情况下,日志仅在两种场景下有用:

  • 发现服务有问题的时候根据日志debug
  • 基于日志的后期处理生成度量指标或者报警

8 监控

为了及时发现问题,最好的方式就是为系统中所有可度量的事物导出度量指标并在这些指标上配置报警,以下展示了一种针对每个接口获取度量指标的代码模板,其核心思想就是在接口处理前初始化度量指标,然后通过defer函数在接口处理完成后完成度量指标的更新。

type serverCall struct {
    *Server
  // requestInfo包含了RPC名称、用户、开始时间、有效QoS以及超时等等  req requestInfo}
func (s *Server) newServerCall(req requestInfo) *serverCall {
  metrics.requestStart(req) // 开始度量指标的初始化  
  return &serverCall{s, req}
  }

func (sc *serverCall) done(m <other metrics>, err error) {
  metrics.requestEnd(sc.req, m, err) 
  // 完成度量指标的更新
  }

func (sc *serverCall) info(m <other metrics>) {
  metrics.requestInfo(sc.req, m)
  }

func (s *Server) SomeRequest(...) (res *Res, err error) {
  sc := s.newServerCall(<requestInfo>)
  defer func() {
    sc.done(<other metrics>, err)
  }()
 
  return sc.handleSomeRequest(ctx, req)}
posted @ 2022-10-14 16:38  不要摇头晃脑  阅读(282)  评论(0)    收藏  举报