Strona główna / Artykuły

Projektowanie warstwowe API w Node.js: od obsługujących wiele zadań kontrolerów do czystej architektury

Dowiedz się, jak przeredagować API w Node.js na warstwy kontrolera, usługi i dostępu do danych, aby naprawić skomplikowaną logikę biznesową, niespójne błędy oraz trudności z skalowaniem.

Wydawca: REACTAPP.TOP10 min czytania2199 słów

Źródła: I Built a Node.js API. Then I Realized I Was Doing It Wrong.

Pрактиczne porady dotyczące prawidłowej strukturyzacji, skalowania i utrzymywania backendu w Node.js, oparte na doświadczeniach z tworzenia API produkcyjnych.

Gdy po raz pierwszy zaczynasz pisać API w Node.js, praca nad backendem może wydawać się zaskakująco prosta.

Zdefiniuj trasę.

Połącz ją z bazą danych.

Wyślij z powrotem trochę JSON.

To wszystko.

I przez jakiś czas taki podejście rzeczywiście działa.

Następnie aplikacja zaczyna rosnąć.

Nagle stajesz przed następującymi problemami:

  • Zasady biznesowe wbudowane bezpośrednio w kontrolery
  • Zapytania do bazy danych rozproszone w każdym pliku trasy
  • Powtarzająca się logika walidacji
  • Obsługa błędów różniąca się w zależności od punktu końcowego
  • Zbyt duże pliki
  • Kod, który utrudnia automatyczne testowanie
  • Logika autoryzacji splątana z logiką domeny
  • Malże zmiany, które psują funkcje wydające się niespowiązane

API technicznie nie „awariuje”.

Występuje coś bardziej subtelnego, co idzie nie tak:

Wnioskowanie zmian w nim stało się naprawdę trudne.

To właśnie wtedy uświadamiamy sobie, że istnieje rzeczywała różnica pomiędzy API, które po prostu działa, a takim, które może wytrzymać ciągły rozwój w rzeczywistych warunkach.

1. Moja wczesna architektura Node.js

Typowe API na wczesnym etapie mogło wyglądać tak:

const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
  const users = await db.query("SELECT * FROM users");  res.json(users);
});app.post("/users", async (req, res) => {
  const { name, email } = req.body;  const user = await db.query(
    "INSERT INTO users (name, email) VALUES ($1, $2)",
    [name, email]
  );  res.json(user);
});app.listen(3000);

Żaden z tych kodów sam w sobie nie jest błędny.

Dla szybkiego prototypu to zupełnie rozsądny punkt wyjścia.

Problemy pojawiają się, gdy projekt zaczyna się rozwijać.

