O repozytoriach i strukturze projektów w aplikacji opartej o mikroserwisy

Zdaję sobie sprawę, że temat dzisiejszego wpisu zdecydowanie nie wygląda „PRO” i wydawać by się mogło, że przeznaczony jest dla absolutnych laików programowania. Myślę jednak, że wielu programistów zaczynających swoją przygodę z mikroserwisami zadaje sobie w duchu pytanie, w jaki sposób ustrukturyzować wszystkie projekty i pliki, aby miało to ręce i nogi. Cóż… ja przynajmniej tak miałem i po kilku próbach + dyskusjach z innymi programistami wypracowałem własny „przepis”, który uwaga – dla mnie działa. Nie śmiem jednak twierdzić, że jest to rozwiązanie optymalne i jedyne słuszne. Zatem jeżeli jesteś na początku swojej drogi z mikroserwisami, potraktuj to proszę jedynie jako punkt wyjścia, a nie rozwiązanie ostateczne. Jeżeli jednak masz już doświadczenie z tego typu architekturą, a po przeczytaniu tego wpisu narodzą się w twej głowie jakiekolwiek obiekcje, to koniecznie podziel się nimi w komentarzach. Skorzystają na tym wszyscy, nie tylko ja 🙂

 

Repozytorium

Przed rozpoczęciem faktycznej pracy z kodem należałoby stworzyć repozytorium u jednego z popularnych dostawców jak GitHub, GitLab czy Bitbucket. Tu pojawia się pierwsze, zasadnicze pytanie. Ile tych repozytoriów powinno być? W zasadzie możliwe mamy dwie opcje:

  • tworzymy jedno repozytorium na solucję ze wszystkimi usługami
  • tworzymy repozytorium per usługa

Z początku pierwsze podejście może wydawać się dosyć kuszące, a to z jednego, prostego powodu. Oferuje nam komfort pracy z kodem i relatywnie niski próg wejścia. Dlaczego? Po pierwsze, jedno repozytorium to jedno miejsce do commitowania naszych wszelkich zmian w obrębie całej aplikacji. Nie musimy sięgać po skrypty czy submoduły w GIT, które ułatwią nam nieco zarządzaniem wieloma repozytoriami na raz. Niejako implikacją tego podejścia jest to, że posiadając jedno repozytorium mamy gwarancję, że system jest wewnętrznie spójny. Mam tu na myśli spójność w rozumieniu kodu. Jeżeli aplikacja się buduje gdzieś na CI to mam poniekąd gwarancję, że wszystkie kontrakty wymieniane między usługami są spójne i nie spowodują błędu w czasie działania aplikacji. Wydaje się oczywistą oczywistością, ale…o tym zaraz. Kolejna zaleta – mało zachodu w obrębie DevOps. Jedno repozytorium to jeden pipeline budowania naszej aplikacji i jedna jednostka, którą musimy wdrożyć na środowisko docelowe. Do tego dochodzą oczywiście inne, mniej istotne aspekty.

No dobrze, a jak zatem sprawa wygląda przy podejściu drugim? Przyrównując argumenty możemy odnieść wrażenie, że jest to wysoce nieopłacalne. Po pierwsze programista ma dużo więcej pracy w obrębie zarządzania swoim kodem. Tak jak wspomniałem, można posłużyć się jakimś wspomaganiem jak skrypty, ale ktoś to musi napisać/skonfigurować, a następnie przeszkolić innych programistów w zakresie obsługi. Po drugie rozproszenie kodu na N repozytoriów niesie za sobą jedną sporą odpowiedzialność w kontekście zapewnienia spójności kodu. Wyobraź sobie sytuację, w której masz repozytoria na następujące projekty:

  • Usługa A
  • Usługa B
  • Wiadomości

