Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Custom Exceptions - specjalne sytuacje

Filtr z poprzedniej lekcji czeka gotowy, ale ma co przechwytywać? W serwisie stoi

throw new Error('nie znaleziono tributu')
. Filtr dostaje obiekt, o którym wie tylko tyle, że jest błędem - żeby rozpoznać, o co chodziło, musiałby czytać treść komunikatu. A gdy ktoś poprawi literówkę w tym zdaniu, obsługa przestanie działać.

Rzymski urzędnik nie opisywał kryzysu zdaniem. Wpisywał kod sprawy, wagę i czy da się zaradzić na miejscu - i dopiero po tych polach wiadomo było, kto ma się tym zająć. Tak samo działa dobrze zbudowany wyjątek.

Po czym dziedziczyć

Pierwsza decyzja bywa myląca, bo NestJS ma własną klasę

HttpException
. Kusi, by z niej skorzystać - przecież już wie o kodach HTTP.

To pułapka.

HttpException
należy do warstwy transportowej, a Twoje wyjątki opisują dziedzinę: brakujący tribut, przekroczony limit legionu, nieopłacony żołd. Gdyby serwis rzucał
HttpException
, wiedziałby o istnieniu HTTP - a wtedy nie użyjesz go w zadaniu wsadowym ani w konsumencie kolejki, gdzie żadnego HTTP nie ma. Pamiętasz regułę z poprzedniej lekcji: to filtr tłumaczy wyjątki wewnętrzne na język HTTP. Serwis ma tylko powiedzieć, co się stało.

Dlatego dziedziczymy po

Error
- wbudowanej klasie JavaScriptu. Uwaga na dwie nazwy, które brzmią prawdopodobnie, ale nie istnieją:
NestException
i
RuntimeException
. Ta druga pochodzi z Javy i C#; w JavaScripcie jej nie ma.

Klasa bazowa

Wspólny przodek wszystkich naszych wyjątków wygląda tak:

1export abstract class LegionaryException extends Error {
2  constructor(
3    message: string,
4    public readonly code: string,
5    public readonly severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL',
6    public readonly recoverable: boolean = false,
7    public readonly context: Record<string, unknown> = {},
8  ) {
9    super(message);
10    this.name = this.constructor.name;
11
12    Error.captureStackTrace(this, this.constructor);
13  }
14
15  toJSON() {
16    return {
17      name: this.name,
18      code: this.code,
19      severity: this.severity,
20      recoverable: this.recoverable,
21      context: this.context,
22    };
23  }
24}

Klasa jest abstrakcyjna, bo nikt nie powinien rzucać jej wprost - służy tylko za wspólny przodek. Dzięki temu w filtrze wystarczy

@Catch(LegionaryException)
, żeby złapać wszystkie wyjątki dziedzinowe naraz.

Prześledźmy cztery pola, bo każde odpowiada na inne pytanie:

code
to stały identyfikator sprawy, na przykład
'TRIBUTE_NOT_FOUND'
. To po nim rozpoznajesz błąd w kodzie i w logach - nigdy po treści komunikatu, która bywa tłumaczona i poprawiana.

severity
mówi, jak poważna jest sytuacja. Cztery poziomy od najmniej do najbardziej krytycznego: LOW to drobne problemy, MEDIUM - problemy wymagające uwagi, HIGH - poważne zagrożenie, CRITICAL - awaria krytyczna. Po tym polu system alarmowy decyduje, czy zapisać wpis w logu, czy obudzić kogoś telefonem.

recoverable
odpowiada na pytanie, czy błąd da się naprawić automatycznie. Chwilowy brak połączenia z bazą jest naprawialny - warto ponowić. Brakujący tribut nie jest: ponawianie nic nie da, bo za sekundę też go nie będzie. To pole steruje mechanizmem ponowień, żeby nie próbował w nieskończoność rzeczy z góry przegranych.

context
niesie dane towarzyszące - identyfikator tributu, nazwę legionu, cokolwiek pomoże zrozumieć sytuację przy czytaniu logu. Typ
Record<string, unknown>
to zwykły obiekt o dowolnych kluczach.

