65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Entity Framework Core 创建简单的审计跟踪

2020年4月11日

CPOL

4分钟阅读

viewsIcon

44642

downloadIcon

1014

将所有数据更改历史记录存储在表中使用 Entity Framework Core

引言

在一个特定的项目中,我们需要记录任何最终用户所做的数据更改。这需要在不大量修改现有解决方案代码的情况下完成。该项目正在使用 Entity Framework,所以我认为为什么不直接在 SaveChanges() 方法中实现呢。

背景

数据库是 SQL Server,ORM 是 Entity Framework Core,该应用程序使用自定义的 SaveChanges(string userName) 方法而不是常规的 SaveChanges()。因此,我们决定在该方法中添加审计功能。而且,由于我们能在该方法中获取审计用户的姓名,这是一个优势。

这是日志表示例

Click to enlarge image

让我们开始编码。

审计日志数据

审计表实体

此实体将用作数据库日志表。

using System;
using System.Collections.Generic;
using System.Text;

namespace Db.Table
{
    public class Audit
    {
        public Guid Id { get; set; }                    /*Log id*/
        public DateTime AuditDateTimeUtc { get; set; }  /*Log time*/
        public string AuditType { get; set; }           /*Create, Update or Delete*/
        public string AuditUser { get; set; }           /*Log User*/
        public string TableName { get; set; }           /*Table where rows been 
                                                          created/updated/deleted*/
        public string KeyValues { get; set; }           /*Table Pk and it's values*/
        public string OldValues { get; set; }           /*Changed column name and old value*/
        public string NewValues { get; set; }           /*Changed column name 
                                                          and current value*/
        public string ChangedColumns { get; set; }      /*Changed column names*/
    }
}
  • Id: 日志 ID 或日志表的主键
  • AuditDateTimeUtc: UTC 时间的日志日期
  • AuditType: 创建/更新/删除
  • AuditUser: 修改数据用户
  • TableName: 创建/更新/删除行的表
  • KeyValues: 已更改行的主键值和列名 (JSON 字符串)
  • OldValues: 已更改行的旧值和列名 (JSON 字符串,仅修改的列)
  • NewValues: 已更改行的当前/新值和列名 (JSON 字符串,仅修改的列)
  • ChangedColumns: 已更改行的列名 (JSON 字符串,仅修改的列)

审计类型

using System;
using System.Collections.Generic;
using System.Text;

namespace Db.Status
{
    public enum AuditType
    {
        None = 0,
        Create = 1,
        Update = 2,
        Delete = 3
    }
}
  • Create: 向表中添加新行
  • Update: 修改现有行
  • Delete: 删除现有行

审计数据库上下文

创建一个接口来指定基于审计日志的数据库上下文,用于 Entity Framework

using Db.Table;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;

namespace Db
{
    public interface IAuditDbContext
    {
        DbSet<Audit> Audit { get; set; }
        ChangeTracker ChangeTracker { get; }
    }
}
  • DbSet<Audit> Audit { get; set; } 是审计日志表。
  • ChangeTracker ChangeTracker { get; }DbContext 的默认属性,我们将用它来跟踪更改详情。

审计表配置

创建实体到表映射的配置,按需配置。如果我们进行代码优先开发而不使用任何表配置类,此步骤是可选的。

using Db.Table;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Db.Configuration
{
    internal class AuditConfig : IEntityTypeConfiguration<Audit>
    {
        public void Configure(EntityTypeBuilder<Audit> entity)
        {
            entity.HasKey(e => e.Id);

            entity.ToTable("tbl_test_audit_trail");

            entity.Property(e => e.Id)
                .HasColumnName("id");
            entity.Property(e => e.AuditDateTimeUtc)
                .HasColumnName("audit_datetime_utc");
            entity.Property(e => e.AuditType)
                .HasColumnName("audit_type");
            entity.Property(e => e.AuditUser)
                .HasColumnName("audit_user");        
            entity.Property(e => e.TableName)
                .HasColumnName("table_name");
            entity.Property(e => e.KeyValues)
                .HasColumnName("key_values");
            entity.Property(e => e.OldValues)
                .HasColumnName("old_values");
            entity.Property(e => e.NewValues)
                .HasColumnName("new_values");
            entity.Property(e => e.ChangedColumns)
                .HasColumnName("changed_columns");
        }
    }
}

将数据更改写入审计表

将实体更改写入审计表实体

创建一个帮助类,用于映射数据库实体中的所有数据更改,并使用这些更改信息创建 Audit 日志实体。在这里,我们使用 JSON 序列化器来指定与列值相关的更改。

