DevGang
Авторизоваться

Angular interceptor для управления HTTP-запросами ⚡ 

Angular как фреймворк предоставляет нам значительное количество инструментов и возможностей «из коробки». Сегодня я напишу об одной из этих полезных функций, называемых HTTP-перехватчиками.

Я кратко опишу, что такое HTTP-перехватчики Angular и как они работают. Затем я расскажу о некоторых типичных применениях с примерами реализации и расскажу о некоторых преимуществах использования перехватчиков в вашем приложении. В этой статье предполагается, что читатель уже имеет некоторый опыт работы с Angular и знаком с наиболее распространенными и базовыми понятиями. Они не будут подробно объяснены, поскольку они не входят в объем данного документа.

В конце концов, что такое Angular-interceptors?

Хотя название может показаться чем-то необычайно причудливым и сложным, перехватчики Angular - это просто особый вид клиентской службы HTTP, единственной целью которой является перехват каждого выполняемого HTTP-запроса. Это верно как для входящих, так и для исходящих HTTP-запросов. Хорошо, я видел это быстрое определение в нескольких местах, но что именно оно означает? Как это работает?

Все мы знаем, что картинка стоит тысячи слов, поэтому давайте попробуем создать простую диаграмму, которая объяснит, что означает перехват запроса:

Типичный рабочий процесс приложения Angular в любой момент времени будет выполнять серию HTTP-запросов к серверу для выполнения повседневных задач. Это аутентификация, загрузка данных и т.д. Мы вернемся к этому чуть позже.

На диаграмме выше показано, что HTTP-перехватчики всегда будут в середине любого отдельного HTTP-запроса. Эти службы будут перехватывать все запросы, выполняемые приложением, что позволяет нам выполнять с ними множество операций, прежде чем они будут отправлены на сервер. Функции включают добавление настраиваемого HTTP-заголовка к окончательному исходящему запросу (например, добавление заголовка авторизации и передача токена авторизации на всех конечных точках, требующих набора разрешений и т.д.), Кэширование, ведение журнала для сбора показателей, обработка ошибок и т.д.

Аналогичный процесс происходит, когда сервер отвечает. Теперь у нас есть ответ, перехватываемый перехватчиком HTTP, что позволяет нам выполнить ряд операций до того, как приложение получит окончательный ответ. Сценарий повседневного использования может преобразовывать объект ответа в формат, более значимый для продукта. Например, очистка объекта ответа и извлечение только необходимых частей вместо обработки каждого компонента, который будет использовать данные.

Хорошо, я думаю, теперь ясно, что такое HTTP-перехватчик, где он находится в повседневном рабочем процессе приложения Angular, и его цель. Но как это работает? Разве мы не рискуем изменить несколько запросов повсюду и вызвать хаотичный набор событий, повторяющихся взад и вперед?

Как работает перехватчик?

Настройка логики, которая может централизованно преобразовывать HTTP-запросы, звучит как отличная функция. Таким образом, нам не нужно создавать несколько уровней дублирования, когда мы хотим выполнить запрос или получить ответ. Без перехватчиков нам пришлось бы многократно реализовывать одну и ту же логику для каждого HTTP-запроса, выполняемого вручную!

Хотя для того, чтобы все это было возможно, необходимо постоянно иметь при себе важные знания. Из Angular документации:

Хотя перехватчики могут изменять запросы и ответы, свойства экземпляра HttpRequest и HttpResponse доступны только для чтения, что делает их практически неизменными. Они неизменяемы по уважительной причине: приложение может несколько раз повторить запрос, прежде чем он завершится успешно, что означает, что цепочка перехватчиков может повторно обработать один и тот же запрос несколько раз. Если перехватчик может изменить исходный объект запроса, повторная попытка будет начинаться с измененного запроса, а не с оригинала. Неизменяемость гарантирует, что перехватчики будут видеть один и тот же запрос при каждой попытке.

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

Можем ли мы иметь несколько перехватчиков?

Да! Приложение может иметь несколько перехватчиков, каждый из которых имеет дело со своей областью действия. Например, у нас может быть перехватчик, предназначенный для работы с auth, один для обработки ошибок, третий для ведения журнала и т.д. Это возможно только потому, что Angular имеет интеллектуальный способ обработки запросов. Согласно документации Angular:

