data.mdx.frontmatter.hero_image

EF Core – Optimistic Concurrency

2020-11-20 | .NET | bd90

Utrzymując w miarę równe tempo pisania (podkreślając "w miarę"), postanowiłem kontynuować tematy dotyczące Entity Framework. Jeśli komuś się wydaje, że sporo już o tym napisałem, to ucieszę (albo zmartwię) Was - jeszcze mnóstwo wiedzy do przekazania, więc zapnijcie pasy bo kontynuujemy naszą podróż.

Większość aplikacji jest używana przez więcej niż jedną osobę. Zdarza się co prawda napisać szybką apke dla Pani Krysi z księgowości do przeliczania funduszy potrzebnych na owocowe czwartki. Jeśli to jest twój target to artykuł możesz pominąć. Dla bardziej ambitnych polecam zapoznać się z treścią, ponieważ opisałem jak za pomocą mechanizmu rowversion wprowadzić Optimistic Concurrency w naszych zapytaniach do serwera SqlServer.

Czym jest rowversion?

W bazie danych SqlServer rowversion jest niczym innym jak typem danych, który potrafi przechować 8 bajtów. Nie brzmi to zbyt wyjątkowo, co nie? Olbrzymia zaleta to to, że jego wartość jest automatycznie generowana przez bazę danych w momencie wprowadzenia bądź modyfikacji danych. Dlatego to właśnie rowversion jest najczęściej wykorzystywany do identyfikowania wersji wiersza w bazie danych.

Wsparcie EF Core dla rowversion

Entity Framework Core daje nam przyjemne w użyciu wsparcie tego mechanizmu. Przyjmijmy prosty scenariusz. Tworzymy program do zarządzania ludźmi w firmie. Zapewne mamy tam tabelkę Persons, gdzie są przechowywane podstawowe informacje. Dla uproszczenia załóżmy nawet, że to tylko i wyłącznie Id i Imię. Jako, że z naszym softem celujemy w wielkie firmy to Pani Krysia z HR nie jest osamotniona - ma młodego pomocnika Adriana. Właśnie przyszedł do nich e-mail z informacją o zmianie imienia przez jednego z pracowników. Oboje odczytali mail-a w tym samym momencie, zalogowali się do systemu i zaczęli nanosić zmiany. Nawet jeśli wysłali żądania do serwera w tym samym momencie ,to na samym końcu ktoś musi być pierwszy. Więc pojawia się pytanie co zrobić ze zmianami, które nie przecięły wstęgi na mecie?

  • Nadpisać treści wprowadzone przez pierwszą osobą?
  • Wrócić z informacją, że w między czasie dane się zmieniły i poprosić o sprawdzenie zmian?

Nie ma prostej odpowiedzi na to pytanie, bo zawsze będzie zależało od naszego przypadku biznesowego. Ponieważ jednak pierwszy przypadek nie jest zbyt interesujący uznajmy, że próbujemy poinformować użytkownika o pracy na starych danych.

Konfiguracja Modelu i Kontekstu

Dobra, dostajemy kod od ostatniej osoby, która pomagała Pani Krysi. Na początku, jak to w każdym systemie, musimy sprawdzić na czym pracujemy. Odpalamy projekt i widzimy bardzo podstawową konfigurację DBContext-u jak i obiekt dto odpowiadający schematowi bazy danych.

// Context
public class PersonsContext : DbContext
{
    public DbSet Persons { get; set; }
        
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    { 
        optionsBuilder.UseSqlServer(@"Server=localhost,1433;Database=Test;User Id=SA;Password=yourStrong(!)Password");
        optionsBuilder.UseLoggerFactory(MyLoggerFactory);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Person>()
           .HasKey(x => x.Id);
    }
        
    static readonly ILoggerFactory MyLoggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); });
}

// Entity
public class Person
{
    public int Id { get; set; }
    public string FirstName { get; set; }
}

