W poprzedniej części artykułu, pokazaliśmy m.in. nieudane próby osiągnięcia celu, jakim było ukrycie Enity Frameworka za warstwą całkowitej abstrakcji. W drugiej odsłonie artykułu pokażemy ostateczne przygotowania i samo rozwiązanie problemu.


Kuba Pilecki. Freelancer, Senior Software Engineer & Architekt, z ponad 15-letnim doświadczeniem w projektowaniu i programowaniu aplikacji oraz systemów IT. Posiada portfolio ponad 20 zrealizowanych projektów. Fan C# ale równie sprawnie porusza się w wielu innych językach i technologiach. Posiada rozległą wiedzę na temat nowoczesnych technologii, narzędzi i metodologii. Wyznaje zasadę, że nie ma rzeczy niemożliwych.


Przypominam główny cel projektu, który opisuję w artykule:

  • Żaden podprojekt solucji nie posiada referencji do Entity Framework-a, z wyjątkiem projektu oferującego warstwę abstrakcji nad EF;

Z tego wynikają szczegółowe ograniczenia i cele, podane poniżej:

  • Brak definicji właściwości typu DbSet<T> w zbiorczej klasie dziedziczącej po DbContext;
  • Brak klasy dziedziczącej z DbContext w ogóle;
  • Nadal jednak chcemy mieć jakąś klasę kontekstową, której obiekty możemy użyć do wyznaczania granic kontekstu operacji na bazie danych;
  • Używamy uniwersalnych atrybutów (System.ComponentModel.DataAnnotations) na klasach i właściwościach encji, w celu wymuszenia odpowiedniego kształtu tabel w bazie danych;

TL;DR

W załączniku do tego artykułu znajdziesz zip-a z demonstracyjną solucją dla .NET Core 🙂

Ostateczne przygotowania

Cofnijmy się do naszego prototypu, mieliśmy tam klasę SqlDataSourceFactory, a w niej metodę CreateContext(), zwracającą obiekt o interfejsie ISqlDataSource. Wstawiliśmy sobie tam zaślepkę, którą teraz będziemy wypełniać.

Na początek warto przyjąć trochę założeń. A więc:

  • chcemy mieć mechanizm (klasę), któremu podamy typy klas encji, a on nam zwróci zlepiony w locie obiekt dziedziczący z DbContext, który następnie podamy na nasz adapter EFDataSource;
  • musimy mieć jakiś sposób dodatkowego skonfigurowania tego DbContext-u, chociażby po to, żeby podać adres hosta bazy danych oraz login i hasło;
  • tworzenie Typu w locie, a później włączenie go do domeny aplikacji nie jest tanie zarówno pod względem obliczeniowym, jak i zapotrzebowania na pamięć operacyjną, nie chcemy zaśmiecać naszej domeny typami jednokrotnego użytku, musimy więc zaimplementować jakiś minimalny chociaż cache;
  • chcemy mieć możliwość zdefiniowania i użycia wielu różnych kontekstów, z różnymi encjami, w ramach jednej aplikacji;

Brzmi jak rozsądne minimum prawda? To lecimy. Na początek zajmijmy się kwestią cache-u, bo najprostsza (przynajmniej w tym prostym przykładzie). Po zmianach, które omówimy za momencik, nasza SqlDataSourceFactory wygląda następująco:

Trochę się pozmieniało, przejdźmy więc całą klasę po kolei. W konstruktorze robimy trzy rzeczy. Pierwsza jest prosta: przepisujemy do zmiennej podane typy encji.

Następnie generujemy i przypisujemy do zmiennej klucz, którym się posłużymy, aby sprawdzać czy w naszym cache-u mamy już typ dynamicznie tworzonego DbContextu dla podanej listy encji czy nie. Klucz ten generujemy w metodzie CreateCacheKey(Type[]), poprzez utworzenie skrótu z posortowanej listy pełnych nazw wszystkich podanych typów encji. Tutaj zwraca uwagę wykorzystanie niekonwencjonalnego algorytmu mieszającego. Zamiast wykorzystywać powszechny MD5 czy SHA1 zdecydowałem się na algorytm Murmur. Dlaczego?

Wydajność. Nie zależy mi na wybitnym bezpieczeństwie, ponieważ nie taki jest cel tych hashy, mają skrócić potencjalnie olbrzymi ciąg znaków (utworzony z wielu długich nazw klas) i mają to zrobić błyskawicznie. Stąd wykorzystanie Murmur-a, który został stworzony w tym właśnie celu. Teoretycznie mógłbym tu użyć CRC32 albo nawet CRC16, tyle że one są bardzo kolizyjne.

