Бинарный протокол для JavaScript

Kate

Administrator
Команда форума
Нативная поддержка JSON одно из преимуществ разработки full-stack JavaScript приложений. JSON является простым, не требующим схемы и человекочитаемым - качества особенно ценимые на ранней стадии разработки, когда ваша модель данных подвержена частым изменениям. Однако за все надо платить, а именно размером и скоростью обработки данных.

JSON будучи текстовым форматом кодирует все значения как UTF-8, что приводит к увеличению размера данных при работе с нетекстовыми данными. Отсутствие схемы означает, что мы должны кодировать нашу структуру данных (ключи объекта) вместе с самими данными. Мы также делаем дополнительную работу при обработке данных, поскольку нам необходимо преобразовать бинарные данные в их текстовое представление до превращения в JSON и соответственно наоборот в случае декодирования.

Очевидно, что эти затраты не проблема для среднестатистического web приложения. И это компенсируется компрессией и тем, что JavaScript движки хорошо оптимизированы для парсинга JSON. Однако есть случаи, когда эти накладные расходы являются проблемой, и компрессия не эффективна и может увеличить размер сообщения, например, при обмене сообщениями в телеметрии, обмене данными в real-time приложениях или при отправке уведомлений. Для решения этих проблем JSON, мы можем использовать бинарный формат.

Бинарный формат​

Существует множество бинарных форматов с различными свойствами. Для того чтобы обойти недостатки JSON, нам необходимы следующие свойства: наличие схемы данных и отсутствие избыточного выделения памяти (Zero-copy).

Бинарные форматы, не требующие схемы, такие как MessagePack или FlexBuffers, обеспечивают меньший размер данных в сравнении с JSON. Главное преимущество таких форматов, что они могут использованы в качестве замены JSON с минимальными затратами. Однако, есть но:

  • мы все еще должны кодировать структуру вместе с данными, а это значительные накладные расходы
  • мы не избавляемся от избыточного копирования данных (no zero-copy)
С протоколами, использующими схему данных, такими, как Protocol Buffers, FlatBuffers или Cap’n Proto, мы избавляемся от кодирования информации о структуре в самих сообщениях, хотя присутствуют некоторые накладные расходы на передачу указателей смещения.

Zero-copy операции в данном контексте означают нашу способность с помощью указателей смещения прочитать необходимый кусок сообщения без полного копирования данных или декодированная всего тела. Например, на сервере, мы можем сперва проверить важные части запроса без парсинга всего тела сообщения, а на клиенте, мы можем парсить и отрисовывать большие данные, пришедшие с сервера, по частям для уменьшения времени отрисовки (First Contentful Paint) . Это означает, что время обработки сообщений в некоторых случаях сократится на порядки. Среди бинарных форматов, Cap’n Proto и FlatBuffers поддерживают zero-copy операции, в то время как Protocol Buffers, JSON и форматы без схемы нет. Однако, Cap’n Proto и FlatBuffers ставят в приоритет быстрый доступ в памяти, но расплачиваются за это большим размером передаваемых данных из-за выравнивания структур.

Raw Buffers with View​

Для достижения минимального размера данных при использовании подхода zero-copy мы можем применить так называемые «сырые» буфферы. Например, для кодирования JavaScript объекта, мы рассчитаем необходимый размер для каждого его поля, последовательно их соединим для получения схемы, а затем c помощью нее закодируем наши поля в ArrayBuffer. Результирующий буффер имеет «сырые» данные и не содержит какой-либо информации об их структуре. Мы можем декодировать его целиком или получить доступ к отдельному полю, используя схему данных, поскольку она общая для всех объектов заданного типа. Нам требуются указатели смещения для опциональных полей или полей переменной длины, но все же накладные расходы значительно меньше, чем в случае кодирования ключей вместе с данными или применения выравнивания структур.

Библиотека Structurae’s View обеспечивает именно это. Она получает на вход JSON схему объекта (или массива, или другого поддерживаемого типа), View вычислит схему компоновки, чтобы сохранить в виде буфера, и создаст класс для работы с ним, который наследуется от DataView:

import { View } from "structurae";

type Animal = { name: string; age: number; }

const AnimalView = View.create<Animal>({
$id: "Animal",
type: "object",
properties: {
name: { type: "string", maxLength: 10 },
age: { type: "number", btype: "uint8" },
},
});

const animal = AnimalView.from({ name: "Gaspode", age: 10 });
animal instanceof DataView; //=> true
animal.byteLength; //=> 14
animal.get("age"); //=> 10
animal.set("age", 20);
animal.toJSON(); //=> { name: "Gaspode", age: 20 }
View использует JSON Schema для декларации схемы. Помимо того, что JSON схема знакома многим разработчикам, ее можно переиспользовать в других утилитах, например JSON валидаторах, в качестве единственного «источника правды». Схемы и все порожденные классы строго типизированные, что обеспечивает возможность IDE показывать подсказки для типов данных. В отличие от других бинарных форматов основанных на схеме данных, View не требует предварительной компиляции - схема вычисляется один раз на этапе инициализации. В целом View предназначен для архитектур, выстроенных вокруг JSON, и процессов разработки современных web-приложений.

