С плюсами и минусами.
В этой статье мы рассмотрим 3 различных варианта, которые разработчики могут использовать для реализации функций аудита для сущностей предметной области (CreatedAt
, ModifiedAt
свойств/столбцов) в своих приложениях с помощью Entity Framework Core. Эта функциональность широко используется в приложениях .NET и сообщает, когда объект был создан или изменен (но не сообщает вам, какое свойство было изменено или старое/новое значение этого свойства).
Определение примера
Давайте определим пример, который мы позже расширим функциональностью аудита.
Вот простая модель домена User
:
public class User { public int Id { get; set; } public string Email { get; set; } }
Теперь нам нужно реализовать DbContext
:
public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<User> Users { get; set; } }
Теперь давайте реализуем репозиторий для User
сущностей с двумя основными операциями для сохранения пользователя и изменения электронной почты:
public interface IUsersRepository { Task Create(User user); Task ChangeEmail(int userId, string email); } public class UsersRepository : IUsersRepository { private AppDbContext _appDbContext; public UsersRepository(AppDbContext appDbContext) => _appDbContext = appDbContext; public async Task Create(User user) { _appDbContext.Add(user); await _appDbContext.SaveChangesAsync(); } public async Task ChangeEmail(int userId, string email) { var user = _appDbContext.Users.SingleOrDefault(x => x.Id == userId); user.Email = email; await _appDbContext.SaveChangesAsync(); } }
Последний шаг — зарегистрировать все компоненты в классе Program
:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped<IUsersRepository, UsersRepository>(); builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) => { options.UseInMemoryDatabase("inMemoryDatabase"); });
Вот и все. Наш довольно типичный код готов, позволяя нам сохранить пользователя в базе данных и изменить его электронную почту. Однако этот код не отслеживает время этих операций. Давайте посмотрим, что мы можем с этим сделать в следующих главах.
👉 Поскольку вы дочитали до этого места, вам интересна тема репозиториев. Так что позвольте мне порекомендовать еще одну мою статью на эту тему:
#1 Наивная реализация аудита
Итак, у нас есть требование реализовать функцию аудита для нашего небольшого приложения, которое пока содержит только одну модель предметной области и один репозиторий.
Первым шагом может быть расширение модели User
двумя новыми столбцами CreatedAt
и ModifiedAt
:
public class User { public int Id { get; set; } public string Email { get; set; } public DateTime CreatedAt { get; set; } public DateTime? ModifiedAt { get; set; } }
После расширения модели новыми столбцами мы можем внести изменения в репозиторий. Метод Create
должен установить для свойства CreatedAt
текущую дату, а метод ChangeEmail
должен установить ModifiedAt
.
public class UsersRepository : IUsersRepository { private AppDbContext _appDbContext; public UsersRepository(AppDbContext appDbContext) => _appDbContext = appDbContext; public async Task Create(User user) { user.CreatedAt = DateTime.UtcNow; //Set the current date _appDbContext.Add(user); await _appDbContext.SaveChangesAsync(); } public async Task ChangeEmail(int userId, string email) { var user = _appDbContext.Users.SingleOrDefault(x => x.Id == userId); user.Email = email; user.ModifiedAt = DateTime.UtcNow; //Set the current date await _appDbContext.SaveChangesAsync(); } }
Этот подход решает нашу проблему сейчас, но он не является действительно расширяемым и ремонтопригодным. У него как минимум две проблемы:
- Когда возникает необходимость сделать то же самое для другого объекта, например
Product
, разработчик может назвать свойства по-разному:ChangedOn
,LastEdited
,LastEditedUtc
и т. д. Это приведет к несогласованности имен свойств, а также имен столбцов в базе данных. - Каждый метод репозитория отвечает за установку свойств аудита на некоторое значение. Это может привести к несоответствиям, т.е. вы можете установить UTC в одном репозитории и только местное время в другом. Кроме того, легко случайно вообще пропустить инициализацию.
Давайте посмотрим, как мы можем преодолеть эти проблемы дальше.
#2 Переопределение метода SaveChanges
Реализация аудита должна быть намного проще в сопровождении, чем приведенный выше пример для реальных проектов. Для этого нам нужно ввести интерфейс IAuditableEntity
, который будет включать свойства аудита:
public interface IAuditableEntity { public DateTime CreatedAt { get; set; } public DateTime? ModifiedAt { get; set; } }
Обратите внимание, что это не обязательно должен быть интерфейс, вы также можете использовать абстрактный класс. Извлечение свойств в интерфейс или абстрактный класс позволит нам сделать их имена согласованными во всех объектах предметной области.
Следующим шагом является реализация этого интерфейса для User
и любых других сущностей, которые должны иметь столбцы аудита в базе данных:
public class User : IAuditableEntity { public int Id { get; set; } public string Email { get; set; } public DateTime CreatedAt { get; set; } public DateTime? ModifiedAt { get; set; } }
Последний прием — переопределить метод SaveChangesAsync
в классе AppDbContext
и реализовать его следующим образом:
public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<User> Users { get; set; } public override Task<int> SaveChangesAsync (CancellationToken cancellationToken = default) { var entries = ChangeTracker.Entries<IAuditableEntity>(); foreach (var entry in entries) { if (entry.State == EntityState.Added) { entry.Entity.CreatedAt = DateTime.UtcNow; } if (entry.State == EntityState.Modified) { entry.Entity.ModifiedAt = DateTime.UtcNow; } } return base.SaveChangesAsync(cancellationToken); } }
Реализация довольно проста. При сохранении изменений он извлекает все объекты, реализующие интерфейс IAuditableEntity
, и устанавливает свойства, подлежащие аудиту, на основе состояния объекта перед сохранением изменений в базе данных.
Обратите внимание, что это единственное место в приложении, где можно задать свойства аудита, больше нет необходимости делать это в репозиториях. Таким образом, в основном все проблемы, которые у нас были с наивной реализацией, решены. Однако есть еще один элегантный способ, который мы можем использовать вместо переопределения метода SaveChangesAsync
.
# 3 Реализация перехватчика
Перехватчики Entity Framework Core можно использовать для реализации различных задач, таких как изменение SQL-запроса перед его отправкой в базу данных, кэширование и, конечно же, аудит.
Первое, что нужно сделать, это разработать перехватчик с той же логикой, которая была у нас ранее в переопределенном методе SaveChangesAsync
:
public class AuditableEntitiesInterceptor : SaveChangesInterceptor { public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { if (eventData.Context is null) { return base.SavingChangesAsync(eventData, result, cancellationToken); } var entries = eventData.Context.ChangeTracker.Entries<IAuditableEntity>(); foreach (var entry in entries) { if (entry.State == EntityState.Added) { entry.Entity.CreatedAt = DateTime.UtcNow; } if (entry.State == EntityState.Modified) { entry.Entity.ModifiedAt = DateTime.UtcNow; } } return base.SavingChangesAsync(eventData, result, cancellationToken); } }
Обратите внимание, что наш пользовательский перехватчик унаследован от SaveChangesInterceptor
, предоставленного EF Core.
Также помните, что базовый класс предоставляет два метода с очень похожими именами SavingChangesAsync
и SavedChangesAsync
. В целях аудита нам нужно использовать метод SavingChangesAsync
, потому что он вызывается перед сохранением изменений в базе данных.
Затем мы должны зарегистрировать перехватчик в DI-контейнере и прикрепить его к классу DbContext
:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped<IUsersRepository, UsersRepository>(); builder.Services.AddSingleton<AuditableEntitiesInterceptor>(); //DI registration builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) => { var interceptor = serviceProvider .GetService<AuditableEntitiesInterceptor>(); options .UseInMemoryDatabase("inMemoryDatabase") .AddInterceptors(interceptor); //Attaching it to the DB context });
Вот и все. Для меня подход с перехватчиком лучше с точки зрения чистого кода, потому что логика аудита инкапсулирована в отдельный класс, и ваш AppDbContext
не будет расти как в примере №2.
Заключение
Мы рассмотрели три различных способа реализации основных функций аудита в проектах .NET с помощью Entity Framework Core. Прежде чем применять одно из доступных решений, посмотрите, что лучше всего работает в вашем конкретном случае.
Спасибо за прочтение. Если вам понравилось то, что вы прочитали, ознакомьтесь с этой историей ниже:
Кроме того, подумайте о том, чтобы стать Medium Member.
Спасибо, что являетесь частью нашего сообщества! Перед тем, как ты уйдешь:
- 👏 Хлопайте за историю и подписывайтесь на автора 👉
- 📰 Смотрите больше контента в публикации Level Up Coding
- 💰 Бесплатный курс собеседования по программированию ⇒ Просмотреть курс
- 🔔 Подписывайтесь на нас: Twitter | ЛинкедИн | "Новостная рассылка"
🚀👉 Присоединяйтесь к коллективу талантов Level Up и найдите прекрасную работу