通過EF/Dapper擴展實現數據庫審計功能

相信大家都有過週末被電話“吵醒”的經歷,這個時候,客服同事會火急火燎地告訴你,客戶反饋生產環境上某某數據“異常”,然後你花費大量時間去排查這些錯誤數據,發現這是客戶使用某一種“騷”操作搞出來的“人禍”。可更多的時候,你不會這麼順利,因爲你缺乏有力的證據去支持你的結論。最終,你不情願地去處理了這些錯誤數據。你開始反思,爲什麼沒有一種流程去記錄客戶對數據的變更呢?爲什麼你總要花時間去和客戶解釋這些數據產生的原因呢?好了,這就要說到我們今天這篇博客的主題——審計。

什麼是審計?

結合本文引言中的描述的場景,當我們需要知道某條數據被什麼人修改過的時候,或者是希望在數據變更的時候去通知某個人,亦或者是我們需要追溯一條數據的變更歷史的時候,我們需要一種機制去記錄數據表中的數據變更,這就是所謂的審計。而實際的業務中,可能會有類似,查詢某一個員工一天內審批了多少單據的需求。你不要笑,人類常常如此無聊,就像我們有一個異常複雜的計費邏輯,雖然審計日誌裏記錄了某個費用是怎麼計算出來的,可花時間最多的地方,無一例外是需要開發去排查和解釋的,對於這一點,我時常感覺疲於應對,這是我這篇文章裏想要寫審計的一個重要原因。

EF/EF Core實體跟蹤

EF和EF Core裏都提供了實體跟蹤的功能,我的領導經常吐槽我,在操作數據庫的時候,喜歡顯式地調用repository.Update()方法,因爲他覺得項目中的實體跟蹤是默認打開的。可當你學習了Vue以後,你瞭解到Vue中是檢測不到數組的某些變化的,所以,這個事情我持保留意見,顯式調用就顯式調用唄,萬一哪天人家把實體跟蹤給關閉了呢?不過,話說回來,實體跟蹤確實可以幫我們做一點工作的,其中,就包括我們今天要說的審計功能。

EF和EF Core中的實體追蹤主要指DbContext類的ChangeTracker,而通過DetachChanges()方法,則可以獲得那些變化了的實體的集合。所以,使用實體追蹤來實現審計功能,本質上就是在SaveChanges()方法調用前後,記錄實體中每一個字段的變化情況。爲此,我們考慮編寫下面的類——AuditDbContextBase,顧名思義,這是一個審計相關的DbContext基類,所以,希望實現審計功能的DbContext都會繼承這個類。這裏,我們重寫其SaveChanges()方法,其基本定義如下:

public class AuditDbContextBase : DbContext, IAuditStorage
{
    public DbSet<AuditLog> AuditLog { get; set; }
    public AuditDbContextBase(DbContextOptions options, AuditConfig auditConfig) : base(options) { }
    public virtual Task BeforeSaveChanges() { }
    public virtual Task AfterSaveChanges() { }

    public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess,
         CancellationToken cancellationToken = default)
    {
        await BeforeSaveChanges();
        var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
        await AfterSaveChanges();
        return result;
    }

    public void SaveAuditLogs(params AuditLog[] auditLogs)
    {
        AuditLog.AddRange(auditLogs);
        base.SaveChangesAsync();
    }
}

接下來,就是去實現BeforeSaveChanges()AfterSaveChanges()兩個方法:

//BeforeSaveChanges
public virtual Task BeforeSaveChanges()
{
    ChangeTracker.DetectChanges();
    _auditEntries = new List<AuditEntry>();
    foreach (var entityEntry in ChangeTracker.Entries())
    {
        if (entityEntry.State == EntityState.Detached 
            || entityEntry.State == EntityState.Unchanged)
            continue;
        if (entityEntry.Entity.GetType() == typeof(AuditLog))
            continue;
        if (_auditConfig.EntityFilters.Any(x => x(entityEntry)))
            continue;

        var auditEntry = new AuditEntry(entityEntry, _auditConfig);
        _auditEntries.Add(auditEntry);
    }

    return Task.CompletedTask;
}

