Poza CRUD: Te nawyki architektoniczne, które zapewniają utrzymaność aplikacji MERN
Przyswoj sobie nawyki na poziomie systemu, które zapewniają zdrowy rozwój aplikacji MERN w miarę jej rozwoju: odpowiedzialność za dane, umowy API, stan pochodny, kod warstwowy, lekkie dane do przesyłania oraz spójne komunikaty o błędach.
Większość tutoriali o MERN kończy się działającą aplikacją typu CRUD: MongoDB przechowuje dane, Express udostępnia kilka tras, React renderuje listę, a czasami istnieje ekran logowania. Ta aplikacja działa, ale rzadko przetrwa rozwój bez zmian. W miarę dodawania nowych funkcji i współpracowników API staje się trudne do modyfikacji, stan aplikacji traci synchronizację, strony zwalniają, a debugowanie zajmuje więcej czasu niż samo tworzenie. Rzadko to narzędzia są przyczyną problemów. Ten przewodnik omawia dziesięć nawyków architektonicznych, które pomagają rozwiązać te problemy, dzięki czemu możesz postrzegać aplikację MERN jako jeden system, a nie cztery oddzielne biblioteki.
Traktuj MERN jako przepływ danych, a nie listę narzędzi
Zwykły opis tej technologii to po prostu jej składniki:
MongoDB + Express + React + Node.js
To jest dokładne, ale nic nie mówi o tym, jak poszczególne elementy ze sobą współpracują, podobnie jak opisanie samochodu jako silnika, czterech kół i kierownicy. Bardziej przydatny obraz przedstawia przepływ danych od użytkownika do bazy danych i z powrotem:
User
│
▼
React
│
HTTP
│
▼
Express + Node
│
Database Queries
│
▼
MongoDB
Każda strzałka oznacza granicę z własnymi regułami: co przeglądarka może wysłać, co serwer przyjmuje, co baza danych przechowuje. Większość poniższych zasad dotyczy decydowania o tym, co dzieje się w tych granicach.
1. Przypisz każdemu elementowi danych jednego właściciela
Podczas tworzenia nowej funkcjonalności najpierw należy sprawdzić, kto jest właścicielem związanych z nią danych. W wielu młodych bazach kodowych odpowiedź na to pytanie jest niejasna. Dane aktualnego użytkownika mogą znajdować się jednocześnie w stanie komponentu, w sklepie Redux, w localStorage, w odpowiedzi z API oraz w jakimś innym buforze pamięci. Prędzej czy później jedna z kopii zostaje w tyle, a interfejs pokazuje dwie sprzeczne wersje tego samego faktu.
Jasniejszy podział obowiązków:
- MongoDB jest źródłem prawdy dla danych trwałych.
- Tło systemu odpowiada za reguły biznesowe określające, w jaki sposób dane mogą ulegać zmianie.
- Frontend wyświetla dane i wysyła prośby o zmiany przez API; każda kopia po stronie klienta stanowi jedynie bufor pamięci, a nie autorytetowe źródło informacji.
Zasadą przewodnią jest to, że każdy element danych ma dokładnie jedno źródło prawdy, a wszystkie inne kopie wiedzą, że mogą być przestarzałe. Biblioteki do zarządzania stanem serwera istnieją głównie po to, by wyraźnie kontrolować ten proces buforowania; więcej na temat tego aspektu problemu znajdziesz w artykule przemyślenia na temat stanu serwera przy użyciu React Query i Redux.
2. Projektowanie API jako umów
Typowy pierwszy endpoint wygląda tak:
app.get("/users", async (req, res) => {
const users = await User.find();
res.json(users);
});
Funkcjonuje to, ale jednocześnie w tle obiecuje, że odpowiedź zawsze będzie tablicą pełnych dokumentów użytkowników, niezależnie od tego, jakie pola ma model. Gdy aplikacja mobilna, panel sterowania, integracja z partnerem lub inne zespoły polegają na takiej strukturze, jej zmiana może je sparaliżować.
Zanim dodasz endpoint, zdecyduj:
- dokładnie które pola są zwracane, zamiast przekazywać surowy model (co może również ujawnić wewnętrzne lub poufne pola);
- czy można to później zmienić bez uszkodzenia klientów, czy też wymaga to wersjonowania;
- kterze inne systemy mogą z niego korzystać.
Dobrze zaprojektowana API może przetrwać kilka interfejsów użytkownika; nieostrożna natomiast staje się długiem technicznym w ciągu zaledwie kilku miesięcy.
3. Przechowuj jak najmniej stanu React
React jest prezentowany jako biblioteka do tworzenia interfejsów użytkownika, ale w rzeczywistych aplikacjach większość trudności wiąże się ze stanem. Częstym błędem jest przechowywanie wartości w stanie, które można obliczyć:
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
Tutaj filteredUsers jest w całości określony przez users. Przechowywanie go oddzielnie oznacza, że każda aktualizacja musi utrzymywać oba elementy w synchronizacji, a jeden zapomniany przypadek powoduje użycie przestarzałej listy. Lepiej obliczyć go podczas renderowania:
const filteredUsers = users.filter(user => user.active);
Zasada polega na przechowywaniu tylko tych danych, których nie można obliczyć, a wszystko inne należy wywnioskować. Jeśli takie wyprowadzanie danych staje się naprawdę kosztowne, useMemo może je zapamiętać w pamięci cache, ale nadal pozostają to dane wywnioskowane, a nie drugie źródło prawdy.
4. Pamiętaj, że CRUD to najłatwiejsza część
Bardzo wiele projektów kończy się na czterech podstawowych operacjach:
Create
Read
Update
Delete
Produkcyjny backend obejmuje te operacje o wiele większą liczbą funkcji: walidację, autoryzację, uprawnienia, reguły biznesowe, ograniczenia szybkości, ślady audytowe, logowanie i powiadomienia. Porównaj prosty mechanizm tworzenia danych:
await User.create(req.body);
z wersją, która najpierw sprawdza dane wejściowe
if (!isValid(req.body))
throw new Error("Invalid input");
i upewnia się, że osoba wywołująca ma prawo do działania, zanim coś zapisać:
if (!canCreateUser(req.user))
throw new Error("Unauthorized");await User.create(req.body);
Prosta wersja niesie również ryzyko masowego przypisywania: przekazywanie bezpośrednio req.body do funkcji create pozwala klientowi ustawiać dowolne pola akceptowane przez schemat, w tym na przykład flagę role. Należy wyraźnie weryfikować i wybierać dopuszczalne pola. Należy również pamiętać, że nieudana weryfikacja uprawnień to koncepcyjnie błąd 403 (zabronione), a nie 401, bez względu na treść komunikatu o błędzie. Zapisywanie danych jest proste; ochrona tych danych stanowi trudny aspekt projektowania backendu.
5. Rozdzielanie zadań na warstwy
Najwyraźniejsza różnica pomiędzy kodem amatorskim a profesjonalnym polega na tym, gdzie znajduje się logika. Mieszanie różnych zadań skutkuje plikami obsługującymi ścieżki pełnymi zapytań do bazy danych, komponentami React pełnymi reguł walidacji oraz kontrolerami przepełnionymi logiką biznesową, przy czym każdy z tych plików stale się powiększa.
Backend zorganizowany w warstwy przydziela jedno zadanie na każdą warstwę:
Routes
│
Controllers
│
Services
│
Repositories
│
Database
Trasy mapują adresy URL na obsługiwacze, kontrolery przekształcają żądania HTTP w wywołania funkcji, usługi przechowują zasady biznesowe, a repozytoria komunikują się z bazą danych. Małe, jednocełowe jednostki są łatwiejsze do testowania i modyfikacji. Aby zapoznać się z bardziej szczegółowym opisem, sprawdź projektowanie warstwowego API w Node.js.
6. Najpierw popraw wydajność na poziomie źródła danych
Gdy pytają, jak przyspieszyć aplikację React, większość programistów ucieka się do useMemo, React.memo i useCallback. One pomagają, ale wiele problemów z wydajnością pojawia się jeszcze przed zaangażowaniem React. Wyobraź sobie takie żądanie:
GET /users
które zwraca
50,000 users
podczas gdy ekran pokazuje tylko
10 users
Żadna ilość memozycji nie może zrekompensować wysyłania i parsowania dziesiątek tysięcy niepotrzebnych rekordów. Trzeba to rozwiązać na poziomie źródła:
- paginuj wyniki;
- filtrowaj na serwerze;
- wysyłaj tylko te pola, których potrzebuje klient;
- kompresuj odpowiedzi;
- przechowuj dane w pamięci cache celowo, z jasnym planem unieważniania.
Najszybciej renderowalnym komponentem jest ten, który nigdy nie otrzymuje danych, których nie potrzebuje.
7. Uczynienie obsługi błędów częścią projektu
Kod rozwojowy często „połyka” takie błędy:
try {
...
}
catch(error){
console.log(error);
}
Rejestrowanie wydarzeń i dalsza praca maskuje awarię przed klientem oraz systemami monitoringu. API produkcyjne wymagają błędów, które są spójne i czytelne dla maszyn:
return res.status(400).json({
message: "Invalid email address",
code: "INVALID_EMAIL"
});
Stabilna struktura z czytelnym dla człowieka message oraz kodem zrozumiałym dla maszyny oznacza, że interfejs użytkownika może przyporządkowywać kody do konkretnych komunikatów, logi można grupować według kodu, alerty mogą być wyświetlane przy nieprawidłowych częstotliwościach, a debugowanie rozpoczyna się od znanej kategorii zamiast od ścieżki wywołania. Błędy są nieuniknione; celem jest ich przewidywalne radzenie sobie.
8. Organizacja kodu według funkcji
Przy dwudziestu plikach każda struktura folderów będzie odpowiednia. Przy pięciuset plikach ma to ogromne znaczenie. Układ grupowany według typu technicznego rozprzestrzenia jedną funkcję po całym drzewie:
routes/
controllers/
models/
Grupowanie według funkcji sprawia, że wszystko związane z jedną dziedziną znajduje się w jednym miejscu:
users/
routes.js
controller.js
service.js
validation.js
orders/
routes.js
controller.js
service.js
Gdy zmienia się logika zamówień, otwiera się folder orders i nic więcej. Folderzy funkcjonalne ułatwiają również określenie odpowiedzialności, przegląd kodu oraz ewentualne przeniesienie go do oddzielnych usług.
9. Myśl w kategoriach systemów, a nie zleceń
Prośba o funkcję typu „dodaj logowanie” może być rozwiązana w wąskim zakresie, poprzez formularz i trasę. Podejście oparte na myśleniu systemowym wymaga zadania dodatkowych pytań: jak funkcjonuje autoryzacja od początku do końca, gdzie przechowywane są tokeny, jak egzekwowane są uprawnienia, co się dzieje, gdy token wygasa, oraz jak przyszły klient mobilny będzie się logował. Odpowiedź na te pytania już teraz wymaga nieco więcej pracy, ale zapobiega konieczności przepisywania kodu później.
10. Świadomie wybieraj kompromisy
Żadna architektura nie jest najlepsza we wszystkich sytuacjach. Każda opcja ma swoje zalety i wady:
- Prosta architektura: szybsza w budowie, trudniejsza do skalowania.
- Mikrosługi: niezależne skalowanie, znacznie wyższa złożoność operacyjna.
- Stan globalny: łatwe dzielenie się danymi między komponentami, trudniejsze debugowanie.
- Znormalizowany projekt bazy danych: mniej duplikatów, więcej połączeń lub zapytań.
- Agresywne cacheowanie: szybsze odpowiedzi, ciągły problem unieważniania cache’u.
Dobrzy inżynierowie to nie ci, którzy znają każdy wzorzec, ale ci, którzy potrafią wyjaśnić, kiedy dany wzorzec jest warte poniesionych kosztów.
Jak projekty MERN w trakcie rozwoju zazwyczaj ulegają degradacji
Etap 1: wszystko jest proste
Pierwsza wersja obejmuje podstawy:
CRUD
Authentication
Dashboard
Deployment
Kod jest niewielki, a wszyscy go rozumieją.
Etap 2: rozwój ujawnia skróty
Pojawia się coraz więcej użytkowników, funkcji oraz programistów. W całym kodzie pojawia się powtarzająca się logika, niespójne punkty końcowe, powolne strony, skomplikowany stan aplikacji oraz trudny proces debugowania.
Etap 3: winą obarczane są narzędzia
Zespół dochodzi do wniosku, że React nie pozwala na skalowanie lub że wybór MongoDB był błędem. Zazwyczaj żadne z tych twierdzeń nie jest prawdziwe. Architektura po prostu nigdy nie ewoluowała wraz z aplikacją.
Analogia restauracji do poszczególnych warstw
Traktuj stack jak restaurację. MongoDB to spiżarnia, w której przechowywane są wszystkie składniki. Express i Node to kuchnia: one decydują, co zostanie ugotowane, jak zostanie przygotowane i kto może złożyć zamówienie. React to obsługa gości, która prezentuje im gotowe dania. Goście nie muszą wiedzieć, jak działa kuchnia, a kuchnia nie przejmuje się tym, jak talerze są ułożone na stole. Każda część dobrze wykonywa swoją pracę, co jest dokładnie tym rozdzieleniem, którego potrzebuje aplikacja MERN.
Główne wnioski
Znajomość MERN polega mniej na pisaniu zapytań, tras i komponentów, a bardziej na rozumieniu ścieżek, którymi przemieszczają się dane, umieszczaniu reguł biznesowych w odpowiedniej warstwie, rozwijaniu API bez uszkadzania klientów, utrzymywaniu minimalnego stanu oraz rozpoznawaniu tego, jak wczesne decyzje mają wpływ na dalszy rozwój.
- Nadaj każdemu elementowi danych jednego właściciela i traktuj wszystkie inne kopie jako pamięć cache.
Gdy pojawia się nowa funkcja, najbardziej przydatne pytanie to nie to, jak ją zbudować, ale gdzie należy umieścić każdą odpowiedzialność.
Literatura pokrewna
- Te nawyki architektoniczne, które utrzymują bazy kodu frontendu w stanie nadajączym się do utrzymania przez lata — Wyjaśnia nawyki strukturalne takie jak optymalizacja pod kątem możliwości usunięcia, wyraźny przepływ danych oraz izolacja logiki biznesowej, które pomagają bazom kodu pozostać nadającymi się do utrzymania na przestrzeni lat zmian.
- Potknięcia w architekturze backendu, które utrudniają pracę zespołom React skupionym najpierw na frontendzie — Wyjaśnia pięć typowych błędów w projektowaniu backendu występujących w projektach opartych na React – od niewłaściwego używania paradygmatu API po kruche implementacje – oraz rozwiązania architektoniczne zapewniające niezawodność na poziomie produkcyjnym.
- Localhost to nie produkcja: diagnozowanie aplikacji React, które zawodzą przy deployu — Dowiedz się, dlaczego aplikacja React działająca poprawnie na twoim komputerze zawodzi po wdrożeniu, oraz jak prześledzić URL-y API, zmienne środowiskowe, CORS, routowanie, zasoby i mechanizmy autoryzacji aż do warstwy powodującej awarię.