Strona główna / Artykuły

Niezmienialny budownik zapytań oparty na zamknięciach dla Angular HttpClient

Zastąp powtarzający się kod HttpParams prostym budowniczym żądań opartym na zamknięciach, połączonym za pomocą InjectionToken, który pozostaje niezmienialny, typowany i łatwy do testowania.

Wydawca: REACTAPP.TOP9 min czytania2004 słów

Źródła: Stop Hand-Wiring HttpParams: Build a Fluent, Type-Safe Request Builder in Angular

Złożone bazy kodu Angulara mają tendencję do gromadzenia tych samych kilku linijek kodu w dziesiątkach usług: tworzy się HttpParams, ustawia filtr, sposób sortowania oraz stronę, a na końcu wysyła się żądanie. Każda kopia jest nieco inna, a te drobne różnice prowadzą do niespójnych nazw parametrów zapytania oraz różnic w konwencjach sortowania pomiędzy zespołami.

Ten przewodnik pokazuje, jak zastąpić tę powtarzalność sprawnym budowniczym żądań skonstruowanym wyłącznie z zamknięć. Nie wymaga on ani klas, ani dekoratorów, ani dziedziczenia; pojedyncza funkcja fabryczna jest dostępna poprzez InjectionToken i rozwiązywana za pomocą inject(). Zobaczysz, jak to działa, dlaczego jest niezmienne z natury, jak przetestować je bez użycia TestBed, oraz kiedy nie warto go dodawać.

Kod szablonowy, który zostanie zastąpiony

Oto typowy endpoint listy napisany ręcznie.

getUsers(status: string, sortField: string, page: number, size: number) {
  let params = new HttpParams()
    .set('status', status)
    .set('sort', sortField)
    .set('direction', 'asc')
    .set('page', page)
    .set('size', size);

  return this.http.get<User[]>('/api/users', { params });
}

Nie ma w tym nic złego, ale nie skaluje się to dobrze. Jeden serwis nazywa rozmiar strony pageSize, inny używa size; jedna zespoły sortuje za pomocą sort i direction, inne kodują oba parametry w jednym. Każdy nowy endpoint wymusza dodatkowe wywołania HttpParams przed częścią, która faktycznie ma znaczenie. Celem jest miejsce wywołania, które przypomina opis zapytania:

loadUsers() {
  return this.request<User[]>('/api/users')
    .where('status', 'active')
    .sortBy('name')
    .paginate(0, 20)
    .execute();
}

Każde wywołanie w tej łańcuchu zwraca nową, niezależną wartość, a ostateczne wywołanie execute() generuje obserwowalny wynik. Reszta tego przewodnika opisuje elementy, które to umożliwiają.

Fabryka, która zwraca samą siebie

Cały konstruktor składa się z około dwudziestu wierszy.

import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';

type SortDirection = 'asc' | 'desc';

interface RequestBuilder<T> {
  where(key: string, value: string | number | boolean): RequestBuilder<T>;
  sortBy(field: string, direction?: SortDirection): RequestBuilder<T>;
  paginate(page: number, size: number): RequestBuilder<T>;
  execute(): Observable<T>;
}

export function createRequest(http: HttpClient) {
  const build = <T>(url: string, params = new HttpParams()): RequestBuilder<T> => ({
    where: (key, value) => build<T>(url, params.set(key, String(value))),

    sortBy: (field, direction = 'asc') =>
      build<T>(url, params.set('sort', field).set('direction', direction)),

    paginate: (page, size) =>
      build<T>(url, params.set('page', page).set('size', size)),

    execute: () => http.get<T>(url, { params }),
  });

  return build;
}

Główny trik: wywołanie metody build zwraca obiekt, a każda metoda w tym obiekcie wywołuje build ponownie. Każdy krok w łańcuchu tworzy nowego budowniczego, który pracuje z zaktualizowanymi danymi HttpParams. Poprzedni budowniczy nigdy nie jest modyfikowany.

To działa tak sprawnie, ponieważ HttpParams jest już niezmienialny: metoda params.set() pozostawia oryginalną instancję nietkniętą i zwraca nową z dodanym elementem. Buder po prostu przenosi tę właściwość o jeden poziom wyżej, dzięki czemu obiekty buderów odziedziczą tę samą gwarancję. Funkcja where przekształca wartości na łańcuchy tekstowe, natomiast sortBy zapisuje klucze sort i direction (z domyślnym ustawieniem rosnącym), a paginate zapisuje page i size. Tylko execute() łączy się z siecią, wywołując http.get z zebranymi parametrami.

