Serializacja złożonych obiektów w JavaScript

Opublikowany: 2022-03-11

Wydajność strony internetowej i buforowanie danych

Nowoczesne witryny zazwyczaj pobierają dane z wielu różnych lokalizacji, w tym z baz danych i zewnętrznych interfejsów API. Na przykład podczas uwierzytelniania użytkownika witryna internetowa może wyszukać rekord użytkownika w bazie danych, a następnie ozdobić go danymi z niektórych usług zewnętrznych za pośrednictwem wywołań API. Minimalizacja kosztownych wywołań do tych źródeł danych, takich jak dostęp do dysku w przypadku zapytań do bazy danych i połączenia internetowe w przypadku wywołań interfejsu API, ma kluczowe znaczenie dla utrzymania szybkiej, responsywnej witryny. Buforowanie danych jest powszechną techniką optymalizacji używaną do osiągnięcia tego celu.

Procesy przechowują swoje dane robocze w pamięci. Jeśli serwer WWW działa w jednym procesie (takim jak Node.js/Express), dane te można łatwo buforować przy użyciu pamięci podręcznej działającej w tym samym procesie. Jednak serwery sieci Web ze zrównoważonym obciążeniem obejmują wiele procesów i nawet podczas pracy z jednym procesem można chcieć zachować pamięć podręczną po ponownym uruchomieniu serwera. Wymaga to rozwiązania buforowania poza procesem, takiego jak Redis, co oznacza, że ​​dane muszą być w jakiś sposób serializowane i deserializowane podczas odczytywania z pamięci podręcznej.

Serializacja i deserializacja są stosunkowo proste do osiągnięcia w językach statycznie wpisanych, takich jak C#. Jednak dynamiczna natura JavaScript sprawia, że ​​problem jest nieco trudniejszy. Chociaż ECMAScript 6 (ES6) wprowadził klasy, pola w tych klasach (i ich typach) nie są zdefiniowane, dopóki nie zostaną zainicjowane — co może nie mieć miejsca w przypadku tworzenia instancji klasy — a zwracane typy pól i funkcji nie są zdefiniowane w ogóle w schemacie. Co więcej, strukturę klasy można łatwo zmienić w czasie wykonywania — pola można dodawać lub usuwać, typy można zmieniać itp. Chociaż jest to możliwe przy użyciu odbicia w C#, odbicie reprezentuje „czarną sztukę” tego języka, a programiści oczekują, że zepsuje funkcjonalność.

Z tym problemem spotkałem się w pracy kilka lat temu, kiedy pracowałem w głównym zespole Toptal. Tworzyliśmy zwinny pulpit nawigacyjny dla naszych zespołów, który musiał być szybki; w przeciwnym razie programiści i właściciele produktów nie skorzystaliby z niego. Pobraliśmy dane z wielu źródeł: naszego systemu śledzenia pracy, naszego narzędzia do zarządzania projektami oraz bazy danych. Witryna została zbudowana w Node.js/Express i mieliśmy pamięć podręczną, aby zminimalizować wywołania tych źródeł danych. Jednak nasz szybki, iteracyjny proces rozwoju oznaczał, że wdrażaliśmy (a zatem ponownie uruchamialiśmy) kilka razy dziennie, unieważniając pamięć podręczną i tym samym tracąc wiele z jej zalet.

Oczywistym rozwiązaniem była pamięć podręczna poza procesem, taka jak Redis. Jednak po kilku badaniach odkryłem, że nie istnieje dobra biblioteka serializacji dla JavaScript. Wbudowane metody JSON.stringify/JSON.parse zwracają dane typu obiektu, tracąc wszelkie funkcje na prototypach oryginalnych klas. Oznaczało to, że zdeserializowanych obiektów nie można było po prostu użyć „w miejscu” w naszej aplikacji, co z tego powodu wymagałoby znacznej refaktoryzacji w celu pracy z alternatywnym projektem.

Wymagania dla Biblioteki

Aby obsługiwać serializację i deserializację dowolnych danych w JavaScript, z wymiennie używanymi reprezentacjami zdeserializowanymi i oryginałami, potrzebowaliśmy biblioteki serializacji o następujących właściwościach:

  • Zdeserializowane reprezentacje muszą mieć ten sam prototyp (funkcje, gettery, settery) co oryginalne obiekty.
  • Biblioteka powinna obsługiwać zagnieżdżone typy złożoności (w tym tablice i mapy), z prawidłowo ustawionymi prototypami zagnieżdżonych obiektów.
  • Powinna istnieć możliwość wielokrotnej serializacji i deserializacji tych samych obiektów — proces powinien być idempotentny.
  • Format serializacji powinien być łatwo transmitowany przez TCP i przechowywany przy użyciu Redis lub podobnej usługi.
  • Oznaczenie klasy jako możliwej do serializacji powinno wymagać minimalnych zmian w kodzie.
  • Procedury biblioteczne powinny być szybkie.
  • W idealnym przypadku powinien istnieć jakiś sposób na obsługę deserializacji starych wersji klasy poprzez pewnego rodzaju mapowanie/wersjonowanie.

