Wprowadzenie
Historia zmian danych w aplikacji wcześniej czy później pojawi się w każdym projekcie. Do tego problemu można podejść na różne sposoby – możemy samemu coś wymyślić lub skorzystać z czegoś gotowego. W przypadku gdy korzystamy z Entity Framework, możemy użyć mechanizmu audytu z darmowego rozszerzenia Entity Framework Plus. W dzisiejszym wpisie pokażę podstawy korzystania z tego mechanizmu, natomiast za tydzień rozbudujemy jego możliwości.
Problem
W tworzonych przez nas systemach bardzo często potrzebujemy wiedzieć, kto, kiedy i co zmienił. W zależności od potrzeb możemy przechowywać tylko informacje o użytkowniku, który dodał lub zmodyfikował dane, oraz kiedy dany użytkownik to zrobił. Z drugiej strony często potrzebujemy również wiedzieć, co się zmieniło.
Z jednej strony możemy skorzystać z dedykowanych do tego baz danych, jak na przykład Event Store (https://eventstore.org/), które mają takie funkcje z pudełka. Czasami chcemy zapisać te informacje w istniejącej bazie, bez konieczności jej zmiany. W przypadku gdy korzystamy z Entity Framework, dodanie tego mechanizmu jest relatywnie proste z rozszerzeniem Entity Framework Plus.
Zanim jednak przejdziemy do samej biblioteki, parę słów o testowym projekcie.
Utworzyłem prostą aplikację konsolową, która w bazie zapisuje informacje o produktach (w tym o kategorii, w jakiej się znajduje). Klasy modelu są bardzo proste i nieraz już gościły na blogu:
public class BaseModel | |
{ | |
public BaseModel() | |
{ | |
IsActive = true; | |
} | |
public int Id { get; set; } | |
public bool IsActive { get; set; } | |
} | |
public class Category : BaseModel | |
{ | |
public string Name { get; set; } | |
public virtual ICollection<Product> Products { get; set; } | |
} | |
public class Product : BaseModel | |
{ | |
public string Name { get; set; } | |
public int CategoryId { get; set; } | |
public virtual Category Category { get; set; } | |
} |
Początkowo obiekt kontekstu Entity Framework również jest standardowy:
public class DataContext : DbContext | |
{ | |
public DataContext() | |
: base("Name=DefaultConnection") | |
{ | |
} | |
public DbSet<Category> Categories { get; set; } | |
public DbSet<Product> Products { get; set; } | |
} |
Dodatkowo w projekcie włączony jest mechanizm migracji, który na końcu dodaje testowe dane z wykorzystaniem biblioteki Bogus.
W klasie program znajdują się trzy testowe metody, które posłużą nam do zapisania informacji o dodaniu, aktualizacji oraz usunięciu produktu. Później rozszerzymy ten kod o wyświetlanie informacji audytowych.
class Program | |
{ | |
static void Main(string[] args) | |
{ | |
int id = AddTest(); | |
EditTest(); | |
DeleteTest(); | |
} | |
static int AddTest() | |
{ | |
using (DataContext db = new DataContext()) | |
{ | |
var category = db.Categories.FirstOrDefault(); | |
var product = new Product() | |
{ | |
Name = "Product Name", | |
Category = category | |
}; | |
db.Products.Add(product); | |
db.SaveChanges(); | |
return product.Id; | |
} | |
} | |
static void EditTest() | |
{ | |
using (DataContext db = new DataContext()) | |
{ | |
var category = db.Categories | |
.OrderByDescending(c => c.Id) | |
.FirstOrDefault(); | |
var product = db.Products | |
.OrderByDescending(c => c.Id) | |
.FirstOrDefault(); | |
product.Category = category; | |
product.Name = "New Product Name"; | |
db.SaveChanges(); | |
} | |
} | |
static void DeleteTest() | |
{ | |
using (DataContext db = new DataContext()) | |
{ | |
var product = db.Products | |
.OrderByDescending(c => c.Id) | |
.FirstOrDefault(); | |
db.Products.Remove(product); | |
db.SaveChanges(); | |
} | |
} | |
} |
Audit
Mechanizm Audit z Entity Framework Plus bardzo ułatwia zapisywanie informacji o zmianach w danych. Aby z niego skorzystać, należy przede wszystkim zainstalować odpowiedni pakiet z Nugeta. Wspierana jest wersja 5 oraz 6 Entity Framework. Możemy zainstalować cały Entity Framework Plus (Z.EntityFramework.Plus.EF6) lub tylko pakiet dla samego audytu (Z.EntityFramework.Plus.Audit.EF6).
Audyt udostępnia dwa nowe typy, które posłużą do przechowywania zmian. AuditEntry zapisuje informacje o zmienionym obiekcie (typ, jaki stan, kto i kiedy). Natomiast AuditEntryProperty służy do zapisania informacji o zmianach w właściwościach obiektu (stara – nowa wartość). Dlatego po zainstalowaniu biblioteki musimy w obiekcie kontekstowym dodać dwie nowe właściwości dla tych dwóch typów.
Audyt umożliwia automatyczne dodawanie obiektów do zapisu historii. Aby to zadziałało, musimy zdefiniować statyczny delegat AutoSavePreAction z klasy AuditManager.DefaultConfiguration. Za pomocą niego możemy określić, dla których obiektów będziemy zapisywać historię (w przykładzie robimy to dla wszystkich obiektów). Ustawienie tego delegatu można zrobić w statycznym konstruktorze obiektu kontekstowego.
public class DataContext : DbContext | |
{ | |
static DataContext() | |
{ | |
AuditManager.DefaultConfiguration.AutoSavePreAction = (context, audit) => | |
(context as DataContext).AuditEntries.AddRange(audit.Entries); | |
} | |
public DataContext() | |
: base("Name=DefaultConnection") | |
{ | |
} | |
public DbSet<Category> Categories { get; set; } | |
public DbSet<Product> Products { get; set; } | |
public DbSet<AuditEntry> AuditEntries { get; set; } | |
public DbSet<AuditEntryProperty> AuditEntryProperties { get; set; } | |
} |
W przypadku gdy korzystamy z mechanizmu migracji, należy generować migrację, która doda odpowiednie tabele do bazy.
Mając już przygotowany obiekt kontekstowy, możemy zapisać dane w historii. Najprostszym sposobem jest utworzenie instancji klasy Audit, ustawienie nazwy aktualnie zalogowanego użytkownika i skorzystanie rozszerzonej wersji metody SaveChanges (dostarczonej przez bibliotekę). Poniżej znajduje się zmieniony kod testowej metody, która dodaje nowy produkt do bazy.
static int AddTest() | |
{ | |
using (DataContext db = new DataContext()) | |
{ | |
var category = db.Categories.FirstOrDefault(); | |
var product = new Product() | |
{ | |
Name = "Product Name", | |
Category = category | |
}; | |
db.Products.Add(product); | |
var audit = new Audit(); | |
audit.CreatedBy = "UserName"; | |
db.SaveChanges(audit); | |
return product.Id; | |
} | |
} |
Poniżej znajduje się zawartość tych dwóch dodatkowych tabel po wykonaniu się powyższej metody:
Jak widać, zmieniony kod spowodował zapis historii w nowych tabelach. Ale niestety takie podejście ma też minus. Musimy zmienić każde wywołanie metody SaveChanges, co niestety może wymagać zmian w wielu miejscach w kodzie. Na szczęście można to zrobić jeszcze trochę inaczej.
Nadpisanie SaveChanges
Innym sposobem zapisu historii zmian jest nadpisanie metody SaveChanges, które będzie automatycznie zapisywała historię. Takie rozwiązanie jest dużo lepsze, ponieważ wystarczy zmienić kod aplikacji w jednym miejscu, co może być szczególnie przydatne w przypadku już istniejącej aplikacji.
Poniżej znajduje się nowa wersja obiektu kontekstowego z nadpisaną metodą SaveChanges:
public class DataContext : DbContext | |
{ | |
static DataContext() | |
{ | |
AuditManager.DefaultConfiguration.AutoSavePreAction = (context, audit) => | |
(context as DataContext).AuditEntries.AddRange(audit.Entries); | |
} | |
public DataContext() | |
: base("Name=DefaultConnection") | |
{ | |
} | |
public DbSet<Category> Categories { get; set; } | |
public DbSet<Product> Products { get; set; } | |
public DbSet<AuditEntry> AuditEntries { get; set; } | |
public DbSet<AuditEntryProperty> AuditEntryProperties { get; set; } | |
public string UserName { get; set; } = "System"; | |
public override int SaveChanges() | |
{ | |
var audit = new Audit(); | |
audit.CreatedBy = UserName; | |
audit.PreSaveChanges(this); | |
var rowAffecteds = base.SaveChanges(); | |
audit.PostSaveChanges(); | |
if (audit.Configuration.AutoSavePreAction != null) | |
{ | |
audit.Configuration.AutoSavePreAction(this, audit); | |
base.SaveChanges(); | |
} | |
return rowAffecteds; | |
} | |
} |
W kodzie poza samą metodą pojawiał się jeszcze nowa właściwość o nazwie UserName. Posłuży nam ona do określenia nazwy aktualnie zalogowanego użytkownika. W realnej aplikacji będzie ona ustawiana przez kontener dependency injection (np. Autofac). W przykładzie ustawiamy domyślną wartość na System.
W metodzie SaveChanges na początku tworzymy, podobnie jak wcześniej, instancję klasy Audit i ustawiamy w niej nazwę użytkownika. Przed wywołaniem bazowej metody SaveChanges i po jej wywołaniu odpalamy metody na obiekcie klasy Audit (są one wykorzystywane przez dodatkowe funkcjonalności mechanizmu audytu).
Później sprawdzamy, czy ustawiony jest delegat dla automatycznego zapisu historii. Jeśli jest, to go odpalamy i później zapisujemy dane audytowe w bazie poprzez ponowne wywołanie metody SaveChanges z klasy bazowej.
Na samym końcu zwracamy z metody liczbę, którą przypisaliśmy do zmiennej lokalnej i w której znajduje się wartość zwrócona przez pierwsze wywołanie metody SaveChanges.
Pobieranie historii zmian
Na koniec pokażę Ci jeszcze, w jaki sposób pobrać historię zmian dla jakiegoś obiektu. W przykładzie w klasie Program znajduje się metoda ShowHistory, która przyjmuje id obiektu, dla którego ma zostać wyświetlona historia. W przykładzie jest to wartość zwrócona przez metodę AddTest i znajduje się tam id dodanego na początku obiektu.
static void ShowHistory(int id) | |
{ | |
using (DataContext db = new DataContext()) | |
{ | |
var histories = db.AuditEntries.Where<Product>(id); | |
foreach (var history in histories) | |
{ | |
Console.WriteLine($"State: {history.State}"); | |
foreach (var property in history.Properties) | |
{ | |
Console.WriteLine($"Propepty: {property.PropertyName}, OldValue: {property.OldValue}, NewValue: {property.NewValue}"); | |
} | |
Console.WriteLine(); | |
} | |
} | |
} |
Do wyświetlania historii możemy skorzystać z dodanej na początku wpisu właściwości AuditEntries. Możemy skorzystać z nowej wersji metody Where (dodanej jako extension method do typu DbSet<AuditEntry>), która jako parametr generyczny otrzymuje typ, dla jakiego chcemy pobrać historię. W normalnym parametrze przekazujemy id obiektu.
Mając już historię zmian, możemy po niej przejść i następnie dla każdej zmiany pobrać informacje o zmienionych właściwościach.
Poniżej znajduje się zrzut ekranu konsoli testowej aplikacji. Jak widać, historia danych została ładnie zapisana.
Przykład
Na githubie (https://github.com/danielplawgo/EFAudit) znajduje się przykład do tego wpisu. Po jego pobraniu należy w app.config ustawić connection string do testowej bazy danych. Po tym można już uruchomić aplikację, która doda, zedytuje oraz usunie produkt i na końcu wyświetli jego historię.
Aktualizacja: Przykład w tym momencie jest rozbudowanych o konfiguracje Audit, która jest opisane w wpisie: Konfiguracja Audit z Entity Framework Plus.
Podsumowanie
Historia zmian danych w aplikacji może się bardzo przydać. W przypadku gdy korzystamy z Entity Framework, w projekcie możemy wykorzystać mechanizm Audit z rozszerzenia Entity Framework Plus. Dzięki temu, pisząc tylko kilka linijek kodu, możemy zapisać historię zmiany danych w swojej aplikacji.
W kolejnym artykule pokazuje jak skonfigurować mechanizm Audit.
Mechanizm z EF+ całkiem przydatny, nawet już z niego korzystałem. Wspomniałeś, że AuditEntry może śledzić zmiany użytkownika, ale w samym artykule w zasadzie pokazane jest tylko trackowanie samych zmian encji, przydałby się jeszcze przykład jak spiąć trackowanie zmian z userem w celu śledzenia zmian konkretnego użytkownika Spojrzę jeszcze na github, może tam jest coś więcej.
@Adam to można zrobić na różne sposoby. Bardzo często po prostu każda z tabelek ma informacja o tym, kto ostatnio edytował ten rekord oraz go dodał. Wtedy te informacja będą od razu zapisywane w historii tak jak każda inna właściwość.
Chyba, że chodzi Ci o coś trochę innego?