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!
}