Warto również zwrócić uwagę na to, czego brakuje. Nie ma tu this, żadnego pola prywatnego ani class. Cały stan znajduje się w argumentach funkcji przechwytywanych przez zamknięcia, co sprawia, że nie ma nic, co mogłoby zostać przypadkowo zmienione, ani nic do śledzenia poza aktualną funkcją. Jeśli przechwytywanie argumentów za pomocą zamknięć wydaje się nieznane, wyjaśnienie sposobu, w jaki łańcuch zakresów JavaScript rozwiązuje zmienne omawia ten mechanizm.

Ujawnianie budowniczego poprzez iniekcję zależności

Funkcja, która przyjmuje HttpClient, jest przydatna, ale komponenty powinny móc ją wstrzykiwać tak jak każdą inną zależność. InjectionToken oparty na fabryce został stworzony właśnie w tym celu.

import { InjectionToken, inject } from '@angular/core';

export const REQUEST_BUILDER = new InjectionToken<ReturnType<typeof createRequest>>(
  'REQUEST_BUILDER',
  {
    providedIn: 'root',
    factory: () => createRequest(inject(HttpClient)),
  }
);

Dwa szczegóły zasługują na uwagę.

Niech kompilator wywnioskuje typ

ReturnType<typeof createRequest> oznacza, że typ budowniczego nigdy nie jest wpisywany ręcznie. Gdy createRequest otrzymuje metodę, typ tokena jest automatycznie przypisywany, a każdy użytkownik widzi tę zmianę.

Rozwiązywanie problemu z HttpClient w kontekście iniekcji

inject(HttpClient) jest wykonywany wewnątrz fabryki tokena, a ta fabryka zawsze działa w kontekście iniekcji. To właściwe miejsce na jego wywołanie. Częstym błędem jest używanie inject() z zwykłej metody lub funkcji zwrotnej, która działa poza jakimkolwiek kontekstem iniekcji; wtedy Angular rzuca błąd NG0203 w czasie wykonywania. Rozwiązanie problemu z HttpClient raz, podczas tworzenia tokena, i przechowywanie go w zamknięciu umożliwia późniejsze użycie budowniczego w dowolnym miejscu: w efektach, operatorach RxJS lub zwykłych funkcjach pomocniczych.

Używanie go z komponentu

Komponent wprowadza token do pola i wywołuje go jak funkcję.

@Component({
  selector: 'app-users',
  template: `...`,
})
export class UsersComponent {
  private readonly request = inject(REQUEST_BUILDER);

  loadUsers() {
    return this.request<User[]>('/api/users')
      .where('status', 'active')
      .sortBy('name')
      .paginate(0, 20)
      .execute();
  }
}

Argument generyczny podany na początku, User[], przechodzi przez cały łańcuch, więc execute() zwraca Observable<User[]> bez żadnego przekształcania typu.

Wzorce, które naruszają zasadę niezmienności

Reaktywne wyszukiwanie za pomocą sygnałów

Ponieważ buder jest tanią, niezmienialną wartością, w pełni wystarczy tworzyć nowy za każdym razem, gdy zmieniają się dane wejściowe reaktywne. W tym przypadku zasób sterowany sygnałami odbudowuje zapytanie na podstawie aktualnego tekstu wyszukiwania i strony.

export class ProductSearchComponent {
  private readonly request = inject(REQUEST_BUILDER);

  readonly query = signal('');
  readonly page = signal(0);

  readonly products = rxResource({
    params: () => ({ q: this.query(), page: this.page() }),
    stream: ({ params }) =>
      this.request<Product[]>('/api/products')
        .where('q', params.q)
        .where('inStock', true)
        .sortBy('price', 'desc')
        .paginate(params.page, 24)
        .execute(),
  });
}

Każdorazowo, gdy zmienia się query lub page, funkcja params tworzy nowy obiekt, a funkcja stream buduje na jego podstawie nowe żądanie. Nie istnieje wspólny mechanizm budowania, który można by zresetować pomiędzy kolejnymi wykonyaniami. Należy zauważyć, że rxResource oraz nazwy jego opcji uległy zmianie w najnowszych wersjach Angular (wcześniejsze wersje używały innych nazw właściwości), dlatego należy sprawdzić dokumentację API aktualnej wersji.

Łańcuchowanie warunkowe

