Аудит или история изменений сущности в EntityFramework Core
Просто о NET | создано: 23.05.2018 | опубликовано: 24.05.2018 | обновлено: 13.01.2024 | просмотров: 6739
Как часто вам необходимо знать, какие действия были произведены с определенной сущностью? Например, в какой момент поменялось значение какого-либо свойства?
Тема статьи
Иногда очень полезно хранить историю изменений сущности. Что это значит попробую объяснить на примере сущности 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 не имеет записей.
А вот что у меня в таблице аудита.
Вот более детально
Мне кажется, это хороший результат. Далее с этими данными можно проделать уйму маниипуляций аналитического характера.
Желаю вам хороших проектов!