1. 程式人生 > >[Abp 原始碼分析]十五、自動審計記錄

[Abp 原始碼分析]十五、自動審計記錄

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 的一個作用是什麼,其基本型別定義由一個 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 進行開發,而且會針對微服務專案進行專門的設計,有興趣的朋友可以持續關注。