Tworzenie prawdziwie modułowego kodu bez zależności

Opublikowany: 2022-03-11

Tworzenie oprogramowania jest świetne, ale… Myślę, że wszyscy możemy się zgodzić, że może to być trochę emocjonalny rollercoaster. Na początku wszystko jest super. Dodajesz nowe funkcje jedna po drugiej w ciągu kilku dni, jeśli nie godzin. Jesteś na fali!

Przewiń o kilka miesięcy do przodu, a prędkość rozwoju spadnie. Czy to dlatego, że nie pracujesz tak ciężko jak wcześniej? Nie całkiem. Przeskoczmy jeszcze kilka miesięcy do przodu, a tempo Twojego rozwoju jeszcze się zmniejszy. Praca nad tym projektem przestała być zabawą i stała się udręką.

Pogarsza się. Zaczynasz odkrywać wiele błędów w swojej aplikacji. Często rozwiązanie jednego błędu tworzy dwa nowe. W tym momencie możesz zacząć śpiewać:

99 małych błędów w kodzie. 99 małych błędów. Zdejmij jednego, załataj go,

…127 małych błędów w kodzie.

Co myślisz o pracy nad tym projektem teraz? Jeśli jesteś taki jak ja, prawdopodobnie zaczynasz tracić motywację. Tworzenie tej aplikacji jest po prostu uciążliwe, ponieważ każda zmiana w istniejącym kodzie może mieć nieprzewidywalne konsekwencje.

To doświadczenie jest powszechne w świecie oprogramowania i może wyjaśnić, dlaczego tak wielu programistów chce wyrzucić swój kod źródłowy i przepisać wszystko.

Powody, dla których rozwój oprogramowania zwalnia z czasem

Więc jaki jest powód tego problemu?

Główną przyczyną jest rosnąca złożoność. Z mojego doświadczenia wynika, że ​​największy wpływ na ogólną złożoność ma fakt, że w zdecydowanej większości projektów oprogramowania wszystko jest połączone. Ze względu na zależności, które ma każda klasa, jeśli zmienisz kod w klasie, która wysyła wiadomości e-mail, Twoi użytkownicy nagle nie będą mogli się zarejestrować. Dlaczego? Ponieważ Twój kod rejestracyjny zależy od kodu, który wysyła e-maile. Teraz nie możesz nic zmienić bez wprowadzania błędów. Po prostu nie da się prześledzić wszystkich zależności.

Więc masz to; prawdziwą przyczyną naszych problemów jest zwiększenie złożoności wynikającej ze wszystkich zależności, jakie posiada nasz kod.

Wielka kula błota i jak ją zmniejszyć

Zabawne jest to, że ten problem jest znany od lat. To powszechny antywzór zwany „wielką kulą błota”. Widziałem ten rodzaj architektury w prawie wszystkich projektach, nad którymi pracowałem przez lata w wielu różnych firmach.

Czym dokładnie jest ten antywzór? Mówiąc najprościej, dostajesz wielką kulę błota, gdy każdy element jest zależny od innych elementów. Poniżej możesz zobaczyć wykres zależności ze znanego projektu open-source Apache Hadoop. Aby zobrazować wielką kłębek błota (a raczej wielką kłębek włóczki), rysujesz okrąg i równomiernie rozmieszczasz na nim klasy z projektu. Po prostu narysuj linię między każdą parą klas, które są od siebie zależne. Teraz możesz zobaczyć źródło swoich problemów.

Wizualizacja „wielkiej kuli błota” Apache Hadoop, z kilkoma tuzinami węzłów i setkami łączących je ze sobą linii.

„Wielka kula błota” Apache Hadoop

Rozwiązanie z modułowym kodem

Zadałem sobie więc pytanie: czy dałoby się zmniejszyć złożoność i nadal bawić się tak, jak na początku projektu? Prawdę mówiąc, nie da się wyeliminować całej złożoności. Jeśli chcesz dodać nowe funkcje, zawsze będziesz musiał zwiększyć złożoność kodu. Niemniej jednak złożoność można przesunąć i oddzielić.