Ostatnia rzecz, która się dzieje w naszym konstruktorze to utworzenie obiektu DbContextOptions, na podstawie podanych danych logowania i adresu hosta bazy danych. W tym przykładzie używam PostgreSQL-a jako docelowej bazy danych, stąd taki, a nie inny wygląd connection stringa i w ogóle użycie metody UseNpgsql(string), jeżeli chcesz użyć innej bazy danych – to jest miejsce, w którym musisz zmienić kilka rzeczy.

Została nam do przeanalizowania metoda CreateContext(). Przede wszystkim wyjaśnijmy sobie, jak działa nasz cache. Do jego implementacji użyłem specjalizowanego słownika ConcurrentDictionary<,>, zdefiniowanego jako zmienna statyczna. Założyliśmy sobie wcześniej, że możemy mieć wiele różnych dynamicznych DbContext-ów w ramach jednej appki (stąd słownik) no i zakładamy również, że fabryki tych kontekstów mogą być tworzone na różnych wątkach i w różnych momentach działania programu (stąd słownik z konkurencyjnym dostępem). W praktyce raczej skłaniałbym się do singletonów albo konstruktorów statycznych, aby wymusić bezpieczeństwo wielowątkowe, nie wiemy jednak jak inni będą z naszego rozwiązania korzystali, więc lepiej podejść do sprawy z w miarę uniwersalnym mechanizmem.

Z naszego słownika, na podstawie wcześniej wyliczonego klucza, pobieramy typ dynamicznego DbContext-u. Jeżeli typ ten nie istnieje, tworzymy go w lambdzie, będącej drugim parametrem metody GetOrAdd(string, Func<string, Type>) i jest on następnie nam zwracany. W lambdzie tej tworzymy instancję klasy DbContextBuilder i wołamy jej metodę Build(Type[]), przekazując listę typów naszych encji.

Następnie używamy aktywatora, w celu utworzenia instancji obiektu naszego dynamicznego typu. Do przeładowanej wersji konstruktora DbContext przekazujemy utworzony wcześniej obiekt DbContextOptions, dzięki czemu wie on z jaką bazą danych się połączyć itd. Jeszcze tylko wywołujemy metodę EnsureCreated(), aby utworzyć tabele w bazie danych i zwracamy utworzony wcześniej adapter EFDataSource, zasilony właśnie stworzoną instancją DbContext-u.

Ok, w końcu zbliżamy się do brzegu. Do zrobienia został nam „tylko” wspomniany powyżej DbContextBuilder, a więc clou niniejszego artykułu.

Typy z Ikei, do samodzielnego montażu

DbContextBuilder będzie miał jedno i tylko jedno zadanie: stworzyć klasę, która:

  • dziedziczy z DbContext,
  • posiada jeden konstruktor, przyjmujący parametr typu DbContextOptions i wołający konstruktor bazowy w klasie DbContext, przekazując mu ten parametr,
  • posiada publiczne właściwości typu DbSet<T>, po jednej dla każdego typu podanych encji.

W praktyce więc zakładając, że mamy dwa typy encji o nazwach MyEntity i MyOtherEntity. Oczekujemy, że DbContextBuilder utworzy klasę, którą gdybyśmy robili ręcznie wyglądałaby następująco:

Początek jest prościutki:

W pierwszym kroku generujemy sobie losowy Guid, będziemy go doklejali do wielu nazw, w celu uniknięcia kolizji. Następnie używamy klasy AssemblyBuilder, z biblioteki System.Reflection.Emit, a konkretnie jej metody statycznej DefineDynamicAssembly (AssemblyName, AssemblyBuilderAccess). Dzięki temu zdefiniujemy naszą dynamiczną DLL-kę, wewnątrz której trzymany będzie nasz upragniony specjalizowany DbContext.

Zanim jednak przejdziemy do definiowania samego typu musimy jeszcze zdefiniować moduł, w którym go osadzimy. Robimy to poprzez wywołanie metody DefineDynamicModule(string), na otrzymanej w poprzednim kroku instancji AssemblyBuilder-a.

