Аудит или история изменений сущности в EntityFramework Core

Сайтостроение | создано: 23.05.2018 | опубликовано: 24.05.2018 | обновлено: 13.01.2024 | просмотров: 5634

Как часто вам необходимо знать, какие действия были произведены с определенной сущностью? Например, в какой момент поменялось значение какого-либо свойства?

Тема статьи

Иногда очень полезно хранить историю изменений сущности. Что это значит попробую объяснить на примере сущности Person. Допустим, вы написали программу, которую используте не только вы, но и еще пара-тройка человек, или может быть целый офис, где работает аж пять человек. Предположим, также, что все сотрудники имеют права на изменения сущностей Person в списке.

Пусть это будет, например, телефонный справочник, который могут все дополнять и править

Но вот, в какой-то момент, вы замечаете, что ваша фамилия в этом справочники нещадно исковеркана. Вместо положенной «Иванов» написано «Суходрищев» и при этом все остальные данные совпадают. Хочется найти эту сволочь и настучать по рукам не правда ли? А если учесть, что народу в офисе может быть очень много, то точно определить причастное лицо не представляется возможным. Обидно, да?

А теперь предположим, что в вашей программе присутствует система аудита изменений для записи Person. В таком случае кому-то точно не поздоровится, не правда ли?

В прошлом веке

Когда-то в стародавние времена… В общем, раньше использовались различные триггеры, которые отслеживали изменения в БД и «складывали» нужные данные (или всё подряд) в специально отведенные для этого таблицы. Время неумолимо идет вперед, и, как и следовало ожидать, такая полезная функциональность не могла не появиться на уровне SQL-сервера. Например, в современных версиях MS SQL Server существуют Temporal Tables или Track Data Changes (SQL Server).

Постановка задачи

Задача для этой статьи показать другой способ ведения аудита для конкретной сущностию. Итак, у меня есть сущность Person.

public class Person
{
    public int Id { get; set; }

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public Status Status { get; set; }
}

Изменения любого из свойств мне бы хотелось видеть в какой-нибудь таблице аудита. Надо упомянуть, что сделать это я хочу для EntityFramework Core (возьмем 2.0 версию) и, соответственно, ASP.NET Core 2.0.

Реализация

Создаем новый проект на ASP.NET Core 2.0. 

Далее Model Viewer Controller

Установливаем нужные nuget-пакеты:

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.8" />
  <PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.0.3" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.0.3" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="2.0.3" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="2.0.3" />
</ItemGroup>

Теперь создаем сущности Person, которую я показал выше и PersonLog, после чего можно создать ApplicationDbContext.

/// <summary>
/// // Calabonga: update summary (2018-05-23 10:29 PM PersonLog)
/// </summary>
public class PersonLog
{
    public int Id { get; set; }

    public string EntityName { get; set; }

    public DateTime OperatedAt { get; set; }

    public string KeyValues { get; set; }

    public string OldValues { get; set; }

    public string NewValues { get; set; }
}
public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {

    }

    public DbSet<Person> Persons { get; set; }

    public DbSet<PersonLog> PersonAudits { get; set; }

}

Не забудьте указать в файле appsettings.json строку подключения к базе данных:

Кстати, хочу заметить, что в EntityFramework Core не поддерживает подключение к MS SQL сервер через alias. Разработчики намерянно удалили эту возможность, мотивируя тем, что это привязка к конкретной платформе.

Теперь давайте добавим, еще одну сущность AuditEntry, которая будет использоваться для обработки данных и сохранения их в специальную таблицу.

public class AuditEntry
{
    public AuditEntry(EntityEntry entry)
    {
        Entry = entry;
    }

    public EntityEntry Entry { get; }
    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<PropertyEntry> TemporaryProperties { get; } = new List<PropertyEntry>();

    public bool HasTemporaryProperties => TemporaryProperties.Any();

    public PersonLog ToAudit()
    {
        return new PersonLog
        {
            EntityName = TableName,
            OperatedAt = DateTime.UtcNow,
            KeyValues  = JsonConvert.SerializeObject(KeyValues),
            OldValues  = OldValues.Count == 0 ? null : JsonConvert.SerializeObject(OldValues),
            NewValues  = NewValues.Count == 0 ? null : JsonConvert.SerializeObject(NewValues)
        };
    }
}

На первый взгляд может показаться очень сложно, но уверяю вас, что это только на первый взгляд. Суть сводится к тому, чтобы собрать все свойства у сущности, и значения этих свойств сериализовать в JSON-формат и сохранить в PersonLog.

