Entity Framework – aktualizacja danych bez ich pobierania

Wprowadzenie

Dzisiejszy wpis jest inspirowany jednym z ostatnich code review, jakie robiłem. Zauważyłem, że gdy pracujemy z Entity Framework, często niektóre rzeczy zaczynamy robić nieefektywnie względem tego, jakbyśmy zrobili to chociażby w przypadku ADO.NET. Taką właśnie czynnością jest aktualizowanie oraz usuwanie obiektów z bazy.

Aktualizacja obiektu z jego pobraniem

Standardowo, gdy zaczynamy uczyć się pracować z Entity Framework, do aktualizacji obiektu możemy użyć mniej więcej takiego kodu:

using (DataContext db = new DataContext())
{
db.Database.Log = m => _logger.Info(m);
var product = db.Products.FirstOrDefault(p => p.Id == productId);
if (product == null)
{
return;
}
product.LockTime = DateTime.UtcNow;
db.SaveChanges();
}
view raw Program1.cs hosted with ❤ by GitHub

Po utworzeniu instancji obiektu kontekstowego za pomocą metody FirstOrDefault wyciągamy interesujący nas obiekt z bazy na podstawie klucza głównego (Id). Następnie aktualizujemy jego właściwości oraz wywołujemy metodę SaveChanges, które wykona stosowny update na bazie. Wynik wykonania powyższego fragmentu kody wygląda tak (jest to log wygenerowany przez Entity Framework – pierwsza linijka w using):

2018-06-30 07:44:29.6430 INFO Opened connection at 30.06.2018 07:44:29 +02:00
2018-06-30 07:44:29.6669 INFO SELECT TOP (1)
[Extent1].[Id] AS [Id],
[Extent1].[Name] AS [Name],
[Extent1].[LockTime] AS [LockTime],
[Extent1].[Count] AS [Count]
FROM [dbo].[Products] AS [Extent1]
WHERE [Extent1].[Id] = @p__linq__0
2018-06-30 07:44:29.6669 INFO
2018-06-30 07:44:29.6800 INFO -- p__linq__0: '2' (Type = Int32, IsNullable = false)
2018-06-30 07:44:29.6800 INFO -- Executing at 30.06.2018 07:44:29 +02:00
2018-06-30 07:44:29.7181 INFO -- Completed in 1 ms with result: SqlDataReader
2018-06-30 07:44:29.7181 INFO
2018-06-30 07:44:29.7340 INFO Closed connection at 30.06.2018 07:44:29 +02:00
2018-06-30 07:44:29.7811 INFO Opened connection at 30.06.2018 07:44:29 +02:00
2018-06-30 07:44:29.7895 INFO Started transaction at 30.06.2018 07:44:29 +02:00
2018-06-30 07:44:29.8758 INFO UPDATE [dbo].[Products]
SET [LockTime] = @0
WHERE ([Id] = @1)
2018-06-30 07:44:29.8830 INFO -- @0: '30.06.2018 05:44:29' (Type = DateTime2)
2018-06-30 07:44:29.8830 INFO -- @1: '2' (Type = Int32)
2018-06-30 07:44:29.9011 INFO -- Executing at 30.06.2018 07:44:29 +02:00
2018-06-30 07:44:29.9198 INFO -- Completed in 11 ms with result: 1
2018-06-30 07:44:29.9198 INFO
2018-06-30 07:44:29.9408 INFO Committed transaction at 30.06.2018 07:44:29 +02:00
2018-06-30 07:44:29.9490 INFO Closed connection at 30.06.2018 07:44:29 +02:00
view raw log1.txt hosted with ❤ by GitHub

Jak widać, w pierwszej kolejności na bazie wykonywany jest select i później update. W niektórych sytuacjach takie podejście może rzutować na działanie aplikacji. Przykładowo, gdy tworzymy funkcjonalność blokowania edycji danych (tylko jeden użytkownik może naraz je edytować), moglibyśmy co 30 sekund (albo jakąś inną wartość) z widoku wysyłać informację, że obiekt jest edytowany, i w bazie zapisywać aktualny czas. Następnie podczas wyświetlania formularza analizowalibyśmy ten czas i odpowiednio blokowali widok lub nie. W takiej sytuacji musimy zaktualizować jedno pole w bazie na podstawie Id obiektu, a inne pola nas nie interesują. Również nie interesuje nas poprzednia wartość tego pola, ważne, aby pojawiła się tam nowa wartość.

