Главная / Статьи

Неизменный конструктор запросов на основе замыканий для Angular HttpClient

Замените повторяющийся шаблон HttpParams на небольшой конструктор запросов, основанный на замыканиях и связанный с помощью InjectionToken, который остается неизменным, имеет типизацию и удобен для тестирования.

Издатель: REACTAPP.TOP9 мин чтения2004 слов

Источники: 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 (в более ранних версиях использовались другие имена свойств), поэтому проверьте документацию к версии, на которой вы работаете.

Условное цепление

Поскольку каждый шаг возвращает значение вместо того, чтобы его изменять, опциональные фильтры можно применить путём переопределения локальной переменной.

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; сами объекты-строители никогда не меняются. Каждая ветвь просто заменяет их на новый, расширенный объект-строитель.

Тестирование без TestBed

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. Однако при большем количестве концовок этот инструмент превращает правила формирования строк запроса, которые людям приходится запоминать, в формальный контракт, который компилятор проверяет за вас.