Realizacja

Aby wypełnić tę lukę, zdecydowałem się napisać Tanagra.js , bibliotekę do serializacji ogólnego przeznaczenia dla JavaScript. Nazwa biblioteki nawiązuje do jednego z moich ulubionych odcinków Star Trek: The Next Generation , w którym załoga Enterprise musi nauczyć się komunikować z tajemniczą rasą obcych, której język jest niezrozumiały. Ta biblioteka serializacji obsługuje popularne formaty danych, aby uniknąć takich problemów.

Tanagra.js została zaprojektowana jako prosta i lekka, a obecnie obsługuje Node.js (nie była testowana w przeglądarce, ale teoretycznie powinna działać) oraz klasy ES6 (w tym Mapy). Główna implementacja obsługuje JSON, a wersja eksperymentalna obsługuje bufory protokołu Google. Biblioteka wymaga tylko standardowego JavaScript (obecnie testowane z ES6 i Node.js), bez zależności od funkcji eksperymentalnych, transpilacji Babel lub TypeScript .

Klasy możliwe do serializacji są oznaczane jako takie za pomocą wywołania metody, gdy klasa jest eksportowana:

module.exports = serializable(Foo, myUniqueSerialisationKey)

Metoda zwraca proxy do klasy, która przechwytuje konstruktor i wstrzykuje unikalny identyfikator. (Jeśli nie zostanie określony, domyślnie jest to nazwa klasy). Ten klucz jest serializowany z resztą danych, a klasa ujawnia go również jako pole statyczne. Jeśli klasa zawiera jakiekolwiek typy zagnieżdżone (tj. elementy członkowskie z typami, które wymagają serializacji), są one również określone w wywołaniu metody:

module.exports = serializable(Foo, [Bar, Baz], myUniqueSerialisationKey)

(Typy zagnieżdżone dla poprzednich wersji klasy można również określić w podobny sposób, dzięki czemu na przykład, jeśli zserializujesz Foo1, można go zdeserializować do Foo2.)

Podczas serializacji biblioteka rekursywnie tworzy globalną mapę kluczy do klas i używa jej podczas deserializacji. (Pamiętaj, że klucz jest serializowany z resztą danych.) Aby poznać typ klasy „najwyższego poziomu”, biblioteka wymaga określenia tego w wywołaniu deserializacji:

const foo = decodeEntity(serializedFoo, Foo)

Eksperymentalna biblioteka automatycznego mapowania przechodzi przez drzewo modułów i generuje mapowania z nazw klas, ale działa to tylko w przypadku klas o unikalnych nazwach.

Układ projektu

Projekt podzielony jest na kilka modułów:

  • tanagra-core - wspólna funkcjonalność wymagana przez różne formaty serializacji, w tym funkcja oznaczania klas jako możliwych do serializacji
  • tanagra-json - serializuje dane do formatu JSON
  • tanagra-protobuf - serializuje dane do formatu protobuforów Google (eksperymentalnie)
  • tanagra-protobuf-redis-cache - biblioteka pomocnicza do przechowywania serializowanych protobufów w Redis
  • tanagra-auto-mapper — przechodzi po drzewie modułów w Node.js , aby zbudować mapę klas, co oznacza, że ​​użytkownik nie musi określać typu, do którego ma zostać zdeserializowany (eksperymentalny).

Zwróć uwagę, że biblioteka używa pisowni amerykańskiej.

Przykładowe użycie