Jak inne branże rozwiązują ten problem

Pomyśl o przemyśle mechanicznym. Kiedy jakiś mały warsztat mechaniczny tworzy maszyny, kupuje zestaw standardowych elementów, tworzy kilka niestandardowych i składa je w całość. Mogą wykonać te komponenty całkowicie osobno i złożyć wszystko na końcu, dokonując tylko kilku poprawek. Jak to jest możliwe? Wiedzą, jak każdy element będzie do siebie pasował, dzięki ustalonym standardom branżowym, takim jak rozmiary śrub i wcześniejsze decyzje, takie jak rozmiar otworów montażowych i odległość między nimi.

Schemat techniczny mechanizmu fizycznego i sposób, w jaki jego części pasują do siebie. Kawałki są ponumerowane w kolejności, w której należy dołączyć dalej, ale kolejność od lewej do prawej to 5, 3, 4, 1, 2.

Każdy element w powyższym zestawie może być dostarczony przez osobną firmę, która nie ma żadnej wiedzy na temat produktu końcowego lub innych jego elementów. Dopóki każdy element modułowy zostanie wyprodukowany zgodnie ze specyfikacją, będziesz mógł stworzyć finalne urządzenie zgodnie z planem.

Czy możemy to powtórzyć w branży oprogramowania?

Oczywiście, możemy! Za pomocą interfejsów i odwrócenia zasady sterowania; najlepsze jest to, że takie podejście można zastosować w dowolnym języku obiektowym: Java, C#, Swift, TypeScript, JavaScript, PHP — lista jest długa. Nie potrzebujesz żadnych wymyślnych frameworków, aby zastosować tę metodę. Musisz tylko trzymać się kilku prostych zasad i zachować dyscyplinę.

Odwrócenie kontroli jest twoim przyjacielem

Kiedy po raz pierwszy usłyszałem o odwróceniu kontroli, od razu zdałem sobie sprawę, że znalazłem rozwiązanie. Jest to koncepcja przejmowania istniejących zależności i odwracania ich za pomocą interfejsów. Interfejsy to proste deklaracje metod. Nie zapewniają żadnej konkretnej realizacji. W rezultacie mogą być używane jako porozumienie między dwoma elementami, jak je połączyć. Mogą być używane jako złącza modułowe, jeśli chcesz. Dopóki jeden element zapewnia interfejs, a inny zapewnia jego implementację, mogą ze sobą współpracować, nie wiedząc nic o sobie nawzajem. To znakomicie.

Zobaczmy na prostym przykładzie, jak możemy rozdzielić nasz system, aby stworzyć modułowy kod. Poniższe schematy zostały zaimplementowane jako proste aplikacje Java. Możesz je znaleźć w tym repozytorium GitHub.

Problem

Załóżmy, że mamy bardzo prostą aplikację składającą się tylko z klasy Main , trzech usług i jednej klasy Util . Te elementy zależą od siebie na wiele sposobów. Poniżej możesz zobaczyć implementację wykorzystującą podejście „wielkiej kuli błota”. Klasy po prostu do siebie dzwonią. Są ściśle połączone i nie da się po prostu wyjąć jednego elementu bez dotykania innych. Aplikacje tworzone przy użyciu tego stylu pozwalają początkowo szybko się rozwijać. Uważam, że ten styl jest odpowiedni dla projektów sprawdzających koncepcję, ponieważ można łatwo bawić się różnymi rzeczami. Niemniej jednak nie jest to odpowiednie dla rozwiązań gotowych do produkcji, ponieważ nawet konserwacja może być niebezpieczna, a każda pojedyncza zmiana może spowodować nieprzewidywalne błędy. Poniższy schemat przedstawia tę wielką kulę architektury błotnej.

Main korzysta z usług A, B i C, z których każdy korzysta z Util. Usługa C również korzysta z Usługi A.

Dlaczego wstrzykiwanie zależności wszystko poszło źle

