Architektura bibliotek rozszerzeń platformy Common Data Service

Intro

W dzisiejszym odcinku cyklu poświęconego wzorcom projektowym przyjrzymy się możliwym sposobom organizacji kodu rozszerzeń platformy Common Data Service (CDS) oraz systemu Dynamics 365 Customer Engagement. Przeanalizujemy stosowane podejścia zarówno pod kątem architektury logicznej, jak i fizycznej. Spróbujemy wyjaśnić sobie, czym naprawdę powinien być legendarny „plugin”, oraz w jaki sposób możemy go zaimplementować oraz zainstalować na środowisku docelowym.

Odpowiedzialności rozszerzeń

Na początku przyjrzyjmy się 2 poniższym nazwom klas, które implementują interfejs IPlugin:

  • AccountPreCreatePlugin
  • SetRecordNamePlugin

Jak zapewne się domyślacie, ww. rozszerzenia prezentują 2 zupełnie różne podejścia do tworzenia rozszerzeń platformy CDS. W pierwszym przypadku (AccountPreCreatePlugin) każdy etap zdarzenia, które zachodzi w systemie (np. utworzenie klienta), posiada osobną, obsługującą go klasę. Wyróżnia się również osobne klasy obsługujące etap zdarzenia dla danej encji, z których pierwsza obsługuje go w sposób synchroniczny, a druga w sposób asynchroniczny. Często spotykaną metodą jest wówczas dodanie sufiksu „Async” w nazwie klasy (np. AccountPostCreatePluginAsync).  

Stosując metodę dekompozycji możemy wyróżnić następujące elementy obecne w nazwie klasy:

NazwaEncji EtapWykonania Zdarzenie Plugin [Async]

Oczywiście powyższa konwencja nazewnicza jest jedynie przykładem, który możemy wykorzystać w naszym wdrożeniu. Ważną kwestią jest natomiast konsekwencja. Jeżeli zdecydujemy się na wykorzystanie określonego sposobu tworzenia rozszerzeń (i związanej z nim konwencji nazewniczej) – trzymajmy się go. Konsekwentne stosowanie danej konwencji zagwarantuje nam spójność implementowanego rozwiązania oraz czytelność kodu.

Drugi w zaprezentowanych przykładów (SetRecordNamePlugin) prezentuje odmienne podejście. W tym przypadku tworzymy klasy rozszerzeń, które mogą być używane do obsługi wielu zdarzeń w systemie. W powyższym przykładzie zaprezentowałem nazwę klasy, która mogłaby służyć do automatycznego uzupełniania nazwy (lub podstawowego atrybutu encji) na podstawie zdefiniowanych oraz zaimplementowanych kryteriów. Teoretycznie tego typu plugin zaopatrzony w odpowiednią konfigurację (do przekazywania której wykorzystuje się często atrybuty konstruktora naszego rozszerzenia: „secure configuration” lub „unsecure configuration) powinien umożliwiać obsługę dowolnego zdarzenia w systemie. W praktyce często spotyka się różnego rodzaju zabezpieczenie polegające np. na sprawdzaniu w kodzie kontekstu wykonania oraz wyrzucaniu wyjątku lub zakończeniu wykonywania kodu rozszerzenia, jeżeli zostanie ono zarejestrowane w miejscu, którego nie przewidzieli jego twórcy (np. obiekt SetRecordNamePlugin obsługujący zdarzenie Delete).

Osobiście skłaniam się bardziej ku pierwszemu z zaprezentowanych podejść. W podejściu tym obiekt rozszerzenia odpowiada jedynie za uruchomienie kodu zaimplementowanego wewnątrz innych klas (np. komendach, serwisach aplikacyjnych lub domenowych).  Generyczne pluginy mają w moim przekonaniu skłonności do nadmiernego rozrastania się, co wiąże się ze wzrostem skomplikowania oraz przejrzystości kodu. Nie oznacza to oczywiście, ze zastosowanie tej metody skazuje nas z góry na niepowodzenie. Zdarzało mi się uczestniczyć w projektach, w których było one stosowane i które kończyły się sukcesem. Natomiast dużo większą wagę musimy w tym przypadku przyłożyć do procedur związanych z jakością dostarczanego rozwiązania, np. okresowych przeglądów. Jest to „must have” zwłaszcza w przypadku, kiedy posiadamy w zespole programistów nieposiadających doświadczeń w pisaniu kodu uruchamianego wewnątrz platformy Common Data Service lub też, co z uwagi na ciągły niedobór pracowników w sektorze IT często się zdarza, nieposiadających doświadczenie w pisaniu kodu w ogóle 😉.