W takiej sytuacji pobieranie za każdym razem całego obiektu przy sporej liczbie użytkowników może dość mocno obciążyć bazę danych przez wykonywanie niepotrzebnych operacji. W klasycznym ADO.NET wykonalibyśmy prosty update bez żadnych selectów.

Aktualizacja obiektu bez jego pobierania

Na szczęście Entity Framework daje nam możliwość zaktualizowania danych bez ich wcześniejszego pobierania. Co istotne, aby ten mechanizm zadziałał, musimy bazować na kluczu głównym naszej encji (w tym przypadku Id). Poniżej zmieniony kod, który zaktualizuje rekord w bazie bez jego pobierania.

using (DataContext db = new DataContext())
{
db.Database.Log = m => _logger.Info(m);
var product = new Product()
{
Id = productId
};
db.Set<Product>().Attach(product);
product.LockTime = DateTime.UtcNow;
product.Count = 0;
db.SaveChanges();
}
view raw Program2.cs hosted with ❤ by GitHub

W pierwszej kolejności tworzymy nową instancję obiektu z ustawieniem klucza głównego (w przykładzie to Id). Następnie z wykorzystaniem metody Attach podpinamy utworzony obiekt do obiektu kontekstowego, który będzie teraz śledził zmiany. Dalsza część kodu jest taka sama, jak wcześniej – czyli zmiana właściwości oraz wywołanie metody SaveChanges. W logu wyraźnie widać, że znajduje się tam tylko update, bez select.

2018-06-30 07:58:28.9270 INFO Opened connection at 30.06.2018 07:58:28 +02:00
2018-06-30 07:58:28.9345 INFO Started transaction at 30.06.2018 07:58:28 +02:00
2018-06-30 07:58:28.9345 INFO UPDATE [dbo].[Products]
SET [LockTime] = @0
WHERE ([Id] = @1)
2018-06-30 07:58:28.9485 INFO -- @0: '30.06.2018 05:58:28' (Type = DateTime2)
2018-06-30 07:58:28.9485 INFO -- @1: '2' (Type = Int32)
2018-06-30 07:58:28.9607 INFO -- Executing at 30.06.2018 07:58:28 +02:00
2018-06-30 07:58:28.9607 INFO -- Completed in 0 ms with result: 1
2018-06-30 07:58:28.9607 INFO
2018-06-30 07:58:28.9876 INFO Committed transaction at 30.06.2018 07:58:28 +02:00
2018-06-30 07:58:28.9946 INFO Closed connection at 30.06.2018 07:58:28 +02:00
view raw log2.txt hosted with ❤ by GitHub

Powyższy fragment kodu nie jest do końca idealny i ma jeden problem. W wykonanym sqlu widać, że nastąpiła tylko aktualizacja kolumny LockTime, mimo że w kodzie aktualizowane są dwie właściwości. Dzieje się tak, ponieważ Entity Framework wykrywa zmiany tylko w momencie, gdy wartość jest różna od wartości domyślnej dla danego typu. W przykładzie Count jest wartością typu int, dlatego próba wyzerowania wartości w bazie się nie powiodła – warto o tym pamiętać. Na szczęście i na to jest rozwiązanie: trzeba dodać jedną linijkę dla właściwości:

using (DataContext db = new DataContext())
{
db.Database.Log = m => _logger.Info(m);
var product = new Product()
{
Id = productId
};
db.Set<Product>().Attach(product);
product.LockTime = DateTime.UtcNow;
product.Count = 0;
db.Entry(product).Property(p => p.Count).IsModified = true;
db.SaveChanges();
}
view raw Program3.cs hosted with ❤ by GitHub

Ręcznie informujemy Entity Framework, że właściwość została zaktualizowana, więc teraz w update będą zaktualizowane dwie kolumny:

