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...