Architektura fizyczna

Kolejną kwestią, którą chciałbym poruszyć, jest sposób „spakowania” naszego kodu do postaci biblioteki (.NET assembly). Każdy programista Dynamics 365 wie o tym, że DLL-ki reprezentujące nasze rozszerzenia niespecjalnie potrafią skorzystać z innych znajdujących się na serwerze bibliotek. O ile w środowisku on-premise istnieją pewne techniki, które pozwalają obejść to ograniczenie (np. przechowywania wszystkich plików rozszerzeń oraz bibliotek zależnych na dysku w folderze „assembly” lub też umieszczenie plików, których wymaga do działania nasz plugin, w Global Assembly Cache albo w wewnętrznych folderach Dynamicsa). Niestety metody te są w większości niewspierane przez Microsoft lub z nierekomendowane z rozmaitych przyczyn (np. ze względu na wydajność lub przenośność aplikacji). Dodatkowo nie istnieje możliwość użycia żadnej z nich w środowisku on-line, gdzie ze zrozumiałych przyczyn nie możemy wdrożyć żadnej biblioteki poza tymi, które są częścią rozwiązania („solucji”) instalowanego na platformie Common Data Service.  

W jaki sposób ominąć ww. ograniczenia?  Spotkałem się w tym przypadku z kilkoma różnymi podejściami, które postaram się omówić poniżej.

“One assembly to rule them all…”

Najprostszą metodą, o której należy wspomnieć, jest umieszczenie całego, wymaganego do prawidłowego działania aplikacji kodu, wewnątrz jednej biblioteki. Osiągnąć możemy to stosując następujące techniki:

1. Pojedynczy projekt Visual Studio dla wszystkich rozszerzeń systemu

WTF!?” Wykrzykną zapewne puryści programowania. Cały kod w jednym projekcie? Nie dość, że łamie to wiele zasad architektonicznych .NET, to na pewno nie może się udać!

Otóż moi drodzy – jak najbardziej może 😊. Stosując pragmatyczne podejście oraz organizując kod w odpowiedni sposób (foldery, przestrzenie nazw), jesteśmy w ten sposób w stanie wdrożyć średniej wielkości rozwiązanie, działające prawidłowo. Jeżeli jednak sama idea posiadania w naszym systemie pojedynczego, ogromne i potencjalnie rozrastającego się komponentu napawa nas wstrętem, wówczas z pomocą może przyjść technika opisana w kolejnym punkcie.

2. Stosowanie projektów współdzielonych (shared projects).

Projekty współdzielone zostały wprowadzone w Visual Studio 2015. Są one specyficznymi typami kontenerów zawierających pliki źródłowe aplikacji. Z poziomu Visual Studio widziane są one jako osobne projekty, natomiast w momencie kompilacji traktowane są jako pliki źródłowe biblioteki, która posiada referencje do nich. Rozwiązanie to jest banalne w swej prostocie. Może jednak powodować pewne problemy, o których za chwilę wspomnę. Pierwszą ważną kwestią jest fakt, że wszystkie projekty wykorzystujące do działania współdzielone elementy – muszą również posiadać bezpośrednie referencje do bibliotek wymaganych przez ten projekt. Przykładowo – jeżeli zdecydujemy się umieścić w projekcie współdzielonym obiekty encji wygenerowane przez narzędzie SvcUtil, to wszystkie projekty korzystające z niego muszą posiadać bezpośrednią referencję do biblioteki zawierającej przestrzeń nazw: Microsoft.xrm.sdk. Może to powodować pewne problemy w sytuacjach, w których korzystamy z różnych wersji CDS SDK dla różnych komponentów naszego systemu, kilku projektów współdzielonych lub różnych wersji .NET Frameworka (np. klasycznej, „pełnej” oraz wersji Core) w naszym rozwiązaniu. Oczywiście, nie są to problemy nie do rozwiązania, natomiast przed rozpoczęciem projektowania struktury solucji musimy być świadomi ich istnienia.

Opisane powyżej techniki nie eliminują problemu wykorzystywania w rozwiązaniu zewnętrznych bibliotek. O ile w przypadku rozwiązań open-source od biedy jesteśmy w stanie po prostu włączyć otwarty kod do naszego rozszerzenia i budować całość na etapie kompilacji, o tyle w przypadku rozwiązań komercyjnych, w których do kodu źródłowego dostępu nie posiadamy, nie jest to już oczywiste.

