Angular 2/4 obsługa błędów w resolverze

Anton Kononenko
dodane przez: Anton Kononenko | Lipiec 11, 2017

Angular 2/4 (lub po prostu Angular) jest jednym z najnowocześniejszych frameworków dla aplikacji webowych, który udostępnia wiele funkcji, takich jak templating, dependency injections, data querying, routeing i wiele innych.

Angular posiada świetną dokumentację prezentującą na wielu przykładach, jak wykorzystywać wymienione funkcje, nie zawsze jednak zawiera wystarczająco informacji lub też nie o wszystkim da się powiedzieć. Dziś chciałbym opowiedzieć o resolverze routingu dla tego frameworka.

Mam nadzieję, że czytający wiedzą o czym mowa, jeśli jednak nie, wyjaśnię to po krótce. Zdarza się, że przed routeingiem chcemy wstępnie załadować dane, takie jak lista lekcji, osobiste dane użytkowników, czy jakiekolwiek inne. W tym celu możemy użyć resolvera. Technicznie rzecz biorąc, jest to usługa stosująca interfejs Resolve i określona w konfiguracji routingu. Zwykle, danych tych żąda się ze zdalnego serwisu, a jak wszyscy wiemy, coś może się czasem w takich wypadkach nie powieść.

Spójrzmy więc na najczęstsze definicje resolvera:

import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable } from 'rxjs/Observable';

import { LessonsService, Lesson } from 'lessons.service';

type Lessons = Array<Lesson>

@Injectable()
export class LessonsResolve implements Resolve<Lessons> {
    constructor(
       private lessonsService: LessonsService
    ) { }

    resolve(): Observable<Lessons> {
        return this.lessonsService.getLessons();
    }
}

Mam nadzieję, że w tym przykładzie wszystko będzie jasne. Mamy tu do czynienia z resolverem, który zwraca lekcje z serwisu, po czym Angular przekazuje je do komponentu. Dość prosta logika.

Jedna rzecz, którą nie zajmuje się ten przykład kodu, to obsługa błędów. Jak widzimy w Resolve, sygnatura: this.lessonsService.getLessons() zwraca strumień danych, miejmy nadzieję zawierający dane. Co jednak, jeśli bazowy serwis zawiedzie i nie będzie w stanie zwrócić danych? W takim przypadku oczekiwalibyśmy, że zwróci błąd ErrorObservable. Wystąpienie tego błędu spowodowałoby zawieszenie strony, a nie ma w tym przypadku odpowiedniej obsługi błędów dla użytkowników.

