在微服务架构或者分布式系统中,客户端如何捕捉服务端的异常?

这里说的客户端指调用方、服务端指被调用方,它们通常运行在不同的进程之中,这些进程可能运行在同一台服务器,也可能运行在不同的服务器,甚至不同的数据机房;其使用的技术栈可能相同,也可能存在很大的差异。

为什么

在Java、C#等高级语言中,程序遇到无法处理的情况,或者不满足运行条件时,比如除数是0的情况,底层代码通常会通过抛出异常(Exception)的方式向上层传递问题,上层代码通过 try-catch 的方式捕捉异常并进行处理,不过这种方式一般只能在同一个进程中使用,如果跨进程就没办法直接使用了。

有的同学可能会问:为什么要跨进程传递异常呢?

大家调用远程接口的时候可能有过这样的体验:

  • 首先远程接口可能会返回一些提前定义好的错误码,此时我们需要从返回数据中提取这些错误码,然后再根据不同的值进行相应的业务处理;
  • 其次我们还需要处理一些未知的错误,它们可能来源于服务端未注意到的地方,比如空指针问题,也可能是底层框架、操作系统或者硬件等抛出的一些问题,比如请求或者返回格式不匹配、网络中断、磁盘故障、内存溢出、文件系统损坏等各种技术问题。

如此我们实际上需要面对两种错误,而且需要采用不同的方式在不同的地方处理它们,这相当繁琐,心智负担比较大。从Java、C#等转Go的同学可能对此也深有体会,随处可见的error判断,还要留心panic的问题,当然Go有自己的意图和坚持,只是写起来真的很糟心。

那我们有什么办法来处理这个问题呢?我的选择是全部统一为处理异常(Exception),异常中可以包含错误码、错误描述,完全可以覆盖错误码的处理方式;而且异常不可避免,错误码则都是上层应用自己定义的。

基本原理

异常信息也是一种数据,所以传递异常也是传输数据。我们想要把数据从一个进程传递给另一个进程有很多种方法,在微服务架构或者分布式系统中,服务之间就是各种远程网络调用,服务的具体实现可能是基于Http协议的Restful、gRPC,也可能是基于TCP的Dubbo等等,我们的异常信息传递也要基于这些框架的约定和底层通信协议。

以Restful为例,当服务端产生异常时,我们通过拦截器或者程序内部的中间件捕捉到这个异常,提取出其中的异常信息,并中断这个异常的继续抛出,然后把拿到的异常信息写到HTTP Header中,返回到客户端。客户端的HTTP请求程序则从HTTP响应的Header中读取到这些异常信息,然后再把他们包装成异常(Exception),throw 出来。最后客户端中的业务代码就可以使用 try-catch 捕捉到这个异常,并根据错误码进行相应的处理。

使用WCF、gPRC和Dubbo等框架时也是类似的方法,只是传递异常时其写入和读取的位置不同。比如Dubbo可以在其数据包的消息头中声明这是一个错误相应,并在消息体中包含详细的异常信息;gPRC则可以利用它提供的Status来传递错误码、错误描述和一些额外的参考信息。

使用Restful、gRPC等协议或者技术还有一个好处,那就是这些技术使用的协议是跨平台的,你用Java开发,他用Go开发,你的程序跑在Windows上,他的程序跑在Linux上,这些都没有问题,都可以按照一套规则正常通信,传递异常也完全没有问题。

有的同学可能会担心性能的问题,因为抛出异常时,程序通常要把整个调用堆栈回溯一遍,这个过程可能会消耗一些计算资源,特别是当异常频繁发生或堆栈层次很深时。不过正常情况下,各种防护到位时,异常应该很少发生;而且现代编译器和运行时环境也会对异常处理进行优化,以减少性能开销。最后,异常处理机制的设计初衷是为了提高代码的健壮性和可维护性,在大多数情况下,异常处理所带来的性能开销是可以接受的。

最佳实践

接下来聊一些具体实现、遇到的问题和应对方法。

抛出业务异常

服务在改变数据状态之前,通常需要对数据进行一些验证,比如必填验证、格式验证、数据一致性验证等等,如果验证不通过,就要返回错误信息。

