Dlaczego Entity Framework nie jest dobrym wyborem
Wiele razy rożni ludzie dziwili mi się, że “nie lubię Entity Frameworka”. Takie stwierdzenie nie tylko bardzo mija się z prawdą, ale wręcz jest zupełnie pozbawione sensu. Lubić można rosół z makaronem, komplementy i kolor niebieski, ale nie ORMa. To samo zresztą dotyczy innych bibliotek czy języków - technologia to jest coś, czego się używa albo i nie (w zależności od potrzeb, zastosowań i możliwości), nie obiekt uczuć. Co do EF zaś - mam po prostu bardzo wiele argumentów za tym, żeby go nie używać. Postaram sie je wymienić w tym krótkim wpisie.
Po co w ogóle używać ORM?
Tutaj są dwie szkoły:
- Aby automatycznie mapować obiekty na relacje (jak sama nazwa zresztą wskazuje) oraz odpytywać bazę danych przy użyciu obiektowego API.
- To samo, ale wydajnie, elastycznie, stabilnie, z dużą dozą automatyzacji, ale jednocześnie mając wpływ na to, co się dzieje, np. jakie zapytania są generowane.
Jak nietrudno się domyślić, ja należę do tej drugiej szkoły. Dlatego też wymagam od ORMa nieco więcej niż tylko wygenerowania kodu zapytań SQL i skonwertowania ich wyników na obiekty C#. Owszem, Entity Framework potrafi co prawda generować SQL (oj, naprawdę dużo tego SQL potrafi wygenerować!) i daje jakieś minimalne możliwości rozszerzania, ale jest naprawdę ubogi funkcjonalnie.
Podejście
To truizm, ale programowanie w języku obiektowym polega na utworzeniu obiektowego modelu jakiegoś zagadnienia. Nawet jeśli aplikacja ma korzystać z jakiegoś źródła danych, to i tak najpierw projektuje się i implementuje model biznesowy oraz struktury danych w języku, w którym powstaje aplikacja. Celem istnienia aplikacji jest przede wszystkim realizacja jakiejś logiki biznesowej, nie łączenie interfejsu użytkownika z bazą danych. A baza danych nie jest centrum aplikacji, lecz szczegółem implementacji. Oczywiście - trzeba mądrze wybrać RDBMS, dobrze go skonfigurować, świadomie używać i dbać o bazę, ale to nadal jest tylko miejsce przechowywania danych. Najważniejsza jest logika biznesowa zapisana w kodzie aplikacji.
Dawno temu takie podejście nie było możliwe. Nie było odpowiedniej technologii ani tak ekspresywnych języków obiektowych, jakie mamty teraz. Logika biznesowa była implementowana w bazach danych. To nie jest dobre podejście - kod SQL trudno refaktoryzować, trudno testować, słabo wspiera modularyzację, a przede wszystkim trzeba go napisać bardzo dużo w porównaniu z językami obiektowymi. Niestety, to podejście z dawnych czasów w wielu miejscach trwa do dzisiaj. Co gorsza, nowe pokolenia programistów są kształcone w przekonaniu, że tworzenie aplikacji zaczyna się od narysowania relacji i powiązań między nimi, a następnie napisania kodu DDL albo wyklikania w desigerze struktury bazy. Potem dopiero można zacząć pisać aplikację. Ten strukturalny sposób tworzenia oprogramowania jest nie tylko staromodny, ale też bardzo nieefektywny. Stosując go tracimy bardzo dużo czasu, chociażby przez utrudnione wprowadzanie zmian.
Na tym podejściu, zwanym database first
od początku swojego istnienia oparty był Entity Framework. Visual Studio 2008 zawierało designer, który pozwalał na przeciągnięcie tabelek z bazy MSSQL na obszar designera, co powodowało automatyczne wygenerowanie klas odpowiadających tabelom oraz ObjectContextu
.
Obiektowe podejście code first
zostało wprowadzone do EF z wersją 4.1, gdzieś w okolicach 2011 roku. Niemniej jednak, wciąż dla wielu programistów .NET to podejście jawi się jako nowe, niektórzy się go obawiają, i nawet korzystając z nowszych wersji EF stosują database first. No cóż, lata promowania aberracji zbierają swoje pokłosie.
Promowane przez EF database first
jest ogromną stratą czasu. Aby wprowadzić jakąkolwiek zmianę w srukturze danych, trzeba zmienić kod SQL (albo użyć designera), następnie odświeżyć model, modlić się, aby nic się nie posypało, wprowadzić poprawki, a potem spróbować skompilować kod.
Tymczasem NHibernate od początku był ORMem dla programistów. Jedynym dostępnym podejściem jest i było code first. Nie ma designerów, ani żadnych innych zaśmiecaczy. Idea jest prosta - piszemy kod modelu biznesowego, piszemy mapowania obiektów na relacje, bazę danych tworzy i aktualizuje ORM. Po to przecież używamy ORMów, żeby nie dłubać ręcznie w bazie danych!
Architektura
Każdy chyba wie (a przynajmniej słyszał), że jedną z podstawowych zasad tworzenia oprogramowania jest SRP. Zasada ta oznacza, że klasa musi mieć jedną odpowiedzialność. Np. klasa, której zadaniem jest odczyt danych z pliku, nie powinna wyświetlać tych danych na ekranie. Klasa, której celem jest konwersja z formatu wav na mp3, nie powinna jednocześnie wysyłać danych w sieć. A klasa, która służy do operowania na bazie danych, nie powinna być swoją fabryką i konfiguracją. Ups… Chyba jednak nie wszyscy słyszeli o SRP.
Klasa DbContext
w Entity Framework jest jednocześnie konfiguracją mapowań, jak i operującym na bazie generycznym repozytorium.
Dla porównania, w NHibernate mamy:
Configuration
- obiekt konfiguracji mapowań obiektowo relacyjnych, połączenia do bazy danych, wszelkich bajerów rozszerzających zachowanie ORMa.ISession
- główny obiekt, “tani” w tworzeniu, który daje generyczny dostęp do bazy danych i przeprowadzania na niej operacji CRUD.ISessionFactory
- fabryka sesji, twór, który wraca obiektISession
na podstawie ustalonej konfiguracji.
Oczywiście Configuration
tworzymy raz, przy starcie aplikacji, pojedyncza instancja ISessionFactory
żyje przez cały okres trwania aplikacji, a ISession
stanowi unit of work żyjący tak długo, jak wymaga tego aplikacja. Np. w przypadku aplikacji webowej będzie to w praktyce pojedynczy web request.
Co to wszystko daje? Dzięki dobrej architekturze, w NH możliwe są rzeczy, które są niewykonalne w EF, takie jak:
- Cache drugiego poziomu - czyli współdzielone dla wszystkich obiektów
ISession
. - Wydajność działania (chociaż EF ponoć juz naprawili, i nie odtwarza całej konfiguracji przy tworzeniu
DbContext
). - Łatwość testowania - interfejsy łatwo mockować w unit testach. W EF początkowo nie było w ogóle takiej możliwości, bo nie było interfejsów.
- Najczęściej używany przez nas obiekt, czyl
ISession
nie jest z zaśmiecony zbędnymi metodami jak to ma miejsce w przypadkuDbContext
. - Intuicyjnie wiadomo, co gdzie jest. Np. oczywiste jest, że metadane mapowania jakiejś klasy znajdziemy w
ISessionFactory
, a nie w jednej z miliona właściwościDbContext
.
Czego brakuje w Entity Frameworku
Tu już nie będzie opowiadania, lecz prosta wyliczanka:
Cache drugiego poziomu
Czyli cache wspólnego dla wszystkich kontenerów unit of work. Można do niego wczytać np. wszystkie słowniki, które nie zmieniają się przecież często podczas życia aplikacji. Pozwala to odciążyć bazę i zmniejszyć ruch między nią a aplikacją. W EF nie jest to chyba w ogóle możliwe do zaimplementowania (przez brak obiektu świadomego istnienia wszystkich kontekstów, odpowiednika ISessionFactory
).
Generowanie identyfikatorów…
Oczywiście to jest coś, co świetnie robi baza danych. Jasnym jest, że baza danych po prostu musi być w stanie nadać identyfikator nowemu rekordowi. Ale czemu mamy przerzucać do zadanie na bazę danych, kiedy korzystamy z ORMa?! Dlaczego nie mielibyśmy sobie wygenerować ID naszego rekordu po stronie aplikacji i nie zapisać całego, gotowego rekordu do bazy? Jeśli zapisujemy np. trzy powiązane ze sobą obiekty w ramach jednego UoW, to NHibernate wygeneruje trzy polecenia insert
, a EF musi dodatkowo po każdym z nich zapytać bazę o ID nowo utworzonego obiektu, żeby móc je przypisać do klucza obcego następnie zapisywanego obiektu. To strata czasu i zbędny narzut wydajnościowy.
…nawet Guidów
Jak wiadomo, Guid nie jest zbyt dobrym typem dla kluczy głównych. Losowość kolejnych jego wartości sprawia, że wstawianie nowych wierszy powoduje przebudowywanie indeksu klastrowanego tabeli (najczęściej zakładanego na kolumnie z kluczem głównym), a więc jest niewydajne. NHibernate rozwiązuje ten problem, gdyż posiada sekwencyjny generator ID typu Guid.
Batch insert
To wiąże się z poprzednim punktem. Ponieważ Entity Framework sam nie potrafi generować kluczy głównych, to i nie może generować wielu poleceń insert i wysłać je w pakiecie np. 100 na raz.
Różne typy kolekcji
W EF właściwości kolekcji deklaruje się jako ICollection<T>
. Nie można mieć listy, nie można mieć baga, nie można mieć tablicy, nie można mieć słownika. Tymczasem NHibernate pozwala na kolekcje będące implementacjami takich interfejsów:
System.Collections.Generic.ICollection<T>
System.Collections.Generic.IList<T>
System.Collections.Generic.IDictionary
System.Collections.Generic.ISet<T>
- lub własnego implementującego:
NHibernate.UserType.IUserCollectionType
.
Dynamiczne API zapytań
Jedyną możliwością odpytywania w Entity Framework jest LINQ. Nie ma co przeczyć, ten sposób jest bardzo przyjemny, każdy z nas zna to API i lubi lambdy, ale to nie znaczy, że LINQ rozwiązuje wszystkie problemy. W przypadku ORMa wręcz rodzi nowe. Prosty przykład - budowanie dynamicznych zapytań. Z webowego interejsu użytkownika dostajemy nazwy właściwości do pobrania/sortowania/filtrowania najczęściej w postaci ich nazw w zmiennych typu string
. Jak zbudować z nich zapytanie w Entity Frameworku? Nie da się. Tymczasem NHibernate ma Criteria API
, które pozwala na budowanie zapytań na podstawie nazw właściwości. Przykład z oficjalnej dokumentacji:
Generowane zapytania
Entity Framework konwertuje wyrażenia LINQ na kod SQL w całkowicie automagiczny sposób. Programista nie ma żadnego wpływu na to, jaki kształt będzie miało to zapytanie, jakie złączenia zostaną użyte, czy będą podzapytania, czy zostaną użyte funkcje agregujące… Po prostu nic. EF próbuje udawać, że coś takiego jak baza danych i język SQL nie istnieje, że nie istnieje algebra relacji, że nie trzeba myśleć o tym, jak się pobiera dane. Niestety, korzystając z bazy danych, nawet przez ORM trzeba myśleć, głównie o wydajności.
API NHibernate pozwalają na definiowanie typów joinów, podzapytań, korzystanie z funkcji agregujących, nawet stosowanie having
. Pisząc kod NHibernate można się spodziewać, jak będzie wyglądał wyjściowy SQL. Źródło poniższych przykładów.
Np. taki kod:
wygeneruje taki SQL:
A taki:
da w efekcie:
Zdarzenia
NHibernate ofertuje ponad 20 zdarzeń: insert
, update
, load
, delete
, flush
w wersji przed i po wykonaniu operacji, do tego zdarzenia dla kolekcji i właściwie wszystkich metod, które udostępnia ISession
(np. SaveOrUpdate
albo Merge
). Dzięki temu wystarczy się podpiąć i wykonać jakąś logikę przed lub po operacji wykonanej przez biliotekę. Za pomocą zdarzeń pre*
można też zastąpić domyślne zachowanie NHibernate.. Daje to ogromne możliwości rozszerzalności i automatyzacji pewnych zadań. Nie trzeba w tym celu budować ogromnych hierarchii klas opakowujących wywołania metod ORMa, aby osiągnać podobny efekt bez pomocy zdarzeń. Dzięki temu implementacja np. mechanizmu soft delete jest banalna. EF dla porównania oferuje całe dwa zdarzenia… Bieda. :(
Globalne filtry
Czyli warunek automatycznie dodawany do każdego zapytania o obiekt danego typu. Dla NHibernate nic niesamowitego, co już pokazywałem w poście o soft delete. O dziwo, w Entity Framework jest coś podobnego (oczywiście znacznie ograniczone, gdyż można jedynie stosować porównanie albo sprawdzenie czy dana wartość jest null czy not null), ale chyba nikt tego nie używa. Pracowałem w kilku firmach, nad różnymi projektami korzystającymi z EF, niemniej jednak nigdzie o tym nie słyszano. Obstawiam, że to przez nazwę - conditional mapping
, która kompletnie nic nie mówi odnośnie możliwości tego mechanizmu.
Optimistic concurrency
Entity Framework wspiera optimistic concurrency tylko na kolmnach typu rowversion
albo porównując wartość wszystkich kolumn rekordu. NHibernate oprócz tego pozwala na dosć intuicyjne rozwiązania takie jak użycie kolumny z kolejnym numerem wersji albo datą i czasem ostatniej modyfikacji.
Future queries
Ten mechanizm pozwala na wysłanie kilku oddzielnie zdefiniowanych zapytań do bazy jednocześnie. Użycie wygląda tak:
W tym przykładzie, oba zapytania zostaną wykonane dopiero w momencie wywołania ToList()
.
Przy okazji widzimy tu całkiem fajną metodę, której brak w EF - ToRowCountQuery
. Usuwa ona sortowanie i paginację z zapytania, a zamiast jego wyniku zwraca count
. Całkiem przydatna rzecz.
Query by example
Dzięki temu mechanizmowi możemy utworzyć wzorcowy obiekt (poprzez ustawienie właściwości na interesujące nas wartości), a następnie ORM wygeneruje na jego podtawie odpowiednie warunki where
. Na przykład:
Wyliczane właściwości
Ten mechanizm pozwala na definiowanie wyliczanych właściwości, ale nie na poziomie kodu C#, lecz poprzez odpowiednie wyrażenie SQL. Dzięki temu można np. dla pozycji faktury zdefiniować sobie wartość TotalPrice jako iloczyn ItemCount oraz ItemPrice, i używać jej w zapytaniach: .Where(x => x.TotalPrice > 5.0)
.
Lazy properties
Nie chodzi tu o powiązane obiekty, lecz właściwości BLOB/CLOB np. przechowujące jakiś duży tekst, dokument XML albo grafikę. W EF brak takiego mechanizmu.
Usuwanie kaskadowe
EF jak i NH pozwalają na usuwanie kaskadowe, ale NHibernate dodatkowo pozwala na ustawienie wartości null
dla klucza obcego u dzieci usuwanego rodzica.
Decydowanie o tym, kiedy zostaną zapisane zmiany w bazie danych
Entity Framework robi to po wywołaniu metody SubmitChanges()
. W NHibernate można skonfigurować, czy chcemy, aby działo się to:
- Automatycznie - np. gdy istnieją w sesji niezapisane obiekty danego typu, na którym jest wykonywane zapytanie.
- Przy każdym commicie aktywnej transakcji.
- Wyłącznie ręcznie, przy wywołaniu metody
Flush()
.
Rozszerzone możliwości transformowania wyników zapytania
W Entity Frameworku możemy za pomocą zapytania wybrać jedynie gotowe obiekty (albo ich projekcję za pomocą metody Select
). To jest niewątpliwie najbardziej potrzebny przypadek. Niemniej jednak czasem może zdarzyć się inna potrzeba, np. zwrócenia po prostu tablicy object
albo odrębnych obiektów z głównej tabeli, na której uruchomione zostało zapytanie, mimo że warunki zapytania dotyczyły tabeli dołączanej. Albo na użycie jakiegoś niestandardowego konstruktora danego typu. Więcej tutaj.
Pozostałe wady
Entity Framework wymaga nadmiarowych właściwości
Na przykład, aby zrealizować połączenie między klasami Invoice
a Customer
oprócz właściwości typu Customer
trzeba dodać także CustomerId
. To łamie DRY, SRP, i pozwala na idiotyczne błędy, gdy do Customer
przypisze się obiekt o innym ID niż wstawi do CustomerId
.
Do tego, jak widać, EF nie wspiera programowania obiektowego - trzeba operować na konceptach bazodanowych w rodzaju klucz obcy. Trochę się to kłóci z ideą całkowitego ukrycia operacji bazodanowych przed programistą.
Konieczne ręczne zarządzanie stanem obiektu
W tysiącach odpowiedzi na pytania dotyczące EF pojawia się taki kod:
Skoro często występuje taka konieczność, świadczy to o tym, że Entity Framework nie bardzo sobie radzi ze śledzeniem zmian.
EF nie jest w stanie określić pochodzenia obiektu.
Obiekt nieistniejący poza kontekstem nie moze zostać przypisany do obiektu w kontekście. W takim przypadku nastąpi próba zapisania go do bazy. Oczywiście rozwiązaniem jest:
Usuwanie “dzieci”
Jeśli usuniemy jakiś mapowany przez EF obiekt z kolekcji swojego rodzica, np. InvoiceItem
z Invoice.Items
i spróbujemy zapisać zmiany, dostaniemy za karę wyjątek:
“The relationship could not be changed because one or more of the foreign-key properties is non-nullable”
Po prostu, EF nie potrafi śledzić obiektów, więc nie wystarczy usunać obiektu z kolekcji, trzeba jeszcze to zrobić z kontekstu.
Powolny rozwój
Drugiego kwietnia 2012 r. Microsoft wypuścił MSSQL 2012. Dodano w nim m.in. nową konstrukcję offset
i fetch
, która znacząco ułatwia konstruowanie zapytań ze stronicowaniem. Entity Framework obsługuje tę konstrukcję od wersji 6.1.2, czyli od 22 grudnia 2014 roku. Ponad dwa i pół roku zajęło Microsoftowi dostosowanie jednego swojego produktu do drugiego…
Własna matematyka
To moja absolutnie ulubiona rzecz w EF. :)
Klasa ma właściwość typu decimal
a kolumna w bazie jest typu decimal(18,2)
. Jeśli ustawimy właściwości wartość np. 20.4777
, co się znajdzie w bazie po insercie? Oczywiście 20.47
. Dlaczego nie 20.48
, jak nakazuje matematyka? Tego nie wiadomo. Żeby było śmieszniej, w czystym ADO.NET działa to dobrze. A EF przecież z niego ponoć korzysta… Niestety nie mam pojęcia jak twórcy EF zhackowali działanie ADO.NET, że to przestało działać, ani kto to testował. ( Może ktoś z ZUS? ;)
Żeby było jeszcze śmieszniej, to nie zostało naprawione - ale w wersji 6.ileś dodano flagę włączającą prawidłowe zaokrąglanie.
Jakby nie patrzeć, żaden inny ORM nie daje możliwości wyboru między stosowaniem matematyki a jej ignorowaniem. Miłośnicy trójkątków o czeterech bokach i dzielenia przez zero powinni być szczęśliwi. ;)
Co ma Entity Framework, czego nie ma NHibernate
Entity Framework wspiera asynchronicznych operacje na bazie danych.
Oczywiście tylko na MSSQL. Wiele osób myśli, że magiczne słówko async
rozwiązuje automatycznie wszytkie problemy z wydajnością w systemie. Mnie zaś szczególnie zastanawia zysk z użycia tego w komunikacji z bazą danych. Operacje na bazie są zazwyczaj synchroniczne - pytamy o coś i oczekujemy danych, dopóki ich nie mamy, nasza aplikacja raczej nie ma co robić (w większości przypadków). Dużo więcej w kwestii wydajności może dać raczej batch insert czy future query niż asynchroniczność.
Lepsza dokumentacja i więcej tutoriali
Entity Framework jest dość dobrze opisany na znanym wszystkim programistom .NET MSDNie, jest też masa tutoriali z tym, co programiści lubią najbardziej - kolorowymi obrazkami. Źródeł wiedzy na temat NHibernate jest niestety znacznie mniej.
Więcej commitów niż NHibernate
Serio, niektórzy fanboje Entity Frameworka używają takiego argumentu! To tak jakby powiedzieć, że lepiej mieszkać w dopiero budowanym domu, bez prądu i wody, bo więcej pustaków się muruje. No ok, można i tak.
Nowy Entity Framework
Microsoft sam zdaje sobie sprawę, że Entity Framework to produkt, który trudno rozwijać. Zapewne narosło w nim już tyle warstw kodu spaghetti, że w pewnym momencie stwierdzono, że pora zacząć od nowa. Co ma swoje dobre i złe strony. Dobre są takie, że może będzie lepszy - chociaż po tym, co widzę, to niekoniecznie. Większość wad, które wymieniłem w tym poście zapewne będzie aktualna w nowej wersji. Złe strony są takie, że będą zapewne nowe błędy, i jeszcze mniejsza funkcjonalność, przynajmniej na początku. Poza tym, Microft zdecydował się na usunięcie designera do modelowania EF, co powoduje blady strach u jego miłośników.
Podsumowanie
To zapewne nie jest wszystko, tę listę można byłoby ciągnąć, pokazać więcej przykładów kodu… A jeszcze jakby wkejać przykłady absurdalnego kodu SQL generowanego przez EF, to miejsca na hostingu by zabrakło. Patrząc czasem w profilerze na wygenerowany przez EF kod SQL, nie sposób nie dojść do wniosku, że został on wygenerowany metodą Monte Carlo - zapytania były losowane tak długo, aż zwrócona lista kolumn przypomina strukturą obiekt żądany przez użytkownika. ;)
Ale do rzeczy… Mamy tu doskonały dowód na to, że ludzie nie używają tego, co jest lepsze, lecz tego, co jest lepiej reklamowane. Entity Framework jest w każdym tutorialu Microsoftu, więc ludzie uczący się dowolnej technologii (np. ASP.NET MVC albo WPF) siłą rzeczy go poznają. A potem używają, nawet nie zastanawiając się, czy są inne alternatywy. To jest właśnie coś, co odróżnia świat .NET od światów innych technologii, np. Javy. U nas ludzie przyspawują się do tego, co daje imperium z Redmont, programiści innych technologii są przyzwyczajeni do wyboru i korzystają z niego.
Entity Framework pozwala zrealizować podstawowe zadania ORMa i odciążyć programistę z najbardziej mozolnej części pracy, niemniej jednak jego nierozszerzalność, słaba wydajność i niemożliwość jej poprawienia sprawiają, że im większy projekt, tym bardziej utrudnia on życie. No, ale wiele osób nawet nie zdaje sobie z tego sprawy, bo są wystarczająco szczęśliwi, że nie muszą pisać SQL ręcznie i to, co daje EF im wystarcza. I w sumie nie ma się czemu dziwić - jeśli ktoś nigdy nie widział na oczy noża i całe życie kroi chleb łyżką, to skąd może wiedzieć, że nożem jest łatwiej?