Angular применяет перехватчики в том порядке, в котором вы их предоставляете. Например, рассмотрим ситуацию, в которой вы хотите обрабатывать аутентификацию ваших HTTP-запросов и регистрировать их перед отправкой на сервер. Для выполнения этой задачи вы можете предоставить службу AuthInterceptor, а затем сервис LoggingInterceptor. Исходящие запросы будут передаваться от AuthInterceptor к LoggingInterceptor. Ответы на эти запросы будут передаваться в другом направлении, от LoggingInterceptor обратно к AuthInterceptor.

Следующая диаграмма может представлять описанный вариант использования:

Хотя только пользователь настраивал перехватчики аутентификации и ведения журнала, в Angular есть еще один перехватчик для обработки всех вызовов внутреннего сервера по умолчанию. Этот перехватчик называется серверной частью HTTP и всегда является последним в цепочке выполнения, независимо от того, сколько других перехватчиков создано и настроено пользователем.

Важно понимать, что после того, как порядок выполнения этой цепочки установлен, его нельзя изменить. Вы также не можете удалить или отключить перехватчик на лету. Из документации:

Вы не можете изменить порядок или удалить перехватчики позже. Если вам нужно динамически включать и отключать перехватчик, вам придется встроить эту возможность в сам перехватчик.

Почему так важно это понимать? Поскольку они будут последовательно выполнять запрос в том же порядке, они были настроены. Это может иметь большое значение, когда вы думаете о том, какой порядок следует использовать, чтобы избежать сюрпризов, ожидая подробного ответа и получая другой, потому что перехватчик, который выполнил его ранее в цепочке, уже преобразовал его.

Как это реализовать?

Теперь, когда у нас есть базовое представление о перехватчике и его основной цели, пора поговорить о реализации. Как реализовать Angular HTTP-перехватчик? Я покажу несколько примеров наиболее распространенных вариантов использования, таких как добавление пользовательских заголовков HTTP, кэширование, ведение журнала и обработка ошибок.

Начальная настройка

Поскольку этот документ посвящен перехватчикам HTTP, я предполагаю, что у читателя будет ранее созданный проект Angular.

Теперь создайте новый перехватчик с помощью Angular CLI. Как упоминалось ранее, перехватчик - это не что иное, как сервис Angular, реализующая определенный интерфейс. Давайте рассмотрим следующую команду: ng generate interceptor example.

Эта команда CLI создаст перехватчик ExampleInterceptor, вызываемый следующим кодом:

import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable()
export class ExampleInterceptor implements HttpInterceptor {

  constructor() {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    return next.handle(request);
  }
}

Как видим, сервис перехватчика реализует интерфейс HttpInterceptor, импортированный из общего модуля Angular. Нам нужно реализовать метод перехвата с нашим индивидуальным кодом для каждого варианта использования. Этот метод получает HTTP-запрос, выполняемый приложением и обработчиком, выполняющим цепочку вызовов. Если пользовательские преобразования отсутствуют, он просто передаст их методу handle (next.handle(request)) и повторит тот же процесс на всех последующих настроенных перехватчиках (как объяснено на схеме рабочего процесса выше).

Пользовательский перехватчик заголовка

Один из наиболее распространенных вариантов использования перехватчиков - обработка запросов аутентификации. Этого легко добиться, добавив требуемые заголовки к исходящему запросу внутри метода перехвата.

В приведенных примерах, конечно же, используются фиктивные примеры токенов безопасности. Основная цель - показать читателю, как реализовать перехватчики аутентификации путем добавления пользовательских заголовков к запросам. В этом сценарии мы добавим токен авторизации. Примеры, обсуждаемые ниже, должны быть легко адаптированы к реальному приложению с надлежащей системой аутентификации.

Базовая проверка подлинности

Рассмотрим сценарий Basic Authentication, в котором мы должны авторизовать каждый запрос, обращающийся к API. У нас может быть такой перехватчик:

import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor
} from '@angular/common/http';
import { Store } from '@ngxs/store';
import { Observable } from 'rxjs';
import { AuthState } from '../../store/auth.state';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(private authService: AuthService) {}

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(this.addAuthToken(request));
  }

  addAuthToken(request: HttpRequest<any>) {
    const token = this.authService.getAuthToken();

    return request.clone({
        setHeaders: {
          Authorization: `Basic ${token}`
        }
    })
  }
}

Нарушение базовой аутентификации

Давайте теперь разберем то, что здесь происходит:

Для этого примера мы предполагаем, что существует одна служба авторизации, отвечающая за поддержку и предоставление токенов базовой аутентификации. Мы внедряем сервис в конструктор, чтобы он был доступен в сервисе, когда это необходимо. Реализация этого сервиса не входит в нашу задачу и выходит за рамки данной статьи.

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  return next.handle(this.addAuthToken(request));
}

