Сериализация сложных объектов в JavaScript

Опубликовано: 2022-03-11

Производительность веб-сайта и кэширование данных

Современные веб-сайты обычно извлекают данные из разных мест, включая базы данных и сторонние API. Например, при аутентификации пользователя веб-сайт может найти запись о пользователе в базе данных, а затем дополнить ее данными из некоторых внешних служб через вызовы API. Сведение к минимуму дорогостоящих обращений к этим источникам данных, таких как доступ к диску для запросов к базе данных и обмен данными через Интернет для вызовов API, имеет важное значение для поддержания быстрого и отзывчивого сайта. Кэширование данных является распространенным методом оптимизации, используемым для достижения этой цели.

Процессы хранят свои рабочие данные в памяти. Если веб-сервер работает в одном процессе (например, Node.js/Express), то эти данные можно легко кэшировать с помощью кэша памяти, работающего в том же процессе. Однако веб-серверы с балансировкой нагрузки охватывают несколько процессов, и даже при работе с одним процессом может потребоваться сохранение кеша при перезапуске сервера. Это требует решения для кэширования вне процесса, такого как Redis, что означает, что данные должны быть каким-то образом сериализованы и десериализованы при чтении из кэша.

Сериализацию и десериализацию относительно просто выполнить в статически типизированных языках, таких как C#. Однако динамическая природа JavaScript немного усложняет задачу. В то время как ECMAScript 6 (ES6) представил классы, поля этих классов (и их типы) не определены до тех пор, пока они не будут инициализированы — чего может не быть при создании экземпляра класса — и возвращаемые типы полей и функций не определены. вообще по схеме. Более того, структура класса может быть легко изменена во время выполнения — поля могут быть добавлены или удалены, типы могут быть изменены и т. д. Хотя это возможно с помощью отражения в C#, отражение представляет собой «темные искусства» этого языка, и разработчики ожидают, что это нарушит функциональность.

Я столкнулся с этой проблемой на работе несколько лет назад, когда работал в основной команде Toptal. Мы создавали гибкую панель инструментов для наших команд, которая должна была быть быстрой; в противном случае разработчики и владельцы продуктов не стали бы его использовать. Мы получили данные из нескольких источников: нашей системы отслеживания работы, нашего инструмента управления проектами и базы данных. Сайт был построен на Node.js/Express, и у нас был кеш памяти для минимизации обращений к этим источникам данных. Однако наш быстрый итеративный процесс разработки означал, что мы развертывали (и, следовательно, перезапускали) несколько раз в день, делая кэш недействительным и тем самым теряя многие из его преимуществ.

Очевидным решением был внепроцессный кеш, такой как Redis. Однако после некоторых исследований я обнаружил, что для JavaScript не существует хорошей библиотеки сериализации. Встроенные методы JSON.stringify/JSON.parse возвращают данные объектного типа, теряя любые функции прототипов исходных классов. Это означало, что десериализованные объекты нельзя было просто использовать «на месте» в нашем приложении, что потребовало бы значительного рефакторинга для работы с альтернативным дизайном.

Требования к библиотеке

Для поддержки сериализации и десериализации произвольных данных в JavaScript с взаимозаменяемыми десериализованными представлениями и оригиналами нам понадобилась библиотека сериализации со следующими свойствами:

  • Десериализованные представления должны иметь тот же прототип (функции, геттеры, сеттеры), что и исходные объекты.
  • Библиотека должна поддерживать вложенные типы сложности (в том числе массивы и карты) с правильно заданными прототипами вложенных объектов.
  • Должна быть возможность сериализовать и десериализовать одни и те же объекты несколько раз — процесс должен быть идемпотентным.
  • Формат сериализации должен легко передаваться по TCP и храниться с помощью Redis или аналогичной службы.
  • Чтобы пометить класс как сериализуемый, необходимо внести минимальные изменения в код.
  • Подпрограммы библиотеки должны быть быстрыми.
  • В идеале должен быть какой-то способ поддержки десериализации старых версий класса посредством какого-то сопоставления/управления версиями.

Реализация

Чтобы заполнить этот пробел, я решил написать Tanagra.js , библиотеку сериализации общего назначения для JavaScript. Название библиотеки — отсылка к одному из моих любимых эпизодов « Звездного пути: Следующее поколение» , где экипаж « Энтерпрайза » должен научиться общаться с загадочной инопланетной расой, чей язык непонятен. Эта библиотека сериализации поддерживает распространенные форматы данных, чтобы избежать таких проблем.

Tanagra.js разработан, чтобы быть простым и легким, и в настоящее время он поддерживает Node.js (он не тестировался в браузере, но теоретически должен работать) и классы ES6 (включая Карты). Основная реализация поддерживает JSON, а экспериментальная версия поддерживает буферы протокола Google. Для библиотеки требуется только стандартный JavaScript (в настоящее время тестируется с ES6 и Node.js), без зависимости от экспериментальных функций, транспиляции Babel или TypeScript .

Сериализуемые классы помечаются как таковые вызовом метода при экспорте класса:

module.exports = serializable(Foo, myUniqueSerialisationKey)