Poniższy przykład deklaruje klasę, którą można serializować i używa modułu tanagra-json do jej serializacji/deserializacji:

 const serializable = require('tanagra-core').serializable class Foo { constructor(bar, baz1, baz2, fooBar1, fooBar2) { this.someNumber = 123 this.someString = 'hello, world!' this.bar = bar // a complex object with a prototype this.bazArray = [baz1, baz2] this.fooBarMap = new Map([ ['a', fooBar1], ['b', fooBar2] ]) } } // Mark class `Foo` as serializable and containing sub-types `Bar`, `Baz` and `FooBar` module.exports = serializable(Foo, [Bar, Baz, FooBar]) ... const json = require('tanagra-json') json.init() // or: // require('tanagra-protobuf') // await json.init() const foo = new Foo(bar, baz) const encoded = json.encodeEntity(foo) ... const decoded = json.decodeEntity(encoded, Foo)

Występ

Porównałem wydajność dwóch serializatorów (serializator JSON i eksperymentalny serializator protobufs ) z kontrolką (natywny JSON.parse i JSON.stringify). Z każdym przeprowadziłem łącznie 10 prób.

Przetestowałem to na moim laptopie Dell XPS15 z 2017 roku z pamięcią 32 GB, z systemem Ubuntu 17.10.

Zserializowałem następujący zagnieżdżony obiekt:

 foo: { "string": "Hello foo", "number": 123123, "bars": [ { "string": "Complex Bar 1", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, { "string": "Complex Bar 2", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } } ], "bazs": Map { 'baz1' => Baz { string: 'baz1', number: 111, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz2' => Baz { string: 'baz2', number: 222, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz3' => Baz { string: 'baz3', number: 333, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, }

Zapisz wydajność

Metoda serializacji Śr. pierwsza próba (ms) Odch.st. w tym pierwsza próba (ms) Śr. pierwsza próba (ms) Odch.st. były. pierwsza próba (ms)
JSON 0,115 0,0903 0,0879 0,0256
Protobufy Google 2.00 2,748 1.13 0,278
Grupa kontrolna 0,0155 0,00726 0,0139 0,00570

Czytać

Metoda serializacji Śr. pierwsza próba (ms) Odch.st. w tym pierwsza próba (ms) Śr. pierwsza próba (ms) Odch.st. były. pierwsza próba (ms)
JSON 0,133 0,102 0,104 0,0429
Protobufy Google 2,62 1.12 2,28 0,364
Grupa kontrolna 0,0135 0,00729 0,0115 0,00390

Streszczenie

Serializator JSON jest około 6-7 razy wolniejszy niż serializacja natywna. Eksperymentalny serializator protobufs jest około 13 razy wolniejszy niż serializator JSON lub 100 razy wolniejszy niż serializacja natywna.

Ponadto wewnętrzne buforowanie informacji o schemacie/strukturze w każdym serializatorze wyraźnie ma wpływ na wydajność. W przypadku serializatora JSON pierwszy zapis jest około cztery razy wolniejszy niż średnia. W przypadku serializatora protobuf jest dziewięć razy wolniejszy. Tak więc pisanie obiektów, których metadane zostały już zapisane w pamięci podręcznej, jest znacznie szybsze w obu bibliotekach.

Ten sam efekt zaobserwowano dla odczytów. W przypadku biblioteki JSON pierwszy odczyt jest około cztery razy wolniejszy od średniej, a w przypadku biblioteki protobuf około dwa i pół raza wolniej.

Problemy z wydajnością serializatora protobuf oznaczają, że nadal znajduje się on w fazie eksperymentalnej i polecam go tylko wtedy, gdy z jakiegoś powodu potrzebujesz formatu. Jednak warto zainwestować trochę czasu, ponieważ format jest znacznie bardziej zwięzły niż JSON, a zatem lepszy do przesyłania po kablu. Stack Exchange używa tego formatu do wewnętrznego buforowania.

Serializator JSON jest wyraźnie znacznie bardziej wydajny, ale nadal znacznie wolniejszy niż implementacja natywna. W przypadku drzew z małymi obiektami ta różnica nie jest znacząca (kilka milisekund powyżej żądania 50 ms nie obniży wydajności Twojej witryny), ale może to stać się problemem w przypadku bardzo dużych drzew obiektów i jest jednym z moich priorytetów programistycznych.

Mapa drogowa

Biblioteka jest wciąż w fazie beta. Serializator JSON jest dość dobrze przetestowany i stabilny. Oto mapa drogowa na najbliższe kilka miesięcy:

  • Ulepszenia wydajności dla obu serializatorów
  • Lepsza obsługa JavaScript w wersji wcześniejszej niż ES6
  • Wsparcie dla dekoratorów ES-Next

Nie znam żadnej innej biblioteki JavaScript, która obsługuje serializację złożonych, zagnieżdżonych danych obiektowych i deserializację do oryginalnego typu. Jeśli wdrażasz funkcję, która przyniosłaby korzyści z biblioteki, wypróbuj ją, skontaktuj się ze swoją opinią i rozważ możliwość wniesienia wkładu.

Strona główna projektu
Repozytorium GitHub