Ponieważ każdy krok zwraca wartość zamiast ją modyfikować, opcjonalne filtry można zastosować poprzez przypisanie nowej wartości lokalnej zmiennej.

loadOrders(filter: OrderFilter) {
  let builder = this.request<Order[]>('/api/orders')
    .paginate(filter.page, filter.size);

  if (filter.status) {
    builder = builder.where('status', filter.status);
  }
  if (filter.sortField) {
    builder = builder.sortBy(filter.sortField, filter.direction);
  }

  return builder.execute();
}

Tylko zmienna builder jest przypisywana na nowo; same obiekty builder nigdy się nie zmieniają. Każda gałąź po prostu zastępuje istniejący obiekt builder nowym, rozszerzonym obiektem.

Testowanie bez TestBed

createRequest to zwykła funkcja klasy HttpClient, więc test jednostkowy w ogóle nie wymaga modułu testowego Angulara – wystarczy mały stub, który rejestruje otrzymywane parametry.

it('accumulates params immutably', () => {
  const captured: HttpParams[] = [];
  const httpStub = {
    get: (_url: string, opts: { params: HttpParams }) => {
      captured.push(opts.params);
      return of([]);
    },
  } as unknown as HttpClient;

  const request = createRequest(httpStub);
  const base = request<unknown[]>('/api/items').where('a', 1);

  base.where('b', 2).execute();
  base.where('c', 3).execute();

  expect(captured[0].get('b')).toBe('2');
  expect(captured[0].get('c')).toBeNull(); // branches don't leak into each other
  expect(captured[1].get('c')).toBe('3');
});

Ostateczne twierdzenia pokazują zalety niezmienności. Łańcuchy, które wychodzą od jednego wspólnego budowniczego base, pozostają całkowicie odizolowane: parametr b z pierwszego łańcucha nigdy nie pojawia się w drugim i odwrotnie. Przy budowniku zmiennym druga wywołanie w tajemnicy odziedziczy filtr pierwszego.

