[Abp 源碼分析]十5、自動審計記錄

0.簡介

Abp 框架爲咱們自帶了審計日誌功能,審計日誌能夠方便地查看每次請求接口所耗的時間,可以幫助咱們快速定位到某些性能有問題的接口。除此以外,審計日誌信息還包含有每次調用接口時客戶端請求的參數信息,客戶端的 IP 與客戶端使用的瀏覽器。有了這些數據以後,咱們就能夠很方便地復現接口產生 BUG 時的一些環境信息。html

固然若是你腦洞更大的話,能夠根據這些數據來開發一個可視化的圖形界面,方便開發與測試人員來快速定位問題。git

PS:github

若是使用了 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 來獲取到一些客戶端的關鍵信息。mvc

2.1 過濾器注入

同上一篇文章所講的同樣,過濾器是在 AddAbp() 方法內部的 ConfigureAspNetCore() 方法注入的。app

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 的一個做用是什麼,其基本類型定義由一個 stringFunc<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 建立審計信息

審計信息在建立的時候,就爲咱們將當前調用接口時的用戶信息存放在了審計信息當中,以後經過 IAuditInfoProviderFill() 方法填充了客戶端 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/

4.點此跳轉到總目錄

相關文章
相關標籤/搜索