Reengineering oprogramowania: od spaghetti do czystego designu

Opublikowany: 2022-03-11

Czy możesz rzucić okiem na nasz system? Faceta, który napisał oprogramowanie, już nie ma i mamy wiele problemów. Potrzebujemy kogoś, kto to sprawdzi i posprząta dla nas.

Każdy, kto zajmuje się inżynierią oprogramowania przez rozsądny czas, wie, że ta pozornie niewinna prośba jest często początkiem projektu, który „całkowicie ma napisaną katastrofę”. Dziedziczenie cudzego kodu może być koszmarem, zwłaszcza gdy kod jest źle zaprojektowany i brakuje mu dokumentacji.

Kiedy więc niedawno otrzymałem prośbę od jednego z naszych klientów, aby przejrzeć jego istniejącą aplikację serwera czatu socket.io (napisaną w Node.js) i ją ulepszyć, byłem bardzo ostrożny. Ale zanim pobiegnę na wzgórza, postanowiłem przynajmniej zgodzić się na zapoznanie się z kodem.

Niestety, spojrzenie na kod tylko potwierdziło moje obawy. Ten serwer czatu został zaimplementowany jako pojedynczy, duży plik JavaScript. Przeprojektowanie tego pojedynczego monolitycznego pliku w czysto zaprojektowane i łatwe w utrzymaniu oprogramowanie byłoby rzeczywiście wyzwaniem. Ale lubię wyzwania, więc się zgodziłem.

przebudowa oprogramowania

Punkt wyjścia — przygotuj się do przebudowy

Istniejące oprogramowanie składało się z jednego pliku zawierającego 1200 linii nieudokumentowanego kodu. Ojej. Co więcej, wiadomo było, że zawiera pewne błędy i ma pewne problemy z wydajnością.

Ponadto badanie plików dziennika (zawsze dobre miejsce do rozpoczęcia dziedziczenia kodu innej osoby) ujawniło potencjalne problemy z wyciekiem pamięci. W pewnym momencie zgłoszono, że proces używa więcej niż 1 GB pamięci RAM.

Biorąc pod uwagę te problemy, natychmiast stało się jasne, że kod będzie musiał zostać zreorganizowany i zmodularyzowany, zanim nawet spróbuje się debugować lub ulepszyć logikę biznesową. W tym celu niektóre z początkowych problemów, które należało rozwiązać, obejmowały:

  • Struktura kodu. Kod nie miał żadnej rzeczywistej struktury, co utrudniało odróżnienie konfiguracji od infrastruktury od logiki biznesowej. Zasadniczo nie było modularyzacji ani rozdzielenia obaw.
  • Zbędny kod. Niektóre części kodu (takie jak kod obsługi błędów dla każdego programu obsługi zdarzeń, kod do tworzenia żądań internetowych itp.) zostały zduplikowane wielokrotnie. Replikowany kod nigdy nie jest dobrą rzeczą, przez co kod jest znacznie trudniejszy w utrzymaniu i bardziej podatny na błędy (gdy nadmiarowy kod zostanie naprawiony lub zaktualizowany w jednym miejscu, ale nie w innym).
  • Wartości zakodowane na sztywno. Kod zawierał pewną liczbę zakodowanych na sztywno wartości (rzadko dobra rzecz). Możliwość modyfikowania tych wartości za pomocą parametrów konfiguracyjnych (zamiast konieczności wprowadzania zmian do wartości zakodowanych na sztywno w kodzie) zwiększyłaby elastyczność i mogłaby również ułatwić testowanie i debugowanie.
  • Logowanie. System logowania był bardzo prosty. Wygenerowałby pojedynczy gigantyczny plik dziennika, który był trudny i niezdarny do przeanalizowania lub przeanalizowania.

Kluczowe cele architektoniczne

