Примерно год назад я провел первые тесты e2e на проекте. Это было довольно большое приложение, использующее JAVA SpringBoot во внутренней части и Angular во фронтальной части. В качестве инструмента тестирования мы использовали Protractor, который использует Selenium. Во внешнем коде была служба, у которой был метод обработчика ошибок. Когда этот метод был вызван, выскакивал модальный диалог, и пользователь мог видеть детали ошибок и трассировку стека.

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

К счастью, Angular имеет встроенный способ обработки ошибок и чрезвычайно прост в использовании. Нам просто нужно создать нашу собственную службу, которая реализует ErrorHandler интерфейс Angular:

import { ErrorHandler, Injectable } from '@angular/core';
@Injectable({
    providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler{
    constructor() {}
handleError(error: any) {
        // Implement your own way of handling errors
    }
}

Хотя мы могли бы легко предоставить нашу услугу в нашем AppModule, было бы неплохо предоставить эту услугу в отдельном модуле. Таким образом, мы могли бы создать нашу собственную библиотеку и использовать ее в наших будущих проектах:

// ERROR HANDLER MODULE
import {ErrorHandler, ModuleWithProviders, NgModule} from '@angular/core';
import {ErrorHandlerComponent} from './components/error-handler.component';
import {FullscreenOverlayContainer, OverlayContainer, OverlayModule} from '@angular/cdk/overlay';
import {ErrorHandlerService} from './error-handler.service';
import {A11yModule} from '@angular/cdk/a11y';
@NgModule({
  declarations: [ErrorHandlerComponent],
  imports: [CommonModule, OverlayModule, A11yModule],
  entryComponents: [ErrorHandlerComponent]
})
export class ErrorHandlerModule {
  public static forRoot(): ModuleWithProviders {
    return {
      ngModule: ErrorHandlerModule,
      providers: [
        {provide: ErrorHandler, useClass: ErrorHandlerService},
        {provide: OverlayContainer, useClass: FullscreenOverlayContainer},
      ]
    };
  }
}

Мы использовали Angular CLI для генерации ErrorHandlerModule, поэтому у нас уже есть сгенерированный компонент, который может быть содержимым нашего модального диалога. Чтобы мы могли поместить его в оверлей Angular CDK, он должен быть entryComponent. Вот почему мы поместили его в массив entryComponents ErrorHandlerModule.

Мы также добавили импорт. OverlayModule и A11yModule поступают из модуля CDK. Они необходимы для создания нашего оверлея и для захвата фокуса при открытии нашего диалогового окна ошибки. Как видите, мы предоставляем OverlayContainer с использованием класса FullscreenOverlayContainer, потому что в случае возникновения ошибки мы хотим ограничить взаимодействие наших пользователей модальным окном ошибки. Если у нас нет полноэкранного фона, пользователи могут взаимодействовать с приложением и вызывать дальнейшие ошибки. Давайте добавим наш недавно созданный модуль в наш AppModule:

// APP MODULE
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {MainComponent} from './main/main.component';
import {ErrorHandlerModule} from '@btapai/ng-error-handler';
import {HttpClientModule} from '@angular/common/http';
@NgModule({
  declarations: [ AppComponent, MainComponent ],
  imports: [
    BrowserModule,
    HttpClientModule,
    ErrorHandlerModule.forRoot(),
    AppRoutingModule,
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}

Теперь, когда у нас есть ErrorHandlerService, мы можем приступить к реализации логики. Мы собираемся создать модальное диалоговое окно, которое будет отображать ошибку в понятной и удобочитаемой форме. Этот диалог будет иметь наложение / фон, и он будет динамически помещен в DOM с помощью Angular CDK. Установим:

npm install @angular/cdk --save

Согласно документации, компоненту Overlay нужны предварительно созданные файлы css. Теперь, если бы мы использовали Angular Material в нашем проекте, в этом не было бы необходимости, но это не всегда так. Давайте импортируем CSS-оверлей в наш файл styles.css. Обратите внимание: если вы уже используете Angular Material в своем приложении, вам не нужно импортировать этот CSS.

@import '~@angular/cdk/overlay-prebuilt.css';

Давайте воспользуемся нашим методом handleError, чтобы создать наш модальный диалог. Важно знать, что служба ErrorHandler является частью фазы инициализации приложения Angular. Чтобы избежать неприятной ошибки циклической зависимости, мы используем инжектор как единственный параметр конструктора. Мы используем систему внедрения зависимостей Angular при вызове фактического метода. Давайте импортируем оверлей из CDK и прикрепим наш ErrorHandlerComponent в DOM:

// ... imports
@Injectable({
   providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
   constructor(private injector: Injector) {}
   handleError(error: any) {
       const overlay: Overlay = this.injector.get(Overlay);
       const overlayRef: OverlayRef = overlay.create();
       const ErrorHandlerPortal: ComponentPortal<ErrorHandlerComponent> = new ComponentPortal(ErrorHandlerComponent);
       const compRef: ComponentRef<ErrorHandlerComponent> = overlayRef.attach(ErrorHandlerPortal);
   }
}

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

// imports
export const ERROR_INJECTOR_TOKEN: InjectionToken<any> = new InjectionToken('ErrorInjectorToken');
@Component({
  selector: 'btp-error-handler',
  // TODO: template will be implemented later
  template: `${error.message}<br><button (click)="dismiss()">DISMISS</button>`
  styleUrls: ['./error-handler.component.css'],
})
export class ErrorHandlerComponent {
  private isVisible = new Subject();
  dismiss$: Observable<{}> = this.isVisible.asObservable();
  constructor(@Inject(ERROR_INJECTOR_TOKEN) public error) {
  }
  dismiss() {
    this.isVisible.next();
    this.isVisible.complete();
  }
}

Как видите, сам компонент довольно прост. Мы собираемся использовать в шаблоне две довольно важные директивы, чтобы сделать диалог доступным. Первый - это cdkTrapFocus, который захватывает фокус при рендеринге диалога. Это означает, что пользователь не может фокусировать элементы за нашим модальным диалогом. Вторая директива - это cdkTrapFocusAutoCapture, которая автоматически сфокусирует первый фокусируемый элемент внутри нашей ловушки фокуса. Кроме того, он автоматически восстановит фокус на ранее сфокусированном элементе, когда наш диалог будет закрыт.

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

// imports
export const DEFAULT_OVERLAY_CONFIG: OverlayConfig = {
  hasBackdrop: true,
};
@Injectable({
  providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
  private overlay: Overlay;
  constructor(private injector: Injector) {
    this.overlay = this.injector.get(Overlay);
  }
  handleError(error: any): void {
    const overlayRef = this.overlay.create(DEFAULT_OVERLAY_CONFIG);
    this.attachPortal(overlayRef, error).subscribe(() => {
      overlayRef.dispose();
    });
  }
  private attachPortal(overlayRef: OverlayRef, error: any): Observable<{}> {
    const ErrorHandlerPortal: ComponentPortal<ErrorHandlerComponent> = new ComponentPortal(
      ErrorHandlerComponent,
      null,
      this.createInjector(error)
    );
    const compRef: ComponentRef<ErrorHandlerComponent> = overlayRef.attach(ErrorHandlerPortal);
    return compRef.instance.dismiss$;
  }
  private createInjector(error: any): PortalInjector {
    const injectorTokens = new WeakMap<any, any>([
      [ERROR_INJECTOR_TOKEN, error]
    ]);
    return new PortalInjector(this.injector, injectorTokens);
  }
}

Давайте сначала сосредоточимся на предоставлении ошибки в качестве вводимого параметра. Как видите, класс ComponentPortal ожидает один обязательный параметр, которым является сам компонент. Второй параметр - это ViewContainerRef, который будет влиять на логическое место компонента в дереве компонентов. Третий параметр - это наш createInejctor метод. Как видите, он возвращает новый экземпляр PortalInjector. Давайте быстро посмотрим на его базовую реализацию:

export class PortalInjector implements Injector {
 constructor(
   private _parentInjector: Injector,
   private _customTokens: WeakMap<any, any>) { }
 get(token: any, notFoundValue?: any): any {
   const value = this._customTokens.get(token);
   if (typeof value !== 'undefined') {
     return value;
   }
   return this._parentInjector.get<any>(token, notFoundValue);
 }
}

Как видите, он ожидает Injector в качестве первого параметра и WeakMap для пользовательских токенов. Мы сделали именно это, используя наш ERROR_INJECTOR_TOKEN, который связан с самой нашей ошибкой. Созданный PortalInjector используется для правильного создания нашего ErrorHandlerComponent, он гарантирует, что сама ошибка будет присутствовать в компоненте.

Наконец, наш attachPortal метод возвращает свойство dismiss$ недавно созданного компонента. Мы подписываемся на него, и когда он изменяется, мы вызываем .dispose() на нашем overlayRef. И наш модальный диалог ошибки закрывается. Обратите внимание, что мы также вызываем завершение по нашей теме внутри компонента, поэтому нам не нужно отказываться от подписки.

Теперь это отлично подходит для ошибок, которые возникают, когда есть проблема в боковом коде клинета. Но мы создаем веб-приложения и используем конечные точки API. Так что же происходит, когда конечная точка REST возвращает ошибку?

Мы можем обработать каждую ошибку в собственном сервисе, но действительно ли мы этого хотим? Если все в порядке, ошибок не будет. Если есть особые требования, например, для обработки кода состояния 418 с летающим единорогом, вы можете реализовать его обработчик в его сервисе. Но когда мы сталкиваемся с довольно распространенными ошибками, такими как 404 или 503, мы можем захотеть отобразить это в том же диалоговом окне ошибки.

Давайте быстро разберемся, что происходит, когда бросается HttpErrorResponse. Это будет асинхронно, поэтому, вероятно, мы столкнемся с некоторыми проблемами обнаружения изменений. Этот тип ошибки имеет свойства, отличные от свойств простой ошибки, поэтому нам может потребоваться метод дезинфекции. Теперь давайте займемся этим, создав довольно простой интерфейс для SanitisedError:

export interface SanitizedError {
  message: string;
  details: string[];
}

Давайте создадим шаблон для нашего ErrorHandlerComponent:

// Imports
@Component({
  selector: 'btp-error-handler',
  template: `
    <section cdkTrapFocus [cdkTrapFocusAutoCapture]="true" class="btp-error-handler__container">
      <h2>Error</h2>
      <p>{{error.message}}</p>
      <div class="btp-error-handler__scrollable">
        <ng-container *ngFor="let detail of error.details">
          <div>{{detail}}</div>
        </ng-container>
      </div>
      <button class="btp-error-handler__dismiss button red" (click)="dismiss()">DISMISS</button>
    </section>`,
  styleUrls: ['./error-handler.component.css'],
})
export class ErrorHandlerComponent implements OnInit {
 // ...
}

Мы обернули весь модальный файл в <section> и добавили к нему директиву cdkTrapFocus. Эта директива предотвратит навигацию пользователя в DOM за нашим оверлеем / модальным окном. [cdkTrapFocusAutoCapture]="true" гарантирует, что кнопка "отклонить" будет немедленно сфокусирована. Когда модальное окно закрыто, элемент, на который был сфокусирован ранее, вернет фокус. Мы просто отображаем сообщение об ошибке и подробную информацию, используя *ngFor. Вернемся к нашему ErrorHandlerService:

// Imports
@Injectable({
  providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
  // Constructor
  handleError(error: any): void {
    const sanitised = this.sanitiseError(error);
    const ngZone = this.injector.get(NgZone);
    const overlayRef = this.overlay.create(DEFAULT_OVERLAY_CONFIG);
    ngZone.run(() => {
      this.attachPortal(overlayRef, sanitised).subscribe(() => {
        overlayRef.dispose();
      });
    });
  }
  
  // ...
  private sanitiseError(error: Error | HttpErrorResponse): SanitizedError {
    const sanitisedError: SanitizedError = {
      message: error.message,
      details: []
    };
    if (error instanceof Error) {
      sanitisedError.details.push(error.stack);
    } else if (error instanceof HttpErrorResponse) {
      sanitisedError.details = Object.keys(error)
        .map((key: string) => `${key}: ${error[key]}`);
    } else {
      sanitisedError.details.push(JSON.stringify(error));
    }
    return sanitisedError;
  }
  // ...
}

С помощью довольно простого sanitiseError метода мы создаем объект, основанный на нашем ранее определенном интерфейсе. Мы проверяем типы ошибок и соответствующим образом заполняем данные. Более интересная часть - использование инжектора для получения ngZone. Когда ошибка возникает асинхронно, это обычно происходит вне обнаружения изменений. Мы оборачиваем наш attachPortal на ngZone.run(/* … */),, поэтому, когда HttpErrorResponse пойман, он правильно отображается в нашем модальном окне.

Несмотря на то, что текущее состояние работает нормально, настройки по-прежнему отсутствуют. Мы используем Overlay из модуля CDK, поэтому было бы неплохо предоставить токен внедрения для пользовательских конфигураций. Еще один важный недостаток этого модуля заключается в том, что при использовании этого модуля другой модуль не может использоваться для обработки ошибок. Например, для интеграции Sentry потребуется реализовать аналогичный, но легкий ErrorHandler модуль. Чтобы иметь возможность использовать оба, мы должны реализовать возможность использования хуков внутри нашего обработчика ошибок. Во-первых, давайте создадим наш InjectionToken и нашу конфигурацию по умолчанию:

import {InjectionToken} from '@angular/core';
import {DEFAULT_OVERLAY_CONFIG} from './constants/error-handler.constants';
import {ErrorHandlerConfig} from './interfaces/error-handler.interfaces';
export const DEFAULT_ERROR_HANDLER_CONFIG: ErrorHandlerConfig = {
  overlayConfig: DEFAULT_OVERLAY_CONFIG,
  errorHandlerHooks: []
};
export const ERROR_HANDLER_CONFIG: InjectionToken<ErrorHandlerConfig> = new InjectionToken('btp-eh-conf');

Затем предоставьте его вместе с нашим модулем, используя наш существующий метод forRoot:

@NgModule({
  declarations: [ErrorHandlerComponent],
  imports: [CommonModule, OverlayModule, A11yModule],
  entryComponents: [ErrorHandlerComponent]
})
export class ErrorHandlerModule {
  public static forRoot(): ModuleWithProviders {
    return {
      ngModule: ErrorHandlerModule,
      providers: [
        {provide: ErrorHandler, useClass: ErrorHandlerService},
        {provide: OverlayContainer, useClass: FullscreenOverlayContainer},
        {provide: ERROR_HANDLER_CONFIG, useValue: DEFAULT_ERROR_HANDLER_CONFIG}
      ]
    };
  }
}

Затем интегрируйте эту обработку конфигурации в наш ErrorHandlerService:

// Imports
@Injectable({
  providedIn: 'root'
})
export class ErrorHandlerService implements ErrorHandler {
  // ...
  handleError(error: any): void {
    const sanitised = this.sanitiseError(error);
    const {overlayConfig, errorHandlerHooks} = this.injector.get(ERROR_HANDLER_CONFIG);
    const ngZone = this.injector.get(NgZone);
    this.runHooks(errorHandlerHooks, error);
    const overlayRef = this.createOverlayReference(overlayConfig);
    ngZone.run(() => {
      this.attachPortal(overlayRef, sanitised).subscribe(() => {
        overlayRef.dispose();
      });
    });
  }
  // ...
  private runHooks(errorHandlerHooks: Array<(error: any) => void> = [], error): void {
    errorHandlerHooks.forEach((hook) => hook(error));
  }
  private createOverlayReference(overlayConfig: OverlayConfig): OverlayRef {
    const overlaySettings: OverlayConfig = {...DEFAULT_OVERLAY_CONFIG, ...overlayConfig};
    return this.overlay.create(overlaySettings);
  }
  // ...
}

И мы почти готовы. Давайте интегрируем сторонний обработчик ошибок в наше приложение:

// Imports
const CustomErrorHandlerConfig: ErrorHandlerConfig = {
  errorHandlerHooks: [
    ThirdPartyErrorLogger.logErrorMessage,
    LoadingIndicatorControl.stopLoadingIndicator,
  ]
};
@NgModule({
  declarations: [
    AppComponent,
    MainComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    ErrorHandlerModule.forRoot(),
    AppRoutingModule,
  ],
  providers: [
    {provide: ERROR_HANDLER_CONFIG, useValue: CustomErrorHandlerConfig}
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}

Как видите, обработка ошибок - чрезвычайно важная часть разработки программного обеспечения, но она также может быть интересной.

Большое спасибо за чтение этого сообщения в блоге. Если вы предпочитаете читать код, загляните в мой репозиторий ng-reusables git. Вы также можете опробовать реализацию, используя этот пакет npm.

Вы также можете подписаться на меня в Twitter или GitHub.