首页 > 其他分享 >微服务架构,客户端如何捕捉服务端的异常?

微服务架构,客户端如何捕捉服务端的异常?

时间:2024-03-06 13:11:05浏览次数:28  
标签:HTTP 错误 处理 错误码 ex 架构 异常 服务端 客户端

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

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

为什么

在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;
           }
           ...
      }
  }
}

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

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

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

标签:HTTP,错误,处理,错误码,ex,架构,异常,服务端,客户端
From: https://www.cnblogs.com/bossma/p/18056293

相关文章

  • 新零售SaaS架构:订单履约系统架构设计(万字图文总结)
    什么是订单履约系统?订单履约系统用来管理从接收客户订单到将商品送达客户手中的全过程。它连接了上游交易(客户在销售平台下单环)和下游仓储配送(如库存管理、物流配送),确保信息流顺畅、操作协同,提升整个供应链的效率和响应速度。系统定位订单履约系统的目标是让订单处理更快、更......
  • 在 Exchange Server 中配置特定于客户端的消息大小限制
    微软官方详细文档如下:https://learn.microsoft.com/zh-cn/exchange/architecture/client-access/client-message-size-limits?view=exchserver-2019解决方法:通过查看官方文档,打开cmd复制执行下面%windir%的部分命令,重新IIS服务,问题解决。注:下面的数值表示200MBActiveSync%E......
  • Redis(主从复制、读写分离、主从切换)架构
    Redis的集群方案大致有三种:1)rediscluster集群方案;2)master/slave主从方案;3)哨兵模式来进行主从替换以及故障恢复。一、sentinel哨兵模式介绍Sentinel(哨兵)是用于监控redis集群中Master状态的工具,是Redis的高可用性解决方案,sentinel哨兵模式已经被集成在redis2.4之后的版本......
  • 【架构师视角系列】QConfig配置中心系列之Server端(三)
    声明原创文章,转载请标注。https://www.cnblogs.com/boycelee/p/17993697《码头工人的一千零一夜》是一位专注于技术干货分享的博主,追随博主的文章,你将深入了解业界最新的技术趋势,以及在Java开发和安全领域的实用经验分享。无论你是开发人员还是对逆向工程感兴趣的爱好者,都能在《......
  • 【架构师视角系列】QConfig配置中心系列之Server端(三)
    声明原创文章,转载请标注。https://www.cnblogs.com/boycelee/p/17993697《码头工人的一千零一夜》是一位专注于技术干货分享的博主,追随博主的文章,你将深入了解业界最新的技术趋势,以及在Java开发和安全领域的实用经验分享。无论你是开发人员还是对逆向工程感兴趣的爱好者,都能在《......
  • 架构漫谈读后感
    架构漫谈读后感架构漫谈是由一个架构师王概凯写的一个专题,是以他的实际架构经验为基础,讨论是什么是架构,怎样做好架构,怎么写好程序等一些问题。共分为九个部分:1) 什么是架构?首先把架构的概念讨论明白,然后在对架构进行分析才显得清晰有意义。架构这个词在软件工程很早之前就已......
  • 从MySQL到ByteHouse,抖音精准推荐存储架构重构解读
    更多技术交流、求职机会,欢迎关注字节跳动数据平台微信公众号,回复【1】进入官方交流群抖音依靠自身推荐系统为用户推送可能感兴趣的视频内容,其中兴趣圈层是推荐的重要能力,通过理解核心用户的偏好特征,判断两者偏好的相似性,从而构建同类用户的兴趣圈层,实现精准推荐。以往的兴趣圈......
  • 推荐一个Mysql客户端命令行神器
    目录软件主页安装选项参考命令链接数据库参考网址​​ 今个推荐一个MySQL命令行客户端---MyCli,其支持命令的自动补全和语法高亮;软件主页MyCli--HomePags安装#Mycli支持在Python环境,MacOS,Windows系统下安装使用,pip3installmycli#Python下安装,解释器......
  • 新零售SaaS架构:订单履约系统的概念模型设计
    订单履约系统的概念模型订单:客户提交购物请求后,生成的买卖合同,通常包含客户信息、下单日期、所购买的商品或服务明细、价格、数量、收货地址以及支付方式等详细信息。子订单:为了更高效地进行履约,大订单可能会被拆分成多个子订单,子订单会根据商品类型、配送地址、仓库位置或......
  • 王概凯架构漫谈学习笔记
    一,什么是架构-架构实际上解决的是人的问题架构产生源于每个人不能自己完成所有哦生活必须品的生产。为了解决人类的延续的问题,自然而然就有男女群居出现,这个时候就出现了分工了,男性和女性所做的事情就会有一定的分工,可是人每天生活的基本需求没有发生变化,还是衣食住行等生活必须......