W poszukiwaniu lepszego podejścia możemy skorzystać z techniki zwanej wstrzykiwaniem zależności. Ta metoda zakłada, że ​​wszystkie komponenty powinny być używane przez interfejsy. Czytałem twierdzenia, że ​​odsprzęga elementy, ale czy to naprawdę? Nie. Spójrz na poniższy diagram.

Poprzednia architektura, ale z wstrzykiwaniem zależności. Teraz Main używa usług interfejsu A, B i C, które są implementowane przez odpowiadające im usługi. Zarówno usługi A, jak i C korzystają z usługi interfejsu B i narzędzia interfejsu, które jest implementowane przez firmę Util. Usługa C również korzysta z usługi interfejsu A. Każda usługa wraz z jej interfejsem jest uważana za element.

Jedyna różnica między obecną sytuacją a wielką kulą błota polega na tym, że teraz zamiast bezpośrednio wywoływać klasy, wywołujemy je przez ich interfejsy. Lekko poprawia oddzielanie elementów od siebie. Jeśli na przykład chcesz ponownie użyć Service A w innym projekcie, możesz to zrobić, usuwając samą Service A wraz z Interface A , a także Interface B i Interface Util . Jak widać, Service A nadal zależy od innych elementów. W rezultacie nadal mamy problemy ze zmianą kodu w jednym miejscu i zepsuciem zachowania w innym. Nadal powoduje to problem polegający na tym, że jeśli zmodyfikujesz Service B i Interface B , będziesz musiał zmienić wszystkie elementy, które od niej zależą. Takie podejście niczego nie rozwiązuje; moim zdaniem po prostu dodaje warstwę interfejsu na wierzchu elementów. Nigdy nie należy wstrzykiwać żadnych zależności, ale zamiast tego należy się ich pozbyć raz na zawsze. Hurra o niepodległość!

Rozwiązanie dla kodu modułowego

Uważam, że podejście, które rozwiązuje wszystkie główne problemy związane z zależnościami, polega na nieużywaniu w ogóle zależności. Tworzysz komponent i jego odbiornik. Listener to prosty interfejs. Ilekroć potrzebujesz wywołać metodę spoza bieżącego elementu, po prostu dodajesz metodę do odbiornika i wywołujesz ją zamiast tego. Element może używać tylko plików, wywoływać metody w swoim pakiecie i używać klas dostarczonych przez główny framework lub inne używane biblioteki. Poniżej znajduje się schemat aplikacji zmodyfikowanej pod kątem wykorzystania architektury elementowej.

Schemat aplikacji zmodyfikowanej w celu wykorzystania architektury elementowej. Główne zastosowania Util i wszystkich trzech usług. Main implementuje również odbiornik dla każdej usługi, która jest używana przez tę usługę. Słuchacz i usługa razem są uważane za element.

Należy pamiętać, że w tej architekturze tylko klasa Main ma wiele zależności. Łączy wszystkie elementy razem i hermetyzuje logikę biznesową aplikacji.

Natomiast usługi są elementami całkowicie niezależnymi. Teraz możesz usunąć każdą usługę z tej aplikacji i użyć jej w innym miejscu. Nie zależą od niczego innego. Ale poczekaj, będzie lepiej: nie musisz już nigdy modyfikować tych usług, o ile nie zmienisz ich zachowania. Dopóki te służby robią to, co powinny, mogą pozostać nietknięte do końca czasu. Mogą zostać stworzone przez profesjonalnego inżyniera oprogramowania lub początkującego programistę, który po raz pierwszy znalazł się w sytuacji, gdy ktoś wykorzysta najgorszy kod spaghetti, jaki ktokolwiek kiedykolwiek ugotował z wymieszanymi stwierdzeniami goto . To nie ma znaczenia, ponieważ ich logika jest zamknięta. Choć może to być straszne, nigdy nie wyleje się na inne klasy. Daje to również możliwość dzielenia pracy w projekcie między wielu programistów, gdzie każdy programista może niezależnie pracować nad własnym komponentem bez konieczności przerywania innym lub nawet wiedzy o istnieniu innych programistów.