W procesie rozpoczynania restrukturyzacji kodu, oprócz rozwiązania konkretnych problemów zidentyfikowanych powyżej, chciałem zająć się niektórymi kluczowymi celami architektonicznymi, które są (lub przynajmniej powinny być) wspólne dla projektowania dowolnego systemu oprogramowania . Obejmują one:

  • Utrzymanie. Nigdy nie pisz oprogramowania oczekując, że będziesz jedyną osobą, która będzie musiała je utrzymywać. Zawsze zastanów się, jak zrozumiały będzie Twój kod dla kogoś innego i jak łatwo będzie mu go modyfikować lub debugować.
  • Rozciągliwość. Nigdy nie zakładaj, że funkcjonalność, którą wdrażasz dzisiaj, to wszystko, co kiedykolwiek będzie Ci potrzebne. Zaprojektuj swoje oprogramowanie w sposób, który będzie łatwy do rozbudowy.
  • Modułowość. Oddziel funkcjonalność na logiczne i odrębne moduły, z których każdy ma swój własny, jasny cel i funkcję.
  • Skalowalność. Dzisiejsi użytkownicy są coraz bardziej niecierpliwi, oczekują natychmiastowej (lub przynajmniej bliskiej natychmiastowej) reakcji. Niska wydajność i duże opóźnienia mogą spowodować awarię nawet najbardziej przydatnej aplikacji na rynku. Jak Twoje oprogramowanie będzie działać, gdy wzrośnie liczba jednoczesnych użytkowników i wymagania dotyczące przepustowości? Techniki, takie jak równoległość, optymalizacja bazy danych i przetwarzanie asynchroniczne, mogą pomóc w poprawie zdolności systemu do pozostawania responsywnym pomimo rosnącego obciążenia i zapotrzebowania na zasoby.

Restrukturyzacja Kodeksu

Naszym celem jest przejście z pojedynczego monolitycznego pliku kodu źródłowego mongo do zmodularyzowanego zestawu czysto zaprojektowanych komponentów. Otrzymany kod powinien być znacznie łatwiejszy w utrzymaniu, ulepszaniu i debugowaniu.

W przypadku tej aplikacji zdecydowałem się uporządkować kod w następujące odrębne komponenty architektoniczne:

  • app.js - to jest nasz punkt wejścia, stąd nasz kod będzie uruchamiany
  • config - tutaj będą znajdować się nasze ustawienia konfiguracyjne
  • ioW - „opakowanie we/wy”, które będzie zawierało całą logikę we/wy (i biznesową)
  • logowanie - cały kod związany z logowaniem (należy zwrócić uwagę, że struktura katalogów będzie zawierała również nowy folder logs , który będzie zawierał wszystkie pliki logów)
  • package.json - lista zależności pakietów dla Node.js
  • node_modules - wszystkie moduły wymagane przez Node.js

W tym konkretnym podejściu nie ma nic magicznego; może istnieć wiele różnych sposobów restrukturyzacji kodu. Po prostu osobiście czułem, że ta organizacja jest wystarczająco czysta i dobrze zorganizowana, ale nie jest zbyt skomplikowana.

Wynikowy katalog i organizacja plików pokazano poniżej.

zrestrukturyzowany kod

Logowanie

Pakiety rejestrujące zostały opracowane dla większości dzisiejszych środowisk programistycznych i języków, więc w dzisiejszych czasach rzadko kiedy trzeba będzie korzystać z możliwości rejestrowania „własnych”.

Ponieważ pracujemy z Node.js, wybrałem log4js-node, który jest w zasadzie wersją biblioteki log4js do użytku z Node.js. Ta biblioteka ma kilka fajnych funkcji, takich jak możliwość rejestrowania kilku poziomów komunikatów (OSTRZEŻENIE, BŁĄD, itp.) i możemy mieć plik kroczący, który można dzielić na przykład codziennie, więc nie musimy radzić sobie z ogromnymi plikami, których otwarcie zajmie dużo czasu i będzie trudne do przeanalizowania i przeanalizowania.

