Незмінний конструктор запитів на основі клоузур для Angular HttpClient
Замініть повторюваний шаблон HttpParams на невеликий конструктор запитів, заснований на клоузурах та підключений через InjectionToken, який залишається незмінним, має типизацію та є простим у тестуванні.
Джерела: Stop Hand-Wiring HttpParams: Build a Fluent, Type-Safe Request Builder in Angular
Великі проекти на Angular часто мають кілька однакових фрагментів коду в десятках сервісів: створюється HttpParams, встановлюється фільтр, порядок сортування та сторінка, а потім формується запит. Кожна копія цього коду трохи відрізняється, і ці незначні відмінності призводять до неузгоджених назв параметрів запиту та правил сортування між командами.
У цьому посібнику показано, як замінити цю повторюваність на ефективний конструктор запитів, створений виключно за допомогою замикань. Для цього не потрібні класи, декоратори чи успадкування; достатньо однієї фабричної функції, яка доступна через InjectionToken та ініціалізується за допомогою inject(). Ви дізнаєтесь, як це працює, чому воно за своєю структурою є незмінним, як тестувати його без TestBed, а також коли не варто його додавати.
Код-шаблон, який замінюється
Ось типовий ендпоїнт для списку, написаний вручну.
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 });
}
У цьому немає нічого поганого, але такий підхід погано масштабується. Одна служба називає розмір сторінки pageSize, інша — size; одна команда сортує за допомогою sort та direction, інша кодує обидва параметри в один. Кожен новий ендпоїнт додає ще один блок викликів HttpParams перед тією частиною, яка насправді має значення. Метою є таке місце виклику, яке читається як опис запиту:
loadUsers() {
return this.request<User[]>('/api/users')
.where('status', 'active')
.sortBy('name')
.paginate(0, 20)
.execute();
}
Кожен виклик у цьому ланцюзі повертає нове, незалежне значення, а остаточний виклик execute() генерує результат, який можна спостерігати. Решта цього посібника описує елементи, які дозволяють це зробити.
Фабрика, яка повертає себе
Весь конструктор складається приблизно з двадцяти рядків.
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;
}
Ключовий трюк: виклик методу build повертає об’єкт, а кожен метод цього об’єкта знову викликає build. Кожен крок у ланцюзі створює нового будівельника, який працює з оновленими даними HttpParams. Попередній будівельник ніколи не змінюється.
Це працює настільки ефективно тому, що HttpParams вже є незмінним: метод params.set() не змінює початкову інстанцію та повертає нову з додатковим елементом. Білдер просто піднімає цю властивість на один рівень, тож об’єкти білдера успадковують ту саму гарантію. Метод where перетворює значення на рядки, тоді як sortBy записує ключі sort та direction (за замовчуванням у порядку зростання), а paginate записує page та size. Лише метод execute() взаємодіє з мережею, викликаючи http.get із накопиченими параметрами.
Варто також звернути увагу на те, чого бракує. Немає this, немає приватних полів та немає class. Усі стани знаходяться у аргументах функцій, зафіксованих через замикання, що не залишає нічого, що могло б випадково змінитися, та нічого, що потрібно було б відстежувати поза поточним викликом. Якщо замикання, які зафіксовують аргументи, здаються вам незнайомими, пояснення того, як ланцюг областей видимості JavaScript вирішує проблему змінних, описує основний механізм.
Виведення конструктора через внутрішнє інжектування залежностей
Функція, яка приймає HttpClient, є корисною, але компоненти повинні мати можливість вставляти його, як і будь-яку іншу залежність. Саме для цього створений InjectionToken на основі фабрики.
import { InjectionToken, inject } from '@angular/core';
export const REQUEST_BUILDER = new InjectionToken<ReturnType<typeof createRequest>>(
'REQUEST_BUILDER',
{
providedIn: 'root',
factory: () => createRequest(inject(HttpClient)),
}
);
Два моменти заслуговують на увагу.
Нехай компілятор визначить тип
ReturnType<typeof createRequest> означає, що тип конструктора ніколи не вводиться вручну. Коли createRequest отримує метод, тип токена автоматично настає за ним, і кожен користувач бачить цю зміну.
Вирішення проблеми з HttpClient у контексті ін’єкцій
inject(HttpClient) виконується всередині фабрики токена, а ця фабрика завжди працює у контексті ін’єкцій. Це правильне місце для її виконання. Поширеною помилкою є виклик inject() зі звичайного методу чи керуючої функції, яка виконується поза будь-яким контекстом ін’єкцій; у такому разі Angular кидає помилку NG0203 під час виконання. Якщо HttpClient буде вирішено один раз під час створення токена та збережено у закритій функції, конструктор, створений у результаті, можна буде використовувати будь-де пізніше: у ефектах, операторах RxJS чи звичайних функціях-засобах.
Використання з компонента
Компонент вставляє токен у поле та викликає його як функцію.
@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();
}
}
Генеричний аргумент, наданий на початку, User[], проходить через усю ланцюгову структуру, тому execute() повертає Observable<User[]> без жодного перетворення типів.
Шаблони, які порушують принцип незмінності
Реактивний пошук за допомогою сигналів
Оскільки об’єкт-будівельник є дешевим та незмінним значенням, цілком доречно створювати новий екземпляр щоразу, коли змінюються реактивні вхідні дані. Тут ресурс, керований сигналами, перебудовує запит на основі поточного тексту пошуку та сторінки.
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(),
});
}
Кожного разу, коли змінюються query або page, функція params створює новий об’єкт, а функція stream будує новий запит на основі цього об’єкта. Не існує спільного конструктора, який можна було б скинути між виконаннями. Зауважте, що rxResource та назви його опцій змінилися у останніх версіях Angular (у попередніх версіях використовувалися інші назви властивостей), тому перевірте API версії, якою ви користуєтесь.
Оскільки кожен крок повертає значення замість того, щоб його модифікувати, опційні фільтри можна застосувати шляхом переатрибуції локальної змінної.
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();
}
Переатрибується лише змінна builder; самі об’єкти конструктора ніколи не змінюються. Кожна гілка просто замінює їх на новий, розширений конструктор.
createRequest — це звичайна функція класу HttpClient, тому для її тестування зовсім не потрібен модуль тестування Angular. Достатньо невеликого стаба, який записує отримані параметри.
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');
});
Кінцеві перевірки демонструють переваги незмінності. Ланцюги, які походять від одного спільного конструктора base, залишаються повністю ізольованими: параметр b з першого ланцюга ніколи не з’являється у другому, і навпаки. За наявності змінного конструктора другий виклик мовчки успадковуватиме фільтр першого.
Чому така архітектура є ефективною
- Незмінність за замовчуванням. Не існує внутрішнього стану, який міг би постраждати. Повторне використання частково сформованого запиту, наприклад одного базового запиту для активних користувачів та іншого для архівованих, є безпечним за замовчуванням. Змінний конструктор на основі класів порушує цей принцип без жодних попереджень.
sort, direction, page та size визначені в одному місці. Коли сервер змінює назву одного з них, достатньо оновити лише один рядок, замість того щоб шукати зміни в сорока сервісах. Такий підхід фактично створює контракт між фронтендом та правилами формування запитів API.execute(). Будівельники на основі класів часто змушують передавати загальні типи знову через кожен рівень успадкування.HttpClient у контексті ін’єкції, що гарантовано наявний, уникаючи всієї серії помилок типу „inject() викликається поза контекстом ін’єкції“, які з’являються, коли об’єкти створюються з запізненням у калебах.providedIn: 'root' видаляється з пакету, якщо ніхто ним не користується. Основа — це звичайна функція, тому для тестування не потрібні ні TestBed.configureTestingModule, ні HttpTestingController, якщо тільки ви спеціально не хочете отримати покриття на рівні інтеграції.execute() у кінцевому підсумку викликає HttpClient.get, тому заголовки автентифікації, логіка повторних спроб та мапування помилок у вашому ланцюзі інтерцепторів залишаються незмінними. Клас builder працює поверх HttpClient; він не замінює його.Переваги та недоліки, які потрібно врахувати
- Ключі — це звичайні рядки. Вираз
where('status', 'active')не перевіряється щодо API. Опечатка в ключі дозволяє скомпілювати код, а проблема виникає лише під час виконання. Обмеження ключів за допомогою генеричного типу, наприкладwhere<K extends keyof UserQuery>, вирішує цю проблему, але призводить до складніших типів; вирішуйте, чи потрібно це вашій команді. - Повторні виклики перезаписують один одного. Метод
HttpParams.setзамінює наявне значення, тому два викликиsortBy()залишать лише результат другого виклику. Це зазвичай те, що потрібно для сортування, але це дивує тих, хто очікує параметрів з кількома значеннями. Для повторюваних ключів, таких як?tag=a&tag=b, слід додати методwhereAll, який використовуєparams.append. - Навмисно лише GET. Цей інструмент призначений для формування запитів. Тіла запитів, заголовки та опції типу відповіді не підтримуються. Ви можете додати методи на кшталт
withHeadersабоpost(body), але кожне додавання призводить до часткової перереалізації функцій, які вже є у об’єктіHttpClient. Варто знати, де потрібно зупинитися. - Новий об’єкт на кожен крок. Кожен послідовний виклик виділяє білдер та
HttpParams. Це дуже маленькі, короткочасні об’єкти, які двигуни JavaScript збирають з незначними витратами, тож у практиці це майже не впливає на продуктивність, але про це варто знати. - Ще одна абстракція, яку потрібно опанувати. Нові члени команди мають з’ясувати, що існує
REQUEST_BUILDERта яку функцію він виконує. У додатку з двома кінцевими точками прямий викликHttpClientє простішим. Ця модель виявляється корисною у кодових базах з багатьма кінцевими точками у форматі списку чи таблиці, які мають спільні правила сторінкування та сортування — що характерно для більшості корпоративних додатків на Angular.
Способи розширення
Як тільки базова версія буде готова, природним чином з’являється кілька можливостей для розширення.
Типовані схеми запитів для кожної кінцевої точки
Другий генеричний тип може описувати, які ключі та значення приймає кінцева точка, перетворюючи неправильно написаний ключ на помилку під час компіляції.
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
Цей уривок демонструє місце виклику. Щоб підтримати це, функція createRequest та інтерфейс RequestBuilder потребують другого параметра типу, а оператор where має бути заданий за допомогою keyof, який вказує тип ключа та його типи значень.
Базові URL, специфічні для середовища
Також необхідно обробляти токен BASE_API_URL у фабриці токенів та додавати його до кожного шляху, який отримує конструктор. Компоненти продовжують передавати відносні шляхи та ніколи не працюють із налаштуваннями середовища.
Адаптери відповідей
Етап map може змінити форму відповіді сервера всередині конструктора, що означає, що компоненти отримують готові до відображення моделі замість сирої структури API.
Підсумок
Будівельник запитів на основі концепції закритості пропонує багато можливостей за дуже мало коду. Приблизно двадцять рядків коду створюють незмінний, типований шар, який можна вставляти в інші компоненти, та стандартизує спосіб взаємодії всього додатку з ендпоїнтами з пагінацією. Він ґрунтується лише на тому, що вже надає Angular: незмінності об’єкта HttpParams, механізмах вставки на основі фабрик та функції inject(), без додаткових засобів під час виконання.
Як і у будь-якій абстракції, існує точка беззбитковості. Для невеликої кількості ендпоїнтів прості виклики HttpClient є зрозумілішими. Однак у більш складних випадках цей будівельник перетворює правила формування запитів, які людям потрібно пам’ятати, на контракт, який компілятор перевіряє за вас.