Важно отметить, что основная цель View не заменить JSON в fullstack JavaScript приложениях. А максимизировать производительность в тех частях приложения, которые могут получить выгоду от радикального уменьшения размера сообщений, и избежать дополнительных шагов в виде парсинга с помощью zero-copy. Мы можем использовать его во всех тех случаях, где JSON не подходит:

  • передача больших числовых данных, которые станут компактнее при использовании бинарного формата
  • высокочастотный обмен сообщениями в реал-тайм приложении или между workers процесса/процессов
  • частичная обработка сообщений для валидации

Пример Full Stack​

Для демонстрации View в действии, мы будем использовать многим знакомый пример доски объявлений. Это не лучший пример для применения бинарного формата, но более специфичные примеры будут более привязаны к конкретному контексту и сложны для понимания.

Представьте, что вы разрабатываете доску объявлений. Давайте задекларируем нашу структуру сообщений:

class BoardMessage {
id: number;
authorId: number;
threadId: number;
created: number;
text: string;
...
}

const MessageView = View.create({
$id: 'BoardMessage',
type: 'object',
properties: {
id: { type: 'integer' },
authorId: { type: 'integer' },
threadId: { type: 'integer' },
created: { type: 'number' },
text: { type: 'string', maxLength: 1000 }
}
}, BoardMessage);
Теперь на клиенте мы можем закодировать наши сообщения с помощью класса MessageView. Поскольку он и есть DataView, мы можем просто отправить его как тело запроса с помощью Fetch API:

const message = new BoardMessage();

window.fetch('/messages', {
method: 'POST',
body: MessageView.from({ id: 1, authorId: 26, threadId: 1035, created: Date.now(), text: 'not one drop!'}),
headers: {
'content-type': 'application/octet-stream',
},
});
На сервере, например, с помощью Node JS (используя, фреймворк Express js), мы можем получить сообщение, как буффер, и работать с ним без парсинга и копирования всего тела сообщения.

app.post('/messages', (req, res) => {
const view = new MessageView(req.body.buffer, req.body.byteOffset, req.body.length);
...
})
Теперь, например, мы можем сделать проверку прав доступа: может ли автор опубликовать сообщение в соответствующий раздел доски объявлений и если нет, то вернуть ошибку без парсинга всего тела сообщения.

...
const view = new MessageView(req.body.buffer, req.body.byteOffset, req.body.length);
const hasAuthorization = getAuthrorization(view.get('authorId'), view.get('threadId'));
if (!hasAuthorization) return res.sendStatus(403);
...
Получение чисел из буфера напрямую напорядок быстрее, чем парсинг JSON, не говоря уже об уменьшении нагрузки на GC (garbare collector) для долгоживущих процессов.

Мы можем усложнить логику и пойти дальше. View использует специальный класс StringView для работы с UTF-8 строками, который является наследником DataView, имеющим набор методов для работы с бинарными данными без превращения их в JavaScript строки. Представим, что мы решили ввести немного цензуры и блокировать сообщения содержащие слова из «черного списка».

const bVord = new TextEncoder().encode('blood');

app.post('/messages', (req, res) => {
const view = new MessageView(req.body.buffer, req.body.byteOffset, req.body.length);
...
const textView = view.getView('text');
if (textView.includes(bVord)) return res.sendStatus(400);
...
})
C помощью view.getView, мы создаем экземляр класса DataView (StringView в нашeм случае) для части буффера без декодирования в строку, а затем используем метод для поиска в буффере. Опять же мы экономим ресурсы при парсинге тела невалидных запросов, а также уменьшаем работу для GC.

Ну и конечно, мы можем декодировать наше сообщение целиком в JavaScript объект. В данном случае, это будет экземпляр класса BoardMessage, так как мы передали ссылку на него при создании представления (Veiw.create).

...
const message = view.toJSON();
...

View также использует преимущества оптимизации hidden class, который используют движки JavaScript, а именно: вместо создания пустых объектов {} и наполнения их декодированными значениями, View либо использует конструктор переданного класса (как в примере выше), либо генерит код для такого конструктора, чтобы каждый объект создавался с таким же количеством полей в том же порядке. Это приводит к более быстрым операциям с объектами и уменьшению количества работы для GC.

Таким образом, с помощью уменьшения размера сообщений, валидации через zero-copy и оптимизации сериализации данных мы значительно увеличили пропускную способность нашей доски объявлений в плане обработки сообщений. Пусть эти бездельники только попробуют нас заспамить! 😃

 
Сверху