3. Wykorzystanie mechanizmu łącznia bibliotek (np. IlMerge)

W wielu projektach spotykałem się z łączeniem wielu bibliotek w jedną, która jest następnie wgrywana do systemu Dynamics jako jego rozszerzenie, za pomocą rozwiązań typu ILMerge lub Fody/Costura. Stosując tego typu rozwiązania, otrzymujemy pojedynczą bibliotekę zawierającą również kod pochodzący z plików, od których zależy nasze rozwiązanie. Niestety, poza wygodą stosowania, podejście te ma kilka dużych wad. Za najważniejsze z nich możemy uznać:

  • Problemy z debugowaniem

W zależności od wybranego rozwiązania w przypadku łączenia bibliotek możemy napotkać problemy z debugowaniem naszych rozszerzeń. Nie wszystkie dostępne narzędzia wspierają proces merge’owania plików PDB. Nawet jeżeli to robią, to zdarzało mi się napotykać na sytuacje, w których debugowanie plików z wykorzystanie profilingu oraz Plugin Registration Toola po prostu nie chciało działać. Czasem pomagał restart aplikacji, czasem ponowne „nagranie” przebiegu błędu. Tak czy inaczej – proces łączenia bibliotek .NET może powodować problemy w omawianym obszarze i musimy być na to świadomi i przygotowani.

  • Duży rozmiar biblioteki

Plik rozszerzenia, zawierającego w sobie kilkanaście różnych .NET-owych bibliotek szybko urośnie do rozmiaru kilkudziesięciu megabajtów. Może to powodować problemy z importem rozwiązania zawierającego omawiany plik do chmury (błąd „Time-out”).

  • Brak wsparcia ze strony producenta

Czy wspominałem o tym, że Microsoft oficjalnie nie wspiera rozszerzeń, które zostały utworzone w wyniku operacji połączenia kilku bibliotek? Jeżeli nie, to właśnie o tym wspominam.

Mikroserwisy i komponenty serverless.

Inną możliwą architekturą, o której chciałbym pokrótce wspomnieć, jest wykorzystanie popularnych mikro-serwisów hostowanych w chmurze lub innej wybranej przez nas lokalizacji. W podejściu tym cała logika biznesowa jest zaimplementowana właśnie jako wspomniany serwis. Może być to usługa REST, SOAP, funkcja Azure lub inna technologia. Jedyna odpowiedzialność implementowanego rozszerzenia polega wówczas na wywołaniu zaimplementowanej usługi. Podejście to eliminuje w całości problem bibliotek zależnych. Wprowadza natomiast inne kwestie, o które musimy zadbać, takie jak obsługa błędów, transakcyjność, uwierzytelnianie klienta czy bezpieczeństwo komunikacji między komponentami. Dodatkowo wiązać się będzie z dodatkowymi kosztami, związanymi z utrzymaniem wspomnianych mikro-serwisów. Wynika to z faktu, że platforma CDS sama w sobie nie umożliwia hostowania niestandardowych usług i w związku z tym zmuszeni jesteśmy korzystać z wybranego dostawcy chmury lub usług sieciowych.

Skoro już jesteśmy przy chmurze – chciałbym w tym miejscu wspomnieć o możliwości zastąpienia rozszerzeń .NET w pipeline’ie CDS wywoływaniem funkcji Azure za pomocą tzw. „web hooks”. Opcja ta wydaje się być nęcąca, natomiast apeluje o stosowanie jej z rozwagą. Zastępując starego dobrego „plugina” funkcją serverless, tracimy możliwość skorzystania z wielu mechanizmów, które rozszerzenia zapewniają nam „z pudełka” (bezpieczeństwo danych, uwierzytelnianie i autoryzacja dostępu, transakcyjność, itp). Dodatkowo ponownie musimy wziąć pod uwagę fakt, że wykorzystanie funkcji wiązać się będzie zapewne z dodatkowymi kosztami. Jakiś czas temu miałem „przyjemność” brać udział w audycie niestandardowego rozwiązania CDS, którego cała logika biznesowa zbudowana została w oparciu o funkcje Azure oraz platformę Azure Logic Apps. Nie piszę tu o integracjach czy przepływach danych między systemami tylko o najzwyklejszej logice biznesowej i operacjach na danych znajdujących się w Common Data Service. Architektura ta bardzo szybko zaczęła generować ogromne koszty dla klienta. Na pytanie, dlaczego system został stworzony w taki sposób, otrzymałem następującą odpowiedź: „Bo nasz lokalny* Microsoft tak zarekomendował”. ☹.

