0.簡介
Abp 框架為我們自帶了審計日志功能,審計日志可以方便地查看每次請求接口所耗的時間,能夠幫助我們快速定位到某些性能有問題的接口。除此之外,審計日志信息還包含有每次調用接口時客戶端請求的參數信息,客戶端的 IP 與客戶端使用的瀏覽器。有了這些數據之后,我們就可以很方便地復現接口產生 BUG 時的一些環境信息。
當然如果你腦洞更大的話,可以根據這些數據來開發一個可視化的圖形界面,方便開發與測試人員來快速定位問題。
PS:
如果使用了 Abp.Zero 模塊則自帶的審計記錄實現是存儲到數據庫當中的,但是在使用 EF Core + MySQL(EF Provider 為 Pomelo.EntityFrameworkCore.MySql) 在高並發的情況下會有數據庫連接超時的問題,這塊推薦是重寫實現,自己采用 Redis 或者其他存儲方式。
如果需要禁用審計日志功能,則需要在任意模塊的預加載方法(PreInitialize()) 當中增加如下代碼關閉審計日志功能。
public class XXXStartupModule
{
public override PreInitialize()
{
// 禁用審計日志
Configuration.Auditing.IsEnabled = false;
}
}
1.啟動流程
審計組件與參數校驗組件一樣,都是通過 MVC 過濾器與 Castle 攔截器來實現記錄的。也就是說,在每次調用接口/方法時都會進入 過濾器/攔截器 並將其寫入到數據庫表 AbpAuditLogs 當中。
其核心思想十分簡單,就是在執行具體接口方法的時候,先使用 StopWatch 對象來記錄執行完一個方法所需要的時間,並且還能夠通過 HttpContext 來獲取到一些客戶端的關鍵信息。

