Tworzenie prawdziwie modułowego kodu bez zależności
Opublikowany: 2022-03-11Tworzenie 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.
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.
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.
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.
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.

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:
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.
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 └── elementsW 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.
