Abp 異常處理

是你晨曦哥呀發表於2022-02-27

Abp 異常處理

最近一直在讀程式碼整潔之道,我在讀到第三章函式的3.9 使用異常替代返回錯誤碼,其實在我的開發經歷中都是使用返回錯誤碼給到前端,之前在閱讀ABP官網文件中就有看到過使用異常替代異常的做法,當時自己還是比較牴觸,在讀完本章之後我們就馬上閱讀了Abp的異常處理原始碼


ABP 提供了一個內建的基礎設施,並提供了一個標準模型來處理異常。

  • 自動處理所有異常並向客戶端傳送標準格式的錯誤訊息以獲取 API/AJAX 請求。
  • 自動隱藏內部基礎架構錯誤並返回標準錯誤訊息。
  • 提供一種簡單且可配置的方式來本地化異常訊息,可以實現多語言返回。
  • 自動將標準異常對映到HTTP 狀態程式碼,並提供一個可配置的選項來對映自定義異常。

業務異常

您自己的大多數異常將是業務異常。該IBusinessException介面用於將異常標記為業務異常。
BusinessExceptionIBusinessException除了IHasErrorCode,IHasErrorDetails和介面之外,還實現了IHasLogLevel介面。
預設日誌級別是Warning.
特定業務異常相關的錯誤程式碼。例如:

throw new BusinessException(QaErrorCodes.CanNotVoteYourOwnAnswer);

QaErrorCodes.CanNotVoteYourOwnAnswer只是一個const string。建議使用以下錯誤程式碼格式:

code-namespace是特定於您的模組/應用程式的唯一值。例子:
Volo.Qa:010002
Volo.Qa是這裡的程式碼名稱空間。然後將在本地化異常訊息時使用程式碼名稱空間。

  • 您可以在需要時直接丟擲BusinessException派生您自己的異常型別。
  • 該類的所有屬性都是可選的BusinessException。但是您通常設定ErrorCodeor Message屬性。

BusinessException(自定義的業務異常)

下面是我們實現一個自定義異常的程式碼邏輯

[Serializable]
// 繼承異常Exception類(實現自定義異常)
// IBusinessException (標識業務異常)
// IHasErrorCode(實現Code欄位)
// IHasErrorDetails(實現Details欄位)
// IHasLogLevel(當前異常實現自定義日誌等級)
public class BusinessException : Exception,
    IBusinessException,
    IHasErrorCode,
    IHasErrorDetails,
    IHasLogLevel
{
    public string Code { get; set; }

    public string Details { get; set; }

    public LogLevel LogLevel { get; set; }

    public BusinessException(
        string code = null,
        string message = null,
        string details = null,
        Exception innerException = null,
        LogLevel logLevel = LogLevel.Warning)
        : base(message, innerException)
    {
        Code = code;
        Details = details;
        LogLevel = logLevel;
    }

    /// <summary>
    /// Constructor for serializing.
    /// </summary>
    public BusinessException(SerializationInfo serializationInfo, StreamingContext context)
        : base(serializationInfo, context)
    {

    }

    public BusinessException WithData(string name, object value)
    {
        Data[name] = value;
        return this;
    }
}

本地化資源(實現多語言)

不知道大家沒有接觸過Abp的多語言設計,Abp通過讀取不同國家的語言包Json實現多語言設計

這個是Abp原始碼中使用多語言的案例,可以看到我們會統一定義一個資料夾儲存不同國家的多語言Json

多語言Json結構案例:

culture是語言

texts是Key-Value

{
  "culture": "zh-Hans",
  "texts": {
    "Volo.Abp.Http.DynamicProxying:10001": "業務異常"
  }
}

然後在模組中將語言包資料夾中的Json,新增到本地化中

        Configure<AbpLocalizationOptions>(options =>
        {
            options.Resources
                .Add<HttpClientTestResource>("en")
                .AddVirtualJson("/Volo/Abp/Http/Localization");
        });

設定異常本地化配置(不同的解決方案一定要進行註冊,如果沒註冊就找不到對應的錯誤碼Key)

        Configure<AbpExceptionLocalizationOptions>(options =>
        {
            // 設定對映解決方案名稱,因為考慮到不同的語言包,需要區分模組設計
            options.MapCodeNamespace("Volo.Abp.Http.DynamicProxying", typeof(HttpClientTestResource));
        });

結構如下:
我們的Key可以通過解決方案加Code的方式(Volo.Abp.Http.DynamicProxying為解決方案:10001是返回給前端的錯誤Code)

