Ein unveränderlicher, auf Schließungen basierender Abfragebauer für Angular HttpClient
Ersetzen Sie den wiederholten HttpParams-Boilerplate durch einen kleinen, auf Schließungen basierenden Anfragebauer, der über ein InjectionToken verbunden ist und unveränderlich, typisiert sowie leicht testbar bleibt.
Quellen: Stop Hand-Wiring HttpParams: Build a Fluent, Type-Safe Request Builder in Angular
Große Angular-Codebasen neigen dazu, dieselben wenigen Codezeilen in Dutzenden von Services anzuhäufen: Ein HttpParams wird erstellt, der Filter sowie die Sortierung eingestellt und die Seite festgelegt, bevor schließlich die Anfrage gestartet wird. Jede Kopie weicht leicht voneinander ab, und diese kleinen Unterschiede führen zu inkonsistenten Namen der Abfrageparameter sowie unterschiedlichen Sortierkonventionen innerhalb der Teams.
Diese Anleitung zeigt, wie man diese Wiederholungen durch einen flüssig arbeitenden Anfragersteller ersetzen kann, der ausschließlich aus Closures aufgebaut ist. Dafür sind weder Klassen noch Dekoratoren noch Vererbung erforderlich; eine einzige Factory-Funktion wird über einen InjectionToken bereitgestellt und mit inject() aufgerufen. Sie werden sehen, wie es funktioniert, warum es von Konstruktion her unveränderlich ist, wie man es ohne TestBed testen kann und wann es sich nicht lohnt, es hinzuzufügen.
Die zu ersetzende Boilerplate-Struktur
Hier ist ein typischer Listen-Endpunkt, der manuell geschrieben wurde.
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 });
}
Es ist nichts falsch daran, aber es skaliert nicht gut. Ein Service benennt die Seitengröße mit pageSize, ein anderer verwendet size; ein Team sortiert mit sort zusammen mit direction, ein anderes kodiert beides in einem einzigen Parameter. Jeder neue Endpunkt führt vor dem eigentlichen wichtigen Teil weitere HttpParams-Aufrufe ein. Das Ziel ist eine Aufrufstelle, die wie eine Beschreibung der Abfrage aussieht:
loadUsers() {
return this.request<User[]>('/api/users')
.where('status', 'active')
.sortBy('name')
.paginate(0, 20)
.execute();
}
Jeder Aufruf in dieser Kette gibt einen neuen, unabhängigen Wert zurück, und der finale execute()-Aufruf erzeugt das Beobachtbare. Der Rest dieses Leitfadens beschreibt die Komponenten, die dies ermöglichen.
Eine Fabrik, die sich selbst zurückgibt
Der gesamte Builder umfasst etwa zwanzig Zeilen.
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;
}
Der entscheidende Trick: Durch Aufruf von build erhält man ein Objekt, und jede Methode in diesem Objekt ruft build erneut auf. Jeder Schritt in der Kette erstellt einen neuen Builder, der die aktualisierten HttpParams berücksichtigt. Der vorherige Builder wird niemals geändert.
Dies funktioniert so reibungslos, weil HttpParams bereits unveränderlich ist: params.set() lässt die ursprüngliche Instanz unberührt und gibt eine neue Instanz mit dem zusätzlichen Eintrag zurück. Der Builder hebt diese Eigenschaft einfach um ein Level an, sodass die Builder-Objekte dieselbe Garantie erben. where wandelt Werte in Zeichenketten um, während sortBy die Schlüssel sort und direction (standardmäßig auf aufsteigend) schreibt und paginate die Schlüssel page und size schreibt. Nur execute() greift auf das Netzwerk zu, indem es http.get mit den gesammelten Parametern aufruft.
Auch das, was fehlt, ist erwähnenswert. Es gibt kein this, keine privaten Felder und keine class. Der gesamte Zustand befindet sich in Funktionsinhalten, die durch Closures erfasst werden, wodurch nichts versehentlich verändert werden kann und nichts außerhalb der vorliegenden Aufrufstelle nachverfolgt werden muss. Falls Closures, die Argumente erfassen, unvertraut erscheinen, erklärt der Artikel wie JavaScripts Scope-Chain Variablen auflöst den zugrundeliegenden Mechanismus.
Den Builder über Dependency Injection freigeben
Eine Funktion, die HttpClient entgegennimmt, ist nützlich, doch Komponenten sollten in der Lage sein, es wie jede andere Abhängigkeit einzuspeisen. Ein auf einer Factory basierender InjectionToken ist genau dafür konzipiert.
import { InjectionToken, inject } from '@angular/core';
export const REQUEST_BUILDER = new InjectionToken<ReturnType<typeof createRequest>>(
'REQUEST_BUILDER',
{
providedIn: 'root',
factory: () => createRequest(inject(HttpClient)),
}
);
Zwei Details verdienen Aufmerksamkeit.
Lassen Sie den Compiler den Typ ableiten
ReturnType<typeof createRequest> bedeutet, dass der Typ des Builders niemals manuell eingetippt werden muss. Wenn createRequest eine Methode erhält, folgt automatisch der Typ des Tokens, und jeder Nutzer sieht diese Änderung.
HttpClient auflösen, wo ein Injection-Context vorhanden ist
inject(HttpClient) wird innerhalb der Factory des Tokens ausgeführt, und diese Factory läuft stets in einem Injection-Context. Das ist der richtige Ort dafür. Ein häufiger Fehler besteht darin, inject() aus einer gewöhnlichen Methode oder einem Callback aufzurufen, der außerhalb eines Injection-Contexts läuft; Angular wirft dann zur Laufzeit NG0203 aus. Durch die einmalige Auflösung von HttpClient beim Erstellen des Tokens und dessen Speichern in der Closure kann der resultierende Builder danach überall verwendet werden: innerhalb von Effects, RxJS-Operatoren oder einfachen Hilfsfunktionen.
Verwendung aus einer Komponente
Ein Komponente injiziert das Token in ein Feld und ruft es wie eine Funktion auf.
@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();
}
}
Der zu Beginn übergebene generische Parameter, User[], wird durch die gesamte Kette weitergeleitet, wodurch execute() ohne jegliches Typumwandeln Observable<User[]> zurückgibt.
Muster, die aus der Unveränderlichkeit hervorgehen
Reaktive Suche mit Signalen
Weil ein Builder ein günstiger, unveränderlicher Wert ist, ist es völlig in Ordnung, jedes Mal einen neuen zu erstellen, wenn sich die reaktiven Eingaben ändern. Hier rebuildet eine signalgesteuerte Ressource die Abfrage anhand des aktuellen Suchtextes und der Seite.
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(),
});
}
Jedes Mal, wenn sich query oder page ändert, erzeugt die params-Funktion ein neues Objekt, und die stream-Funktion erstellt daraufhin eine neue Anfrage. Es gibt keinen gemeinsamen Konstruktor, der zwischen den Ausführungen zurückgesetzt werden könnte. Beachten Sie, dass sich rxResource sowie die Namen seiner Optionen in neueren Angular-Versionen geändert haben (frühere Versionen verwendeten andere Eigenschaftsnamen); prüfen Sie daher die API der von Ihnen genutzten Version.
Konditionales Kettensuchen
Da jeder Schritt einen Wert zurückgibt anstatt etwas zu modifizieren, können optionale Filter durch Neuzuweisen einer lokalen Variablen angewendet werden.
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();
}
Nur die builder-Variable wird neu zugewiesen; die Builder-Objekte selbst ändern sich niemals. Jede Abzweigung ersetzt einfach durch ein neues, erweitertes Builder-Objekt.
Testing ohne TestBed
createRequest ist eine einfache Funktion von HttpClient, weshalb ein Unit-Test überhaupt kein Angular-Testing-Modul benötigt. Ein kleiner Stumpf, der die empfangenen Parameter aufzeichnet, reicht aus.
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');
});
Die abschließenden Assertionen zeigen den Vorteil der Unveränderlichkeit. Ketten, die von einem gemeinsamen base-Builder abzweigen, bleiben vollständig isoliert: Der b-Parameter der ersten Kette erscheint niemals in der zweiten und umgekehrt. Bei einem veränderlichen Builder würde der zweite Aufruf stillschweigend den Filter des ersten erben.
Warum das Design funktioniert
- Per Konstruktion unveränderlich. Es existiert kein internes Zustand, der beschädigt werden könnte. Die Wiederverwendung einer teilweise erstellten Anfrage – beispielsweise eine Basisabfrage für aktive Benutzer und eine weitere für archivierte Benutzer – ist standardmäßig sicher. Ein veränderlicher, klassenbasierter Builder macht dies ohne jegliche Warnung falsch.
sort, direction, page und size sind an einem einzigen Ort definiert. Wenn der Backend-Server einen dieser Schlüssel umbenennt, muss nur eine Zeile geändert werden, anstatt in vierzig verschiedenen Diensten nachzusehen. Der Builder fungiert somit effektiv als Vertrag zwischen der Frontend-Seite und den Abfrakonventionen der API.execute(). Builder, die auf Klassen basieren, zwingen oft dazu, Generiker in jeder Erweckungsebene erneut zu übergeben.HttpClient innerhalb eines garantierten Einbettungskontexts auf, wodurch alle Fehler der Art „inject() außerhalb eines Einbettungskontexts aufgerufen“ vermieden werden, die auftreten, wenn Konstrukteure in Callbacks verzögert erstellt werden.providedIn: 'root' wird aus dem Bundle entfernt, wenn es nicht verwendet wird. Der Kern besteht aus einer einfachen Funktion, sodass Tests weder TestBed.configureTestingModule noch HttpTestingController benötigen – es sei denn, man möchte ausdrücklich eine Integrationstestsabdeckung.execute() ruft letztendlich HttpClient.get auf, sodass Authentifizierungs-Header, Wiederholungslogik und Fehlerkennzeichnung in Ihrer Interceptor-Kette unverändert angewendet werden. Der Builder befindet sich oberhalb von HttpClient; er ersetzt diesen nicht.Zu abwägende Kompromisse
- Schlüssel sind einfache Zeichenketten.
where('status', 'active')wird nicht gegen die API überprüft. Ein Tippfehler im Schlüssel lässt sich kompilieren und führt erst zur Laufzeit zu Fehlern. Durch Einschränkung der Schlüssel mit einem Generik, wie z. B.where<K extends keyof UserQuery>, wird das behoben – allerdings zu Lasten komplexerer Typen; entscheiden Sie, ob Ihr Team dies benötigt. - Mehrfache Aufrufe überschreiben sich.
HttpParams.setersetzt einen bereits vorhandenen Wert, wodurch ein zweiter Aufruf vonsortBy()nur den zweiten Wert beibehält. Das ist in der Regel genau das, was man für die Sortierung möchte, überrascht aber alle, die mit mehrwertigen Parametern rechnen. Für wiederholte Schlüssel wie?tag=a&tag=bsollte einewhereAll-Methode hinzugefügt werden, dieparams.appendverwendet. - Bewusst nur GET. Der Builder erstellt Abfragesätze. Anfragedatenkörper, Header sowie Optionen zum Antworttyp fallen außerhalb des Anwendungsbereichs. Man kann Methoden wie
withHeadersoderpost(body)hinzufügen, doch jede Erweiterung führt dazu, dass Teile des bereits vorhandenen Options-Objekts vonHttpClientneu implementiert werden müssen. Man sollte wissen, wo man aufhören muss. - Ein neues Objekt pro Schritt. Jeder aufgereihte Aufruf weist einen Builder sowie ein
HttpParamszu. Es handelt sich dabei um kleine, kurzlebige Objekte, die von JavaScript-Engines kostengünstig verwaltet werden; daher ist der Aufwand in der Praxis vernachlässigbar, doch es lohnt sich, dies zu kennen. - Noch eine Abstraktion, die man erlernen muss. Neue Teammitglieder müssen herausfinden, dass
REQUEST_BUILDERexistiert und welche Funktion es hat. In einer Anwendung mit zwei Endpunkten ist der direkte Aufruf vonHttpClienteinfacher. Dieses Muster lohnt sich in Codebasen mit vielen Liste- und Tabellenendpunkten, die gemeinsame Paginierungs- und Sortierregeln nutzen – was einen großen Teil der Unternehmens-Anwendungen mit Angular ausmacht.
Wege zur Erweiterung
Sobald die grundlegende Version feststeht, sind einige Erweiterungen naheliegend.
Typisierte Abfrageschemata pro Endpunkt
Ein zweiter Generik-Typ kann beschreiben, welche Schlüssel und Werte ein Endpunkt akzeptiert, wodurch ein falsch geschriebener Schlüssel zu einem Fehler zur Kompilierzeit führt.
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
Dieser Auszug zeigt den vorgesehenen Aufrufort. Um dies zu unterstützen, benötigen createRequest sowie die RequestBuilder-Schnittstelle einen zweiten Typparameter, und where muss in Bezug auf keyof des Abfrachtyps sowie seiner Werttypen typisiert werden.
Umgebungsspezifische Basis-URLs
Lösen Sie auch einen BASE_API_URL-Token in der Token-Factory auf und fügen Sie ihn vor jeden Pfad ein, den der Builder erhält. Die Komponenten übergeben weiterhin relative Pfade und kümmern sich nie um die Konfiguration der Umgebung.
Antwort-Adapter
In einer map-Phase kann die Serverantwort innerhalb des Builders umgestaltet werden, wodurch die Komponenten modellbasierte Daten erhalten, die für die Anzeige bereit sind, anstatt rohe API-Daten.
Zusammenfassung
Der auf Schließung basierende Anfragersteller bietet viel bei sehr wenig Code. Etwa zwanzig Zeilen erzeugen eine unveränderliche, typisierte, injizierbare Schicht, die standardisiert, wie die gesamte Anwendung mit paginierten Endpunkten kommuniziert. Er stützt sich ausschließlich auf das, was Angular bereits bereitstellt: die Unveränderlichkeit von HttpParams, in Fabriken basierende Injektionstoken sowie inject(), ohne zusätzliche Laufzeitmechanismen.
Wie bei jeder Abstraktion gibt es einen Punkt, ab dem der Nutzen die Komplexität überwiegt. Für eine Handvoll Endpunkte sind einfache HttpClient-Aufrufe klarer. Ab diesem Punkt wandelt der Builder die Konventionen von Abfragesätzen – Dinge, die man sich merken muss – in einen Vertrag um, den der Compiler für einen überprüft.