Dlaczego taka architektura jest skuteczna

  • Niezmienny z założenia. Nie istnieje żaden stan wewnętrzny, który mógłby zostać uszkodzony. Ponowne użycie częściowo zbudowanej żądania, na przykład jednego zapytania bazowego dla aktywnych użytkowników i innego dla archiwizowanych, jest domyślnie bezpieczne. Budowniczy oparty na klasach, który może ulec zmianie, robi to bez żadnego ostrzeżenia.
  • Jedno słownictwo dla parametrów zapytania. Klucze sort, direction, page i size są zdefiniowane w jednym miejscu. Gdy serwer tła zmienia nazwę któregoś z nich, wystarczy edytować jedną linię kodu, zamiast przeszukiwać czterdzieści różnych usług. Taki mechanizm w praktyce stanowi umowę pomiędzy interfejsem użytkownika a konwencjami zapytań API.
  • Inferencja prowadząca do końca łańcucha. Jeden generyk w punkcie wejścia określa typ każdego kroku, włącznie z metodą execute(). Budowniki oparte na klasach często zmuszają do ponownego przekazywania generyków na każdym poziomie dziedziczenia.
  • Rozwiązywanie zależności odbywa się tylko raz, we właściwym miejscu. Fabryka tokenów rozwiązuje problem HttpClient w kontekście iniekcji gwarantowanym, unikając całej serii błędów typu „inject() wywoływane poza kontekstem iniekcji”, które pojawiają się, gdy elementy są tworzone opóźnionie w funkcjach zwrotnych.
  • Dostępne do usuwania i łatwe w testowaniu. Token fabryki z ustawieniem providedIn: 'root' jest usuwany z pliku pakietu, jeśli nikt go nie używa. Jądro składa się z zwykłej funkcji, więc testy nie wymagają ani TestBed.configureTestingModule, ani HttpTestingController, chyba że konkretnie potrzebujesz testowania na poziomie integracji.
  • Interceptory nadal działają. Funkcja execute() ostatecznie wywołuje HttpClient.get, więc nagłówki autoryzacji, logika ponawiania prób oraz mapowanie błędów w łańcuchu interceptorów pozostają bez zmian. Builder funkcjonuje na wierzchu HttpClient; nie zastępuje go.
  • Koszty i korzyści do rozważenia

    • Klucze to zwykłe łańcuchy znaków. Wyrażenie where('status', 'active') nie jest weryfikowane pod kątem API. Błąd pisowni w kluczu pozwala na skompilowanie kodu, a problem pojawia się dopiero w czasie wykonywania. Ograniczenie kluczy za pomocą typu ogólnego, np. where<K extends keyof UserQuery>, rozwiązuje ten problem, ale kosztem bardziej złożonych typów; należy zdecydować, czy to jest potrzebne dla zespołu.
    • Wielokrotne wywołania przywracają ostatnią wartość. Metoda HttpParams.set zastępuje istniejącą wartość, więc dwukrotne wywołanie sortBy() zachowuje tylko drugie wywołanie. Zazwyczaj jest to dokładnie to, czego chcesz przy sortowaniu, ale zaskakuje to wszystkich, którzy oczekują parametrów wielowartościowych. W przypadku powtarzających się kluczy, takich jak ?tag=a&tag=b, dodaj metodę whereAll, która korzysta z params.append.
    • Celowo tylko GET. Narzędzie to służy do tworzenia ciągów zapytań. Ciała żądań, nagłówki oraz opcje typu odpowiedzi nie są w jego zakresie. Możesz dodać metody takie jak withHeaders lub post(body), ale każda dodatkowa funkcja oznacza powrót do ponownej implementacji opcji, które już posiada obiekt HttpClient. Wiedz, kiedy przestać.
    • Nowy obiekt na każdy krok. Każde łączone wywołanie alokuje budownicza oraz HttpParams. Są to małe, krótkotrwałe obiekty, które silniki JavaScripta mogą łatwo gromadzić, więc w praktyce koszt jest zaniedbywalny, ale warto o tym wiedzieć.
    • Jeszcze jedna abstrakcja do opanowania. Nowi członkowie zespołu muszą dowiedzieć się, że istnieje REQUEST_BUILDER i jakie ma funkcje. W aplikacji z dwoma punktami końcowymi prostsze jest bezpośrednie wywołanie HttpClient. Ten wzorzec okazuje się przydatny w kodach z wieloma punktami końcowymi typu lista i tabela, które dzielą reguły paginacji i sortowania – co odnosi się do dużej części korporacyjnych aplikacji Angular.

    Sposoby jego rozszerzania

    Gdy już ustalimy wersję podstawową, naturalne stają się kilka rozszerzeń.

    Schematy zapytań typowanych dla każdego punktu końcowego

    Drugi parametr typu ogólnego może określić, jakie klucze i wartości akceptuje punkt końcowy, co przekształca błędnie wpisany klucz w błąd podczas kompilacji.

    interface UserQuery {
      status: 'active' | 'archived';
      role: string;
    }
    
    const users = this.request<User[], UserQuery>('/api/users')
      .where('status', 'active'); // 'staus' would now be a compile error
    

    Ten fragment pokazuje miejsce zamierzonego wywołania. Aby to umożliwić, createRequest oraz interfejs RequestBuilder wymagają drugiego parametru typu, a wyrażenie where musi być sformułowane w oparciu o keyof, który określa ten typ klucza oraz jego typy wartości.

    Adresy bazowe specyficzne dla środowiska

    Należy również rozwiązać token BASE_API_URL w fabryce tokenów i dodać go na początku każdej ścieżki otrzymywanej przez buder. Komponenty nadal przekazują ścieżki względne i w ogóle nie zajmują się konfiguracją środowiska.

    Adaptery odpowiedzi

    Etap map może przekształcić odpowiedź serwera wewnątrz budera, co oznacza, że komponenty otrzymują modele gotowe do wyświetlenia zamiast surowych struktur API.

    Podsumowanie

    Budownik zapytań oparty na mechanizmie zamknięć oferuje wiele za bardzo mało kodu. Około dwadzieścia wierszy kodu wystarcza, by stworzyć niezmienne, typizowane warstwy, które umożliwiają standaryzację sposobu komunikacji całego aplikacji z punktami końcowymi paginowanymi. Opiera się wyłącznie na tym, co Angular już dostarcza: niezmiennością obiektu HttpParams, mechanizmach iniekcji opartych na fabrykach oraz funkcji inject(), bez żadnych dodatkowych elementów w czasie wykonywania.

    Jak w przypadku każdej abstrakcji, istnieje punkt równowagi. Dla niewielkiej liczby punktów końcowych prostsze okazują się być wywołania HttpClient. Po przekroczeniu tej granicy budownik przekształca konwencje zapisu ciągów zapytań, które ludzie muszą pamiętać, w umowę, którą kompilator sprawdza za nas.

    Literatura pokrewna