Załóżmy, że obie usługi komunikują się ze sobą wiadomościami, które znajdują się w osobnym projekcie, zatem obie posiadają do niego referencję. Co się stanie kiedy nieuważny programista wypchnie nowe zmiany dla obu usług, ale zapomni wykonać analogicznej operacji dla nowej wersji projektu wiadomości? Wtedy dojdzie do klasycznej sytuacji „u mnie działa”. Lokalnie wszystko wydaje się spójne, jednak na środowisku docelowym okazuje się, że stara wersja projektu nie dostarcza wymaganych kontraktów lub dostarcza je w nieaktualnej formie. Możemy się oczywiście przed tym bronić pisząc testy integracyjne, ale znów… ktoś to musi zrobić i przekonać biznes, że warto. A skoro jesteśmy przy referencjach do innych projektów to warto wspomnieć, że opisany przykład jest nieco uproszczony (ale nadal prawdziwy) z jednego powodu. Posiadając kod rozproszony na kilka/kilkanaście repozytoriów nie możemy używać klasycznych referencji do innego projektu, ponieważ repozytoria nie widzą się nawzajem. I znów na lokalnej maszynie wszystko działa,  ponieważ referencja wygląda następująco:

 

<ProjectReferenceInclude="..\..\..\DNC-DShop.Common\src\DShop.Common\DShop.Common.csproj"/>

 

Na CI okazuje się jednak, że nie można znaleźć katalogu pod zadaną ścieżką bo fizycznie go tam po prostu nie ma. Jest w kompletnie innym „kontenerze”, który jest niewidoczny z aktualnego miejsca. W jaki sposób poradzić sobie z tym problemem? Opiszę to wkrótce na blogu 🙂

Jednym z ostatnich problemów podejścia z wieloma repozytoriami jest DevOps. Tu sprawa jest już zdecydowanie bardziej skomplikowana, ponieważ bez jakiejkolwiek infrastruktury, która automatycznie zbuduje, przetestuje, a następnie wdroży nam aplikację na środowisko, będzie nam bardzo ciężko dostarczać kolejne wersje aplikacji. Oczywiście zaraz ktoś może powiedzieć, że to nie problem wykonać ręcznie podane czynności dla 5 projektów. Pytanie brzmi co w przypadku, gdy ta liczba jest większa o rząd wielkości? W moim odczuciu próba ogarnięcia i zarządzania taką ilością usług bez infrastruktury jest z góry skazana na porażkę. Człowiek jest omylny i prędzej czy później popełni błąd. Do tego dochodzi czas poświęconego na taką pracę. Kosmos.

