Serializacja i transformacja danych w Angular przy użyciu class-transformer

Dane z API, które generują serwisy http, tylko w teorii spełniają interfejs, który im przypisujemy. W przyczywistości są one obiektami js, od których oczekujemy jego spełnienia. Zobaczmy przykład.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
export class AccountModel {
  id: string;
  name: string;
  amount: number;
}

@Injectable()
export class AccountService {
  constructor(
    private http: HttpClient,
  ) { }

  get(id: string): Observable<AccountModel> {
    return this.http.get<AccountModel>(`account/${id}`);
  }
}

export class AccountEditComponent implements OnInit {
  account: AccountModel;

  constructor(
    private accountService: AccountService,
    private route: ActivatedRoute,
    private router: Router,
  ) { }

  ngOnInit() {
    this.route.params.subscribe((params) => {
      this.prepareAccount(params.id);
    });
  }

  prepareAccount(id) {
    this.accountService.get(id)
      .subscribe((result) => {
        console.log(result);
        this.account = result;
      });
  }
}

console.log wyświetli nam coś takiego:

1
{"_id":"5b36418ede197e2d3a248f20","name":"Kamil - mBank","amount":8489.33,"createdAt":"2018-06-29T14:26:22.891Z","updatedAt":"2019-02-25T20:18:34.930Z","__v":0}

Jak widać, nie jest to instancja obiektu, którego oczekujemy. Mimo iż AccountModel nie zawiera createdAt, updatedAt _v i id, dostajemy te dane. Dopóki plączą się, ale ich nie wykorzystujemy, problemu nie ma. Potem jednak okazuje się, że datę chcielibyśmy do aplikacji dostarczyć w formie instancji klasy Date, upewnić się że number to number, a gdy firstname z API od jutra będzie się nazywać firstName, nie trzeba będzie przekopywać połowy aplikacji.

Przybliżę Ci teraz bibliotekę, z której od pownego czasu korzystam i dzięki niej praca z danymi z zewnątrz oraz tych wewnątrz aplikacji, jest zdecydowanie prostsza i lepiej zorganizowana.

class-transformer

Jego głównym zadaniem jest serializacja danych i transformacja ich z czystego obiektu na instancję klasy i na odwrót. Oczywiście to nie wszystkie jej możliwości, ale o tym za chwilę.

Zainstalujmy teraz bibliotekę:

1
2
npm i --save class-transformer
npm i --save reflect-metadata

Następnie w pliku main.ts, importujemy drugą z zainstalowanych bibliotek - import "reflect-metadata";.

Teraz zmodyfikujmy wcześniejszy przykład, aby tego użyć.

AccountModel będzie wyglądać teraz tak:

1
2
3
4
5
6
7
8
9
10
11
12
import {Expose} from 'class-transformer';

export class AccountModel {
  @Expose({ name: '_id' })
  id: string;

  @Expose()
  name: string;

  @Expose()
  amount: number;
}

Serwis też nieco się zmieni, musimy wykorzystać pipe i map oraz funkcję plainToClass, dostarczoną przez class-transformer do zamiany struktury js, na instancję modelu:

1
2
3
4
5
6
  get(id: string): Observable<AccountModel> {
    return this.http.get<AccountModel>(`account/${id}`)
      .pipe(
        map(result => plainToClass(AccountModel, result as Object, { strategy: 'excludeAll', excludeExtraneousValues: true }))
      );
  }

Sprawdźmy teraz co zwróci API:

Właśnie o to chodziło, żadnych niezadeklarowanych właściwości, a na wyjściu instancja obiektu.

Dwie rzeczy w związku w powyższym zapisem wypada mi omówić - result as Object oraz {strategy: 'excludeAll', excludeExtraneousValues: true}. Pierwszy zapis służy wyłącznie do zlikwidowania błędów typowania wyświetlanych przez IDE. Plik deklaracji ClassTransformer.d.ts zawiera dwie wersje metody plainToClass, jedna dla mapowania pojedynczego obiektu, druga dla mapowania kolekcji, dlatego używam zapisu result as Object lub result as Object[], aby WebStorm nie krzyczał. Drugi wątek omówię w dalszej części artykułu.

Dostępne funkcje

plainToClass

Podstawa w pracy na danych z zewnątrz. Zamienia obiekt js zwracany przez httpClient, na instancję klasy.

1
2
3
import {plainToClass} from "class-transformer";

let user = plainToClass(UserModel, userJson as Object);

plainToClassFromExist

Podobnie jak powyższa, ale na wejściu otrzymuje instancję obiektu. Przydaje się, przy konstruowaniu obiektu, wprowadzamy startowe wartości, a później, np. po poprawnej walidacji formularza, uzupełniamy jego zawartością wcześniej stworzony obiekt.

1
2
3
4
5
6
7
8
import {plainToClass} from "class-transformer";

let user = new UserModel();
user.role = UserRolesEnum.admin;

if (this.form.valid) {
  user = plainToClassFromExist(user, this.form.value);
}

classToPlain

Wykonuje to samo co plainToClass, ale w drugą stronę, tj. zamienia instancję klasy na strukturę JS.

1
2
3
import {classToPlain} from "class-transformer";

let user = classToPlain(user);

classToClass

Funkcja tworzy obiekt z istniejącego już obiektu. Nigdy jej nie wykorzystywałem bo nie znalazłem dla niej zastosowania. Uznałem też, że może być niebezpieczna, jeśli transformujemy propercję, np. z typu string na instancję Date z użyciem toClassOnly (przeczytasz o tym w sekcji “Transformacja typu”). Treba wtedy pamiętać, aby ustawić warunek na typ danych wejściowych w deklaracji adnotacji @Transform. Inaczej *classToClass spróbuje transformować Date na Date rzucając przy okazji exception.