2.1 過濾器注入
同上一篇文章所講的一樣,過濾器是在 AddAbp() 方法內部的 ConfigureAspNetCore() 方法注入的。
private static void ConfigureAspNetCore(IServiceCollection services, IIocResolver iocResolver)
{
// ... 其他代碼
//Configure MVC
services.Configure<MvcOptions>(mvcOptions =>
{
mvcOptions.AddAbp(services);
});
// ... 其他代碼
}
而下面就是過濾器的注入方法:
internal static class AbpMvcOptionsExtensions
{
public static void AddAbp(this MvcOptions options, IServiceCollection services)
{
// ... 其他代碼
AddFilters(options);
// ... 其他代碼
}
// ... 其他代碼
private static void AddFilters(MvcOptions options)
{
// ... 其他過濾器注入
// 注入審計日志過濾器
options.Filters.AddService(typeof(AbpAuditActionFilter));
// ... 其他過濾器注入
}
// ... 其他代碼
}
2.2 攔截器注入
注入攔截器的地方與 DTO 自動驗證的攔截器的位置一樣,都是在 AbpBootstrapper 對象被構造的時候進行注冊。
public class AbpBootstrapper : IDisposable
{
private AbpBootstrapper([NotNull] Type startupModule, [CanBeNull] Action<AbpBootstrapperOptions> optionsAction = null)
{
// ... 其他代碼
if (!options.DisableAllInterceptors)
{
AddInterceptorRegistrars();
}
}
// ... 其他代碼
// 添加各種攔截器
private void AddInterceptorRegistrars()
{
ValidationInterceptorRegistrar.Initialize(IocManager);
AuditingInterceptorRegistrar.Initialize(IocManager);
EntityHistoryInterceptorRegistrar.Initialize(IocManager);
UnitOfWorkRegistrar.Initialize(IocManager);
AuthorizationInterceptorRegistrar.Initialize(IocManager);
}
// ... 其他代碼
}
轉到 AuditingInterceptorRegistrar 的具體實現可以發現,他在內部針對於審計日志攔截器的注入是區分了類型的。
internal static class AuditingInterceptorRegistrar
{
public static void Initialize(IIocManager iocManager)
{
iocManager.IocContainer.Kernel.ComponentRegistered += (key, handler) =>
{
// 如果審計日志配置類沒有被注入,則直接跳過
if (!iocManager.IsRegistered<IAuditingConfiguration>())
{
return;
}
var auditingConfiguration = iocManager.Resolve<IAuditingConfiguration>();
// 判斷當前 DI 所注入的類型是否應該為其綁定審計日志攔截器
if (ShouldIntercept(auditingConfiguration, handler.ComponentModel.Implementation))
{
handler.ComponentModel.Interceptors.Add(new InterceptorReference(typeof(AuditingInterceptor)));
}
};
}
// 本方法主要用於判斷當前類型是否符合綁定攔截器的條件
private static bool ShouldIntercept(IAuditingConfiguration auditingConfiguration, Type type)
{
// 首先判斷當前類型是否在配置類的注冊類型之中,如果是,則進行攔截器綁定
if (auditingConfiguration.Selectors.Any(selector => selector.Predicate(type)))
{
return true;
}
// 當前類型如果擁有 Audited 特性,則進行攔截器綁定
if (type.GetTypeInfo().IsDefined(typeof(AuditedAttribute), true))
{
return true;
}
// 如果當前類型內部的所有方法當中有一個方法擁有 Audited 特性,則進行攔截器綁定
if (type.GetMethods().Any(m => m.IsDefined(typeof(AuditedAttribute), true)))
{
return true;
}
// 都不滿足則返回 false,不對當前類型進行綁定
return false;
}
}
可以看到在判斷是否綁定攔截器的時候,Abp 使用了 auditingConfiguration.Selectors 的屬性來進行判斷,那么默認 Abp 為我們添加了哪些類型是必定有審計日志的呢?
通過代碼追蹤,我們來到了 AbpKernalModule 類的內部,在其預加載方法里面有一個 AddAuditingSelectors() 的方法,該方法的作用就是添加了一個針對於應用服務類型的一個選擇器對象。
public sealed class AbpKernelModule : AbpModule
{
public override void PreInitialize()
{
// ... 其他代碼
AddAuditingSelectors();
// ... 其他代碼
}
// ... 其他代碼
private void AddAuditingSelectors()
{
Configuration.Auditing.Selectors.Add(
new NamedTypeSelector(
"Abp.ApplicationServices",
type => typeof(IApplicationService).IsAssignableFrom(type)
)
);
}
// ... 其他代碼
}
我們先看一下 NamedTypeSelector 的一個作用是什么,其基本類型定義由一個 string 和 Func<Type, bool> 組成,十分簡單,重點就出在這個斷言委托上面。
public class NamedTypeSelector
{
// 選擇器名稱
public string Name { get; set; }
// 斷言委托
public Func<Type, bool> Predicate { get; set; }
public NamedTypeSelector(string name, Func<Type, bool> predicate)
{
Name = name;
Predicate = predicate;
}
}
回到最開始的地方,當 Abp 為 Selectors 添加了一個名字為 "Abp.ApplicationServices" 的類型選擇器。其斷言委托的大體意思就是傳入的 **type ** 參數是繼承自 IApplicationService 接口的話,則返回 true,否則返回 false。
這樣在程序啟動的時候,首先注入類型的時候,會首先進入上文所述的攔截器綁定類當中,這個時候會使用 Selectors 內部的類型選擇器來調用這個集合內部的斷言委托,只要這些選擇器對象有一個返回 true,那么就直接與當前注入的 type 綁定攔截器。
2.代碼分析
2.1 過濾器代碼分析
首先查看這個過濾器的整體類型結構,一個標准的過濾器,肯定要實現 IAsyncActionFilter 接口。從下面的代碼我們可以看到其注入了 IAbpAspNetCoreConfiguration 和一個 IAuditingHelper 對象。這兩個對象的作用分別是判斷是否記錄日志,另一個則是用來真正寫入日志所使用的。
public class AbpAuditActionFilter : IAsyncActionFilter, ITransientDependency
{
// 審計日志組件配置對象
private readonly IAbpAspNetCoreConfiguration _configuration;
// 真正用來寫入審計日志的工具類
private readonly IAuditingHelper _auditingHelper;
public AbpAuditActionFilter(IAbpAspNetCoreConfiguration configuration, IAuditingHelper auditingHelper)
{
_configuration = configuration;
_auditingHelper = auditingHelper;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// ... 代碼實現
}
// ... 其他代碼
}
接着看 AbpAuditActionFilter() 方法內部的實現,進入這個過濾器的時候,通過 ShouldSaveAudit() 方法來判斷是否要寫審計日志。
之后呢與 DTO 自動驗證的過濾器一樣,通過 AbpCrossCuttingConcerns.Applying() 方法為當前的對象增加了一個標識,用來告訴攔截器說我已經處理過了,你就不要再重復處理了。
再往下就是創建審計信息,執行具體接口方法,並且如果產生了異常的話,也會存放到審計信息當中。
最后接口無論是否執行成功,還是說出現了異常信息,都會將其性能計數信息同審計信息一起,通過 IAuditingHelper 存儲起來。
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// 判斷是否寫日志
if (!ShouldSaveAudit(context))
{
await next();
return;
}
// 為當前類型打上標識
using (AbpCrossCuttingConcerns.Applying(context.Controller, AbpCrossCuttingConcerns.Auditing))
{
// 構造審計信息(AuditInfo)
var auditInfo = _auditingHelper.CreateAuditInfo(
context.ActionDescriptor.AsControllerActionDescriptor().ControllerTypeInfo.AsType(),
context.ActionDescriptor.AsControllerActionDescriptor().MethodInfo,
context.ActionArguments
);
// 開始性能計數
var stopwatch = Stopwatch.StartNew();
try
{
// 嘗試調用接口方法
var result = await next();
// 產生異常之后,將其異常信息存放在審計信息之中
if (result.Exception != null && !result.ExceptionHandled)
{
auditInfo.Exception = result.Exception;
}
}
catch (Exception ex)
{
// 產生異常之后,將其異常信息存放在審計信息之中
auditInfo.Exception = ex;
throw;
}
finally
{
// 停止計數,並且存儲審計信息
stopwatch.Stop();
auditInfo.ExecutionDuration = Convert.ToInt32(stopwatch.Elapsed.TotalMilliseconds);
await _auditingHelper.SaveAsync(auditInfo);
}
}
}
2.2 攔截器代碼分析
攔截器處理時的總體思路與過濾器類似,其核心都是通過 IAuditingHelper 來創建審計信息和持久化審計信息的。只不過呢由於攔截器不僅僅是處理 MVC 接口,也會處理內部的一些類型的方法,所以針對同步方法與異步方法的處理肯定會復雜一點。
攔截器呢,我們關心一下他的核心方法 Intercept() 就行了。
public void Intercept(IInvocation invocation)
{
// 判斷過濾器是否已經處理了過了
if (AbpCrossCuttingConcerns.IsApplied(invocation.InvocationTarget, AbpCrossCuttingConcerns.Auditing))
{
invocation.Proceed();
return;
}
// 通過 IAuditingHelper 來判斷當前方法是否需要記錄審計日志信息
if (!_auditingHelper.ShouldSaveAudit(invocation.MethodInvocationTarget))
{
invocation.Proceed();
return;
}
// 構造審計信息
var auditInfo = _auditingHelper.CreateAuditInfo(invocation.TargetType, invocation.MethodInvocationTarget, invocation.Arguments);
// 判斷方法的類型,同步方法與異步方法的處理邏輯不一樣
if (invocation.Method.IsAsync())
{
PerformAsyncAuditing(invocation, auditInfo);
}
else
{
PerformSyncAuditing(invocation, auditInfo);
}
}
// 同步方法的處理邏輯與 MVC 過濾器邏輯相似
private void PerformSyncAuditing(IInvocation invocation, AuditInfo auditInfo)
{
var stopwatch = Stopwatch.StartNew();
try
{
invocation.Proceed();
}
catch (Exception ex)
{
auditInfo.Exception = ex;
throw;
}
finally
{
stopwatch.Stop();
auditInfo.ExecutionDuration = Convert.ToInt32(stopwatch.Elapsed.TotalMilliseconds);
_auditingHelper.Save(auditInfo);
}
}
// 異步方法處理
private void PerformAsyncAuditing(IInvocation invocation, AuditInfo auditInfo)
{
var stopwatch = Stopwatch.StartNew();
invocation.Proceed();
if (invocation.Method.ReturnType == typeof(Task))
{
invocation.ReturnValue = InternalAsyncHelper.AwaitTaskWithFinally(
(Task) invocation.ReturnValue,
exception => SaveAuditInfo(auditInfo, stopwatch, exception)
);
}
else //Task<TResult>
{
invocation.ReturnValue = InternalAsyncHelper.CallAwaitTaskWithFinallyAndGetResult(
invocation.Method.ReturnType.GenericTypeArguments[0],
invocation.ReturnValue,
exception => SaveAuditInfo(auditInfo, stopwatch, exception)
);
}
}
private void SaveAuditInfo(AuditInfo auditInfo, Stopwatch stopwatch, Exception exception)
{
stopwatch.Stop();
auditInfo.Exception = exception;
auditInfo.ExecutionDuration = Convert.ToInt32(stopwatch.Elapsed.TotalMilliseconds);
_auditingHelper.Save(auditInfo);
}
這里異步方法的處理在很早之前的工作單元攔截器就有過講述,這里就不再重復說明了。
2.3 核心的 IAuditingHelper
從代碼上我們就可以看到,不論是攔截器還是過濾器都是最終都是通過 IAuditingHelper 對象來儲存審計日志的。Abp 依舊為我們實現了一個默認的 AuditingHelper ,實現了其接口的所有方法。我們先查看一下這個接口的定義:
public interface IAuditingHelper
{
// 判斷當前方法是否需要存儲審計日志信息
bool ShouldSaveAudit(MethodInfo methodInfo, bool defaultValue = false);
// 根據參數集合創建一個審計信息,一般用於攔截器
AuditInfo CreateAuditInfo(Type type, MethodInfo method, object[] arguments);
// 根據一個參數字典類來創建一個審計信息,一般用於 MVC 過濾器
AuditInfo CreateAuditInfo(Type type, MethodInfo method, IDictionary<string, object> arguments);
// 同步保存審計信息
void Save(AuditInfo auditInfo);
// 異步保存審計信息
Task SaveAsync(AuditInfo auditInfo);
}
我們來到其默認實現 AuditingHelper 類型,先看一下其內部注入了哪些接口。
public class AuditingHelper : IAuditingHelper, ITransientDependency
{
// 日志記錄器,用於記錄日志
public ILogger Logger { get; set; }
// 用於獲取當前登錄用戶的信息
public IAbpSession AbpSession { get; set; }
// 用於持久話審計日志信息
public IAuditingStore AuditingStore { get; set; }
// 主要作用是填充審計信息的客戶端調用信息
private readonly IAuditInfoProvider _auditInfoProvider;
// 審計日志組件的配置相關
private readonly IAuditingConfiguration _configuration;
// 在調用 AuditingStore 進行持久化的時候使用,創建一個工作單元
private readonly IUnitOfWorkManager _unitOfWorkManager;
// 用於序列化參數信息為 JSON 字符串
private readonly IAuditSerializer _auditSerializer;
public AuditingHelper(
IAuditInfoProvider auditInfoProvider,
IAuditingConfiguration configuration,
IUnitOfWorkManager unitOfWorkManager,
IAuditSerializer auditSerializer)
{
_auditInfoProvider = auditInfoProvider;
_configuration = configuration;
_unitOfWorkManager = unitOfWorkManager;
_auditSerializer = auditSerializer;
AbpSession = NullAbpSession.Instance;
Logger = NullLogger.Instance;
AuditingStore = SimpleLogAuditingStore.Instance;
}
// ... 其他實現的接口
}
2.3.1 判斷是否創建審計信息
首先分析一下其內部的 ShouldSaveAudit() 方法,整個方法的核心作用就是根據傳入的方法類型來判定是否為其創建審計信息。
其實在這一串 if 當中,你可以發現有一句代碼對方法是否標注了 DisableAuditingAttribute 特性進行了判斷,如果標注了該特性,則不為該方法創建審計信息。所以我們就可以通過該特性來控制自己應用服務類,控制里面的的接口是否要創建審計信息。同理,我們也可以通過顯式標注 AuditedAttribute 特性來讓攔截器為這個方法創建審計信息。
public bool ShouldSaveAudit(MethodInfo methodInfo, bool defaultValue = false)
{
if (!_configuration.IsEnabled)
{
return false;
}
if (!_configuration.IsEnabledForAnonymousUsers && (AbpSession?.UserId == null))
{
return false;
}
if (methodInfo == null)
{
return false;
}
if (!methodInfo.IsPublic)
{
return false;
}
if (methodInfo.IsDefined(typeof(AuditedAttribute), true))
{
return true;
}
if (methodInfo.IsDefined(typeof(DisableAuditingAttribute), true))
{
return false;
}
var classType = methodInfo.DeclaringType;
if (classType != null)
{
if (classType.GetTypeInfo().IsDefined(typeof(AuditedAttribute), true))
{
return true;
}
if (classType.GetTypeInfo().IsDefined(typeof(DisableAuditingAttribute), true))
{
return false;
}
if (_configuration.Selectors.Any(selector => selector.Predicate(classType)))
{
return true;
}
}
return defaultValue;
}
2.3.2 創建審計信息
審計信息在創建的時候,就為我們將當前調用接口時的用戶信息存放在了審計信息當中,之后通過 IAuditInfoProvider 的 Fill() 方法填充了客戶端 IP 與瀏覽器信息。
public AuditInfo CreateAuditInfo(Type type, MethodInfo method, IDictionary<string, object> arguments)
{
// 構建一個審計信息對象
var auditInfo = new AuditInfo
{
TenantId = AbpSession.TenantId,
UserId = AbpSession.UserId,
ImpersonatorUserId = AbpSession.ImpersonatorUserId,
ImpersonatorTenantId = AbpSession.ImpersonatorTenantId,
ServiceName = type != null
? type.FullName
: "",
MethodName = method.Name,
// 將參數轉換為 JSON 字符串
Parameters = ConvertArgumentsToJson(arguments),
ExecutionTime = Clock.Now
};
try
{
// 填充客戶 IP 與瀏覽器信息等
_auditInfoProvider.Fill(auditInfo);
}
catch (Exception ex)
{
Logger.Warn(ex.ToString(), ex);
}
return auditInfo;
}
2.4 審計信息持久化
通過上一小節我們知道了在調用審計信息保存接口的時候,實際上是調用的 IAuditingStore 所提供的 SaveAsync(AuditInfo auditInfo) 方法來持久化這些審計日志信息的。
如果你沒有集成 Abp.Zero 項目的話,則使用的是默認的實現,就是簡單通過 ILogger 輸出審計信息到日志當中。