SaveChanges

Перед тем как сделать что-то с базой данных, надо ее создать. Для этого я запустил команду создания миграции:

PM> Add-Migration Initial

После ее выполнения у меня появился новый класс. Далее команда Update-Database и... вуаля! У меня есть база данных:

Чтобы всё получилось, надо переопределить метод SaveChanges у нашего ApplicationDbContext наследника от DbContext (и, желательно, SaveChangesAsync тоже), в котором проделать некоторые операции.

public override int SaveChanges()
{
    var auditEntries = ActionBeforeSaveChanges();
    var result = base.SaveChanges();
    ActionAfterSaveChanges(auditEntries);
    return result;
}

И второй...

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

Ну, и наконец самое главное:

private List<AuditEntry> ActionBeforeSaveChanges()
{
    ChangeTracker.DetectChanges();
    var entries = new List<AuditEntry>();
    foreach (var entry in ChangeTracker.Entries())
    {
        if (entry.Entity is PersonLog || entry.State == EntityState.Detached || entry.State == EntityState.Unchanged)
        {
            continue;
        }

        var auditEntry = new AuditEntry(entry)
        {
            TableName = entry.Metadata.Relational().TableName
        };
        entries.Add(auditEntry);

        foreach (var property in entry.Properties)
        {
            if (property.IsTemporary)
            {
                auditEntry.TemporaryProperties.Add(property);
                continue;
            }

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

            switch (entry.State)
            {
                case EntityState.Added:
                    auditEntry.NewValues[propertyName] = property.CurrentValue;
                    break;

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

                case EntityState.Modified:
                    if (property.IsModified)
                    {
                        auditEntry.OldValues[propertyName] = property.OriginalValue;
                        auditEntry.NewValues[propertyName] = property.CurrentValue;
                    }
                    break;
                case EntityState.Detached:
                    break;
                case EntityState.Unchanged:
                    break;
            }
        }
    }

    foreach (var entry in entries.Where(_ => !_.HasTemporaryProperties))
    {
        PersonAudits.Add(entry.ToAudit());
    }

    return entries.Where(_ => _.HasTemporaryProperties).ToList();
}

В ActionBeforeSaveChange мы собираем все старые значения, которые были в сущности Person. А после того как сохраним новые данные в базу данных, собираем новые значения:

private Task ActionAfterSaveChanges(List<AuditEntry> auditEntries)
{
    if (auditEntries == null || auditEntries.Count == 0)
        return Task.CompletedTask;

    foreach (var entry in auditEntries)
    {
        foreach (var prop in entry.TemporaryProperties)
        {
            if (prop.Metadata.IsPrimaryKey())
            {
                entry.KeyValues[prop.Metadata.Name] = prop.CurrentValue;
            }
            else
            {
                entry.NewValues[prop.Metadata.Name] = prop.CurrentValue;
            }
        }

        PersonAudits.Add(entry.ToAudit());
    }

    return SaveChangesAsync();
}

Заключение

В заключении cначала код, который создает сущность Person, потом меняет, и потом удаляет. Приведу код всего контролера, который мне пришлось дописать:

public class HomeController : Controller
{
    private readonly ApplicationDbContext _context;

    public HomeController(ApplicationDbContext context)
    {
        _context = context;
    }

    public IActionResult Index()
    {
        var person= new Person
        {
            FirstName = $"FirstNamr {DateTime.Now}",
            LastName = $"LastName {DateTime.Now}",
            Status = Status.None
        };
        _context.Persons.Add(person);
        _context.SaveChanges();
        ViewData["Message"] = "Entity successfully CREATED";
        return View();
    }

    public IActionResult About()
    {
        var person = _context.Persons.First();
        person.FirstName = $"Changed {DateTime.Now}";
        _context.Update(person);
        _context.SaveChanges();
        ViewData["Message"] = "Entity successfully UPDATED";
        return View();
    }

    public IActionResult Contact()
    {
        var person = _context.Persons.First();
        _context.Remove(person);
        _context.SaveChanges();
        ViewData["Message"] = "Entity successfully DELETED";

        return View();
    }

    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

Я не использовал паттерн Repository для краткости, но вы в праве использовать его.

После того как я покликал по перечисленным выше страницам моя таблица Person не имеет записей.

А вот что у меня в таблице аудита.

Вот более детально

Мне кажется, это хороший результат. Далее с этими данными можно проделать уйму маниипуляций аналитического характера.

Желаю вам хороших проектов!