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.
Ź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.
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.execute(). Budowniki oparte na klasach często zmuszają do ponownego przekazywania generyków na każdym poziomie dziedziczenia.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.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.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.setzastępuje istniejącą wartość, więc dwukrotne wywołaniesortBy()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 zparams.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
withHeaderslubpost(body), ale każda dodatkowa funkcja oznacza powrót do ponownej implementacji opcji, które już posiada obiektHttpClient. 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_BUILDERi jakie ma funkcje. W aplikacji z dwoma punktami końcowymi prostsze jest bezpośrednie wywołanieHttpClient. 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
- Budowanie modułu React opartego na funkcjach: CRUD dla postów z TanStack Query — Przeprojektuj aplikację React według funkcji zamiast typu pliku, połącz pełne operacje CRUD dla postów z TanStack Query i axios oraz sprawdź, gdzie ten model przestaje być skalowalny.
- Projektowanie aplikacji Angular pod kątem nieudanych zapytań: stany, interceptory i próby ponowne — Dowiedz się, jak klasyfikować błędy HTTP w Angularze, uporządkować stan ładowania, centralizować obsługę błędów w interceptorach, bezpiecznie próbować ponownie oraz wyświetlać komunikaty, na które użytkownicy mogą zareagować.