默認有這兩種實現,至於第一種是 Abp 的單元測試項目所使用的。
這里我們就簡單將一下 AuditingStore 這個實現吧,其實很簡單的,就是注入了一個倉儲,在保存的時候往審計日志表插入一條數據即可。
這里使用了 AuditLog.CreateFromAuditInfo() 方法將 AuditInfo 類型的審計信息轉換為數據庫實體,用於倉儲進行插入操作。
public class AuditingStore : IAuditingStore, ITransientDependency
{
private readonly IRepository<AuditLog, long> _auditLogRepository;
public AuditingStore(IRepository<AuditLog, long> auditLogRepository)
{
_auditLogRepository = auditLogRepository;
}
public virtual Task SaveAsync(AuditInfo auditInfo)
{
// 向表中插入數據
return _auditLogRepository.InsertAsync(AuditLog.CreateFromAuditInfo(auditInfo));
}
}
同樣,這里建議重新實現一個 AuditingStore,存儲在 Redis 或者其他地方。
3. 后記
前幾天發現 Abp 的團隊有開了一個新坑,叫做 Abp vNext 框架,該框架全部基於 .NET Core 進行開發,而且會針對微服務項目進行專門的設計,有興趣的朋友可以持續關注。
其 GitHub 地址為:https://github.com/abpframework/abp/
官方地址為:https://abp.io/