{
  "culture": "sl",
  "texts": {
    "Volo.Abp.Http.DynamicProxying:10001": "Poslovna izjema s podatki",
    "Volo.Abp.Http.TestProxying:10002": "Poslovna izjema s podatki"
  }
}

然後可以使用錯誤程式碼丟擲業務異常:

// QaDomainErrorCodes.CanNotVoteYourOwnAnswer="Volo.Abp.Http.DynamicProxying:10001"
// 這樣通過一個常量管理異常就簡潔明瞭。
throw new BusinessException(QaDomainErrorCodes.CanNotVoteYourOwnAnswer);

HTTP 狀態碼對映

ABP 嘗試按照以下規則自動確定最適合常見異常型別的 HTTP 狀態程式碼:

  • 對於AbpAuthorizationException:
    • 401如果使用者尚未登入,則返回(未經授權)。
    • 如果使用者已登入,則返回403(禁止)。
  • 的返回400(錯誤請求)AbpValidationException。
  • 返回404(未找到)EntityNotFoundException。
  • (並且因為它擴充套件了)返回403(禁止)。IBusinessExceptionIUserFriendlyExceptionIBusinessException
  • 的返回501(未實現)NotImplementedException。
  • 500其他異常(假定為基礎設施異常)的返回(內部伺服器錯誤)。

IHttpExceptionStatusCodeFinder用於自動確定 HTTP 狀態碼。預設實現是DefaultHttpExceptionStatusCodeFinder類。它可以根據需要更換或擴充套件。

自定義對映

自定義對映可以覆蓋自動 HTTP 狀態程式碼確定。例如:

services.Configure<AbpExceptionHttpStatusCodeOptions>(options =>
{
    options.Map("Volo.Qa:010002", HttpStatusCode.Conflict);
});

異常事件訂閱(ExceptionSubscriber)

下面我們會涉及到處理異常,Abp框架的處理異常給我們提供通知入口ExceptionSubscriber

[ExposeServices(typeof(IExceptionSubscriber))]
// 繼承IExceptionSubscriber介面,注入週期Transient(瞬態)
public abstract class ExceptionSubscriber : IExceptionSubscriber, ITransientDependency
{
    public abstract Task HandleAsync(ExceptionNotificationContext context);
}

我們只需要繼承ExceptionSubscriber抽象類,然後Abp將自動注入,一對多的形式進行注入。
觸發通知的程式碼在ExceptionNotifier原始碼

ExceptionNotifier(異常通知)

下面的程式碼就是實現異常通知發生事件的程式碼,我們只需要在異常過濾器中獲取ExceptionNotifier然後呼叫NotifyAsync方法就可以啦

// 異常通知
public class ExceptionNotifier : IExceptionNotifier, ITransientDependency
{
    public ILogger<ExceptionNotifier> Logger { get; set; }

    protected IServiceScopeFactory ServiceScopeFactory { get; }

    public ExceptionNotifier(IServiceScopeFactory serviceScopeFactory)
    {
        ServiceScopeFactory = serviceScopeFactory;
        Logger = NullLogger<ExceptionNotifier>.Instance;
    }

    // 通知入口
    public virtual async Task NotifyAsync([NotNull] ExceptionNotificationContext context)
    {
        Check.NotNull(context, nameof(context));

        using (var scope = ServiceScopeFactory.CreateScope())
        {
            // 1.獲取所有實現IExceptionSubscriber介面的實現了類
            var exceptionSubscribers = scope.ServiceProvider
                .GetServices<IExceptionSubscriber>();
            // 2.批量呼叫實現類的HandleAsync方法
            foreach (var exceptionSubscriber in exceptionSubscribers)
            {
                try
                {
                    await exceptionSubscriber.HandleAsync(context);
                }
                catch (Exception e)
                {
                    Logger.LogWarning($"Exception subscriber of type {exceptionSubscriber.GetType().AssemblyQualifiedName} has thrown an exception!");
                    Logger.LogException(e, LogLevel.Warning);
                }
            }
        }
    }
}

AbpExceptionFilter異常攔截器原始碼

我們首先可以看到AbpExceptionFilter繼承我們的異常攔截器,依賴注入的生命週期是瞬態的

// 我們首先可以看到AbpExceptionFilter繼承我們的異常攔截器,依賴注入的生命週期是瞬態的
public class AbpExceptionFilter : IAsyncExceptionFilter, ITransientDependency
{
   ·····省略程式碼
}