Wreszcie możemy utworzyć nasz typ! Służy do tego metoda DefineType(string, TypeAttributes, Type), którą znajdziemy na obiekcie klasy ModuleBuilder, otrzymanym w poprzednim kroku. Tutaj zatrzymajmy się na chwilę i rozpatrzmy parametry, jakie przekazaliśmy do tej metody:

  • Pierwszy oczywisty – nazwa naszego typu, używamy wygenerowanego wcześniej Guid-a poprzedzonego stałym ciągiem znaków (po pierwsze, żeby łatwiej odnaleźć ten typ w logach, po drugie, aby upewnić się, że nazwa typu jest poprawna, ponieważ Guid może zaczynać się od cyfry, co nie jest poprawną nazwą klasy);
  • Drugi argument to flagi atrybutów naszego typu, innymi słowy definiujemy naszą klasę jako publicznie dostępną (Public), ale blokujemy dziedziczenie z niej (Sealed), pozostałe flagi mówią interpreterowi CIL-u jak ma się z tą klasą obchodzić;
  • Ostatni argument to typ bazowy, w tym miejscu mówimy, że nasza klasa ma dziedziczyć z klasy DbContext Entity Framework-a.

No i fajnie, ale jeszcze musimy utworzyć sobie konstruktor, który przyjmuje jeden argument w postaci obiektu klasy DbContextOptions i puszcza go dalej do konstruktora klasy bazowej. Służy do tego metoda CreateConstructor(TypeBuilder), ale zanim do niej przejdziemy odpalmy wspomnianego wcześniej Msiler-a i spójrzmy, jak wygląda tego typu konstruktor w języku CIL. Posłużymy się wcześniej pokazaną klasą TestDbContext, która będzie doskonałą podstawą analizy tego, co chcemy uzyskać:

Idąc kolejno od góry dzieje się tutaj niewiele, ale jest to trochę inny rodzaj operacji niż ten, do którego przywykliśmy, przeanalizujmy więc kolejne linie:

  • Ldarg.0 – na stos maszyny wirtualnej .NET wkładamy argument „zerowy”, argumentem tym jest zwykle po prostu instancja naszego obiektu, więc jest to swego rodzaju odpowiednik „this” w C#;
  • Ldarg.1 – na stos odkładamy argument pierwszy, tym jest nasz obiekt DbContextOptions, przekazany jako argument do konstruktora;
  • Call (…) – oczywiste, wołamy konstruktor klasy bazowej, jako parametr zostanie przekazany tam element leżący wyżej na stosie, a więc nasz DbContextOptions;
  • Nop – czyli no-operation, są to puste cykle przetwarzania, zwykle wstawiane jako bufor w celu zajęcia czymkolwiek procesora;
  • Ret – wychodzimy z metody;

Jak to teraz odpowiednio wyemitować z poziomu C#-a? Dodajmy metodę CreateConstructor(TypeBuilder):

W pierwszych trzech liniach przygotowujemy podłoże do emisji kodu CIL konstruktora, definiujemy jakie będą jego argumenty (jeden), jakie będzie miał flagi (publiczny + trochę rzeczy dla kompilatora) oraz odnajdujemy bazowy konstruktor, który będziemy chcieli zawołać (pamiętajmy, że dziedziczymy z DbContext, więc ten konstruktor tam jest i mamy go w naszym TypeBuilderze).

Następnie uzyskujemy ILGenerator, pozwalający nam ręcznie układać kolejne rozkazy dla maszyny wirtualnej (schodzimy w końcu na poziom .NET-owego asemblera). I jak widać, w zasadzie powieliliśmy rozkazy, jakie zostały wygenerowane przez kompilator .NET dla naszej klasy testowej, którą przeglądaliśmy za pomocą Msilera trochę wcześniej.

Ok, ostatni krok, dodajmy nasze właściwości. Iterujemy po tablicy typów encji, które zostały przekazane do naszego DbContextBuilder-a i dla każdego z tych typów chcemy w naszej klasie utworzyć właściwość. W tym celu wołamy metodę CreateDbSetProperty(Type entityType, TypeBuilder typeBuilder), która jest jednocześnie najbardziej skomplikowanym elementem naszej układanki:

Żeby zrozumieć co tutaj się dzieje, przypomnijmy sobie jak działają właściwości w C#, weźmy taką:

Jest to właściwość automatyczna, która w trakcie kompilacji jest rozwijana do pełnej postaci:

Każda właściwość musi posiadać tzw. pole wspierające (ang. „backing field”), w praktyce także rozwijane są metody: zwracająca (get) oraz ustawiająca (set) wartość. Widzimy to, po przeanalizowaniu kodu CIL. Przede wszystkim znajdziemy tam dwie fizyczne metody:

Metoda pobierająca wartość jest prosta, kolejno:

  • Na stos wkładany jest argument „zerowy” (czyli, jak już ustaliliśmy „this”);
  • Następnie na stos wkładana jest wartość z pola wspierającego, utworzonego automatycznie, a więc posiadającego „dziwną” nazwę k__BackingField;
  • Następuje wyjście z metody, zwracany jest poprzedni element stosu.