Widzimy standardowy przykład konfiguracji, jakby wyciągnięty z przykładu z podręcznika. Na szczęście ktoś już zaczął używać fluent api do oddzielenia konfiguracji EF Core od samego modelu.

Dodanie rowversion do Modelu i kontekstu

No to zaczynamy implementacje naszego scenariusza. Przygotujmy model poprzez dodanie do niego dodatkowego propertis-a. Jak już pisałem wyżej będzie się tam znajdowało 8 bajtów danych. Tak więc jako typ w C# możemy użyć po prostu byte[]

public class Person
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    
    public byte[] TimeStamp { get; set; }
}

Następnie musimy powiedzieć naszej konfiguracji, że chcemy aby to pole stało się miejscem przechowywania rowversion. Możemy to zrobić za pomocą fleunt api tak, jak w poniższym przykładzie (lub, jeżeli ktoś nie korzysta z fluent api, za pomocą odpowiedniego atrybutu).

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    //...
    modelBuilder.Entity<Person>()
        .Property(x => x.TimeStamp)
        .IsRowVersion();
}

Po zmianach i zrobieniu migracji w bazie danych, EF Core zacznie używać tego pola przy zapytaniach do tabeli Persons

Microsoft.EntityFrameworkCore.Database.Command[20101] Executed DbCommand (2ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (Size = 4000), @p2='?' (Size = 8) (DbType = Binary)], CommandType='Text', CommandTimeout='30']SET NOCOUNT ON; UPDATE [Persons] SET [FirstName] = @p0 WHERE [Id] = @p1 AND [TimeStamp] = @p2; SELECT [TimeStamp] FROM [Persons] WHERE @@ROWCOUNT = 1 AND [Id] = @p1;

Oczywiście, jeżeli nie chcemy aby w naszym modelu był propertis RowVersion , możemy użyć mechanizmu ShadowProperty

private static readonly string RowVersion = nameof(RowVersion);

protected override void OnModelCreating(ModelBuilder modelBuilder) { 
    //... 
    modelBuilder.Entity<Person>() 
       .Property<byte[]>(RowVersion) 
       .IsRowVersion(); 
}

Obsługa DBUpdateConcurrencyException

Ostatnim elmentem, który pozostał do zakodowania, jest obsługa wyjątków - DbUpdateConcurrencyException. Bez tego będziemy mogli zobaczyć "piękny" błąd:

Database operation expected to affect 1 row(s) but actually affected 0 row(s). Data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.

Nie ma tu wielkiej magi, wystarczy nam stary dobry blok try/catch

var person = personsContext.Persons.Find(1);
try
{
    person.FirstName = "Maciej";

    personsContext.Persons.Update(person);
    personsContext.SaveChanges();
}
catch (DbUpdateConcurrencyException exception)
{
    Console.WriteLine(exception.Message);
}

Zapewne samo wypisanie błędu czy jego zapisanie ciężko nazwać wystarczającą obsługą. Tak więc możemy wykorzystać inne możliwości EF Core, jak "poproszenie" aby ORM odświeżył dane w encjach, których dotyczył ten błąd.

catch (DbUpdateConcurrencyException exception)
{
    // ...
    foreach (var entry in exception.Entries)
    {
        entry.OriginalValues.SetValues(entry.GetDatabaseValues());
    }
}

Na koniec zwracamy nowe informacje użytkownikowi, aby przejrzał zmieniony wiersz w naszej bazie danych.

Podsumowanie

Na koniec chciałbym jeszcze dodać, że nie jest to jedyny sposób na rozwiązywanie podobnych problemów. Możemy sami stworzyć nasz Concurrency Token. W ten sposób będziemy mieli większa władzę nad ich generowaniem, jednak baza danych nie będzie przeliczała tego pola w momencie otwarcia klienta bazy danych i zmiany danych bezpośrednio w wierszu.

Mam nadzieje że artykuł się podobał :) Do Następnego!

Referencje

By Bd90 | 20-11-2020 | .NET