using Db.Status;
using Db.Table;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Db.Helper.AuditTrail
{
    public class AuditEntry
    {
        public EntityEntry Entry { get; }
        public AuditType AuditType { get; set; }
        public string AuditUser { get; set; }
        public string TableName { get; set; }
        public Dictionary<string, object> 
               KeyValues { get; } = new Dictionary<string, object>();
        public Dictionary<string, object> 
               OldValues { get; } = new Dictionary<string, object>();
        public Dictionary<string, object> 
               NewValues { get; } = new Dictionary<string, object>();
        public List<string> ChangedColumns { get; } = new List<string>();

        public AuditEntry(EntityEntry entry, string auditUser)
        {
            Entry = entry;
            AuditUser = auditUser;
            SetChanges();
        }

        private void SetChanges()
        {
            TableName = Entry.Metadata.Relational().TableName;                     /*For EF Core 7: Entry.Metadata.GetTableName();*/
            foreach (PropertyEntry property in Entry.Properties)
            {
                string propertyName = property.Metadata.Name;
                string dbColumnName = property.Metadata.Relational().ColumnName;  /*For EF Core 7: property.Metadata.GetColumnName();*/

                if (property.Metadata.IsPrimaryKey())
                {
                    KeyValues[propertyName] = property.CurrentValue;
                    continue;
                }

                switch (Entry.State)
                {
                    case EntityState.Added:
                        NewValues[propertyName] = property.CurrentValue;
                        AuditType = AuditType.Create;
                        break;

                    case EntityState.Deleted:
                        OldValues[propertyName] = property.OriginalValue;
                        AuditType = AuditType.Delete;
                        break;

                    case EntityState.Modified:
                        if (property.IsModified)
                        {
                            ChangedColumns.Add(dbColumnName);

                            OldValues[propertyName] = property.OriginalValue;
                            NewValues[propertyName] = property.CurrentValue;
                            AuditType = AuditType.Update;
                        }
                        break;
                }
            }
        }

        public Audit ToAudit()
        {
            var audit = new Audit();
            audit.Id = Guid.NewGuid();
            audit.AuditDateTimeUtc = DateTime.UtcNow;
            audit.AuditType = AuditType.ToString();
            audit.AuditUser = AuditUser;
            audit.TableName = TableName;
            audit.KeyValues = JsonConvert.SerializeObject(KeyValues);
            audit.OldValues = OldValues.Count == 0 ? 
                              null : JsonConvert.SerializeObject(OldValues);
            audit.NewValues = NewValues.Count == 0 ? 
                              null : JsonConvert.SerializeObject(NewValues);
            audit.ChangedColumns = ChangedColumns.Count == 0 ? 
                                   null : JsonConvert.SerializeObject(ChangedColumns);

            return audit;
        }
    }
}

所有实体更改写入审计表

此帮助类使用 AuditEntry 类,并

  • 考虑来自当前 IAuditDbContext 的所有可能数据更改,创建 Audit 日志实体。
  • 将日志实体添加到日志表中
using Db.Table;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System.Collections.Generic;
using System.Linq;

namespace Db.Helper.AuditTrail
{
    class AuditHelper
    {
        readonly IAuditDbContext Db;

        public AuditHelper(IAuditDbContext db)
        {
            Db = db;
        }

        public void AddAuditLogs(string userName)
        {
            Db.ChangeTracker.DetectChanges();
            List<AuditEntry> auditEntries = new List<AuditEntry>();
            foreach (EntityEntry entry in Db.ChangeTracker.Entries())
            {
                if (entry.Entity is Audit || entry.State == EntityState.Detached || 
                    entry.State == EntityState.Unchanged)
                {
                    continue;
                }
                var auditEntry = new AuditEntry(entry, userName);
                auditEntries.Add(auditEntry);
            }

            if (auditEntries.Any())
            {
                var logs = auditEntries.Select(x => x.ToAudit());
                Db.Audit.AddRange(logs);
            }
        }
    }
}

在现有 DbContext 中使用审计日志

让我们创建一个继承自 IAuditDbContext 的接口 IMopDbContext,以创建 DbContext 对象。

using Db.Table;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System;

namespace Db
{
    public interface IMopDbContext : IAuditDbContext, IDisposable
    {
        DbSet<Role> Role { get; set; }

        DatabaseFacade Database { get; }
        int SaveChanges(string userName);
    }
}
  • DbSet<Role> Role { get; set; } 是一个现有数据表。
  • SaveChanges(string userName) 方法中,我们将使用 AuditHelper 类来创建 Audit 实体,考虑所有实体更改。然后,Audit 实体将被添加到审计日志表中。

创建 DbContext

在我们的现有/测试数据库上下文中,我们将

  • 添加审计表 DbSet<Audit> Audit { get; set; }
  • OnConfiguring(DbContextOptionsBuilder optionsBuilder) 方法中添加审计表配置 modelBuilder.ApplyConfiguration(new AuditConfig()),正如我之前提到的,这只是可选的。
  • 添加 SaveChanges(string userName) 方法来创建审计日志。
