Структурированный протокол обмена данных Protobuf или JSON во фронтенде?

в 15:11, , рубрики: api, javascript, node.js, protobuf

image

В новом проекте в нашей команде мы выбрали frontend framework VUE для нового продукта, бэкенд написан на PHP, и уже как 17 лет успешно работает.

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

Про бэкенд

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

Про фронтенд

4мес. разработки фронта мы использовать JSON в качестве ответа от сервера, мапили в State Vuex в удобном нам формате. Но для отдачи на сервер нам требовалось преобразовывать в обратную сторону, чтобы сервер смог прочитать и замапить свои DTO объекты (может показаться странным, но так надо :) )

Проблемы

Вроде бы ничего, работали с тем что есть, состояние разрасталось до объектов больших размеров. Начали разбивать на еще меньшие модули в каждом из которых были свои состояния, мутации и т.п… API стало меняться вслед новым задачам от менеджеров, и все сложнее стало управлять всем этим, то там замапили не так, то поля изменились…

И тут мы начали думать об универсальных структурах данных на сервере и фронте чтобы исключить ошибки в парсингах, мапингах и т.п.

После некоторых поисков, мы пришли к двум вариантам:

  1. protocol-buffers
  2. Автогенерация JS DTO на стороне сервера для фронта, с дальнейшей обработкой JSON в эти DTO.

После пробы пера, было принято использовать Protobuf от google.

И вот почему:

  1. Уже есть функционал который компилирует описанные структуры для множества платформ, в том числе и для PHP и для JS.
  2. Есть автогенератор документации по созданным структурам .proto
  3. Можно легко прикрутить некую версионность для структур.
  4. Облегчается поиск объектов при рефакторинге как на PHP так и на JS.
  5. И другие фишки как gRPC и т.п если потребуется.

Хватит болтовни, давайте посмотрим как все это выглядит

Как это выглядит на стороне PHP я не буду описывать, там примерно все тоже самое, объекты те же.

Покажу на примере простого клиентского JS и мини сервера на Node.js.

Для начала описываем структуры данных которые нам потребуется. Дока.

product.proto

syntax = "proto3";

package api;

import "price.proto";

message Product {
    message Id {
        uint32 value = 1;
    }
    Id id = 1;
    string name = 2;
    string text = 3;
    string url = 4;
    Price price = 5;
}

price.proto

syntax = "proto3";
package api;

message Price {
    float value = 1;
    uint32 tax = 2;
}

service.proto

syntax = "proto3";

package api;

import "product.proto";

service ApiService {
    rpc getById (Product.Id) returns (Product);
}

Поясню немного про сервис, зачем он нужен, если даже не используется. Сервис описывается только ради документации в нашем случае, что принимает и что отдает, чтобы мы могли подставлять нужные объекты. Он нужен только для gRPC.

Далее скачивается генератор кода на основании структур.

И запускается команда генерации под JS.

./protoc --proto_path=/Users/user/dev/habr_protobuf/public/proto --js_out=import_style=commonjs,binary:/Users/user/dev/habr_protobuf/src/proto/ /Users/user/dev/habr_protobuf/public/proto/*.proto

Подробнее в доке.

После генерации появляется 3 JS файла, в которых уже все приведено к объектам, с функционалом сериализации в буфер и десериализации из буфера.

price_pb.js
product_pb.js
service_pb.js

Далее описываем уже JS код.

import { Product } from '../proto/product_pb';

// В структуре мы описали что ждем от клиента Product.Id
const instance = new Product.Id().setValue(12345);
let message = instance.serializeBinary();

let response = await fetch('http://localhost:3008/api/getById', {
    method: 'POST',
    body: message
});

let result = await response.arrayBuffer();

// Ждем от сервера объект Product, его и подставляем, он сам все сделает, 
// распарсит в нужные структуры и сабструктуры.
const data = Product.deserializeBinary(result);
console.log(data.toObject());

В принципе клиент готов.

На сервере заюзаем Express

const express = require('express');
const cors = require('cors');

const app = express();
app.use(cors());

// подключаем все нужные объекты для их заполнения и отправки на клиент.
const Product = require('./src/proto/product_pb').Product;
const Price = require('./src/proto/price_pb').Price;

// обрабатываем буфер с клиента, т.к данные обмениваются только через него.
app.use (function(req, res, next) {
  let data = [];
  req.on('data', function(chunk) {
    data.push(chunk);
  });
  req.on('end', function() {
    if (data.length <= 0 ) return next();
    data = Buffer.concat(data);
    console.log('Received buffer', data);
    req.raw = data;
    next();
  })
});

app.post('/api/getById', function (req, res) {
  // От клиента мы ожидаем Product.Id, его и подставляем
  const prId = Product.Id.deserializeBinary(req.raw);
  const id = prId.toObject().value;

  // Далее куча "бизнес логики" и мы красивыми объектами оформляем ответ
  const product = new Product();
  product.setId(new Product.Id().setValue(id));
  product.setName('Sony PSP');
  product.setUrl('https://mysite.ru/product/psp/');

  const price = new Price();
  price.setValue(35500.00);
  price.setTax(20);

  product.setPrice(price);

  // и отправляем обратно клиенту сериализованный объект Product
  res.send(Buffer.from(product.serializeBinary()));
});

app.listen(3008, function () {
  console.log('Example app listening on port 3008!');
});

Что мы имеем в итоге

  1. Единая точка правды в виде сгенерированных объектов на основании структур, которые описываются один раз для множества платформ.
  2. Нет путаницы, есть понятная документация как в виде автосгенерированного HTML так и просто просмотра .proto файлов.
  3. Везде ведется работа с конкретными сущностями, без своих модификаций и т.п (а все мы знаем что фронтенд любит отсебятину :) )
  4. Очень удобная работа данным протоколом обмениваться по веб сокетам.

Есть конечно небольшой минус, это скорость сериалиации и десериализации, вот пример.

Я взял lorem ipsum на 10 абзацев, получилось 5.5кб данных с учетом заполненных объектов Price, Product. И погонял данные по Protobuf и JSON (все тоже самое только заполненные JSON схемы, вместо Protobuf объектов)

без учета самих запросов

Protobuf parsing

client
2.804999ms
1.8150000ms
0.744999ms
server
1.993ms
0.495ms
0.412ms

JSON

client
0.654999ms
0.770000ms
0.819999ms

server
0.441ms
0.307ms
0.242ms

Всем спасибо за внимание :)

Автор: bagzon

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js