Na koniec możesz jeszcze raz zacząć pisać niezależny kod, tak jak na początku ostatniego projektu.

Wzór elementu

Zdefiniujmy wzór elementu konstrukcyjnego tak, abyśmy mogli go tworzyć w sposób powtarzalny.

Najprostsza wersja elementu składa się z dwóch rzeczy: klasy głównego elementu i odbiornika. Jeśli chcesz użyć elementu, musisz zaimplementować listener i wywołać klasę główną. Oto schemat najprostszej konfiguracji:

Schemat pojedynczego elementu i jego odbiornika w aplikacji. Tak jak poprzednio, aplikacja korzysta z elementu, który wykorzystuje swój odbiornik, który jest zaimplementowany przez aplikację.

Oczywiście w końcu będziesz musiał dodać więcej złożoności do elementu, ale możesz to zrobić łatwo. Upewnij się tylko, że żadna z twoich klas logiki nie zależy od innych plików w projekcie. Mogą używać tylko głównego frameworka, importowanych bibliotek i innych plików w tym elemencie. Jeśli chodzi o pliki zasobów, takie jak obrazy, widoki, dźwięki itp., należy je również umieścić w elementach, aby w przyszłości można je było łatwo ponownie wykorzystać. Możesz po prostu skopiować cały folder do innego projektu i gotowe!

Poniżej przykładowy wykres przedstawiający bardziej zaawansowany element. Zauważ, że składa się z widoku, którego używa i nie zależy od innych plików aplikacji. Jeśli chcesz poznać prostą metodę sprawdzania zależności, zajrzyj do sekcji importu. Czy są jakieś pliki spoza bieżącego elementu? Jeśli tak, musisz usunąć te zależności, przenosząc je do elementu lub dodając odpowiednie wywołanie do odbiornika.

Prosty schemat bardziej złożonego elementu. Tutaj większe znaczenie słowa „element” składa się z sześciu części: Widok; Logika A, B i C; Element; i Odbiornik Elementów. Relacje między tymi dwoma ostatnimi a aplikacją są takie same jak wcześniej, ale element wewnętrzny również używa logiki A i C. Logika C używa logiki A i B. Logika A używa logiki B i widoku.