Wyobraźmy sobie warstwowanie takich funkcjonalności jak:

  • Autoryzacja
  • Prawa dostępu
  • Walidacja danych wejściowych
  • Weryfikacja adresu e-mail
  • Przetwarzanie płatności
  • Zarządzanie kuponami
  • Kierowanie zamówieniami
  • Śledzenie zapasów
  • Powiadomienia
  • Przetwarzanie zadań w tle
  • Caching oparty na Redis
  • A teraz wyobraź sobie, że musisz włączyć wszystkie te elementy bezpośrednio do swoich obsługowników tras.

    W takim przypadku nie masz już prawdziwej architektury.

    Masz po prostu masę kodu, który się wykonuje.

    2. Problemem nie był Node.js

    To błąd, który łatwo popełnić na początku.

    Pierwotny odruch to myślenie w ten sposób:

    ">Mój kod staje się chaotyczny tylko dlatego, że aplikacja rośnie.

    To jest tylko część historii.

    Prawdziwą przyczyną była brak wyraźnego podziału obowiązków.

    Jeden kontroler zajmował się zbyt wieloma rzeczami.

    Odpowiadał za:

    1. Analizę przychodzącej żądania
    2. Walidację danych wejściowych
    3. Weryfikację autoryzacji
    4. Wykonywanie logiki biznesowej
  • Rozmowa z bazą danych
  • Kształtowanie odpowiedzi
  • Likwidacja i obsługa błędów
  • To sześć lub siedem odrębnych zadań zebranych w jednym miejscu.

    Za każdym razem, gdy zmieniały się wymagania, trzeba było ponownie edytować ten sam plik.

    Ponawianie tego procesu.

    3. Architektura, której używam obecnie

    Czystszą strategią jest podzielenie aplikacji na odrębne warstwy.

    Naprzимер:

    src/
    │
    ├── controllers/
    │   └── user.controller.ts
    │
    ├── services/
    │   └── user.service.ts
    │
    ├── repositories/
    │   └── user.repository.ts
    │
    ├── dto/
    │   └── create-user.dto.ts
    │
    ├── middleware/
    │   └── auth.middleware.ts
    │
    ├── routes/
    │   └── user.routes.ts
    │
    ├── utils/
    │   └── error-handler.ts
    │
    └── app.ts
    

    Zasada leżąca u podstaw jest prosta:

    Każda warstwa powinna mieć jedną jasno określoną responsybilność.

    4. Kontrolery nie powinny zawierać logiki biznesowej

    Zadaniem kontrolera powinny być wyłącznie kwestie związane z HTTP.

    Mniej więcej tak:

    export class UserController {
      constructor(
        private readonly userService: UserService
      ) {}
    
      async createUser(req: Request, res: Response) {
        try {
          const user = await this.userService.createUser(req.body);      return res.status(201).json({
            success: true,
            data: user,
          });
        } catch (error) {
          return res.status(500).json({
            success: false,
            message: error.message,
          });
        }
      }
    }
    

    Kontroler nie musi wiedzieć:

    • Gdzie lub jak przechowywane są dane użytkownika
    • Która technologia bazy danych jest używana
  • Jak hasła są haszowane
  • Czy konieczne jest wysłanie e-maila potwierdzającego
  • Czy Redis jest w jakikolwiek sposób zaangażowany
  • To nie jest jego zadaniem.

    Jego jedyną odpowiedzialnością jest obsługa przychodzących żądań i kształtowanie wychodzących odpowiedzi.

    5. Logika biznesowa w usługach

    Szczegółowa logika aplikacji znajduje się w warstwie usług.

    export class UserService {
      constructor(
        private readonly userRepository: UserRepository
      ) {}
    
      async createUser(data: CreateUserDto) {
        const existingUser =
          await this.userRepository.findByEmail(data.email);    if (existingUser) {
          throw new Error("User already exists");
        }    const hashedPassword = await hashPassword(
          data.password
        );    return this.userRepository.create({
          ...data,
          password: hashedPassword,
        });
      }
    }
    

    To umożliwia ponowne użycie tej logiki.

    Inna część systemu, na przykład, może po prostu wywołać:

    userService.createUser(data);
    

    bez konieczności symulowania żądania HTTP.

    Ta możliwość ponownego użycia ma duże znaczenie zarówno pod względem testowania, jak i długoterminowej utrzymywalności.

    6. Dostęp do bazy danych wymaga osobnej warstwy

    Logika biznesowa nie powinna być przepełniona surowymi zapytaniami SQL czy rozproszonymi wywolaniami ORM.

    To właśnie problem, który rozwiązują repozytoria.

    export class UserRepository {
    
      async findByEmail(email: string) {
        return prisma.user.findUnique({
          where: {
            email,
          },
        });
      }  async create(data: CreateUserInput) {
        return prisma.user.create({
          data,
        });
      }
    }
    

    Gdy to zostanie wdrożone, rozdzielenie zadań staje się oczywiste.

    Request
       ↓
    Controller
       ↓
    Service
       ↓
    Repository
       ↓
    Database
    

    Każda warstwa zna tylko to, co koniecznie musi wiedzieć, i nic więcej.

    7. Dlaczego DTO są ważniejsze, niż się spodziewasz

    Częstym błędem na początku jest przekazywanie surowych treści żądań bezpośrednio do aplikacji.

    Cos takiego:

    const user = await userService.createUser(req.body);
    

    To jest ryzykowne.

    Co tak naprawdę znajduje się wewnątrz req.body?

    Czy możesz być pewien?

    DTO ustala jasną, określoną umowę dotyczącą tego, czego oczekuje się.

    class CreateUserDto {
      name: string;
      email: string;
      password: string;
    }
    

    Następnie dane przychodzące są weryfikowane, zanim zostanie im pozwolone dotrzeć do logiki aplikacji.

    Naprzимер, treść żądania taka jak:

    {
      "name": "Mit",
      "email": "mit@example.com",
      "password": "secret123"
    }
    

    może zostać sprawdzona pod kątem takich reguł jak:

    • Pole obowiązkowe
    • Format adresu e-mail
    • Długość hasła
    • Typy danych
  • Dozwolone wartości
  • Zasada podstawowa jest prosta:

    Nigdy nie ufaj danych wprowadzanych przez klienta.

    Frontend nie pełni roli bariery bezpieczeństwa.

    8. Spójność w obsłudze błędów ma znaczenie

    Kolejnym problemem w wczesnych fazach tworzenia API jest niestabilna struktura odpowiedzi.

    Jedna ścieżka może zwracać:

    {
      "error": "User not found"
    }
    

    Inna odpowiada w ten sposób:

    {
      "message": "Not found"
    }
    

    A jeszcze inna wygląda tak:

    {
      "success": false,
      "data": null
    }
    

    Dla każdego, kto buduje frontend integrujący się z tym API, ta niestabilność jest wyczerpująca.

    Zastosowanie jednego, centralizowanego podejścia do obsługi błędów znacznie ułatwia pracę z API.

    Naprzимер:

    {
      "success": false,
      "message": "User not found",
      "code": "USER_NOT_FOUND"
    }
    

    Konkretna struktura nie ma większego znaczenia.

    Ważne jest to, że zawsze jest taka sama.

    9. Autoryzacja to nie upoważnienie

    To rozróżnienie sprawia wiele trudności wielu programistom na wczesnym etapie.

    Autoryzacja odpowiada na jedno pytanie:

    Kto jesteś?

    Prawa dostępu odpowiadają na inne pytanie:

    Cóż możesz robić?

    W uproszczeniu wygląda to mniej więcej tak:

    Login
      ↓
    Authentication
      ↓
    JWT / Session
      ↓
    User identified
      ↓
    Authorization
      ↓
    Can this user delete the product?
    

    To, że użytkownik został zautoryzowany, nie oznacza, że może wykonywać dowolne działania, jakie chce.

    Dlatego właśnie sprawdzanie ról i uprawnień musi odbywać się oddzielnie od podstawowej logiki logowania i autoryzacji.

    10. W końcu trzeba pomyśleć o skalowalności

    Gdy kod jest już w miarę dobrze zorganizowany, pojawia się nowe pytanie:

    Co się stanie, gdy ruch wzrośnie?

    To właśnie w tym momencie narzędzia takie jak Redis, kolejki zadań i indeksy bazy danych zaczynają odgrywać ważną rolę.

    Załóżmy endpoint, który przy każdym żądaniu wykonywałby kosztowną zapytanie do bazy danych.

    Zamiast tego:

    Request
       ↓
    Database
       ↓
    Response
    

    system może ostatecznie przejść na coś w rodzaju:

    Request
       ↓
    Redis Cache
       ↓
    Cache Hit → Response
       │
       └── Cache Miss
              ↓
           Database
              ↓
           Redis
              ↓
           Response
    

    A jeśli chodzi o wszystko, co nie powinno blokować odpowiedzi:

    API
     ↓
    Queue
     ↓
    Worker
     ↓
    Email / Notification / Processing
    

    To zapobiega temu, by powolne, nieważne operacje opóźniały rzeczywiste żądanie.

    Ale oto zastrzeżenie, które warto powtórzyć:

    Nie uciekaj się do Redis, kolejek, Kafka, mikrosług czy Kubernetes tylko dlatego, że wyglądają imponująco.

    Architektura istnieje po to, by rozwiązywać rzeczywiste problemy.

    Nie powinna tworzyć nowych.

    11. Najważniejsza lekcja

    Prawdziwa zmiana sposobu myślenia sprowadza się do tego:

    Przestań optymalizować szybkość pisania kodu.

    Zamiast tego optymalizuj to, jak bezpiecznie można go później zmienić.

    Gdy rozpoczyna się pierwszy projekt, umieszczanie wszystkiego w jednym pliku wydaje się efektywne.

    I na początku rzeczywiście tak jest.

    Jednak napisanie wersji początkowej to zaledwie niewielka część całej pracy.

    Większość zmian w bazie kodu następuje później.

    Z upływem czasu projekt będzie wymagał:

    • Dodawania nowych funkcji
    • Likwidowania błędów
    • Przystosowywania się do zmieniających się wymagań
    • Zmiany bibliotek
    • Aktualizacji schematów bazy danych
    • Dodawania mechanizmów autoryzacji
    • Wprowadzania systemów uprawnień
    • Sprawiania, by zapytania działały szybciej
    • Łączenia się z usługami third-party

    Jeśli architektura sprawia, że każda z tych czynności staje się trudna, szybkość osiągnięta na początku tak naprawdę nie była zaletą.

    12. Lepszy punkt wyjścia

    Nowoczesny backend w Node.js mógłby być rozplanowany mniej więcej w ten sposób:

    src/
    │
    ├── modules/
    │   ├── users/
    │   │   ├── user.controller.ts
    │   │   ├── user.service.ts
    │   │   ├── user.repository.ts
    │   │   ├── user.dto.ts
    │   │   └── user.routes.ts
    │   │
    │   ├── products/
    │   │   ├── product.controller.ts
    │   │   ├── product.service.ts
    │   │   ├── product.repository.ts
    │   │   └── product.dto.ts
    │
    ├── middleware/
    ├── config/
    ├── database/
    ├── utils/
    └── app.ts
    

    W większych aplikacjach organizowanie kodu wokół modułów lub funkcji zazwyczaj przynosi lepsze rezultaty niż gromadzenie wszystkiego w ogromnych folderach współdzielonych, pełnych niespowiązanych kontrolerów i usług.

    Nie chodzi o dokładną strukturę folderów.

    Ważne jest, aby odpowiedzialność każdej części była oczywista.

    13. Siedem zasad, których warto przestrzegać

    W skrócie wszystko sprowadza się do siedmiu zasad:

    1. Utrzymuj kontrolery w prostocie

    Kontrolery służą do obsługi HTTP, nic więcej.

    2. Umieszczaj logikę biznesową w usługach

    To właśnie w usługach powinno znajdować się rzeczywiste zachowanie aplikacji.

    3. Izoluj operacje bazodanowe

    Unikaj sytuacji, gdy zapytania do bazy danych przenikają do każdego zakątka kodu.

    4. Waliduj dane wejściowe

    Nie zakładaj, że req.body zawiera to, czego się oczekuje.

    5. Ujednolicaj błędy

    Zrób tak, aby odpowiedzi na błędy były przewidywalne i jednolite.

    6. Dodawaj infrastrukturę tylko w razie potrzeby

    Redis, kolejki i mikrosługi to narzędzia stosowane w uzasadnionych przypadkach, a nie oznaka wyższej kwalifikacji.

    7. Projektuj z myślą o zmianach

    Każdy, kto będzie utrzymywał ten kod, będzie go modyfikował znacznie częściej niż pisał od nowa.

    Wniosek

    Rozruch API w Node.js nie jest trudny.

    Zachowanie jego zrozumiałości przez miesiące podczas ciągłej pracy nad nowymi funkcjami to znacznie większe wyzwanie.

    Latwo utożsamiać dobrą pracę nad backendem z umiejętnością podłączania:

    GET
    POST
    PUT
    DELETE
    

    To jest właściwie tylko punkt wyjścia.

    Prawdziwa umiejętność polega na wiedzy o tym, gdzie powinna znajdować się każda część logiki, jak różne elementy systemu ze sobą komunikują się oraz jak rozwijać go, nie niszcząc przy tym reszty.

    Nie każdy projekt wymaga skomplikowanej architektury.

    Wymaga on architektury proporcjonalnej do złożoności rzeczywistego problemu.

    Ponieważ celem nigdy nie było:

    "Ustawić to do pracy."

    Prawdziwym celem jest:

    "Ułatwić adaptację, gdy wymagania nieuchronnie ulegną zmianie."

    Literatura pokrewna

  • Jak process.nextTick() cicho głodzi pętlę zdarzeń Node.js — Wyjaśnia, dlaczego rekurencyjne wywołania process.nextTick() całkowicie blokują fazę poll w libuv oraz jak naprawić problem głodzenia pętli zdarzeń za pomocą setImmediate().
  • Testowanie Node.js w przedsiębiorstwach: nazewnictwo AAA, objętość testowania i fabryki testów — Dowiedz się, jak strukturyzować testy Node.js przy użyciu wzorca AAA, zrównoważyć objętość testów jednostkowych i integracyjnych, sprawdzić pięć kluczowych wyników w domenie oraz izolować testy za pomocą fabryk.
  • Zrozumienie kluczy idempotencji w Node.js endpunktach POST — Wyjaśnia, dlaczego żądania POST zawodzą w nieprzewidywalny sposób przy ponownych próbach, oraz jak klucze idempotencji generowane przez klienta pozwalają API Node.js bezpiecznie obsługiwać duplikatowe żądania.
  • Strukturyzacja usług Node.js za pomocą modułów opartych na domenie i czystych warstw — Dowiedz się, jak zorganizować bazę kodu Node.js w komponenty oparte na domenie, wprowadzić ścisłą architekturę 3-warstwową oraz udostępnić wspólne narzędzia poprzez czyste publiczne API.