2018-06-30 08:11:06.8940 INFO Opened connection at 30.06.2018 08:11:06 +02:00
2018-06-30 08:11:06.8999 INFO Started transaction at 30.06.2018 08:11:06 +02:00
2018-06-30 08:11:06.8999 INFO UPDATE [dbo].[Products]
SET [LockTime] = @0, [Count] = @1
WHERE ([Id] = @2)
2018-06-30 08:11:06.9149 INFO -- @0: '30.06.2018 06:11:06' (Type = DateTime2)
2018-06-30 08:11:06.9149 INFO -- @1: '0' (Type = Int32)
2018-06-30 08:11:06.9310 INFO -- @2: '2' (Type = Int32)
2018-06-30 08:11:06.9310 INFO -- Executing at 30.06.2018 08:11:06 +02:00
2018-06-30 08:11:06.9469 INFO -- Completed in 2 ms with result: 1
2018-06-30 08:11:06.9469 INFO
2018-06-30 08:11:06.9651 INFO Committed transaction at 30.06.2018 08:11:06 +02:00
2018-06-30 08:11:06.9651 INFO Closed connection at 30.06.2018 08:11:06 +02:00
view raw log3.txt hosted with ❤ by GitHub

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Usuwanie obiektu bez jego pobierania

Podobna sytuacja zaistnieje w przypadku usuwania obiektu – możemy ją wykonać bez pobierania encji. Rozwiązanie tego problemu jest bardzo podobne – tutaj też bazujemy na kluczu naszej encji:

using (DataContext db = new DataContext())
{
db.Database.Log = m => _logger.Info(m);
var product = new Product()
{
Id = productId
};
db.Entry(product).State = EntityState.Deleted;
db.SaveChanges();
}
view raw Program4.cs hosted with ❤ by GitHub

Sam log wykonania powyższego kodu wygląda tak:

2018-06-30 08:11:06.9930 INFO Opened connection at 30.06.2018 08:11:06 +02:00
2018-06-30 08:11:06.9930 INFO Started transaction at 30.06.2018 08:11:06 +02:00
2018-06-30 08:11:07.0100 INFO DELETE [dbo].[Products]
WHERE ([Id] = @0)
2018-06-30 08:11:07.0100 INFO
2018-06-30 08:11:07.0100 INFO -- @0: '4' (Type = Int32)
2018-06-30 08:11:07.0260 INFO -- Executing at 30.06.2018 08:11:07 +02:00
2018-06-30 08:11:07.0260 INFO -- Completed in 1 ms with result: 1
2018-06-30 08:11:07.0391 INFO
2018-06-30 08:11:07.0391 INFO Committed transaction at 30.06.2018 08:11:07 +02:00
view raw log4.txt hosted with ❤ by GitHub

W tym przypadku tym bardziej widać, jak niepotrzebne jest pobieranie obiektu w momencie, gdy chcemy go usunąć. Zachęcam do usuwania obiektu w ten sposób, bez wcześniejszego jego pobierania.

W obu przypadkach, gdy obiektu nie ma w bazie, to podczas wykonywania operacji nie otrzymamy błędu. Po prostu zapytanie wykona się, ale żadne dane nie zostaną zmienione lub usunięte z bazy.

Przykład