serialize

Klasyczna serializacja obiektu do postaci JSON.

deserialize

Deserializacja z JSON na pojedynczy obiekt. Ta funkcja nie działa z tablicami.

deserializeArray

Jak wyżej, ale dostarczamy do niej kolekcję obiektów.

Parametry transformacji

Definiujemy je podczas wywoływania funkcji transformującej, np. plainToClass i służą do zdefiniowania zachowania się transformacji.

excludeAll

Jest to niezwykle przydatny parametr. Ustawienie go sprawia, że podczas tranformacji pomijane są wszystkie propercje, które nie mają dodanej adnotacji @Expose(). Przydaje się kiedy chcesz pominąć część właściwości podczas transformacji.

1
plainToClass(User, createUserForm.value, { strategy: 'excludeAll' });

excludeExtraneousValues

Równie przydatna opcja co powyższa i właściwie clue działania class-transformera. Blokuje dodawanie do obiektu propercji, które nie zostały zdefiniowane w definicji klasy wyjściowej. Zabezpiecza tym samym aplikację przed dostaniem się do jej obiektów nieprzewidzianych propercji, np. gdy samozwańczy backend doda coś do API, a frontend nie będzie jeszcze na to gotowy.

Przykład wykorzystania excludeExtraneousValues podałem w pierwszym przykładzie tego artykułu.

Transformacja typu

1
2
3
4
5
  @Expose()
  @Type(() => DateTime)
  @Transform(value => DateTime.fromISO(value), { toClassOnly: true })
  @Transform(value => value.toISO(), { toPlainOnly: true })
  date: DateTime;

Jak widzisz, dekoratora Transform użyłem dwukrotnie. Wynika to z odmiennych zachowań transformacji w zależności od jej kierunku. Właściwość toClassOnly oznacza, że transformacja odbywać się będzie wyłącznie podczas transformacji do obiektu, np. podczas wywołania plainToClass. Natomiast toPlainOnly działa w drugą stronę, np. kiedy wysyłamy dane do API.

Zagnieżdżone obiekty

Niewiele jest aplikacji, którym wystarczy płaska struktura danych. Najczęściej istnieją powiązania między nimi i zagnieżdżone kolekcje. Także w tym aspekcie class-transformer będzie sobie świetnie radził.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import {Type, plainToClass} from "class-transformer";

export class Post {

    id: number;

    @Type(() => User)
    author: User;

    name: string;

    content: string;

    @Type(() => Comment)
    comments: Comment[];
}

export class Comment {
    id: number;
    author: string;
    content: string;
}

export class User {
  id: number;
  firstname: string;
  lastname: string;
  email: string;
  password: string;
}

let post = plainToClass(Post, postApiResult);

Wykluczanie właściwości

Czasami zdarza się, że część właściwości musi być deklarowana w innym miejscu niż podczas transformacji, np. język nowo tworzonego użytkownika pobrany z ustawień przeglądarki. Używamy wtedy adnotacji @Exclude() nad definicją propercji.

Istnieje też możliwość wykluczenia właściwości o ustalonym prefiksie lub prywatnych.

1
2
3
import {classToPlain} from "class-transformer";

let article = classToPlain(article, { excludePrefixes: ['_'] });

Zmiana nazwy właściwości

Z APi otrzymujemy _id, ale w aplikacji chcemy używać id.

1
2
  @Expose({ name: '_id' })
  id: string;

Grupy transformacji

W różnych przypadkach użycia, mogą być potrzebne odmienne propercje lub ich transformacja będzie odmienna. Grupy transformacji pozwolą w zależności od przypadku użycia, dostarczyć to co potrzebne.

Dla przykładu, pobieramy z tego samego endpointu API listę artykułów, ale w aplikacji klienckiej pracujemy tylko na podstawowych danych, a w panelu admina dodatkowo mamy do dyspozycji historię zmian.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {Type, plainToClass} from "class-transformer";

export class Post {

    id: number;

    title: string;

    content: string;

    @Expose({ groups: ['admin'] })
    @Type(() => History)
    history: History[];
}

export class History {
    id: number;
    articleId: number;
    field: string;
    valueBefore: string;
    valueAfter: string;
}
1
2
3
4
import {plainToClass} from "class-transformer";

let articlesToAdmin = classToPlain(user, { groups: ['admin'] }); // zwróci id, title, content i history
let articlesToClient = classToPlain(user); // zwróci id, title i content

Wersjonowanie

Wartościową opcją jest też korzystanie z wersjonowania klas. Przydaje się w niemal każdej aplikacji, która komunikuje się z API w różnych wersjach.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import {Type, Expose, plainToClass} from "class-transformer";

export class Post {

    id: number;

    title: string;

    content: string;

    @Expose({ since: 1 })
    @Type(() => Image)
    images: Image[];
}

export class Image {
    id: number;
    url: string;
    filename: string;
}
1
2
3
4
import {plainToClass} from "class-transformer";

let post = plainToClass(Post, postApiResult, { version: 0.7 }); // zwróci id, title, content
let post = plainToClass(Post, postApiResult, { version: 1 }); // zwróci id, title, content, images

Podsumowanie

Powyższe przykłady to oczywiście nie wszystkie możliwości class-transformer. Biblioteka pozwala też definiować własne klasy to obsługi kolekcji, pracować z typami generycznymi, obsłużyć problem z circular references i pewnie coś tam jeszcze potrafi.

Pracowałem z użyciem class-transformera w kilku projektach i stał się jedną z tych bibliotek, które instaluję jako pierwsze. Świetnie spełnia swoją rolę jako narzędzie do serializacji i kontroli danych. Amen.

Link do class-transformer