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.