Metoda ustalająca wartość również nie jest skomplikowana:

  • Na stos wkładany jest argument „zerowy”,
  • Na stos trafia argument („value”),
  • Ostatni element stosu jest ustawiany jako wartość pola k__BackingField;
  • Wyjście z metody, nie zwracamy niczego.

Aby ten sam efekt uzyskać przy pomocy emisji musimy najpierw utworzyć w naszej klasie pole wspierające, dzieje się to za pomocą metody DefineField(string, Type, FieldAttributes)Wcześniej jednak musimy zdefiniować typ tego pola (który później wykorzystamy również jako typ właściwości, typ zwracany przez metodę pobierającą wartość i przyjmowany przez metodę ustawiającą). W tym celu tworzymy w locie definicję typu generycznego, którego argumentem generycznym będzie typ naszej encji:

Co do nazwy właściwości to użyjemy bezpośrednio nazwy samego typu encji, dzięki temu Entity Framework utworzy tabele w bazie danych o tych samych nazwach. Wreszcie możemy zdefiniować także samą właściwość, używając metody DefineProperty(string, PropertyAttributes, Type, Type[]).

No dobrze, pozostało nam utworzyć metody pobierającą oraz ustalającą wartość właściwości. Tutaj korzystamy z metody DefineMethod(string, MethodAttributes, Type, Type[]), pamiętając o nadaniu odpowiednich nazw (przedrostki „get_” oraz „set_”). Jak widać na przedstawionym wcześniej listingu, ponownie korzystamy z ILGenerator-a i układamy kolejne komendy kodu CIL zgodnie z tym, co widzieliśmy w kodzie wygenerowanym dla naszej przykładowej klasy.

Ostatni krok to już formalność – używamy metod SetGetMethod oraz SetSetMethod na builderze właściwości, aby powiązać wcześniej utworzone metody.

I to tyle! Dopłynęliśmy 🙂 Cała nasza klasa DbContextBuilder prezentuje się następująco:

No dobrze, ale czy to działa?

Działa i to jeszcze jak. Zacznijmy od uruchomienia naszej przykładowej aplikacji, zatrzymania debugera i zobaczenia, jak wygląda nasz dynamicznie tworzony typ:

Na podglądzie widać, że typ istnieje i posiada dwie właściwości o nazwach „MyEntity” oraz „MyOtherEntity” o typach odpowiednio DbSet<MyEntity> oraz DbSet<MyOtherEntity>. O to chodziło.

A teraz zobaczmy czy tabele w bazie danych zostały poprawnie utworzone, czy są klucze i restrykcje. I tutaj sukces:

No i na końcu czy dane do bazy są poprawnie zapisywane i z niej pobierane:

Czyli wszystko działa jak należy!

Podsumowanie

Dzięki możliwości dynamicznego tworzenia typów byliśmy w stanie stworzyć prawie pełną warstwę abstrakcji dla Entity Framework-a. Mamy dynamiczną fabryczkę kontekstów, która jest w stanie fajnie wytworzyć nam odpowiednią klasę kontekstu EF, zależnie od listy typów encji, jakie jej podamy na wejściu.

Przy okazji pogrzebaliśmy trochę na jednym z najniższych poziomów całego .NET-a, zobaczyliśmy jak to, co na co dzień tworzymy w C# czy VB, jest kompilowane do języka pośredniego, strawnego dla maszyny wirtualnej. To jest bardzo potężna technika, za pomocą której możecie osiągnąć naprawdę ciekawe rzeczy i przeskoczyć bariery, które w innych sytuacjach są nie do przeskoczenia. Dzięki emisji własnych typów możecie w locie wygenerować dowolną klasę i od razu zacząć jej używać w waszym programie. Możecie oszukać frameworki (jak my tutaj), czy stworzyć wydajny cache (jak np. Razor kompilujący pliki .cshtml do kodu CIL).

Brakuje jednak jednego elementu w tym wszystkim – migracji. Nad tym jednak jeszcze muszę popracować i, o ile chcecie, opowiem o tym w następnym artykule. A może ktoś z Was grzebał już w generatorze migracji i komendach add-migration oraz update-database i mógłby się podzielić doświadczeniami w komentarzach?


Pierwsza część artykułu dostępna jest pod tym adresem. Na koniec obiecany zip z demonstracyjną solucją dla .NET Core.

Zapraszamy do dyskusji
Nie ma więcej wpisów

Send this to a friend