Podział na komponenty

Ostatnią kwestią, o której chciałbym wspomnieć, jest logiczny podział elementów rozszerzeń oraz umieszczenie tych elementów wewnątrz fizycznych komponentów. Najpopularniejsze podejścia, na które napotykałem w ciągu ostatnich kilku lat, zostały opisane poniżej.

Architektura z podziałem pionowym

W podejściu tym mamy do czynienia z bibliotekami rozszerzeń, które są od siebie w pełni niezależne. Każde rozszerzenie posiada własny model abstrakcji,  warstwę dostępu do danych oraz zaimplementowaną za pomocą wybranych wzorców logikę biznesową. Zaletą tego podejścia jest to, że nasze komponenty są od siebie zupełnie niezależne. Możemy dobierać ich architekturę oraz modyfikować elementy bez obawy, że wpłynie to na inne biblioteki zainstalowane w systemie. Wada to natomiast potencjalna duplikacja kodu oraz logiki między poszczególnymi komponentami. W końcu na ile sposobów można pobrać  z systemu klienta wraz z listą kontaktów oraz szans sprzedaży 😊?  

Architektura z podziałem poziomym

Architektura „pozioma” prezentuje nieco inne podejście do tworzenia fizycznych komponentów. W tym przypadku odpowiedzialności poszczególnych bibliotek nie przekładają się na domenę biznesową lub grupę funkcjonalności, tylko na techniczny aspekt działania aplikacji. Jest to tzw. „lasagna architecture” 😊. Poszczególne biblioteki zawierają odpowiednio: implementacje dostępu do danych, logikę biznesową naszej aplikacji, czy w końcu model domenowy i klasy odpowiedzialne za obsługę zdarzeń. W podejściu tym nie występuje praktycznie kwestia duplikacji kodu (oczywiście, o ile za i wdrożenie odpowiada względnie ogarnięty zespół 😉). Natomiast problemem może być wielkość samego rozwiązania oraz jego spójność. Bardzo szybko możemy dorobić się w tym przypadku bibliotek zawierających setki, a nawet tysiące klas. Architekturę tę rekomendowałbym wobec tego przede wszystkim dla mniejszych projektów.

Oczywiście możliwe są również podejścia mieszane. Najważniejszą kwestią w tym przypadku jest to, żeby w trakcie pogoni za realizacją coraz to nowych wymagań biznesowych, nie stracić z oczu wybranej architektury oraz czytelności i łatwości modyfikacji implementowanego rozwiązania. Konsekwencją tego może być coraz większa trudność w implementowaniu zmian (co wiąże się z większym kosztem dla biznesowego właściciela systemu), większa ilość błędów (powodowana ilością rosnących zależności) oraz demotywacja członków zespołu i trudność we wprowadzaniu do niego nowych osób („Znowu mam pracować z kodem-spaghetti? Nigdy!” 😉).

Outro

Wszystkie przedstawione powyżej podejścia mają swoje dobre i złe strony. Pochodzą one z rzeczywistych projektów, w których uczestniczyłem, i które kończyły się w większości przypadków powodzeniem. Wybór stosownej architektury zależeć będzie od potencjalnego stopnia skomplikowania oraz rozmaitych warunków wejściowych dla systemu, który będziecie budować. Niezwykle ważną kwestią jest natomiast spójność implementowanego rozwiązania. Jeżeli zdecydujemy się już na którąś z opisanych powyżej metod – trzymajmy się jej i nie zmieniajmy przyjętego podejścia (a jeżeli zmiana jest wymagana – róbmy to metodycznie i zgodnie z uzgodnionym wcześniej planem). Nie ma gorszego systemu niż taki, w którym każdy developer buduje rozszerzenia „po swojemu,” mając w poważaniu jakąkolwiek architekturę i ustalenia. Tworzone w ten sposób rozwiązanie stanie się szybko trudne do utrzymania i będzie jedynie generatorem kosztów i bólu głowy, zamiast narzędziem ułatwiającym ludziom życie i umożliwiającym organizacji osiągnięcie przewagi nad konkurencją.

* Rozwiązanie powstało na pewnym dużym, anglojęzycznym rynku z którego pochodzili zarówno klient jak i dostawca systemu.

Zdjęcie tytułowe do artykułu pochodzi jak zwykle z serwisu: https://unsplash.com/

Total Views: 296 ,
Be the first to comment

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *