Галоўная / Артыкулы

Незменны кампань для стварэння запыткаў на аснове клаусуры для 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. Весь стан знаходзіцца ў аргументах функцыі, якія зафіксаваны праз клаузуры, таму не застаецца нічога, што магло бы випадкова змяніцца, і нічога, што можна было б стежыць паza самым вызывам. Якщо клаузуры, якія зафіксаваюць аргументы, здаюцца вам незнайомымі, адказ на пытанне як ланцуг дыяпазона 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; самыя об’екты канструювальніка ніколі не зміняюцца. Кожны варыант проста заменяе іх на новы, расширэны канструювальнік.

Тэставанне без 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 є болей зрозумелымі. Па захадзе ад гэтага конструктор ператварае правілы запыткаў у формате шаблонаў, якія людзі павінны запамятаваць, у контракт, які кампайляр пераканальвае для вас.