Angularのエラー処理について考える(実装編)

前回の記事では、Angularのエラー処理について設計時の目線で整理しました。
今回は、それを実装するときに、どんなことになるのか、実例を添えて説明します。

エラー分類と画面側の実装

前回の設計編に記述したエラー分類別に、画面側に実装するエラー処理について、一覧化しました。
画面固有の機能として実装すべきものか、あるいは、共通処理として実装すべきかについて、区別します。

#エラー画面側の実装
1-1サーバー接続不能なし
1-2通信タイムアウトなし
2-1未認証なし
2-2権限不一致なし
2-3セキュリティ保護なし
2-4バグなし
3-1排他制御エラーPageコンポーネントでAppErrorをcatchして実装する
3-2ユニーク制約違反エラー同上
3-3データが存在しないエラー同上
3-4バリデーションエラー同上
3-5画面機能固有のエラーPageコンポーネントかサービスクラスで実装する

共通処理としての実装例

例外クラス

まずは、例外クラスを作って、 error を catch したときに、区別できるようにします。

export namespace AppError {
  export function isInstance(error: BaseError, clazz) {
    return error.name && error.name === clazz.name;
  }

  export class BaseError extends Error {
    constructor(message?: string, error?: Error) {
      super(message);
      this.name = 'AppError.BaseError';
      this.message = message;
      if (error) {
        this.stack += `\nCaused by: ${error.message}`;
        if (error.stack) {
          this.stack += `\n${error.stack}`;
        }
      }
    }
  }

  export class ApiError extends BaseError {
    private response: HttpResponseBase;

    constructor(message?: string, response?: HttpResponseBase, error?: Error) {
      super(message, error);
      this.name = 'ApiError';
      this.response = response;
    }
  }

  export class BadRequest extends ApiError {
    constructor(message?: string, response?: HttpResponseBase, error?: Error) {
      super(message, response);
      this.name = 'BadRequest';
    }
  }

  export class Unauthorized extends ApiError {
    constructor(message?: string, response?: HttpResponseBase, error?: Error) {
      super(message, response);
      this.name = 'Unauthorized';
    }
  }

  ・・・

  export class ApiErrorFactory {
    public static getError(res: HttpResponseBase): ApiError {
      let error: ApiError = null;
      switch (res.status) {
        case 400:
          error = new AppError.BadRequest(null, res);
          break;
        case 401:
          error = new AppError.Unauthorized(null, res);
          break;
        case 403:
          error = new AppError.Forbidden(null, res);
          break;

        ・・・
      }
      return error;
    }
  }

AppError.isInstanceは Typescript の instanceof が意図した結果を返してくれないので、各エラークラス内に、クラス名を保持するようにして、それと一致するかをチェックするためのユーティリティです。

インターセプター

http通信のエラーを処理するためのインターセプターです。

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
  constructor(private router: Router, private alertService: AppAlertService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    if (req.reportProgress) {
      throw new AppError.BaseError('not implements');
    }
    return next.handle(req).pipe(
      map((event: HttpEvent<any>) => {
        if (event instanceof HttpResponse) {
          const err = this.handleAppError(event);
          if (err) {
            throw err;
          }
        }
        return event;
      }),
      catchError((errRes: HttpErrorResponse) => {
        if (errRes.error instanceof ErrorEvent) {
          const message = `An error occurred: ${errRes.error.message}`;
          this.errorLog(message);
          this.alertService.error(message);
        } else {
          const err = this.handleAppError(errRes);
          if (err) {
            throw err;
          }
        }
        return throwError(errRes);
      })
    );
  }

  private handleAppError(event: HttpResponseBase) {
    const err = AppError.ApiErrorFactory.getError(event);
    if (err === null) {
      return err;
    }
    if (AppError.isInstance(err, AppError.Unauthorized)) {
      this.errorLog(err);
      this.router.navigate(['/login']);
      return null;
    }
    if (AppError.isInstance(err, AppError.Forbidden)) {
      this.errorLog(err);
      this.router.navigate(['/error/403']);
      return null;
    }
    if (AppError.isInstance(err, AppError.ServerError)) {
      this.errorLog(err);
      this.router.navigate(['/error/500']);
      return null;
    }
    if (AppError.isInstance(err, AppError.Maintenance)) {
      this.errorLog(err);
      this.router.navigate(['/error/503']);
      return null;
    }
    return err;
  }