Dla naszych celów stworzyłem małe opakowanie wokół węzła log4js, aby dodać kilka dodatkowych pożądanych możliwości. Zwróć uwagę, że zdecydowałem się utworzyć opakowanie wokół węzła log4js, którego będę używał w całym kodzie. To lokalizuje implementację tych rozszerzonych możliwości rejestrowania w jednej lokalizacji, unikając w ten sposób nadmiarowości i niepotrzebnej złożoności w całym kodzie podczas wywoływania rejestrowania.

Ponieważ pracujemy z I/O i mielibyśmy kilku klientów (użytkowników), którzy będą tworzyli kilka połączeń (gniazd), chcę móc śledzić aktywność konkretnego użytkownika w plikach dziennika, a także chcę wiedzieć źródło każdego wpisu w dzienniku. Dlatego spodziewam się, że będę mieć kilka wpisów dziennika dotyczących stanu aplikacji, a niektóre są specyficzne dla aktywności użytkownika.

W moim kodzie opakowania logowania mogę zmapować identyfikator użytkownika i gniazda, co pozwoli mi śledzić akcje, które zostały wykonane przed i po zdarzeniu ERROR. Opakowanie rejestrowania pozwoli mi również na tworzenie różnych rejestratorów z różnymi informacjami kontekstowymi, które mogę przekazać do obsługi zdarzeń, dzięki czemu znam źródło wpisu w dzienniku.

Kod opakowania logowania jest dostępny tutaj.

Konfiguracja

Często konieczna jest obsługa różnych konfiguracji systemu. Różnice te mogą być różnicami między środowiskami programistycznymi i produkcyjnymi, a nawet wynikać z potrzeby wyświetlania różnych środowisk klientów i scenariuszy użytkowania.

Zamiast wymagać zmian w kodzie w celu obsługi tego, powszechną praktyką jest kontrolowanie tych różnic w zachowaniu za pomocą parametrów konfiguracyjnych. W moim przypadku potrzebowałem możliwości posiadania różnych środowisk wykonawczych (staging i produkcyjnych), które mogą mieć różne ustawienia. Chciałem również upewnić się, że testowany kod działa dobrze zarówno w fazie testowej, jak i produkcyjnej, a gdybym musiał w tym celu zmienić kod, unieważniłoby to proces testowania.

Używając zmiennej środowiskowej Node.js, mogę określić, którego pliku konfiguracyjnego chcę użyć do konkretnego wykonania. Dlatego przeniosłem wszystkie wcześniej zakodowane parametry konfiguracyjne do plików konfiguracyjnych i stworzyłem prosty moduł konfiguracyjny, który ładuje odpowiedni plik konfiguracyjny z żądanymi ustawieniami. Sklasyfikowałem również wszystkie ustawienia, aby wymusić pewien stopień organizacji w pliku konfiguracyjnym i ułatwić nawigację.

Oto przykład wynikowego pliku konfiguracyjnego:

 { "app": { "port": 8889, "invRepeatInterval":1000, "invTimeOut":300000, "chatLogInterval":60000, "updateUsersInterval":600000, "dbgCurrentStatusInterval":3600000, "roomDelimiter":"_", "roomPrefix":"/" }, "webSite":{ "host": "mysite.com", "port": 80, "friendListHandler":"/MyMethods.aspx/FriendsList", "userCanChatHandler":"/MyMethods.aspx/UserCanChat", "chatLogsHandler":"/MyMethods.aspx/SaveLogs" }, "logging": { "appenders": [ { "type": "dateFile", "filename": "logs/chat-server", "pattern": "-yyyy-MM-dd", "alwaysIncludePattern": false } ], "level": "DEBUG" } }

Przepływ kodu

Do tej pory stworzyliśmy strukturę folderów do obsługi różnych modułów, ustaliliśmy sposób ładowania informacji specyficznych dla środowiska i stworzyliśmy system rejestrowania, więc zobaczmy, jak możemy połączyć wszystkie elementy bez zmiany specyficznego kodu biznesowego.