在传统的方案中,我们可能会定义一个通用的消息格式,其中包含错误码、错误描述,以及正常的业务字段,如下这样:

public class Response{
  // 处理状态:错误码、错误描述
  public int ErrCode{get;set;}
  public string ErrMsg{get;set;}

  // 处理成功时返回的业务数据
  public string UserId{get;set;}
  public string UserName{get;set;}
  ...
}

需要返回错误时,我们就会创建一个Response的实例,然后返回它,就像下边这样:

if(stirng.IsNullOrEmpty(id)){
  return new Response(100,"Id为空");
}

为了实现更为统一的错误处理方式,我们这里可以把返回Response实例的方式改为抛出异常。

if(stirng.IsNullOrEmpty(id)){
  throw new FireflySoftException(100,"Id为空");
}

如此,我们只需要在拦截器或者中间件中捕捉异常,并进行相应的处理就可以了,不管它是一个业务上的验证错误,还是底层框架中的某种未知异常。

比如在ASP.NET Core的异常拦截器中可以这样统一处理:

/// <summary>
/// WebAPI异常过滤器
/// </summary>
internal class WebAPIAsyncExceptionFilter : IAsyncExceptionFilter
{
    /// <summary>
    /// 异步异常处理
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    public async Task OnExceptionAsync(ExceptionContext context)
    {
          // 将自定义的异常或系统自带异常都转换为一种异常
          FireflySoftException ex;
          if(context.Exception is FireflySoftException){
            ex = (FireflySoftException)context.Exception;
          }else{
            ex = ConvertToFireflySoftException(context.Exception);
          }

          // 将异常信息写到 Http Header 中
          context.HttpContext.Response.StatusCode = 500;
          context.HttpContext.Response.Headers.Add("errcode", ex.Code.ToString());
          context.HttpContext.Response.Headers.Add("errmsg", System.Web.HttpUtility.UrlEncode(ex.Message));
          // 异常描述也写到 Http Body 中,方便人看
          var bodyContent = Encoding.UTF8.GetBytes(ex.Message);
          await context.HttpContext.Response.Body.WriteAsync(bodyContent, 0, bodyContent.Length).ConfigureAwait(false);
          
          context.ExceptionHandled = true;
    }
}

在底层处理异常

不应该让业务程序开发者关心异常的传递实现,比如上边编写的拦截器应该内置到团队的开发框架或者规范类库中,业务程序开发者只需要抛出异常或者捕捉异常就够了。

服务端的异常拦截器上边已经给了个例子,对于客户端,我们可以通过包装网络请求方法来达到相同的目的。这里还是用ASP.NET Core举个例子:

// 包装的Post请求方法
public async Task<HttpResponseMessage> PostAsync<TRequest>(string hostAndPort, string resourceUri, TRequest request)
{
    string requestJson = JsonConvert.SerializeObject(request);
    var content = new StringContent(requestJson, Encoding.UTF8, "application/json");

    // 在实际的网络请求外边包一层
    return await DoHttp(async client =>
    {
        var uri = new Uri(client.BaseAddress, resourceUri);
        var requestMessage = new HttpRequestMessage()
        {
            Method = HttpMethod.Post,
            RequestUri = uri,
            Content = content
        };

        return await client.SendAsync(requestMessage).ConfigureAwait(false);
    }, hostAndPort).ConfigureAwait(false);
}

// 拦截HTTP错误并包装为自定义的异常
private async Task<HttpResponseMessage> DoHttp(Func<HttpClient, Task<HttpResponseMessage>> action, string hostAndPort)
{
    HttpResponseMessage response;
    try
    {
        var client = GetHttpClient();
        response = await action(client).ConfigureAwait(false);
        return response.EnsureSuccessStatusCode();
    }
    catch (Exception ex)
    {
        // 如果 HTTP StatusCode 是错误码,会进入这里
        // 从 HTTP Header中提取错误码和错误描述
        // 然后可以创建并抛出对应的异常
         if (response.Headers.TryGetValues("errcode", out IEnumerable<string> errcodes))
         {
             var code = errcodes.FirstOrDefault();
             throw new FireflySoftException(code,"xxxxx");
         }
         ...
    }
}