Metoda

toJSON()
zwraca wyjątek w postaci gotowej do zapisania w logu. Zauważ, czego w niej nie ma: śladu stosu. To celowe - stos przydaje się przy debugowaniu, ale w logu zbieranym z produkcji zajmowałby dziewięć dziesiątych miejsca.

captureStackTrace - czysty ślad

Jedna linia zasługuje na osobne wyjaśnienie:

1Error.captureStackTrace(this, this.constructor);

Bez niej ślad stosu zaczynałby się wewnątrz konstruktora wyjątku - czyli w miejscu, które Cię nie interesuje, bo napisałeś je raz i działa. Drugi argument mówi: pomiń ten konstruktor i wszystko powyżej, zacznij ślad od miejsca, w którym wyjątek naprawdę rzucono.

To drobiazg, który przy czytaniu logów oszczędza sporo mrużenia oczu. Sama linia niczego nie szyfruje, nie kasuje ani nie wysyła - jedynie ustawia początek śladu.

Konkretny wyjątek

Mając przodka, poszczególne wyjątki są krótkie:

1export class TributeNotFoundException extends LegionaryException {
2  constructor(tributeId: string) {
3    super(
4      `Tribute ${tributeId} not found`,
5      'TRIBUTE_NOT_FOUND',
6      'MEDIUM',
7      false,
8      { tributeId },
9    );
10  }
11}

Kolejność jest zawsze ta sama: deklaracja klasy,

extends LegionaryException
, konstruktor z danymi potrzebnymi do opisania sprawy, a w nim
super(...)
wypełniające pola przodka.

Zwróć uwagę, co zyskujesz w miejscu użycia. Zamiast

throw new Error('nie znaleziono tributu ' + id)
piszesz
throw new TributeNotFoundException(id)
- krócej, a filtr rozpozna typ przez
instanceof
bez czytania komunikatu. Identyfikator ląduje w
context
, więc log powie, o który tribut chodziło.

To polecam jako regułę, @name: jeden wyjątek na jedną sytuację dziedzinową, z kodem i wagą nadanymi raz, w konstruktorze. Wtedy w serwisie nie ma już decyzji do podjęcia - rzucasz właściwą klasę i idziesz dalej.

Podsumowanie

Kryzysy mają kody spraw, nie zdania:

  • wyjątki dziedzinowe dziedziczą po
    Error
    - wbudowanej klasie JavaScriptu,
  • nie po
    HttpException
    , bo należy ona do warstwy transportowej, a serwis nie powinien wiedzieć o HTTP;
    NestException
    i
    RuntimeException
    w ogóle nie istnieją,
  • to filtr tłumaczy wyjątek na kod HTTP - wyjątek mówi tylko, co się stało,
  • klasa bazowa jest abstrakcyjna, dzięki czemu
    @Catch(LegionaryException)
    łapie całą rodzinę,
  • code
    to stały identyfikator sprawy - rozpoznawaj błędy po nim, nie po treści komunikatu,
  • severity
    od najmniej do najbardziej krytycznego: LOW → MEDIUM → HIGH → CRITICAL,
  • recoverable
    mówi, czy błąd da się naprawić automatycznie
    - steruje ponowieniami,
  • context
    niesie dane pomocne przy czytaniu logu,
    toJSON()
    przygotowuje wpis bez śladu stosu,
  • Error.captureStackTrace(this, this.constructor)
    ustawia początek śladu na miejscu rzucenia
    , pomijając konstruktor - niczego nie szyfruje ani nie kasuje,
  • konkretny wyjątek: klasa,
    extends
    po bazowej, konstruktor,
    super(...)
    z kodem i wagą.

W następnej lekcji zajmiemy się tym, co dzieje się z tymi wyjątkami dalej - jak zapisywać je w kronice niepowodzeń, żeby dało się z niej cokolwiek wyczytać. A na razie zapamiętaj: wyjątek to formularz sprawy z kodem, wagą i notatką - a nie zdanie, które ktoś kiedyś poprawi.

Przejdź do CodeWorlds