  private errorLog(message: string | Error) {
    if (message instanceof Error) {
      const err = message;
      message = `${err.message}: ${err.stack}`;
    }
    console.error(message);
  }
}

アラート表示用のサービス

タイムアウトなどのErrorEventが発生したときには、特定のコンポーネントに依存しないSnackBarで、アラートメッセージを表示する例です。

@Injectable()
export class AppAlertService {
  constructor(private snackBar: MatSnackBar, private zone: NgZone) {}

  error(message: string) {
    this.zone.run(() => {
      const snackBar = this.snackBar.open(message, 'OK', {
        verticalPosition: 'bottom',
        horizontalPosition: 'center',
      });
      snackBar.onAction().subscribe(() => {
        snackBar.dismiss();
      });
    });
  }
}

バグなどのグローバルエラーハンドラー

バグ発生時などの不測のエラーでは、2次被害が出ないように、システムエラーの画面にリダイレクトさせる例です。

@Injectable({
  providedIn: 'root',
})
export class AppErrorHandler implements ErrorHandler {
  constructor(private injector: Injector) {}

  handleError(error) {
    this.errorLog(error);
    const router = this.injector.get(Router);
    const zone = this.injector.get(NgZone);
    zone.run(() => {
      router.navigateByUrl('/error/500');
    });
  }

  private errorLog(message: string | Error) {
    if (message instanceof Error) {
      const err = message;
      message = `${err.message}: ${err.stack}`;
    }
    console.error(message);
  }
}

画面側の実装例

排他制御エラー

ページコンポーネントの〇〇更新サービスを実行する箇所で、例外をcatchする例

return this.hogeService.update(data).pipe(
  map((result) => {・・・}),
  catchError((err) => {
    if (AppError.isInstance(err, AppError.BadRequest)) {
      this.errorMessage = '他のユーザーによって更新されています。最初からやり直してください';
      return EMPTY;
    } else if (AppError.isInstance(err, AppError.NotFound)) {
      this.errorMessage = '他のユーザーによって削除されています。最初からやり直してください';
      return EMPTY;
    }
    return throwError(err);
  })
);

ユニーク制約違反エラー

ページコンポーネントの〇〇保存サービスを実行する箇所で、例外をcatchする例

return this.hogeService.update(data).pipe(
  map((result) => {・・・}),
  catchError((err) => {
    if (AppError.isInstance(err, AppError.Conflict)) {
      this.errorMessage = 'キーが重複しました。最初からやり直してください';
      return EMPTY;
    }
    return throwError(err);
  })
);

データが存在しないエラー

データ存在しないときに、どうするのかは、画面固有の仕様として策定する必要があります。
よくあるパターンを上げてみます。

  • システム例外として、404の画面に遷移する例
      return this.hogeService.get(data).pipe(
        map((result) => {・・・}),
        catchError((err) => {
          if (AppError.isInstance(err, AppError.NotFound)) {
            this.router.navigate(['/error/404']);
            return EMPTY;
          }
          return throwError(err);
        })
      );
    
  • データがないときには、別のページに遷移する例(例えば遷移前の画面が一覧画面だったら、その画面にリダイレクトする)
      return this.hogeService.get(data).pipe(
        map((result) => {・・・}),
        catchError((err) => {
          if (AppError.isInstance(err, AppError.NotFound)) {
            this.router.navigate(['/hoges/list']);
            return EMPTY;
          }
          return throwError(err);
        })
      );
    
  • データがないときは、エラーメッセージを表示する
      return this.hogeService.get(data).pipe(
        map((result) => {・・・}),
        catchError((err) => {
          if (AppError.isInstance(err, AppError.NotFound)) {
            this.errorMessage = 'データが存在しません';
            return EMPTY;
          }
          return throwError(err);
        })
      );
    

バリデーションエラー

サーバー側でバリデーションエラーが発生することもあるでしょう。 コンポーネント内のエラーメッセージにセットする例です。

return this.hogeService.register(data).pipe(
  map((result) => {・・・}),
  catchError((err) => {
    if (AppError.isInstance(err, AppError.BadRequest)) {
      this.errorMessage = 'XXXXXXXXXXXX';
      return EMPTY;
    }
    return throwError(err);
  })
);
最近の記事タグ

\(^▽^*) 私たちと一緒に働いてみませんか? (*^▽^)/

少しでも興味をお持ちいただけたら、お気軽に、お問い合わせください。

採用応募受付へ

(採用応募じゃなく、ただ、会ってみたいという方も、大歓迎です。)