Dzięki naszej nowej modułowej strukturze kodu, nasz punkt wejścia app.js jest wystarczająco prosty i zawiera tylko kod inicjujący:

 var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);

Kiedy zdefiniowaliśmy naszą strukturę kodu, powiedzieliśmy, że folder ioW będzie zawierał kod biznesowy i powiązany z socket.io. W szczególności będzie zawierać następujące pliki (pamiętaj, że możesz kliknąć dowolną z wymienionych nazw plików, aby wyświetlić odpowiedni kod źródłowy):

  • index.js – obsługuje inicjalizację i połączenia socket.io, a także subskrypcję zdarzeń, a także scentralizowaną obsługę błędów dla zdarzeń
  • eventManager.js – obsługuje całą logikę związaną z biznesem (obsługę zdarzeń)
  • webHelper.js – metody pomocnicze do wykonywania żądań internetowych.
  • linkedList.js – klasa narzędziowa połączonej listy

Dokonaliśmy refaktoryzacji kodu, który tworzy żądanie sieciowe i przenieśliśmy go do osobnego pliku, dzięki czemu nasza logika biznesowa pozostała w tym samym miejscu i niezmodyfikowana.

Jedna ważna uwaga: na tym etapie eventManager.js nadal zawiera pewne funkcje pomocnicze, które naprawdę powinny zostać wyodrębnione do osobnego modułu. Ponieważ jednak naszym celem w tym pierwszym przejściu była reorganizacja kodu przy jednoczesnym zminimalizowaniu wpływu na logikę biznesową, a te funkcje pomocnicze są zbyt misternie powiązane z logiką biznesową, zdecydowaliśmy się odroczyć to do kolejnego przejścia w celu poprawy organizacji kod.

Ponieważ Node.js jest z definicji asynchroniczny, często spotykamy się ze szczurzym gniazdem „piekła zwrotnego”, co sprawia, że ​​nawigacja po kodzie i debugowanie są szczególnie trudne. Aby uniknąć tej pułapki, w mojej nowej implementacji użyłem wzorca obietnic i specjalnie wykorzystuję bluebird, który jest bardzo przyjemną i szybką biblioteką obietnic. Obietnice pozwolą nam śledzić kod tak, jakby był synchroniczny, a także zapewnią zarządzanie błędami i czysty sposób na standaryzację odpowiedzi między wywołaniami. W naszym kodzie istnieje niejawna umowa, zgodnie z którą każdy program obsługi zdarzeń musi zwrócić obietnicę, abyśmy mogli zarządzać scentralizowaną obsługą i rejestrowaniem błędów.

Wszystkie programy obsługi zdarzeń zwrócą obietnicę (bez względu na to, czy wykonują wywołania asynchroniczne, czy nie). Dzięki temu możemy scentralizować obsługę i rejestrowanie błędów oraz upewnić się, że jeśli mamy nieobsługiwany błąd w module obsługi zdarzeń, zostanie on przechwycony.

 function execEventHandler(socket, eventName, eventHandler, data){ var sLogger = logging.createLogger(socket.id + ' - ' + eventName); sLogger.info(''); eventHandler(socket, data, sLogger).then(null, function(err){ sLogger.error(err.stack); }); };

W naszej dyskusji na temat rejestrowania wspomnieliśmy, że każde połączenie będzie miało swój własny rejestrator z informacjami kontekstowymi. W szczególności wiążemy identyfikator gniazda i nazwę zdarzenia z rejestratorem podczas jego tworzenia, więc gdy przekazujemy ten rejestrator do programu obsługi zdarzeń, każdy wiersz dziennika będzie zawierał te informacje:

 var sLogger = logging.createLogger(socket.id + ' - ' + eventName);