Na githubie (https://github.com/danielplawgo/UpdateInEntityFramework) znajduje się przykład pokazujący działanie tego mechanizmu. Aplikacja korzysta z mechanizmu migracji Entity Framework, w tym metody Seed, która dodaje testowy obiekt do bazy. Dlatego przed uruchomieniem aplikacji w app.config należy ustawić connection stringa do testowej bazy.

Aplikacja na końcu usuwa testowy obiekt, dlatego przed jej kolejnym uruchomieniem należy wykonać aktualizację bazy (update-database w konsoli nugeta), który wywoła metodę Seed przywracającą dane testowe.

Gorąco zachęcam do pobrania projektu oraz testów tego rozwiązania.

Podsumowanie

Jednym z najczęściej popełnianych błędów podczas pracy z Entity Framework jest zbyt częste pobieranie danych do aplikacji, w szczególności gdy chcemy zmienić tylko niektóre właściwości obiektu lub go usunąć. Takie podejście może wpłynąć negatywnie na działanie aplikacji, zwłaszcza gdy jest bardzo często wykonywane (patrz przykład z aktualizacją co 30 sekund informacji o tym, że obiekt jest aktualizowany).

Dlatego właśnie warto zwiększyć wydajność aplikacji poprzez zminimalizowanie liczby pobieranych obiektów do aplikacji podczas korzystania z Entity Framework. Oczywiście są również inne sposoby na wykonanie tych operacji efektywnie – na przykład skorzystanie z klasycznego ADO.NET. Ale to temat na inny wpis. 🙂

A w jaki sposób Ty aktualizujesz lub usuwasz dane w bazie, gdy korzystasz z Entity Framework w aplikacji?

9 thoughts on “Entity Framework – aktualizacja danych bez ich pobierania

  • Pingback: dotnetomaniak.pl
    • Dzięki za komentarz, nie znałem tej biblioteki, w wolnej chwili trzeba rzucić okiem.

      A co do wpisu to chciałem pokazać, że czasami prosta zmiana w kodzie, potrafi wpłynać na wydajność. A zauważyłem, że właśnie zbyt częste pobieranie obiektów (tak jak w wpisie) oraz nieużywanie metody Include są chyba najczęstszym problemem w pracy z Entity Framework.

  • Robię bardzo podobnie, z jednym małym wyjątkiem – zamiast korzystać z właściwości „IsModified” ustawiam przy zerowanych wartościach (i przy false dla typu bool) inne wartości.
    Przy takim podejściu, jednak zetknąłem się z problemem dotyczącym pola typu string.
    Załóżmy, że w opisywanym przykładzie tabela Product posiada jeszcze jedno pole typu string – „Name”. Załóżmy też, że wszystkie kolumny w tabeli są „Not null”. W tej sytuacji chcąc zmienić LockTime nie wystarczy utworzyć obiektu klasy Product ustawiając jedynie ID. Z mojego doświadczenia wynika, że db.SaveChanges() rzuci wyjątkiem mówiącym, że nie może zaakceptować pola „Name” z nullem (a taka będzie jego wartość przy utworzeniu pustego obiektu), bo w bazie jest to „not null”. Stanie się tak nawet w przypadku, gdy pola Name wcale nie chcemy aktualizować. Z tego, co zaobserwowałem, wyjątek jest wyrzucany nie ze względu na wykonane finalnie zapytanie SQL, ale dlatego, że przed wykonaniem polecenia EF dokonuje walidacji zgodności danych ze strukturą danej tabeli w bazie.
    Aby rozwiązać ten problem, wszystkie pola tekstowe które w bazie nie mogą być „null” należy ustawić co najmniej na string.Empty (albo cokolwiek innego). Osobiście ten problem rozwiązuję tak, że mam klasę pomocniczą, która generuje mi „puste” (ale zgodne z bazą) obiekty.

    • Ciekawe podejście, tylko zastanawiam się, czy na dłuższą metę nie jest to problematyczne? Ja mam do tego snippeta, więc dodanie tej linijki z IsModified nie jest dużym problemem.

      • Biorąc pod uwagę problemy ze stringami które muszą byc not null, nie jest dużym kłopotem ustawienie innych wartości domyślnych dla parametrów. Zazwyczaj mam klasę z sufixem „Factory” dla każdej schemy w bazie danych i ta klasa generuje mi „puste” obiekty, ustawiając przy okazji wartości dla pól, które nie mogą być inicjowane domyślnie. Prawie zawsze metoda tworząca taki obiekt oprócz ID przyjmuje jeszcze opcjonalną wartość dla pola IsDeleted – jako że nie uznąję polecenia DELETE na bazie danych, kasowanie informacji załatwiam przez UPDATE zmieniający status wiersza na skasowany.

    • Jeszcze nie miałem okazji pracować z EF w .NET Core (akturat w aktualnym projekcie używamy Dappera), ale z ciekawości w wolnej chwili sprawdę.

  • Biorąc pod uwagę problemy ze stringami które muszą byc not null, nie jest dużym kłopotem ustawienie innych wartości domyślnych dla parametrów. Zazwyczaj mam klasę z sufixem „Factory” dla każdej schemy w bazie danych i ta klasa generuje mi „puste” obiekty, ustawiając przy okazji wartości dla pól, które nie mogą być inicjowane domyślnie. Prawie zawsze metoda tworząca taki obiekt oprócz ID przyjmuje jeszcze opcjonalną wartość dla pola IsDeleted – jako że nie uznąję polecenia DELETE na bazie danych, kasowanie informacji załatwiam przez UPDATE zmieniający status wiersza na skasowany.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.