//AfterSaveChanges
public virtual Task AfterSaveChanges()
{
    if (_auditEntries == null || !_auditEntries.Any())
        return Task.CompletedTask;

    _auditEntries.ForEach(auditEntry => auditEntry.UpdateTemporaryProperties());

    var auditLogs = _auditEntries.Select(x => x.AsAuditLog()).ToArray();
    if (!_auditConfig.AuditStorages.Any())
        _auditConfig.AuditStorages.Add(this);
    _auditConfig.AuditStorages.ForEach(
        auditStorage => auditStorage.SaveAuditLogs(auditLogs)
    );

    return Task.CompletedTask;
}

可以注意到,我們會在SaveChanges()方法執行前,通過ChangeTracker.DetectChanges()方法顯式地捕獲“變化",這些“變化”會被存儲到一個臨時的列表中。而在SaveChanges()方法執行後,則會更新那些只有在數據提交後纔可以獲得的“臨時”數據,最典型的例子是自增的ID,在數據提交前,我們是無法獲得真正的ID的。這個列表中的內容最終會通過AsAuditLog()方法進行轉化。下面是AuditEntry中的部分代碼片段:

//SetValuesCollection
private void SetValuesCollection(List<PropertyEntry> properties)
{
    foreach (var property in properties)
    {
        var propertyName = property.Metadata.GetColumnName();
        if (_auditConfig.PropertyFilters.Any(x => x(_entityEntry, property)))
            continue;

        switch (OperationType)
        {
            case OperationType.Created:
                NewValues[propertyName] = property.CurrentValue;
            break;
            case OperationType.Updated:
                if (_auditConfig.IsIgnoreSameValue 
                    && property.OriginalValue.ToString() == property.CurrentValue.ToString())
                    continue;
                OldValues[propertyName] = property.OriginalValue;
                NewValues[propertyName] = property.CurrentValue;
            break;
            case OperationType.Deleted:
                OldValues[propertyName] = property.OriginalValue;
            break;
        }
    };
}

//AsAuditLog
public AuditLog AsAuditLog()
{
    return new AuditLog()
    {
        Id = Guid.NewGuid().ToString("N"),
        TableName = TableName,
        CreatedBy = string.Empty,
        CreatedDate = DateTime.Now,
        NewValues = NewValues.Any() ? JsonConvert.SerializeObject(NewValues) : null,
        OldValues = OldValues.Any() ? JsonConvert.SerializeObject(OldValues) : null,
        ExtraData = ExtraData.Any() ? JsonConvert.SerializeObject(ExtraData) : null,
        OperationType = (int)OperationType
    };
}

在此基礎上,我們可以編寫我們實際的DbContext,這裏以CustomerContext爲例,當我們向其中添加、修改和刪除Customer的時候,就會觸發審計相關的邏輯,默認情況下,審計產生的數據AuditLog和Customer在同一個數據庫上下文中,當然,我們可以通過注入IAuditStore來實現更精細的控制,例如,可以將審計日誌輸入到文本文件,甚至是Mongodb這樣的非關係型數據庫裏,因爲有依賴注入的存在,這些實現起來會非常的簡單!

//注入AuditLog配置
services.AddAuditLog(config => 
    config
    .IgnoreTable<AuditLog>()
    .IgnoreProperty<AuditLog>(x => x.CreatedDate)
    .WithExtraData("Tags", ".NET Core")
    .WithStorage<FileAuditStorage>()
    .WithStorage<MongoAuditStorage>()
);

//注入DbContext
services.AddDbContext<CustomerContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

//像平時一樣使用EF
var entity = _context.Customer.Where(x => x.Id == customer.Id).FirstOrDefault();
entity.Name = customer.Name;
entity.Email = customer.Email;
entity.Address = customer.Address;
entity.Tel = customer.Tel;
_context.Customer.Update(entity);
await _context.SaveChangesAsync();

下面是最終生成的審計日誌信息:

生成的審計日誌

Castle動態代理