AbpExceptionFilter如果滿足以下任何條件,則處理異常:

  • 異常由返回物件結果(不是檢視結果)的控制器操作引發。
  • 該請求是一個 AJAX 請求(X-Requested-WithHTTP 標頭值為XMLHttpRequest)。
  • 客戶端明確接受application/json內容型別(通過acceptHTTP 標頭)。

如果異常得到處理,它會自動記錄下來,並將格式化的JSON 訊息返回給客戶端。

   // 判斷當前請求的異常是否需要自動處理
    protected virtual bool ShouldHandleException(ExceptionContext context)
    {
        // 1.判斷當前請求是否是控制器方法
        // 2.並且有返回結果
        if (context.ActionDescriptor.IsControllerAction() &&
            context.ActionDescriptor.HasObjectResult())
        {
            return true;
        }
        // 1.當前請求中頭accept是否是application/json內容型別
        if (context.HttpContext.Request.CanAccept(MimeTypes.Application.Json))
        {
            return true;
        }
        // 1.當前請求是否是AJAX 請求
        if (context.HttpContext.Request.IsAjax())
        {
            return true;
        }

        return false;
    }

如果ShouldHandleException()方法返回 true就會進入HandleAndWrapException() 自動格式化處理異常方法

    // 自動格式化處理異常
    protected virtual async Task HandleAndWrapException(ExceptionContext context)
    {
        //TODO: Trigger an AbpExceptionHandled event or something like that.
        // 1.首先還是老樣子讀取當前模組的配置資訊
        var exceptionHandlingOptions = context.GetRequiredService<IOptions<AbpExceptionHandlingOptions>>().Value;
        // 2.獲取異常格式轉換器,因為需要將我們的異常格式化,多語言實現也是在這個格式化轉換器中實現的
        var exceptionToErrorInfoConverter = context.GetRequiredService<IExceptionToErrorInfoConverter>();
        // 3.通過格式化轉換器,將異常資訊轉換成為前端展示資料(這裡就會使用到我們的配置資訊)
        var remoteServiceErrorInfo = exceptionToErrorInfoConverter.Convert(context.Exception, options =>
       {
           // 是否向客戶端傳送異常詳細資訊(預設是false)
           options.SendExceptionsDetailsToClients = exceptionHandlingOptions.SendExceptionsDetailsToClients;
           // 傳送堆疊跟蹤到客戶端(預設是true)
           options.SendStackTraceToClients = exceptionHandlingOptions.SendStackTraceToClients;
       });
        // 4.獲取我們業務異常日誌等級
        var logLevel = context.Exception.GetLogLevel();
        // 5.建立一個StringBuilder物件拼接異常資訊
        var remoteServiceErrorInfoBuilder = new StringBuilder();
        remoteServiceErrorInfoBuilder.AppendLine($"---------- {nameof(RemoteServiceErrorInfo)} ----------");
        remoteServiceErrorInfoBuilder.AppendLine(context.GetRequiredService<IJsonSerializer>().Serialize(remoteServiceErrorInfo, indented: true));
        
        // 6.獲取日誌資訊
        var logger = context.GetService<ILogger<AbpExceptionFilter>>(NullLogger<AbpExceptionFilter>.Instance);
        
        logger.LogWithLevel(logLevel, remoteServiceErrorInfoBuilder.ToString());

        logger.LogException(context.Exception, logLevel);

        // 7.獲取注入IExceptionNotifier介面的實現類,給IExceptionSubscriber實現類介面批量傳送事件
        await context.GetRequiredService<IExceptionNotifier>().NotifyAsync(new ExceptionNotificationContext(context.Exception));
        
        // 8.判斷當前異常是不是身份認證異常
        if (context.Exception is AbpAuthorizationException)
        {
            await context.HttpContext.RequestServices.GetRequiredService<IAbpAuthorizationExceptionHandler>()
                .HandleAsync(context.Exception.As<AbpAuthorizationException>(), context.HttpContext);
        }
        else
        {
            // 9.新增請求頭標識_AbpErrorFormat(給告訴呼叫者,這次的異常已經是被我們格式化的)
            context.HttpContext.Response.Headers.Add(AbpHttpConsts.AbpErrorFormat, "true");
            // 10.設定返回狀態碼
            context.HttpContext.Response.StatusCode = (int)context
                .GetRequiredService<IHttpExceptionStatusCodeFinder>()
                .GetStatusCode(context.HttpContext, context.Exception);
            // 11.將我們序列化好的錯誤資訊放入請求返回結果中
            context.Result = new ObjectResult(new RemoteServiceErrorResponse(remoteServiceErrorInfo));
        }
        // 12.清空當前請求的異常
        context.Exception = null; //Handled!
    }

參考資料