Rzućmy też okiem na prosty przykład „Hello World” stworzony w Javie.

 public class Main { interface ElementListener { void printOutput(String message); } static class Element { private ElementListener listener; public Element(ElementListener listener) { this.listener = listener; } public void sayHello() { String message = "Hello World of Elements!"; this.listener.printOutput(message); } } static class App { public App() { } public void start() { // Build listener ElementListener elementListener = message -> System.out.println(message); // Assemble element Element element = new Element(elementListener); element.sayHello(); } } public static void main(String[] args) { App app = new App(); app.start(); } }

Początkowo definiujemy ElementListener , aby określić metodę, która wypisuje dane wyjściowe. Sam element jest zdefiniowany poniżej. Po wywołaniu sayHello na elemencie, po prostu drukuje wiadomość za pomocą ElementListener . Zauważ, że element jest całkowicie niezależny od implementacji metody printOutput . Można go wydrukować na konsoli, fizycznej drukarce lub fantazyjnym interfejsie użytkownika. Element nie zależy od tej implementacji. Ze względu na tę abstrakcję element ten można łatwo ponownie wykorzystać w różnych aplikacjach.

Teraz spójrz na główną klasę App . Implementuje słuchacza i montuje element wraz z konkretną implementacją. Teraz możemy zacząć z niego korzystać.

Możesz również uruchomić ten przykład w JavaScript tutaj

Architektura elementów

Przyjrzyjmy się wykorzystaniu wzorca elementu w aplikacjach na dużą skalę. Co innego pokazać to w małym projekcie, a co innego zastosować w prawdziwym świecie.

Struktura aplikacji webowej typu full-stack, z której lubię korzystać, wygląda następująco:

 src ├── client │ ├── app │ └── elements │ └── server ├── app └── elements

W folderze kodu źródłowego początkowo podzieliliśmy pliki klienta i serwera. Jest to rozsądne, ponieważ działają w dwóch różnych środowiskach: przeglądarce i serwerze zaplecza.

Następnie dzielimy kod w każdej warstwie na foldery o nazwie app i elements. Elements składa się z folderów z niezależnymi komponentami, podczas gdy folder aplikacji łączy wszystkie elementy razem i przechowuje całą logikę biznesową.

W ten sposób elementy mogą być ponownie wykorzystywane między różnymi projektami, podczas gdy cała złożoność specyficzna dla aplikacji jest zamknięta w jednym folderze i dość często zredukowana do prostych wywołań elementów.

Praktyczny przykład

Wierząc, że praktyka zawsze przebija teorię, spójrzmy na rzeczywisty przykład stworzony w Node.js i TypeScript.

Przykład z prawdziwego życia

Jest to bardzo prosta aplikacja internetowa, która może służyć jako punkt wyjścia dla bardziej zaawansowanych rozwiązań. Jest zgodny z architekturą elementów, a także wykorzystuje szeroko strukturalny wzorzec elementów.

Z wyróżnień widać, że strona główna została wyróżniona jako element. Ta strona zawiera własny widok. Jeśli więc, na przykład, chcesz go ponownie użyć, możesz po prostu skopiować cały folder i upuścić go do innego projektu. Po prostu połącz wszystko razem i gotowe.

To podstawowy przykład, który pokazuje, że już dziś możesz zacząć wprowadzać elementy we własnej aplikacji. Możesz zacząć rozróżniać niezależne komponenty i oddzielać ich logikę. Nie ma znaczenia, jak niechlujny jest kod, nad którym obecnie pracujesz.

Rozwijaj się szybciej, używaj ponownie częściej!

Mam nadzieję, że dzięki temu nowemu zestawowi narzędzi będziesz mógł łatwiej tworzyć kod, który jest łatwiejszy w utrzymaniu. Zanim przejdziesz do używania wzorca elementów w praktyce, szybko podsumujmy wszystkie główne punkty:

  • Wiele problemów w oprogramowaniu występuje z powodu zależności między wieloma komponentami.

  • Dokonując zmiany w jednym miejscu, możesz wprowadzić nieprzewidywalne zachowanie gdzie indziej.

Trzy popularne podejścia architektoniczne to:

  • Wielka kula błota. Świetnie nadaje się do szybkiego rozwoju, ale nie jest tak dobry do stabilnych celów produkcyjnych.

  • Wstrzykiwanie zależności. To na wpół upieczony roztwór, którego należy unikać.

  • Architektura elementów. Takie rozwiązanie pozwala tworzyć niezależne komponenty i wykorzystywać je ponownie w innych projektach. Jest łatwy w utrzymaniu i genialny dla stabilnych wydań produkcyjnych.

Podstawowy wzorzec elementu składa się z klasy głównej, która posiada wszystkie najważniejsze metody oraz z listenera, który jest prostym interfejsem pozwalającym na komunikację ze światem zewnętrznym.

Aby osiągnąć pełną architekturę elementów stosu, najpierw oddzielasz swój front-end od kodu back-endu. Następnie tworzysz folder w każdym dla aplikacji i elementów. Folder elementów składa się ze wszystkich niezależnych elementów, podczas gdy folder aplikacji łączy wszystko razem.

Teraz możesz zacząć tworzyć i udostępniać własne elementy. Na dłuższą metę pomoże Ci stworzyć łatwe w utrzymaniu produkty. Powodzenia i daj mi znać, co stworzyłeś!

Ponadto, jeśli zauważysz, że przedwcześnie optymalizujesz swój kod, przeczytaj artykuł Jak uniknąć klątwy przedwczesnej optymalizacji autorstwa innego Toptalera, Kevina Blocha.

Powiązane: Najlepsze praktyki JS: Zbuduj bota Discord za pomocą TypeScript i Dependency Injection