而對於像Dapper這種輕量級的ORM,它本身沒有類似EF/EF Core的ChangeTracker的設計,如果我們在項目中使用Dapper,並且希望實現審計的相關功能,直觀上看就會有一點困難。其實,平時在混合使用EF/Dapper的過程中,經常遇到的問題就是,如何確保傳統的ADO.NET和EF在一個數據庫事務中,如何確保Dapper和EF在一個數據庫事務中等等。此時,我們就需要一點抽象,首先去實現一個Dapper的倉儲模式,然後再借助Castle這類動態代理庫實現對接口的攔截。這裏以Dapper的擴展庫Dapper.Contrib爲例。首先,我們定義一個倉儲接口IRepository:

public interface  IRepository
{
    TEntity GetByID<TEntity>(object id) where TEntity : class;

    TEntity GetByKeys<TEntity>(object keys) where TEntity : class;

    TEntity QueryFirst<TEntity>(string sql, object param) where TEntity : class;

    TEntity QuerySingle<TEntity>(string sql, object param) where TEntity : class;

    [AuditLog(OperationType.Created)]
    void Insert<TEntity>(params TEntity[] entities) where TEntity : class;

    [AuditLog(OperationType.Updated)]
    void Update<TEntity>(params TEntity[] entities) where TEntity : class;

    [AuditLog(OperationType.Deleted)]
    void Delete<TEntity>(params TEntity[] entities) where TEntity : class;

    void Delete<TEntity>(params object[] ids) where TEntity : class;

    IEnumerable<TEntity> GetByQuery<TEntity>(Expression<Func<TEntity,bool>> exps) where TEntity : class;

    IEnumerable<TEntity> GetByQuery<TEntity>(string sql, object param) where TEntity : class;

    IEnumerable<TEntity> GetAll<TEntity>() where TEntity : class;
}

接下來,我們就可以在攔截器中實現數據審計功能,因爲Dapper本身沒有ChangeTracker,所以,我們必須要在先從數據庫中查出來OldValue,所以,實際效率應該並不會特別高,這裏權當做爲大家擴展思路吧!

public class AuditLogInterceptor : IInterceptor
{
    public void Intercept(IInvocation invocation)
    {
        var repository = invocation.Proxy as IRepository;
        var entityType = GetEntityType(invocation);
        var tableName = GetTableName(entityType);
        var tableIdProperty = entityType.GetProperty("Id");
        var auditLogAttrs = invocation.Method.GetCustomAttributes(typeof(AuditLogAttribute), false);
        if (auditLogAttrs == null || auditLogAttrs.Length == 0 || entityType == typeof(AuditLog))
        {
            invocation.Proceed();
            return;
        }

        var auditLogAttr = (auditLogAttrs as AuditLogAttribute[])[0];
        var auditLogs = new List<AuditLog>();
        switch (auditLogAttr.OperationType)
        {
            case Domain.OperationType.Created:
                auditLogs = GetAddedAuditLogs(invocation, tableName);
            break;
            case Domain.OperationType.Updated:
                auditLogs = GetUpdatedAuditLogs(invocation, tableName, entityType, 
                    tableIdProperty, repository);
            break;
            case Domain.OperationType.Deleted:
                auditLogs = GetDeletedAuditLogs(invocation, tableName);
            break;
        }
            
        invocation.Proceed();
        repository.Insert<AuditLog>(auditLogs.ToArray());
    }
}

同樣地,這裏需要需要使用Autofac將其註冊到IoC容器中:

builder.RegisterType<DapperRepository>().As<IRepository>()
    .InterceptedBy(typeof(AuditLogInterceptor))
    .EnableInterfaceInterceptors();
builder.RegisterType<AuditLogInterceptor>();

思路延伸:領域事件

最近這段時間,對於數據同步這類“需求”略有感觸,譬如某種單據在兩個互爲上下游的系統裏流轉,譬如不同系統間實時地對基礎資料進行同步等。這類需求可能會通過ETLDBLink這類“數據庫”手段實現,亦有可能是通過互相調用API的方式實現,再者無非是通過數據庫實現類似消息隊列的功能……而我個人,更推崇通過事件來處理,因爲它更接近人類思考的本質,希望在適當的時機來“通知”對方,而論詢實際上是一種相當低效的溝通方式。一個訂單被創建,一條記錄被修改,本質上都是一個特定事件,而在業務上對此感興趣的任何第三方,都可以去訂閱這個事件,這就是事件驅動的思想。