Наш метод перехвата относительно прост, поскольку у него всего один вызов функции, ответственной за преобразование запроса путем добавления нового заголовка.

addAuthToken(request: HttpRequest<any>) {
    const token = this.appService.getAuthToken();

    return request.clone({
        setHeaders: {
          Authorization: `Basic ${token}`
        }
    })
  }

Наконец, метод addAuthToken запросит новый токен и установит заголовок «Authorization», определив его как «Basic». Одним из важнейших аспектов сохранения этой небольшой логики является вызов метода request.clone(). Как упоминалось ранее, все запросы неизменяемы, поэтому это правильный способ преобразования существующего запроса путем создания новой версии с предполагаемыми изменениями.

Полностью рабочий пример можно проверить здесь. При нажатии на кнопку «Basic Authentication» мы можем проверить в инструментах разработчика на сетевой панели, что заголовок авторизации был добавлен с помощью «superSecretToken», предоставленного сервисом авторизации:

JWT аутентификация

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

Несмотря на небольшое количество вариантов использования, в настоящее время «базовая проверка подлинности» не является распространенным сценарием для большинства приложений.

Одним из распространенных способов использования перехватчика аутентификации является обработка запросов, связанных с токеном JWT. Я начну с демонстрации типичной реализации, а затем разобью ее на шаги для большей ясности.

Давайте рассмотрим вариант использования, когда у нас есть приложение с аутентификацией JWT с поддержкой токена обновления:

import {
  HttpErrorResponse,
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, filter, finalize, switchMap, take } from 'rxjs/operators';
import { AuthService } from './auth.service';

@Injectable()
export class JwtAuthService implements HttpInterceptor {
  private refreshTokenInProgress = false;
  private refreshTokenSubject = new BehaviorSubject(null);

  constructor(private authService: AuthService) {}

  intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    return next.handle(this.addAuthToken(request)).pipe(
      catchError((requestError: HttpErrorResponse) => {
        if (requestError && requestError.status === 401) {
          if (this.refreshTokenInProgress) {
            return this.refreshTokenSubject.pipe(
              filter((result) => result),
              take(1),
              switchMap(() => next.handle(this.addAuthToken(request)))
            );
          } else {
            this.refreshTokenInProgress = true;
            this.refreshTokenSubject.next(null);

            return this.authService.refreshAuthToken().pipe(
              switchMap((token) => {
                this.refreshTokenSubject.next(token);
                return next.handle(this.addAuthToken(request));
              }),
              finalize(() => (this.refreshTokenInProgress = false))
            );
          }
        } else {
          return throwError(() => new Error(requestError.message));
        }
      })
    );
  }

  addAuthToken(request: HttpRequest<any>) {
    const token = this.authService.getAuthToken();

    if (!token) {
      return request;
    }

    return request.clone({
      setHeaders: {
        Authorization: `Bearer ${token}`,
      },
    });
  }
}

Нарушение аутентификации JWT

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

private refreshTokenInProgress = false;
private refreshTokenSubject = new BehaviorSubject(null);

При работе с JWT обычно используется токен обновления. Это одна из используемых практик. В нашем перехватчике мы будем использовать логическую переменную для хранения временного состояния. В то же время загружается токен обновления и Behaviour Subject, чтобы сохранить состояние последнего изменения. Мы, конечно, инициализируем его значением как null, поскольку при загрузке приложения, пока аутентификация пользователя не будет успешно завершена, токен не будет создан.