如此,开发者通过Post调用接口时就可以这样写:

// 根据实际情况,可能需要try-catch,也可能不需要
try
{
  PostAsync("localhost:8080","api/getweather",new Request{
    City="帝都"
  })
}
catch(FireflySoftException ex)
{
    // 这里处理可能的业务异常
}

统一记录异常日志

有的同学为了方便跟踪异常信息,喜欢在程序中catch异常,并记录到日志中。

如果使用统一的异常方式来处理错误,则都可以在拦截器或者中间件中来做这件事,只需要在其中加入日志的记录逻辑就可以了。

当然有些异常可能还是要 catch 一下的,比如“添加信息时重复提交”、“给用户发消息时用户已取消授权”等等,这些异常可能都是要被忽略的,catch 住它们之后,程序可以吞掉这些异常,因为服务调用方也不关心这些异常,就没必要再向上抛出。

区分Warn和Error

这里是说要给异常分个等级,有些异常就是个警告级别的,比如用户没有填写某个参数,只要告诉用户就行了,运维或者开发者不太关心这些消息。有些异常则十分严重,比如空指针异常、除0异常等等,这往往说明程序存在BUG,需要反馈给开发者进行修复。

我们可以在自定义的异常构造函数中增加一个异常等级的参数,如下所示:

if(stirng.IsNullOrEmpty(id)){
  throw new FireflySoftException(100,"Id为空",ErrorLevel.Light);
}

注意也不是所有的警告都无需管理员过问,比如对于一个网络请求库,我们可能只是把请求超时作为一种警告,但是如果超时发生的非常频繁,也需要通知管理员来进行关注。

根据异常级别,我们就可以记录不同级别的日志,然后监控程序就可以根据日志级别和相应的频率为管理员提供相应的处理建议。

返回200还是500

使用HTTP作为服务之间的通信协议时,发生异常时服务端一般会返回500错误,也就是 HTTP StatusCode = 500,这一般是底层通信框架的默认设计。但是这会导致一个监控问题,监控程序会跟踪服务调用之间的HTTP状态,如果遇到500错误,它就会认为程序发生了错误,而这个错误可能只是一个参数验证不通过的情况,管理员不需要关心这个问题。

此时我们可以在拦截器中处理异常的地方稍微改造一下,将所有的HTTP状态码都改为200,或者当错误级别比较轻(ErrorLevel.Light)时设置为200,错误级别比较重(ErrorLevel.Heavy)时设置为500。

context.HttpContext.Response.StatusCode = 200;

这样做并不影响客户端对错误的处理,因为不管HTTP的状态码如何,客户端都可以从HTTP Header中提取处理错误所需的错误码和错误描述。

自动重试

有时服务端的错误可能只是瞬时的,或者只是多个节点中的少数节点不可用,重新发起请求就能成功完成调用。

我们可以把这个重试机制包装到网络请求方法中,减少业务程序中处理重试的代码量,此举也能更好的规范代码,避免BUG或者性能问题。

一种可行的方法是,我们根据异常的类型或者提前约定好的错误码,在包装的网络请求方法中针对这些异常进行特殊处理。具体实现可以参考下边的代码:

private async Task<HttpResponseMessage> DoHttp(Func<HttpClient, Task<HttpResponseMessage>> action, string hostAndPort)
{
  int tryCount = 0;
  while (true)
  {
      HttpResponseMessage response;
      try
      {
          var client = GetHttpClient();
          response = await action(client).ConfigureAwait(false);
          return response.EnsureSuccessStatusCode();
      }
      catch (Exception ex)
      {
           // 遇到某种特定的异常时,我们就进行一次重试
           if (ex is TaskCanceledException)
           {
              if(tryCount<1){
                tryCount++;
                continue;
              }
              throw;
           }
           ...
      }
  }
}


以上就是本文的主要内容,文章虽然描述了微服务架构下异常传递的基本原理,也探讨了一些具体的实践方法,但要完完整整的实现并集成到自己的开发框架中,必然还有很多的工作要做,比如错误码的定义,异常处理与限流、熔断等的整合,等等。

文章难免错漏,如有问题欢迎交流讨论。

关注萤火架构,加速技术提升!

标签: none

添加新评论