微服務架構,客戶端如何catch服務端的異常?

来源:https://www.cnblogs.com/bossma/p/18056293
-Advertisement-
Play Games

在微服務架構或者分散式系統中,客戶端如何捕捉服務端的異常?這裡說的客戶端指調用方、服務端指被調用方,它們通常運行在不同的進程之中,這些進程可能運行在同一臺伺服器,也可能運行在不同的伺服器,甚至不同的數據機房;其使用的技術棧可能相同,也可能存在很大的差異。 ...


在微服務架構或者分散式系統中,客戶端如何捕捉服務端的異常?

這裡說的客戶端指調用方、服務端指被調用方,它們通常運行在不同的進程之中,這些進程可能運行在同一臺伺服器,也可能運行在不同的伺服器,甚至不同的數據機房;其使用的技術棧可能相同,也可能存在很大的差異。

為什麼

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

以上就是本文的主要內容,文章雖然描述了微服務架構下異常傳遞的基本原理,也探討了一些具體的實踐方法,但要完完整整的實現並集成到自己的開發框架中,必然還有很多的工作要做,比如錯誤碼的定義,異常處理與限流、熔斷等的整合,等等。

文章難免錯漏,如有問題歡迎交流討論。

關註螢火架構,加速技術提升!

  • 本文作者: 螢火架構
  • 本文鏈接: https://www.cnblogs.com/bossma/p/18056293
  • 關於博主: 使用微信掃描左側二維碼關註我的訂閱號,每天獲取新知識
  • 版權聲明: 本博客所有文章除特別聲明外,均採用 BY-NC-SA 許可協議。轉載請註明出處!

  • 您的分享是我們最大的動力!

    -Advertisement-
    Play Games
    更多相關文章
    • 金融業務產品授信準入、交易營銷等環節存在廣泛的風控訴求,隨著業務種類增多,傳統的專家規則、評分卡模型難以應付日趨複雜的風控場景。 在傳統風控以專家規則系統為主流應用的語境下,規則模型的入參習慣被稱為“變數”。基於專家規則的風險評估,存在規則觸發閾值難量化的特點,規則命中精準度提升存在瓶頸。 隨著機器 ...
    • 本文分享自華為雲社區《GaussDB資料庫SQL系列-動態語句》,作者:Gauss松鼠會小助手2。 一、前言 在資料庫中構建動態SQL語句是指根據不同的條件或參數創建不同的SQL語句。這通常是為了適應不同的業務需求,提高SQL的靈活性和效率。GaussDB資料庫是一款具備高性能、高可用性和高擴展性的 ...
    • 本文分享自華為雲社區《GaussDB跨雲容災:實現跨地域的資料庫高可用能力》,作者:GaussDB 資料庫。 金融、銀行業等對數據的安全有著較高的要求,同城容災建設方案,在絕大多數場景下可以保證業務數據的安全性,但是在極端情況下,如遇不可抗力因素等,要保證數據的安全性,就需要採取跨地域的容災方案。 ...
    • Ubuntu22.04安裝 本操作在虛擬機上 安裝Redis 1)更新系統 sudo apt update sudo apt upgrade 2)安裝Redis sudo apt install redis-server 3)測試Redis是否工作 redis-cli --version syste ...
    • Linux入門(五) 本篇文章主要講述下文件處理相關的命令 1: 顯示許可權 ls -lh 總用量 36K drwxrwxr-x 5 zh zh 4.0K 2月 28 16:47 app -rw-rw-r-- 1 zh zh 530 2月 22 18:25 build.gradle drwxrwxr- ...
    • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 開始之前 Composition API 可以說是Vue3的最大特點,那麼為什麼要推出Composition Api,解決了什麼問題? 通常使用Vue2開發的項目,普遍會存在以下問題: 代碼的可讀性隨著組件變大而變差 每一種代碼復用的方式 ...
    • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 一、Object.defineProperty 定義:Object.defineProperty() 方法會直接在一個對象上定義一個新屬性,或者修改一個對象的現有屬性,並返回此對象 為什麼能實現響應式 通過defineProperty 兩 ...
    • SpringBoot底層預設使用logback日誌框架。 切換使用Log4j2日誌框架。 pom.xml配置 <!-- web場景啟動器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-b ...
    一周排行
      -Advertisement-
      Play Games
    • GoF之工廠模式 @目錄GoF之工廠模式每博一文案1. 簡單說明“23種設計模式”1.2 介紹工廠模式的三種形態1.3 簡單工廠模式(靜態工廠模式)1.3.1 簡單工廠模式的優缺點:1.4 工廠方法模式1.4.1 工廠方法模式的優缺點:1.5 抽象工廠模式1.6 抽象工廠模式的優缺點:2. 總結:3 ...
    • 新改進提供的Taurus Rpc 功能,可以簡化微服務間的調用,同時可以不用再手動輸出模塊名稱,或調用路徑,包括負載均衡,這一切,由框架實現並提供了。新的Taurus Rpc 功能,將使得服務間的調用,更加輕鬆、簡約、高效。 ...
    • 本章將和大家分享ES的數據同步方案和ES集群相關知識。廢話不多說,下麵我們直接進入主題。 一、ES數據同步 1、數據同步問題 Elasticsearch中的酒店數據來自於mysql資料庫,因此mysql數據發生改變時,Elasticsearch也必須跟著改變,這個就是Elasticsearch與my ...
    • 引言 在我們之前的文章中介紹過使用Bogus生成模擬測試數據,今天來講解一下功能更加強大自動生成測試數據的工具的庫"AutoFixture"。 什麼是AutoFixture? AutoFixture 是一個針對 .NET 的開源庫,旨在最大程度地減少單元測試中的“安排(Arrange)”階段,以提高 ...
    • 經過前面幾個部分學習,相信學過的同學已經能夠掌握 .NET Emit 這種中間語言,並能使得它來編寫一些應用,以提高程式的性能。隨著 IL 指令篇的結束,本系列也已經接近尾聲,在這接近結束的最後,會提供幾個可供直接使用的示例,以供大伙分析或使用在項目中。 ...
    • 當從不同來源導入Excel數據時,可能存在重覆的記錄。為了確保數據的準確性,通常需要刪除這些重覆的行。手動查找並刪除可能會非常耗費時間,而通過編程腳本則可以實現在短時間內處理大量數據。本文將提供一個使用C# 快速查找並刪除Excel重覆項的免費解決方案。 以下是實現步驟: 1. 首先安裝免費.NET ...
    • C++ 異常處理 C++ 異常處理機制允許程式在運行時處理錯誤或意外情況。它提供了捕獲和處理錯誤的一種結構化方式,使程式更加健壯和可靠。 異常處理的基本概念: 異常: 程式在運行時發生的錯誤或意外情況。 拋出異常: 使用 throw 關鍵字將異常傳遞給調用堆棧。 捕獲異常: 使用 try-catch ...
    • 優秀且經驗豐富的Java開發人員的特征之一是對API的廣泛瞭解,包括JDK和第三方庫。 我花了很多時間來學習API,尤其是在閱讀了Effective Java 3rd Edition之後 ,Joshua Bloch建議在Java 3rd Edition中使用現有的API進行開發,而不是為常見的東西編 ...
    • 框架 · 使用laravel框架,原因:tp的框架路由和orm沒有laravel好用 · 使用強制路由,方便介面多時,分多版本,分文件夾等操作 介面 · 介面開發註意欄位類型,欄位是int,查詢成功失敗都要返回int(對接java等強類型語言方便) · 查詢介面用GET、其他用POST 代碼 · 所 ...
    • 正文 下午找企業的人去鎮上做貸後。 車上聽同事跟那個司機對罵,火星子都快出來了。司機跟那同事更熟一些,連我在內一共就三個人,同事那一手指桑罵槐給我都聽愣了。司機也是老社會人了,馬上聽出來了,為那個無辜的企業經辦人辯護,實際上是為自己辯護。 “這個事情你不能怪企業。”“但他們總不能讓銀行的人全權負責, ...