Метод возвращает классу прокси, который перехватывает конструктор и внедряет уникальный идентификатор. (Если не указано, по умолчанию используется имя класса.) Этот ключ сериализуется вместе с остальными данными, и класс также предоставляет его как статическое поле. Если класс содержит какие-либо вложенные типы (т. е. члены с типами, требующими сериализации), они также указываются в вызове метода:

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

(Вложенные типы для предыдущих версий класса также могут быть указаны аналогичным образом, так что, например, если вы сериализуете Foo1, его можно будет десериализовать в Foo2.)

Во время сериализации библиотека рекурсивно создает глобальную карту ключей для классов и использует ее во время десериализации. (Помните, ключ сериализуется вместе с остальными данными.) Чтобы узнать тип класса «верхнего уровня», библиотека требует, чтобы он был указан в вызове десериализации:

const foo = decodeEntity(serializedFoo, Foo)

Экспериментальная библиотека автоматического сопоставления обходит дерево модулей и генерирует сопоставления на основе имен классов, но это работает только для классов с уникальными именами.

Макет проекта

Проект разделен на несколько модулей:

  • tanagra-core — общая функциональность, необходимая для различных форматов сериализации, включая функцию пометки классов как сериализуемых .
  • tanagra-json — сериализует данные в формат JSON
  • tanagra-protobuf — сериализует данные в формат протобуферов Google (экспериментальный)
  • tanagra-protobuf-redis-cache — вспомогательная библиотека для хранения сериализованных protobuf в Redis
  • tanagra-auto-mapper — просматривает дерево модулей в Node.js для создания карты классов, что означает, что пользователю не нужно указывать тип для десериализации (экспериментальный).

Обратите внимание, что в библиотеке используется орфография США.

Пример использования

В следующем примере объявляется сериализуемый класс и используется модуль tanagra-json для его сериализации/десериализации:

 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)

Представление

Я сравнил производительность двух сериализаторов (сериализатор JSON и экспериментальный сериализатор protobufs ) с контролем (собственный JSON.parse и JSON.stringify). Я провел в общей сложности 10 испытаний с каждым.

Я протестировал это на своем ноутбуке Dell XPS15 2017 года выпуска с 32 ГБ памяти под управлением Ubuntu 17.10.

Я сериализовал следующий вложенный объект:

 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 } } }, }

Запись производительности

Метод сериализации пр. вкл. первая попытка (мс) Св. Дев. вкл. первая попытка (мс) пр. экс. первая попытка (мс) Св. Дев. бывший. первая попытка (мс)
JSON 0,115 0,0903 0,0879 0,0256
Протобафы Google 2.00 2,748 1.13 0,278
Контрольная группа 0,0155 0,00726 0,0139 0,00570

Читать

Метод сериализации пр. вкл. первая попытка (мс) Св. Дев. вкл. первая попытка (мс) пр. экс. первая попытка (мс) Св. Дев. бывший. первая попытка (мс)
JSON 0,133 0,102 0,104 0,0429
Протобафы Google 2,62 1.12 2,28 0,364
Контрольная группа 0,0135 0,00729 0,0115 0,00390

Резюме

Сериализатор JSON примерно в 6-7 раз медленнее, чем собственная сериализация. Экспериментальный сериализатор protobufs примерно в 13 раз медленнее, чем сериализатор JSON , или в 100 раз медленнее, чем собственная сериализация.

Кроме того, внутреннее кэширование схемы/структурной информации в каждом сериализаторе явно влияет на производительность. Для сериализатора JSON первая запись выполняется примерно в четыре раза медленнее, чем в среднем. Для сериализатора protobuf это в девять раз медленнее. Таким образом, запись объектов, метаданные которых уже кэшированы, в любой библиотеке выполняется намного быстрее.

Тот же эффект наблюдался и при чтении. Для библиотеки JSON первое чтение происходит примерно в четыре раза медленнее, чем в среднем, а для библиотеки protobuf — примерно в два с половиной раза медленнее.

Проблемы с производительностью сериализатора protobuf означают, что он все еще находится на экспериментальной стадии, и я бы рекомендовал его, только если вам по какой-то причине нужен формат. Тем не менее, на это стоит потратить некоторое время, так как формат намного короче, чем JSON, и, следовательно, лучше подходит для отправки по сети. Stack Exchange использует этот формат для внутреннего кэширования.

Сериализатор JSON явно намного более производительный, но все же значительно медленнее, чем нативная реализация. Для небольших деревьев объектов эта разница незначительна (несколько миллисекунд сверх 50-миллисекундного запроса не снизят производительность вашего сайта), но это может стать проблемой для очень больших деревьев объектов и является одним из моих приоритетов разработки.

Дорожная карта

Библиотека все еще находится в стадии бета-тестирования. Сериализатор JSON достаточно хорошо протестирован и стабилен. Вот дорожная карта на ближайшие несколько месяцев:

  • Улучшения производительности для обоих сериализаторов
  • Улучшенная поддержка JavaScript до ES6.
  • Поддержка декораторов ES-Next

Я не знаю другой библиотеки JavaScript, поддерживающей сериализацию данных сложных вложенных объектов и десериализацию до исходного типа. Если вы реализуете функциональность, которая могла бы принести пользу библиотеке, попробуйте ее, поделитесь своими отзывами и рассмотрите возможность внесения своего вклада.

Домашняя страница проекта
Репозиторий GitHub