領域事件

我拜讀了幾篇關於“領域驅動設計(DDD)”文章,瞭解到DDD中有領域事件和集成事件的概念。最直接的體會就是,DDD是主張“充血模型”的,它把事件附加到實體上,最大的好處就是,可以讓“發送(Dispatch)”事件的代碼,集中地放在一個地方。而我們現在的業務代碼,基本是高度耦合的,每次去添加一個事件的時候,最擔心地就是遺漏了某個地方。按照DDD的思想,實現領域事件,最常用的伎倆是重寫DbContext的SaveChanges()方法,或者在EF中去指定DbContext的Complate事件。這裏同樣藉助了ChangeTracker來實現:

public class OrderContext : DbContext
{
    public async Task<bool> SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
    {
        var aggregateRoots = dbContext.ChangeTracker.Entries().ToList();
        await _eventDispatcher.DispatchAsync(aggregateRoots,cancellationToken);
        var result = await base.SaveChangesAsync();
    }
}

其中,_eventDispatcher作爲事件分發器來分發事件,它實現了IEventDispatcher接口。相對應地,事件訂閱者需要實現IDomainEventHandler接口。如果是最簡單的進程內通信,那麼你需要一個容器來管理IDomainEventIDomainEventHandler間的關係;而如果是不同微服務間的通信,那麼你需要引入RabbitMQ或者kafka這類消息隊列中間件。

public interface IDomainEvent
{

}

public interface IDomainEventHandler<in TDomainEvent>
        where TDomainEvent : IDomainEvent
{
    Task HandleAysnc(TDomainEvent @event, CancellationToken cancellationToken = default);
}

public interface IEventDispatcher
{
    Task DispatchAsync<TDomainEvent>(
        TDomainEvent @event,
        CancellationToken cancellationToken = default) where TDomainEvent :IDomainEvent;
}

所以,你現在問我怎麼樣做數據同步好,我一定會說,通過事件來處理。因爲這樣,每一條數據的新增、更新、刪除,都可以事件的形式發佈出去,而關心這些數據的下游系統,則只需要訂閱這些事件,該幹嘛好嘛,何樂而不爲呢?搞什麼中間表,打什麼標記,用數據庫一遍遍地實現消息隊列有意思嗎?同樣地,你會意識到,倉儲模式,哪怕ORM換成Dapper,我們一樣可以去發佈這些事件,增量同步自然是要比全量同步優雅而且高效的。最重要的是,程序員再不需要到處找地方埋點了,你看我博客更新頻率這麼低,不就是因爲這些事情浪費了時間嗎(逃?因爲,全量 + 實時同步就是一個非常愚蠢的決定。

本文小結

本文分別針對EF CoreDapper實現了數據庫審計的功能。對於前者,主要是通過重寫DbContext的SaveChanges()方法來實現,而EFEF Core中的ChangeTracker則提供了一種獲取數據庫表記錄變化前後值的能力。而對於後者,主要是實現了Dapper的倉儲模式,在此基礎上結合Castle的動態代理功能,對倉儲接口進行攔截,以此實現審計日誌的記錄功能。整體來看,後者對代碼的侵入性要更小一點,理論上我們可以實現EFEF Core的倉儲模式,這樣兩者在實現上會更接近一點,當然,更直接的方案是去攔截SaveChanges()方法,這和我們使用繼承的目的是一樣的,由於Dapper本身沒有ChangeTracker,所以,在處理Update()相關的倉儲接口時,都需要先查詢一次數據庫,這一點是這個方案裏最大的短板。而順着這個方案擴展下去,我們同樣可以挖掘出一點DDD領域事件的意味,這就變得很有意思了,不是嗎?這篇博客就先寫到這裏吧……再見

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章