Jednym rozwiązaniem byłoby tu zastosowanie metody Observable.prototype.catch, która służy właśnie do obsługi błędów.

    resolve(): Observable<Lessons> {
    return this.lessonsService.getLessons()
        .catch((err: any) => Observable.of([]));

OK, tak już dużo lepiej; udaje nam się przynajmniej uniknąć zatrzymania aplikacji, jednak spróbujmy postawić się w pozycji użytkowników. Użytkownik nadal oczekuje na swoje lekcje, jednak nie wyświetla mu się nic. Nie za dobrze. Czy możemy stwierdzić w komponencie, że resolver zawiódł? Niestety nie, gdyż może po prostu nie być żadnej lekcji. Jedynym rozwiązaniem jest przekazanie błędu do komponentu. Tak zróbmy.

    resolve(): Observable<Lessons> {
    return this.lessonsService.getLessons()
        .catch((err: any) => Observable.of(err));
    }

OK, teraz w przypadku błędu, jest on propagowany do komponentu, a użytkownikom wyświetla się przybliżona wiadomość, że coś poszło nie tak, po czym mogą skontaktować się z naszym działem obsługi klienta lub, np. odświeżyć stronę, etc. Z punktu widzenia UX jest to wystarczające, jednak z technicznego punktu widzenia nie jest to najlepsze rozwiązanie. Połączyliśmy dwie wartości w jedną, ponieważ rezultatem może być błąd lub faktyczne dane. I jeszcze jedna rzecz: ponieważ JS pozwala na rzucanie jakichkolwiek danych, błąd może potencjalnie być pustą tablicą. Coś takiego oczywiście, nie powinno się przydarzyć, jednak serwis ten może być opracowany przez inny zespół, który mógł dojść do wniosku, że to właściwa obsługa, bo koniec końców zostanie to rzucone.

Poszukajmy więc lepszego rozwiązania.

Jedna z opcji nie wyrzuca faktycznych lekcji, tylko wrapper. Dla wrappera możemy użyć, np. nowego strumienia danych, promise, tablicy czy obiektu. Spróbujmy użyć obiektu. Po pierwsze musimy zdefiniować w tym celu nową klasę:

class ResolvedValue<T> {
    constructor(
        public value: T,
        public error: Error = null
    ) {}

    hasError(): boolean {
        return error !== null;
    }
}

Ten dość prosty wrapper pozwala nam odróżnić zachowania, w których błąd występuje, a w których nie, bez potrzeby majstrowania przy typie Resolved w komponencie. Stwórzmy też funkcję pomocniczą do wrappowania faktycznej odpowiedzi ze strony serwisu:

class UnclassifiedError extends Error {
    constructor(public originalError: any) {
        super();
    }
}

function wrapError(err: any): Error {
    return err instanceof Error ? err : new UnclassifiedError(err);
}

function handleResolveData<T>(observable: Observable<T>, defaultValue: T = null): Observable<ResolvedValue<T>> {
    return observable
        .map((value: T) => new ResolvedValue(value))
        .catch((error: any) => Observable.of(new ResolvedValue(defaultValue, wrapError(error))));
}

OK, mamy teraz bezpieczną typowo (type-safe) obsługę błędów w resolverze. Jak widać w przykładowym kodzie, zaprezentowano nową klasę błędów, dzięki czemu nawet przy pustym rzucie, nie przerwie to naszej logiki i nadal będziemy w stanie odróżnić błąd od rezultatu.

Pozostaje tylko jeden problem z tym kodem, typy dla resolvera wyglądają na skomplikowane, jest to jednak proste do poprawienia:

type ResolveValue<T> = Resolve<ResolvedValue<T>>;
type ResolveValueStream<T> = Observable<ResolvedValue<T>>;

To osłodzi nieco nasze definicje i uczyni je czytelniejszymi. Zerknijmy na ostateczną wersję resolvera:

import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/catch';

import { LessonsService, Lesson } from 'lessons.service';

type Lessons = Array<Lesson>;

class UnclassifiedError extends Error {
    constructor(public originalError: any) {
        super();
    }
}

class ResolvedValue<T> {
    constructor(
        public value: T,
        public error: Error = null
    ) {}

    hasError(): boolean {
       return error !== null;
    }
}

type ResolveValue<T> = Resolve<ResolvedValue<T>>;
type ResolveValueStream<T> = Observable<ResolvedValue<T>>;

function wrapError(err: any): Error {
    return err instanceof Error ? err : new UnclassifiedError(err);
}

function handleResolveData<T>(observable: Observable<T>, defaultValue: T = null): ResolveValueStream<T> {
    return observable
        .map((value: T) => new ResolvedValue(value))
        .catch((error: any) => Observable.of(new ResolvedValue(defaultValue, wrapError(error))));
}

@Injectable()
export class LessonsResolve implements ResolveValue<Lessons> {
    constructor(
        private lessonsService: LessonsService
    ) { }

    resolve(): ResolveValueStream<Lessons> {
        return handleResolveData(
            this.lessonsService.getLessons(),
            []);
        }
    }
}

Oczywiście pomocnicze klasy, funkcje i typy możemy, a nawet powinniśmy przenieść do odrębnego wspólnego modułu, by móc użyć ich ponownie w innych miejscach w projekcie.

Mam nadzieję, że to podejście pomoże w tworzeniu solidnych aplikacji webowych w Angularze zapewniając przy tym waszym użytkownikom możliwie najlepszy UX.

Anton Kononenko

Anton Kononenko

Frontend Developer who always seeks for the best solution, taking into account all perspectives. Anton's motto: "everything is possible".
Informacja dotycząca plików cookies

Informujemy, iż w celu optymalizacji treści dostępnych w naszym serwisie, dostosowania ich do Państwa indywidualnych potrzeb korzystamy z informacji zapisanych za pomocą plików cookies na urządzeniach końcowych użytkowników. Pliki cookies użytkownik może kontrolować za pomocą ustawień swojej przeglądarki internetowej. Dalsze korzystanie z naszego serwisu internetowego, bez zmiany ustawień przeglądarki internetowej oznacza, iż użytkownik akceptuje stosowanie plików cookies.

Akceptuję