using System;
using Db.Table;
using Db.Configuration;
using Microsoft.EntityFrameworkCore;
using Db.Helper.AuditTrail;

namespace Db
{
    public abstract class MopDbContext : DbContext, IMopDbContext
    {
        public virtual DbSet<Audit> Audit { get; set; }
        public virtual DbSet<Role> Role { get; set; }

        public MopDbContext(DbContextOptions<MopDbContext> options)
            : base(options)
        {
        }

        protected MopDbContext() : base()
        {
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.ApplyConfiguration(new AuditConfig());
            modelBuilder.ApplyConfiguration(new RoleConfig());
        }

        public virtual int SaveChanges(string userName)
        {
            new AuditHelper(this).AddAuditLogs(userName);
            var result = SaveChanges();
            return result;
        }
    }
}

数据库:SQL Server

为进行测试,我们使用了 MS SQL Server 数据库,但它适用于任何数据库。

查找相关表对象的脚本如下。

创建对象

CREATE TABLE [dbo].[tbl_test_audit_trail] (
    [id]                 UNIQUEIDENTIFIER NOT NULL,
    [audit_datetime_utc] DATETIME2        NOT NULL,
    [audit_type]         NVARCHAR (50)    NOT NULL,
    [audit_user]         NVARCHAR (100)   NOT NULL,
    [table_name]         NVARCHAR (150)   NULL,
    [key_values]         NVARCHAR (250)   NULL,
    [old_values]         NVARCHAR (MAX)   NULL,
    [new_values]         NVARCHAR (MAX)   NULL,
    [changed_columns]    NVARCHAR (MAX)   NULL,
    PRIMARY KEY CLUSTERED ([id] ASC)
);

CREATE TABLE [dbo].[tbl_test_role] (
    [id]   INT           IDENTITY (1, 1) NOT NULL,
    [name] NVARCHAR (50) NOT NULL,
    [details] NVARCHAR (150) NULL,
    PRIMARY KEY CLUSTERED ([id] ASC)
);
  • [tbl_test_audit_trail] 将存储审计数据
  • [tbl_test_role] 一个简单的测试数据表

删除对象

DROP TABLE [dbo].[tbl_test_audit_trail]
DROP TABLE [dbo].[tbl_test_role]

使用 DbContext

在这里,我们使用 Entity Framework 执行 Insertupdatedelete 相关操作。我们不是调用默认的 SaveChanges(),而是使用 SaveChanges(string userName) 来创建审计日志。

IMopDbContext Db = new MopDb();
string user = "userName";

/*Insert*/
Role role = new Role()
{
    Name = "Role",
};
Db.Role.Add(role);
Db.SaveChanges(user);


/*Update detail column*/
role.Details = "Details";
Db.SaveChanges(user);
/*Update name column*/
role.Name = role.Name + "1";
Db.SaveChanges(user);
/*Update all columns*/
role.Name = "Role All";
role.Details = "Details All";
Db.SaveChanges(user);

/*Delete*/
Db.Role.Remove(role);
Db.SaveChanges(user);

让我们检查 [tbl_test_audit_trail],审计日志表,审计日志将是这样的

解决方案和项目

这是一个 **Visual Studio 2017** 解决方案,包含 **.NET Core 2.2** 项目

  • Db 包含数据库和 Entity Framework 相关代码
  • Test.Integration 包含集成的 NUnit 单元测试

Test.Integration 项目中,我们需要在 appsettings.json 中更改连接字符串。

  "ConnectionStrings": {
    /*test*/
    "MopDbConnection": "server=10.10.20.18\\DB03;database=TESTDB;
                        user id=TEST;password=dhaka" /*sql server*/
  },

参考文献

限制

  • 避免使用 DbContext.AutoDetectChangesEnabled = falseAsNoTracking()
  • 在使用此日志辅助程序时,如果我们添加/更新/删除 1 行,它会添加/更新/删除 2 行。Entity Framework 对于大量数据集处理不是很好。我们应该在处理完一定数量的行(例如 100-200 行)后重新初始化 DbContext 对象。
  • 此审计日志程序无法跟踪数据库生成的 `IDENTITY` 等值。这是可能的,但如果管理不当,可能会导致事务失败。请查看此 审计历史记录 文章了解该选项。
  • 我们存储的是类属性名,而不是实际的列名。
  • 性能可能是一个问题。

该代码对于未经测试的输入可能会抛出意外错误。如果有,请告诉我。

下一步?

  • 支持数据库生成的数值
  • 为 Entity Framework 创建同样的东西

历史

  • 2020年4月11日:初始版本
© . All rights reserved.