Warto wspomnieć o jeszcze jednym punkcie dotyczącym obsługi zdarzeń: W oryginalnym pliku mieliśmy wywołanie funkcji setInterval , która znajdowała się w procedurze obsługi zdarzenia połączenia socket.io i zidentyfikowaliśmy tę funkcję jako problem.

 io.on('connection', function (socket) { ... Several event handlers .... setInterval(function() { try { var date = Date.now(); var tmp = []; while (0 < messageHub.count() && messageHub.head().date < date) { var item = messageHub.remove(); tmp.push(item); } ... Post Data to an external web service... } catch (e) { log('ERROR: ex: ' + e); } }, CHAT_LOGS_INTERVAL); });

Ten kod tworzy timer z określonym interwałem (w naszym przypadku był to 1 minuta) dla każdego otrzymanego żądania połączenia . Tak więc, na przykład, jeśli w danym momencie mamy 300 gniazd online, wtedy co minutę będzie wykonywanych 300 timerów. Problem z tym, jak widać w powyższym kodzie, polega na tym, że nie ma użycia gniazda ani żadnej zmiennej, która została zdefiniowana w zakresie obsługi zdarzeń. Jedyną używaną zmienną jest zmienna messageHub , która jest zadeklarowana na poziomie modułu, co oznacza, że ​​jest taka sama dla wszystkich połączeń. W związku z tym nie ma absolutnie potrzeby stosowania oddzielnego timera na połączenie. Więc usunęliśmy to z obsługi zdarzeń połączenia i uwzględniliśmy w naszym ogólnym kodzie initialize , który w tym przypadku jest funkcją Initialize.

Wreszcie, w naszym przetwarzaniu odpowiedzi W webHelper.js dodaliśmy przetwarzanie dla każdej nierozpoznanej odpowiedzi, która będzie rejestrować informacje, które będą pomocne w procesie debugowania:

 if (!res || !res.d || !res.d.IsValid){ logger.debug(sendData); logger.debug(data); reject(new Error('Request failed. Path ' + params.path + ' . Invalid return data.')); return; }

Ostatnim krokiem jest skonfigurowanie pliku dziennika dla standardowego błędu Node.js. Ten plik będzie zawierał nieobsłużone błędy, które mogliśmy przeoczyć. Aby ustawić proces węzła w systemie Windows (nie idealny, ale wiesz…) jako usługę, używamy narzędzia o nazwie nssm, które ma wizualny interfejs użytkownika, który pozwala zdefiniować standardowy plik wyjściowy, standardowy plik błędów i zmienne środowiskowe.

O wydajności Node.js

Node.js to jednowątkowy język programowania. Aby poprawić skalowalność, istnieje kilka alternatyw, które możemy zastosować. Istnieje moduł klastra węzłów lub po prostu dodaje się więcej procesów węzłów i umieszcza na nich nginx, aby wykonać przekazywanie i równoważenie obciążenia.

Jednak w naszym przypadku, biorąc pod uwagę, że każdy podproces klastra węzłów lub proces węzłów będzie miał własną przestrzeń pamięci, nie będziemy w stanie łatwo udostępniać informacji między tymi procesami. Tak więc w tym konkretnym przypadku będziemy musieli użyć zewnętrznego magazynu danych (takiego jak redis), aby gniazda online były dostępne dla różnych procesów.

Wniosek

Mając to wszystko na swoim miejscu, osiągnęliśmy znaczące oczyszczenie kodu, który został nam pierwotnie przekazany. Nie chodzi o to, aby kod był doskonały, ale raczej o przeprojektowanie go, aby stworzyć czysty fundament architektoniczny, który będzie łatwiejszy w utrzymaniu i utrzymaniu oraz który ułatwi i uprości debugowanie.

Stosując się do kluczowych zasad projektowania oprogramowania wymienionych wcześniej – konserwowalności, rozszerzalności, modułowości i skalowalności – stworzyliśmy moduły i strukturę kodu, które jasno i wyraźnie określają obowiązki poszczególnych modułów. Zidentyfikowaliśmy również pewne problemy w oryginalnej implementacji, które prowadziły do ​​dużego zużycia pamięci, które obniżało wydajność.

Mam nadzieję, że podobał Ci się artykuł, daj mi znać, jeśli masz dalsze komentarze lub pytania.