return next.handle(this.addAuthToken(request)).pipe(
      catchError((requestError: HttpErrorResponse) => {   

Этот вызов метода перехвата немного отличается от вызова в второстепенном примере. Когда пользователь пытается выполнить запрос к API или представлению приложения, для которого он еще не авторизован на правильно спроектированном продукте, он получит исключение с кодом ошибки HTTP 401. Зная это, правильный способ обработки рабочий процесс на перехватчике должен захватить исключения и обработать запрос в соответствии с типом инициированного исключения.

if (requestError && requestError.status === 401) {
  if (this.refreshTokenInProgress) {
    return this.refreshTokenSubject.pipe(
      filter((result) => result),
      take(1),
      switchMap(() => next.handle(this.addAuthToken(request)))
    );
  }

Если пользователь попытается получить доступ к API без ожидаемой авторизации, он получит исключение с кодом состояния 401 (неавторизованный). На этом этапе необходимо выполнить некоторые дополнительные проверки, чтобы решить, как продолжить выполнение запроса. Если у нас уже есть новый обрабатываемый токен обновления, рабочий процесс будет ждать, пока токен не будет доступен и предоставлен субъекту поведения. Как только он наконец станет доступен, мы добавляем токен в заголовок и пропускаем преобразованный запрос.

else {
  this.refreshTokenInProgress = true;
  this.refreshTokenSubject.next(null);

  return this.authService.refreshAuthToken().pipe(
    switchMap((token) => {
      this.refreshTokenSubject.next(token);
      return next.handle(this.addAuthToken(request));
    }),
    finalize(() => (this.refreshTokenInProgress = false))
  );
}

Когда токен обновления еще не запрошен, запускается новый процесс. Мы начинаем с того, что помечаем приложение, что теперь имеется запрос на новый токен обновления, и гарантируем, что для объекта поведения не ожидается никакого неожиданного значения, установив для него значение null. Это гарантирует, что запрос будет ждать, пока не будет предоставлен токен (как показано в предыдущем примере).

Осталось только запросить новый токен обновления, отправить его объекту токена обновления, как только он станет доступен, а затем добавить токен в заголовок запроса.

Наконец, нам нужно убедиться, что мы оставляем процесс чистым, поэтому мы сбрасываем флаг токена обновления на false.

else {
  return throwError(() => new Error(requestError.message));
}

В рамках этого примера, если код состояния ошибки не 401, мы выдаем ошибку, чтобы она могла быть перехвачена специальным перехватчиком ошибок.

Кэширующий перехватчик

Кэширование - это довольно обширная и сложная тема. Некоторые конфигурации и нюансы могут значительно улучшить производительность приложения или стать причиной значительного количества проблем при плохой реализации.

Документация Angular предоставляет исчерпывающий пример того, как реализовать кэширующий перехватчик. Однако это может быть ошеломляющим для читателя, который переживает первые шаги по реализации перехватчика. В этом разделе будет представлена ​​упрощенная версия, объясняющая, как это работает.

Angular перехватчики могут сами обрабатывать HTTP-запросы, не передавая их следующему обработчику запросов. Мы можем воспользоваться этой функцией, установив некоторые запросы в кеш, чтобы повысить производительность и удобство работы пользователей, сократив количество раз, необходимое для перехода к серверу.

Упрощенный перехватчик кеширования может быть реализован следующим образом:

import {
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
  HttpResponse,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of, tap } from 'rxjs';

@Injectable()
export class CachingInterceptor implements HttpInterceptor {
  private cache = new Map<string, any>();

  intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    if (request.method !== 'GET') {
      return next.handle(request);
    }
    const cachedResponse = this.cache.get(request.url);
    if (cachedResponse) {
      return of(cachedResponse);
    }

    return next.handle(request).pipe(
      tap((response) => {
        if (response instanceof HttpResponse) {
          this.cache.set(request.url, response);
        }
      })
    );
  }
}

Наш кеш определяется структурой Map, которая будет хранить пару ключ-значение. В нашем упрощенном примере кеш будет хранить URL-адрес в качестве ключа и результата вызова ответа к образцу API. Мы кэшируем только запросы GET, поскольку они идемпотентны. Это означает, что для одного и того же ввода, независимо от того, сколько раз был сделан запрос, он должен давать один и тот же вывод.

const cachedResponse = this.cache.get(request.url);
if (cachedResponse) {
  return of(cachedResponse);
}

Мы начинаем с получения существующего кешированного ответа на запрошенный URL. Когда у нас есть попадание в кеш, мы возвращаем ранее сохраненный ответ.

return next.handle(request).pipe(
  tap((response) => {
    if (response instanceof HttpResponse) {
      this.cache.set(request.url, response);
    }
  })
);

Мы передаем запрос серверу при промахе кеша и сохраняем ответ в кеше. Это возможно, потому что, как объяснялось вначале, перехватчики могут обрабатывать вместе как HTTP-запросы, так и HTTP-ответы.

Полностью рабочий пример можно найти здесь. При первом нажатии кнопки «Cached Request» выполняется запрос к API. Это можно проверить на сетевой панели инструментов разработчика. Любые последующие нажатия на кнопку не вызовут дополнительных запросов. Это можно проверить, нажав кнопку «Clear Data», а затем еще раз нажав кнопку «Cached Request». Хотя отображаемые данные очищаются и отображаются снова, новые запросы к API сервера не поступают. После первого запроса все остальные вернутся из кеша.

Логирующий перехватчик

Современные приложения обычно предоставляют конечным пользователям значительное количество функций. Чем сложнее эти приложения, тем больше они подвержены ошибкам. Сбор значимых данных из всей HTTP-операции или определенных свойств из пользовательских данных позволит проницательно и динамично продумать некоторые ценные статистические данные. Они могут измерять среднее время, затраченное на запрос, для обнаружения потенциальных узких мест или регистрировать входные данные запросов для обнаружения искаженных запросов, которые вызывают неожиданные ответы. Существуют сотни других ценных сценариев, в которых может быть полезно ведение журнала.

В этом примере мы будем использовать реализацию, предоставленную в документации Angular и разберем ее:

import {
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
  HttpResponse,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { finalize, tap } from 'rxjs';
import { MessageService } from './message.service';

@Injectable()
export class LoggingInterceptor implements HttpInterceptor {
  constructor(private messageService: MessageService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    const started = Date.now();
    let ok: string;

    return next.handle(req).pipe(
      tap({
        next: (event) =>
          (ok = event instanceof HttpResponse ? 'succeeded' : ''),
        error: (error) => (ok = 'failed'),
      }),

      finalize(() => {
        const elapsed = Date.now() - started;
        const msg = `${req.method} "${req.urlWithParams}"
             ${ok} in ${elapsed} ms.`;
        this.messageService.add(msg);
      })
    );
  }
}
const started = Date.now();
let ok: string;

Мы начинаем с определения момента перехвата запроса и переменной для хранения результата ответа внутреннего сервера.

tap({
  next: (event) =>
    (ok = event instanceof HttpResponse ? 'succeeded' : ''),
  error: (error) => (ok = 'failed'),
}),

Поскольку перехватчики могут обрабатывать как исходящий запрос, так и входящий ответ, давайте сохраним результат в нашей ранее определенной переменной. В зависимости от того, какой сервер возвращает успех или ошибку, переменная будет печатать «succeeded» или «failed».

finalize(() => {
  const elapsed = Date.now() - started;
  const msg = `${req.method} "${req.urlWithParams}"
    ${ok} in ${elapsed} ms.`;
  this.messageService.add(msg);
})

Метод finalize всегда выполняется, независимо от наблюдаемого возвращающей успех или ошибочный ответ. Тогда это будет идеальное место, чтобы подсчитать, сколько времени в целом потребовалось на запрос, и, в этом примере, создать сообщение с истекшим временем и статусом ответа.

Полностью рабочий пример можно найти здесь. Нажав кнопку «Cached Request», он войдет в консоль, указав время, прошедшее для запроса, и его статус.

Любопытный читатель попытается нажать кнопку несколько раз, но журналы больше не будут отображаться на консоли. Почему это происходит? В качестве подсказки попробуйте посмотреть файл app.module и посмотреть, как объявлены перехватчики и в каком порядке. Имеет ли значение порядок? Попробуйте разместить перехватчик журналирования перед перехватчиком кеширования и наблюдайте за результатами.

Перехватчик обработки ошибок

Ошибки из ответа API на HTTP-вызов никогда не желательны для какого-либо приложения. Тем не менее, лучший способ справиться с ними - это предположить, что они могут (и произойдут), и предоставить элегантный способ справиться с ними. Неудачные запросы могут происходить по многим причинам, и последнее, что хотел бы получить конечный пользователь, - это неправильное представление или отображаемое значительное количество ошибок.

Элегантное решение может быть реализовано путем создания обработчика ошибок для перехвата всех ошибок HTTP.

import {
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MessageService } from 'primeng/api';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
  constructor(private messageService: MessageService) {}

  intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    return next.handle(request).pipe(
      catchError((requestError) => {
        if (requestError.status !== 401) {
          const { error } = requestError;
          this.messageService.add({
            severity: 'error',
            summary: `HTTP Error - ${requestError.status}`,
            detail: error && error.message,
          });
        }
        return throwError(() => new Error(requestError));
      })
    );
  }
}

Особых объяснений не требуется, поскольку код должен быть понятным. Единственная деталь, которую важно обсудить, - это фильтрация ошибок. Мы имеем дело только с ошибками, в которых ответ HTTP отличается от 401.

Почему это? В начале этой статьи я упоминал, что можно иметь несколько перехватчиков, выполняющих их в цепочке. Поскольку у нас уже есть перехватчик auth, который справляется со всеми ошибками 401 и обрабатывает эти запросы, нет смысла управлять ими и на этом перехватчике.

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

#Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

Vladimir Shaitan - Видео блог о frontend разработке и не только

Посмотреть