Zatem, które rozwiązanie bym Ci polecił? DRUGIE! I jest to według mnie jedyna, właściwa droga. Już spieszę wyjaśniać. Pomijając niemałą liczbę komplikacji związanych z zarządzaniem wieloma repozytoriami, to podejście posiada cechę, która w przypadku mikroserwisów jest naczelna. Ta cecha to niezależność usług. Pełna niezależność nie tylko na poziomie domenowym, ale także infrastrukturalnym i na poziomie samego zarządzania cyklem życia oprogramowania. Rozproszenie kodu po pierwsze niweluje wszelki chaos związany z systemami kontroli wersji. Mniej gałęzi, tagów, commitów, kontrybutorów nie raz ułatwi Ci pracę nad konkretną usługą. Ponadto zespół deweloperski może śmiało podzielić się na mniejsze podzespoły (pracujące w skrajnie różnym tempie), z których każdy otrzyma swój dedykowany mikroserwis do zaimplementowania WRAZ ze swoją niezależną przestrzenią na kod (repozytorium), własną niezależną technologią, która najlepiej sprawdzi się w budowaniu konkretnej usługi, a dodatkowo może stworzyć własną dedykowaną infrastrukturę potrzebną do szybkiego wdrożenia usługi na środowisko (dedykowany build pipeline). Oczywiście nie twierdzę, że posiadanie kilku/kilkunastu skrajnych technologii (C#, Java, Python, Scala) w jednym projekcie jest nie do opanowania, ale zdecydowanie wolałbym, aby wszystko miało swoje osobne repozytorium z dedykowaną obsługą dla danego języka. Kolejny plus tak daleko idącej niezależności usług to samo wdrażanie i dostępność systemu. Wiele repozytoriów to owszem, sporo zachodu przy deploy-owaniu, ale zmiana w jednym mikroserwisie nie musi oznaczać podgrywania całego systemu, a co za tym idzie uczynienia go niedostępnym dla użytkownika. Zamiast tego system na pewien czas ograniczy jakość wybranych usług i nie wyłoży się użytkowniki z wielkim errorem mówiącym „SORRY”. Oczywiście wymaga to także odpowiedniego zaprojektowania aplikacji zarówno backendowej jak i frontendowej, ale to temat na inny wpis 🙂

Na koniec mały tip. Decydując się na podejście z wieloma repo pomyśl nad założeniem własnej organizacji (np. na GitHub) dla projektu. Dzięki temu cały kod będzie ładnie zagregowany u wybranego providera. Przykład takiej organizacji dla aplikacji DShop znajdziesz tutaj.

 

Struktura projektów i plików

Czas omówić strukturę samych projektów dla usług, którą niejawnie przedstawiłem w poprzednim wpisie poświęconym uruchomieniu projektu DShop – czyli aplikacji-demo na potrzeby prezentacji „Distributed .NET Core”. Pozwolę sobie teraz posłużyć się ponownie screenem, który tam zamieściłem:

 

 

Omówmy po krótce co zawiera każde repozytorium:

  • DNC-DShop – to swego pojemnik na metadane, który powinien zawierać wszystkie potrzebne informacje dotyczące całego projektu. To tam umieszczałbym takie rzeczy jak dokumentacja, instrukcję uruchomienia aplikacji, wiki, opis projektu oraz skrypty do jego uruchomienia (widoczne na obrazku). Jeżeli zdecydujesz się na używanie submodułów GIT, to właśnie to repozytorium powinieneś uczynić rootem.
  • DNC-DShop.API – zawiera API, które będzie cienką warstwą bez logiki, odpowiedzialną jedynie za obsługę żądań HTTP i przekazywanie odpowiednich wiadomości do konkretnych usług.
  • DNC-DShop.Common – zawiera całą infrastrukturę, która jest współdzielona przez Twoje usługi. Wszelkie konfiguracje ASP.NET Core, MongoDB, RabbitMQ, Redis, FTP, SMTP itd. powinny zleźć się właśnie w tym repozytorium. Dzięki temu zachowasz DRY i unikniesz duplikowania masy kodu, który nie jest faktyczną domeną Twojej aplikacji. Tak jak wspomniałem, będziesz musiał w odpowiedni sposób referować do tego projektu, aby uniknąć problemu z odnalezieniem plików podczas budowania aplikacji w systemie CI.
  • DNC-DShop.Messages – zawiera wszystkie wiadomości tj. komendy i zdarzenia, które usługi i API wymieniają między sobą. Wiem, że są zwolennicy tworzenia takiego projektu dla każdego mikroserwisu (zawierającego tylko lokalnie widoczne wiadomości), ale mi osobiście wydaje się to przerostem formy nad treścią bez wielkich benefitów. Ten projekt również będziesz musiał magicznie zareferować (chyba nie ma takie słowa) w swoich usługach i API.
  • DNC-DShop.Services.XXXX – zawiera kod konkretnej usługi, która została wydzielona w procesie projektowania aplikacji.

 

Gdybyś był ciekaw o co chodzi z tym DNC w nazwie to akronim od Distributed .NET Core 😉

 

Znając z grubsza zasadę podziału kodu na konkretne repozytoria, zejdźmy jeden poziom niżej i przeanalizujmy strukturę plików. Poniżej przykład dla repo DNC-DShop.Services.Customers:

 

 

 

Nie chciałbym abyś skupiał swoją uwagę na nazwach kolejnych katalogów, ponieważ szczegóły odnośnie samej architektury i implementacji konkretnych usług opiszę w innych artykułach. Na potrzeby dzisiejszego wpisu przyjmij, że każda usługa to mała aplikacja z architekturą trójwarstwową tj. API, domena i DAL czyli warstwa dostępu do danych. To co zapewne rzuciło Ci się w oczy to fakt, że wszystkie składowe tej aplikacji są ułożone razem. Brak tu typowego, podręcznikowego podziału na osobne projekty, z których każdy zawiera tylko jedną warstwę aplikacyjną. Jest to oczywiście działanie celowe, a jego uzasadnienie jest dosyć banalne i zrozumiem, że dla niektórych nieakceptowalne. Ten powód to relatywna prostota w zarządzaniu kodem, który i tak nie jest już najłatwiejszy do ogarnięcia przez jego uprzednie rozproszenia na N repozytoriów. Czy jest sens operować na 100 projektach zamiast na 30 tylko dlatego, aby było to zgodne ze sztuką? Moim zdaniem nie. Tutaj jednak należy się mała gwiazdka. Dotychczas pracowałem z czterema projektami opartymi o mikroserwisy i żaden nie był jakoś specjalnie ogromny (myśle tu o liczbie usług 50+). Być może to z tego względu nie odczułem bólu po podjęciu takiej decyzji co do ułożenia kodu. Oczywiście w przypadkach gdy byłoby uzasadnione zastosować np. architekturę heksagonalną to wypadałoby dokonać podziału na domenę/core wolną od wszelkich zależności technologicznych i całą resztę tj. infrastrukturę, ale to już sprawa do indywidualnego rozpatrzenia. Reszta projektu jest w zasadzie bardzo typowa dlatego pozwolę sobie pominąć jej dalsze objaśnienie. Gdybyś chciał jednak przeanalizować cały kod aplikacji DShop to wpadnij na GitHuba.

To tyle na dziś. Jak widzisz można rozpisać się o pozornie błahych sprawach na ponad 1600 znaków 😀 Mam jednak nadzieję, że nie było to zbytnie lanie wody, a wartościowa treść, która pomoże Ci rozpocząć przygodę z mikroseriwsami. Tak jak wspomniałem na początku wpisu, jeżeli masz jakieś uwagi lub dodatkowe spostrzeżenia dotyczące wyżej opisywanych zagadnień to podziel się nimi w komentarzach.

Miłego dnia!

 

You may also like...

  • Pingback: dotnetomaniak.pl()

  • Random Guy

    Powinno być „nie jest to rozwiązanie najbardziej optymalne”

  • Random Guy

    Panie Darku (zakładam, że jest Pan starszy niż ja) całkiem wartościowy wpis. Czy nie mógł Pan już teraz zdradzić jak sobie radzić z zależnościami do projektów, które są w oddzielnym repozytorium?

  • Conway Law
    „organizations which design systems … are constrained to produce designs which are copies of the communication structures of these organizations”

    Zdecydowanie wiele repo! To wymusza na zespolach, pracownikach, teamach opracowanie kultury pracy rozproszonej i asynchronicznej. Co ideanie wspolgra ze zrozumieniem i nauka systemow rozproszonych ktore tez sa niezalezne i asynchroniczne.

  • Tworzenie projektu takiego jak DNC-DShop.Messages jest przeciwne obecnym best practice jeżeli chodzi o mikro usługi. Po pierwsze jeżeli usługa będzie w innej technologii to niestety z takiej paczki nie skorzysta. Po drugie załóżmy że MS wprowadzi nowy typu super-duper-date-time w .NET core 3.0 – jeżeli w swojej biblioteczce użyjesz tego typu to zmuszasz wszystkich swoich klientów do automatycznej migracji na nowy core 3.0 gdy będą potrzebowali skorzystać z nowej wersji kontraktu. Po trzecie wprowadzasz tutaj silny coupling (każdy serwis wie o każdym innym bo widzi wszystkie komendy) i psuje również organizację pracy bo ten projekt będzie dotykany przez wszystkie zespoły (np. ja releasue messages i ktoś krzyczy że mu zrelesowałem niepotrzebnie wersję beta jakiegoś kontraktu). Co do projektu commons to zobacz (5 min): https://www.youtube.com/watch?v=qE3CGte-xm4 – lepiej nie współdzielić za dużo a jak już to podzielić to na parę tematycznych bibliotek nie na jeden worek (problem z transitive reference do innych bibliotek nadal pozostaje).

    • Cześć Marcin,
      dzięki za tak fajny i wyczerpujący komentarz 🙂 Generalnie z wieloma argumentami się zgadzam choć nie wszystkimi. Ale po kolei:

      1) Zdecydowanie zgadzam się, że to podejście nie jest transparentne względem używanych technologii w konkretnych usługach. Wiem, że taka możliwość doboru technologi/narzędzi do fragmentu domeny jest wielką zaletą mikroserwisów, ale mi osobiście jeszcze nie zdarzyło zrzec się z .NET-a na rzecz czegoś innego (tak jak pisałem projekty nie były jakieś olbrzymie). Stąd też nie miałem jeszcze „przyjemności” wpaść w tą pułapkę 😉 W przypadku DShop doszedł jeszcze jeden argument za tym podejściem tj. prostota, która nie skomplikuje dodatkowo kursu o i tak nie łatwej architekturze.

      2) Z tym się nie do końca zgadzam. .NET Core to jedno, a .NET Standard to drugie. Poza tym pozostawanie „up-to-date” w kontekście wersji konkretnych technologii jest tak czy siak problematyczne. Wystarczy, że ten super-duper-date-time chciałbym wykorzystać w każdej usłudze.

      3) Czy możesz rozwinąć ten podpunkt? Nie rozumiem jak widoczność komend czyli „intencji użytkownika końcowego” ma się do wiedzy o każdym innym mikroseriwsie? Co do organizacji pracy to się zgadzam, ale każdy kij ma dwa końce 😀 Co prawda może być trochę więcej zabawy z organizacją pracy jak i GIT-em, ale za to łatwiej jest utrzymać spójny kontrakt pomiędzy mikroseriwsami i na lini mikroseriws-API. W przypadku gdy każda usługa ma swoją paczkę z kontraktami trzeba pilnować, aby zachować spójność między nimi.

      4) Jeśli chodzi o Common to rozumiem ideę i jasne, że DRY jest złe w kontekście domeny, ale u nas jest to czysta infrastruktura. Oczywiście można było pokusić się o konkretne wydzielanie mniejszych paczek dla np. Service Bus, Mongo itd. bo np. API nie potrzebuje połowy Utilsów, które mamy w Common. Tylko znów… to mocne komplikowanie sobie życia i właśnie utrudnianie organizacji/ utrzymania wszystkiego w miarę aktualnego. Przykład to np. RawRabbit, który w wersji 2.0 postawił na pełną modularyzację paczek nugetowych i tak skończyli z 30 paczkami osobno do publish, osobno do subscribe itd. No fajnie tylko połapać się w tym jest ciężko. Generalnie jeśli chodzi o ten podpunkt to znów w przypadku DShop by to powodowało bezsensowną komplikację w kursie.

      Tak jak pisałem we wpisie – wiem, że sporo jest osób, które podchodzą do tego tak samo jak Ty. Z tego co pamiętam Piotrek Gankiewicz robił tak samo w swoim projekcie https://becollective.ly/pl/ ale z jakiegoś powodu się wycofał 😀 Może go tu przywołam i też podzieli się swoimi spostrzeżeniami 😀

      • 3) Powiedzmy że mamy mikrousługę od faktur, jeżeli jest tylko jedna dll’ka z kontraktami to jeżeli dołączę ją do mojej usługi to pewna jego część (powiedzmy tam gdzie są handlery do commandów) zobaczy np. komendę SendEmail czy zdarzenie UserCreated których w ogóle nie potrzebuje. Potraktuj to jak regułkę I z SOLID tylko na poziomie klas (po co mam widzieć klasy których nie używam?). Będzie to o wiele bardziej dotkliwe gdy serwisów będzie wiele i będziesz obsługiwał klika bounded contextów z ddd. Na dużym projekcie zaczną się kolizje np. typu eventy o tej samej nazwie z róźnych bounded contextów. Dużo lepiej IMHO jak usługa widzi tylko te REST endpointy czy dostępne eventy/komendy których faktycznie używa (łatwiej też będzie z tego zrobić diagram zależności pomiędzy usługami).

        • Argument z kolizją nazw eventów do mnie przemawia. Tylko znów, jeszcze nie miałm okazji się na tym przejechać 😛 Dzięki za doprecyzowanie!

    • Piotr Gankiewicz

      Z usług może skorzystać każdy, niezależnie od technologii, wystarczy, że zdefiniuje kontrakt odpowiadający wskazanym typom po swojej stronie (robiłem tak np. .NET + NodeJS itp.).

      Argument odnośnie wprowadzenia nowego typu – no niestety, raczej nikt nie zmieni podstawowych typów wartościowych, a komenda/zdarzenie to zazwyczaj zbiór prostych właściwości, więc migracja może być raczej w obrębie systemowych API lub bibliotek, a nie zwykłych DTO.

      Silny coupling – z własnego doświadczenia, wersjonowanie kontraktów per usługa jest bardzo bolesne i szybko wprowadza chaos, więc albo lepiej wybrać mniejsze zło i stworzyć wspólną paczkę z wiadomościami albo w ogóle nie tworzyć własnych paczek, tylko udostępnić dokumentację wiadomości dla danej usługi i wtedy każdy serwis tworzy sobie samodzielnie kontrakty pod dany serwis.

      Jeśli chodzi o Common library – ok, może jedna duża biblioteka wprowadza zbyt wiele zależności (kwestia dyskusyjna), więc można podzielić ją np. na utils dla uwierzytelniania, bazy danych, cache (jako osobne paczki), natomiast argument odnośnie kopiowania kodu i utrzymywania w każdym serwisie takiej samej logiki połączenia z bazą czy generowania tokenów, no cóż. DRY jak najbardziej można „naruszyć” czy to w kontekście DTO czy modeli domeny natomiast nie widzę nic złego w paczkach z helperami (w przeciwnym przypadku w ogóle po co używać NuGeta).

      • Z życia wzięte 😉 DateTime -> DateTimeOffset

        To może i ja opowiem jak ja robię na swoim podwórku. Do kontraktów używam Swaggera (ponoć jest już w nowym VS opcja żeby wygenerować klientów w C# ze Swaggera), oraz RestEase do generowania klientów (definiujemy tylko interface + atrybuty, dosyć wygodne w użyciu – z tym że warto to i tak opakować w usługę). Ponieważ mam continous delivery to wersjonowanie usług jest must-have, jak do tej pory 20+ usług/3 teamy + architekt nie było z tym problemów (jedyna wada jest taka że przez jakiś czas potrafi żyć wersja v1,v2 i v3 pewnych endpointów). Takie ja mam doświadczenia.

        Jeżeli chodzi o common library to podział jest faktycznie wymagany – tu się zgadzamy. Znowu z mojego doświadczenia: mam w projekcie Dappera, EF Core i MongoDB (Redis w drodze ;)) – jak zrobiłbym powiedzmy jedną paczkę HealthCheck to ciągnąłbym zależności do tych 3 wykluczających się (w 90% przypadków) technologi – więc trzeba dzielić. Pytanie czy będziesz miał na tyle siły żeby zawsze gdy będzie taka potrzeba tworzyć nowy projekt + paczkę nuget (niestety sporo osób idzie po najmniejszej linii oporu – o tu dołożę jeszcze klaskę bo mi pasuje). Problem z commons który mnie boli to właśnie te extra zależności do innych bibliotek. Niedawno była na Devoxx Poland prezentacja Dominika Boszko w której znowu padła teza że DRY w przypadku microservice’ów może nie być najlepszym rozwiązaniem. Jeżeli robisz natomiast bibliotekę która jest niezależna od tego że robisz microserwisy np. parser GraphQL’a to jest to dla mnie najbardziej jest to OK.

  • crisu

    Bardzo ciekawy wpis. Czy znacie może więcej źródeł z przykładami jak fajnie ułożyć strukturę plików w projekcie ?

  • Maciek Misztal

    Bardzo mi się podoba ten przykład, jednak przy próbie zasetupowania podobnej struktury lokalnych referencji nadziewam się na błędy podczas dotnet restore. Nadzialiście się na coś takiego?