nested_flatbuffers
.Правда, как это часто водится за разработчиками протоколов, на нормальные примеры сил им уже не хватает. И даже на тематических форумах типа stackoverflow, groups.google и т.п. сложно найти полную информацию приходится буквально по крупицам собирать все части паззла, чтобы в конце концов понять, как именно написать рабочий код.
В статье я раскрою проблему подробнее и приведу примеры на C, C++ и Rust.
In concept this is very simple: a nested buffer is just a chunk of binary data stored in a ubyte vector, typically with some convenience methods generated to access a stored buffer. In praxis it adds a lot of complexity.
Intro
Во многих проектах часто встречается ситуация, когда необходимо использовать какой-то протокол для передачи данных между компонентами например, по сети. Конечно, можно использовать самописный вариант, но что если в проекте бэкенд на C, сервер на C++, а фронтенд на JS? Одним языком ограничиться уже не получается, и тогда на помощь могут прийти сторонние библиотеки, например Protocol Buffers (или Protobuf) и FlatBuffers (FB), обе от Google.
Как это устроено? Программист создает специальный файл со схемой данных, где описывает, какие типы и структуры будут использоваться в качестве сообщений. Затем с помощью отдельного компилятора протокола генерируются файлы на нужном языке. После чего они импортируются в проект: сгенерированный код содержит необходимые типы, структуры и функции (классы и методы), с помощью которых создается сообщение с данными. После чего производится сериализация это превращение данных в байтовый буффер типа
uint8_t*
. Этот буффер можно отправлять куда-нибудь по
сети, и на приёмной стороне распаковывать обратно в
человекочитаемые данные это десериализация.Для справки: у Protobuf схема хранится в файле формата .proto, компилятор protoc; у FlatBuffers соответственно файлы с расширением .fbs, компилятор flatc.
И хотя FlatBuffers является официально более новым протоколом по сравнению с Protobuf первый релиз в 2014 году против 2008 года соответственно, возможности для написания кода как будто бы сильнее ограничены. Например, из-за отсутствия таких, казалось бы, жизненно важных функций, как
CopyMessage
, во FlatBuffers
приходится долго курить документацию и сгенерированные файлы. С
другой стороны FB считается более быстрым в плане
сериализации/десериализации, а данные занимают меньше памяти.Минутка самолюбования
Сразу скажу, что я не являюсь экспертом в
этой области, хотя у меня был опыт с обоими протоколами. Например,
на одной работе я делал визуальный редактор сообщений для Protobuf
на wxPython это были прекрасные человеко-часы дебага и поиска
ошибок, потому что вот эта вот динамическая типизация, отсутствие
компиляции, рекурсия, рефлексия всё, как вы любите. Бывшие
коллеги кстати говорят, что до сих пор пользуются, успех что
ли.
В этой статье я не собираюсь сравнивать плюсы-минусы этих библиотек за меня это уже сделали другие люди и не один раз: от небольшого обзора до целой научной статьи. Здесь я хочу рассмотреть решение конкретной задачи с использованием FlatBuffers, которая может вам встретиться в проекте: надеюсь, что для кого это окажется полезным.
Problem Setting
Обычно для обмена между компонентами используют сообщения разных типов например, данные пользователя (имя, уникальный идентификатор, местоположение), значения с датчиков (гироскоп, акселерометр) и так далее. В таком случае сообщения стандартно заключают в объединение
union
, которое хранится внутри какого-то основного
сообщения:Файл client.fbs:
namespace my_project.client;table Request{ name:string;}table Response{ x:int; y:int; z:int;}union CommonMessage{ Request, Response}table Message{ content:CommonMessage;}root_type Message;
Здесь тип
Message
является основным для передачи
данных. Автоматически сгенерированный код будет храниться в файлах
client_reader.h, client_builder.h и client_verifier.h для C, для
C++ в client_generated.h и т.д.В чем недостатки такого подхода? Допустим, клиент отправил на сервер сообщение типа
Response
, а на сервере его не
надо читать только переслать дальше без изменений. Предположим, что
сервер использует собственную схему данных.Файл server.fbs:
namespace my_project.server;table Response{ extra_info: string; data: my_project.client.Response;}
Получили сообщение
my_project.client.Response
от
клиента, хотим добавить к нему какие-то данные и отправить
my_project.server.Response
куда-нибудь дальше
(например, клиенту на JS). В таком случае придётся собирать это
сообщение как-то так (если сервер написан на C++):
void processClientResponse(const my_project::client::Response* msg){ flatbuffers::FlatBufferBuilder fbb; auto clientResponse = my_project::client::CreateResponse(fbb, msg->x(), msg->y(), msg->z()); auto extraInfo = fbb.CreateString("SomeInfo"); auto serverResp = my_project::server::CreateResponse(fbb, extraInfo, clientResponse);//}
Обратили внимание на
clientResponse
? Мы пересоздаём
заново сообщение, которое только что получили! Причем надо
полностью перечислить все поля для копирования из старого в новое.
Почему бы просто не написать как-то так: auto clientResponse
{*msg}
или вообще использовать msg
напрямую?Увы, но так хорошо жить API флэтбуфферов нам просто не позволяет.
А тем более на C, откуда там взяться конструктору копирования.
Итак, в чём именно мы здесь проигрываем:
- время выполнения программы надо сначала прочитать сообщение, а потом запаковать его обратно
- время работы программиста затраты на написание кода для
перегона сообщения из старого в новое. Я здесь рассмотрел простой
пример с типом
Response
: но что если поля сами являются сложными структурами, а их ещё и много и всё это по новой, да ещё и на другом языке программирования! Бррр, мы разве за этим здесь, в IT? - память по сути мы храним внутри
Message
специальную структуру данных с какими-то внутренними особенностями, которые могут раздувать размер сообщения
И какой выход?
Attribute nested_flatbuffers
На помощь приходят специальные возможности можно заменить тип сообщения на массив байтов
[ubyte]
и добавить к нему
атрибут nested_flatbuffers
указывающий на тип, который
раньше соответствовал сообщению ну почти. Тогда возвращаясь к схеме
client.fbs:
union CommonMessage{ Request, Response}table MessageHolder{ data: CommonMessage;}table Message{ content: [ubyte](nested_flatbuffer: "MessageHolder");}
Почему нам понадобился
MessageHolder
, и мы не могли
обойтись просто CommonMessage
? Дело в том, что
nested_flatbuffer
не может иметь тип union, поэтому нужна
промежуточная обертка.Хорошо, у нас теперь есть обновлённое сообщение типа
Message
: но как узнать, что за данные хранятся внутри
массива content
?Для этого можно завести вспомогательный enum
Type
, завернуть его в заголовок Header
и
добавить как новое поле в типе Message
. Перечисление
Type
будет по сути повторять объединение
CommonMessage
.На самом деле промежуточная структура
Header
в данном
примере необязательна, но в общем случае удобна, если вы захотите
добавить что-нибудь ещё.Файл client.fbs (финальные правки):
// остальная часть схемы без измененийenum Type:ubyte{ request, response}table Header{ type: Type;}table Message{ header: Header; content: [ubyte](nested_flatbuffer: "MessageHolder");}
Круто! А как этим пользоваться?
It's coding time
Наконец мы добрались до практики а как именно писать код для сериализации и десериализации данных с помощью нового атрибута? Сразу скажу, что я приведу полные примеры кода без подробных разъяснений, надеюсь, этого будет достаточно для понимания общей идеи.
Как это делается на C
В C используется особый подход в работе с FB: это не ООП язык, у него даже компилятор другой
flatcc
вместо общего
flatc
(и не всем это оказалось удобно). А ещё на C
принято использовать специальный макрос для сокращения
неймспейса:#define ns(x) FLATBUFFERS_WRAP_NAMESPACE(my_project_client,
x)
.Основывался я на этом примере от разработчиков и на этом от одного из пользователей.
Конструирование и сериализация сообщений
#include "client_builder.h"#include "client_verifier.h"#include <stdio.h>int main(int argc, char ** argv){ printf("Testing on C\n"); // builder необходим для сериализации: превращения сообщения в байтовый буффер flatbuffers_builder_t builder; flatcc_builder_init(&builder); size_t size = 0; void* buf; { createRequest(&builder); buf = flatcc_builder_finalize_aligned_buffer(&builder, &size); receiveBuffer(buf, size); flatcc_builder_aligned_free(buf); } { flatcc_builder_reset(&builder); createResponse(&builder); buf = flatcc_builder_finalize_aligned_buffer(&builder, &size); receiveBuffer(buf, size); flatcc_builder_aligned_free(buf); } flatcc_builder_clear(&builder); return 0;}void createRequest(flatbuffers_builder_t* builder){ ns(Type_enum_t) type = ns(Type_request); ns(Header_ref_t) header = ns(Header_create(builder, type)); ns(Message_start_as_root(builder)); ns(Message_header_create(builder, type)); ns(Message_content_start_as_root(builder)); flatbuffers_string_ref_t name = flatbuffers_string_create_str(builder, "This is some string for tests!"); ns(Request_ref_t) request = ns(Request_create(builder, name)); ns(CommonMessage_union_ref_t) msg_union = ns(CommonMessage_as_Request(request)); ns(MessageHolder_data_add(builder, msg_union)); ns(Message_content_end_as_root(builder)); ns(Message_end_as_root(builder));}void createResponse(flatbuffers_builder_t* builder){ ns(Type_enum_t) type = ns(Type_response); ns(Header_ref_t) header = ns(Header_create(builder, type)); ns(Message_start_as_root(builder)); ns(Message_header_create(builder, type)); ns(Message_content_start_as_root(builder)); ns(Response_ref_t) response = ns(Response_create(builder, 2, 3, 9)); ns(CommonMessage_union_ref_t) msg_union = ns(CommonMessage_as_Response(response)); ns(MessageHolder_data_add(builder, msg_union)); ns(Message_content_end_as_root(builder)); ns(Message_end_as_root(builder));}
Обработка полученного буффера и десериализация
сообщений
void receiveBuffer(void* buf, size_t size){ const int verification_result = ns(Message_verify_as_root(buf, size)); if (flatcc_verify_error_ok != verification_result) { printf("Unable to verify flatbuffer message\n"); } ns(Message_table_t) msg = ns(Message_as_root(buf)); ns(Header_table_t) header = ns(Message_header(msg)); ns(Type_enum_t) type = ns(Header_type(header)); printf("Received Type: %u\n", type); switch(type) {case ns(Type_request): processRequest(&msg); break;case ns(Type_response): processResponse(&msg); break;default: printf("Unknown type!\n");}}void processRequest(ns(Message_table_t)* msg){ ns(MessageHolder_table_t) content = ns(Message_content_as_root(*msg)); ns(Request_table_t) request = (ns(Request_table_t)) ns(MessageHolder_data(content)); const char* name = ns(Request_name(request)); printf("Result request: %s\n", name);}void processResponse(ns(Message_table_t)* msg){ ns(MessageHolder_table_t) content = ns(Message_content_as_root(*msg)); ns(Response_table_t) response = (ns(Response_table_t)) ns(MessageHolder_data(content)); int x = ns(Response_x(response)); int y = ns(Response_y(response)); int z = ns(Response_z(response)); printf("Result response: %d.%d.%d\n", x, y, z);}
В функции
receiveBuffer
используется верификация
данных, потому что при ошибках могут съехать внутренние смещения и
выравнивания данных это позволит не обрабатывать заведомо сломанный
буффер.Как это делается на C++
Конструирование и сериализация сообщений
#include "flatbuffers/flatbuffers.h"#include "client_generated.h"#include "server_generated.h"#include <iostream>namespace cli = my_project::client;namespace srv = my_project::server;void createRequest(flatbuffers::FlatBufferBuilder& fbb){ auto type = cli::Type::request; auto header = cli::CreateHeader(fbb, type); flatbuffers::FlatBufferBuilder fbb2; auto name = fbb2.CreateString("This is some string for tests!"); auto rq = cli::CreateRequest(fbb2, name); auto data = cli::CreateMessageHolder(fbb2, cli::CommonMessage::Request, rq.Union()); fbb2.Finish(data); auto content = fbb.CreateVector(fbb2.GetBufferPointer(), fbb2.GetSize()); auto msg = cli::CreateMessage(fbb, header, content); cli::FinishMessageBuffer(fbb, msg);} void createResponse(flatbuffers::FlatBufferBuilder& fbb){ auto type = cli::Type::response; auto header = cli::CreateHeader(fbb, type); flatbuffers::FlatBufferBuilder fbb2; auto rp = cli::CreateResponse(fbb2, 2, 3, 9); auto data = cli::CreateMessageHolder(fbb2, cli::CommonMessage::Response, rp.Union()); fbb2.Finish(data); auto content = fbb.CreateVector(fbb2.GetBufferPointer(), fbb2.GetSize()); auto msg = cli::CreateMessage(fbb, header, content); cli::FinishMessageBuffer(fbb, msg);}
Обработка полученного буффера и десериализация
сообщений
void receiveBuffer(std::uint8_t* buf, std::size_t size){ flatbuffers::Verifier verifier(buf, size); if (!cli::VerifyMessageBuffer(verifier)) { std::cerr << "Unable to verify flatbuffer message\n"; return; } auto msg = cli::GetMessage(buf); auto header = msg->header(); auto type = header->type(); std::cout << "Received HeaderType from client: " << static_cast<uint16_t>(type) << "\n"; switch (type) { case cli::Type::request: processRequest (msg); break; case cli::Type::response: processResponse(msg); break; }}void processRequest(const cli::Message* msg){ auto content = msg->content_nested_root(); auto rq = content->data_as_Request(); auto name = rq->name(); std::cout << "Result request: " << name->str() <<"\n";}void processResponse(const cli::Message* msg){ auto content = msg->content_nested_root(); auto rp = content->data_as_Response(); auto x = rp->x(); auto y = rp->y(); auto z = rp->z(); std::cout << "Result response: " << x << "." << y << "." << z <<"\n";}
Код практически не отличается по смыслу от написанного на C, за исключением того, что построение сообщения производится другим способом с помощью дополнительного экземпляра
fbb2
типа FlatBufferBuilder
для вложенного сообщения. На
самом деле разработчики заявляют, что вложенный флэтбуффер можно
собирать и так, и так, но в C мне не удалось заставить
такую конструкцию работать (а жаль cо вторым экземпляром билдера
код выглядит несколько читабельнее).Level Up
А теперь самое главное для чего всё это было нужно? Как именно воспользоваться преимуществом атрибута
nested_flatbuffers
?Рассмотрим вариант, когда C++-сервер использует следующую схему данных:
Файл server.fbs:
include "client.fbs";namespace my_project.server;table ClientData{ extra_info:string; client_msg:[ubyte](nested_flatbuffer: "my_project.client.MessageHolder");}table ServerData{ server_name:string;}union CommonMessage{ ServerData, ClientData}table Message{ header: my_project.client.Header; content: CommonMessage;}root_type Message;
Здесь тоже используется сообщение типа
Message
, но
хранящее просто union со своими собственными типами. Самое
главное находится в таблице ClientData
: сообщение с
информацией от клиента, которое мы хотим переслать на сервере,
содержит вложенный флэтбуффер client_msg
и он должен
быть точно такого же типа, что отправил клиент. Под катом
продемонстрировано, как правильно его скопировать не распаковывая
(комментарии на русском я делал для статьи, на английском для себя
в коде):example.cpp
// используем глобальный экземпляр для простоты flatbuffers::FlatBufferBuilder fbb_;// общий обработчик сообщения, полученного от клиентаvoid processClientMessage(const cli::Message* msg){ fbb_.Clear(); // constructing srv::Message from cli::Message switch (msg->header()->type()) { case cli::Type::request: forwardMessage(msg, "SERVER-REQUEST"); break; case cli::Type::response: forwardMessage(msg, "SERVER-RESPONSE"); break; } // processing constructed srv::Message to verify it is correct processServerMessage();}// Самая интересная часть пересылка сообщения от клиента без распаковки деталейvoid forwardMessage(const cli::Message* msg, const char* extra_info_str){ // Yes, we should recreate header as FlatBuffers don't have API to just copy it from msg->header() auto header = cli::CreateHeader(fbb_, msg->header()->type()); auto extra_info = fbb_.CreateString(extra_info_str); // The main part: copying nested buffer from client to server message auto client_msg = msg->content(); auto client_msg_vec = fbb_.CreateVector(client_msg->Data(), client_msg->size()); auto content = srv::CreateClientData(fbb_, extra_info, client_msg_vec); auto server_msg = srv::CreateMessage(fbb_, header, srv::CommonMessage::ClientData, content.Union()); srv::FinishMessageBuffer(fbb_, server_msg);}// пример обработки сообщения, сгенерированного серверомvoid processServerMessage() const{ std::uint8_t* buf = fbb_.GetBufferPointer(); auto msg = srv::GetMessage(buf); auto header = msg->header(); auto header_type = header->type(); auto content_type = msg->content_type(); std::cout << "Received HeaderType from server: " << static_cast<uint16_t>(header_type) << "\n"; std::cout << "Received ContentType from server: " << static_cast<uint16_t>(content_type) << "\n"; if (content_type != srv::CommonMessage::ClientData) { std::cerr << "Not implemented Handler for this content_type\n"; return; } // process only ClientData for demonstration purposes auto content = msg->content_as_ClientData(); auto extra_info = content->extra_info(); auto client_msg = content->client_msg_nested_root(); std::cout << "Result request from server: extra_info: " << extra_info->str() << "\n"; switch(header_type) { case cli::Type::request: { auto client_rq = client_msg->data_as_Request(); auto client_name = client_rq->name(); std::cout << "- client_msg: " << client_name->str() << "\n"; break; } case cli::Type::response: { auto client_rp = client_msg->data_as_Response(); auto x = client_rp->x(); auto y = client_rp->y(); auto z = client_rp->z(); std::cout << "- client_msg: " << x << "." << y << "." << z << "\n"; break; } }}
Отдельно ещё раз хочу сделать акцент на копировании:
auto client_msg = msg->content();auto client_msg_vec = fbb_.CreateVector(client_msg->Data(), client_msg->size());
И всё! Не надо знать деталей, что именно к нам пришло в
msg->content()
мы просто берём и копируем сырой
буффер как есть.Красиво и удобно
Bonus
Так случилось, что у меня для вас есть ещё и полноценный пример на Rust. Согласен, внезапно, но почему бы и нет. Сейчас язык набирает обороты, и уже всё чаще случается, что им заменяют C++.
main.rs
extern crate flatbuffers;#[allow(dead_code, unused_imports, non_snake_case)]#[path = "../../fbs/client_generated.rs"]mod client_generated;pub use client_generated::my_project::client::{ get_root_as_message, Type, Request, Response, Header, CommonMessage, MessageHolder, Message, RequestArgs, ResponseArgs, HeaderArgs, MessageHolderArgs, MessageArgs};fn create_request(mut builder: &mut flatbuffers::FlatBufferBuilder){// в Rust слово type является ключевым, поэтому генератор автоматически добавляет _ let type_ = Type::request; let header = Header::create(&mut builder, &mut HeaderArgs{type_}); let data_builder = { let mut b = flatbuffers::FlatBufferBuilder::new(); let name = b.create_string("This is some string for tests!"); let rq = Request::create(&mut b, &RequestArgs{name: Some(name)}); let data = MessageHolder::create(&mut b, &MessageHolderArgs{ data_type: CommonMessage::Request, data: Some(rq.as_union_value())}); b.finish(data, None); b }; let content = builder.create_vector(data_builder.finished_data()); let msg = Message::create(&mut builder, &MessageArgs{ header: Some(header), content: Some(content)}); builder.finish(msg, None);}fn create_response(mut builder: &mut flatbuffers::FlatBufferBuilder){ let type_ = Type::response; let header = Header::create(&mut builder, &mut HeaderArgs{type_}); let data_builder = { let mut b = flatbuffers::FlatBufferBuilder::new(); let rp = Response::create(&mut b, &ResponseArgs{x: 2, y: 3, z: 9}); let data = MessageHolder::create(&mut b, &MessageHolderArgs{ data_type: CommonMessage::Response, data: Some(rp.as_union_value())}); b.finish(data, None); b }; let content = builder.create_vector(data_builder.finished_data()); let msg = Message::create(&mut builder, &MessageArgs{ header: Some(header), content: Some(content)}); builder.finish(msg, None);}fn process_request(msg: &Message){ let content = msg.content_nested_flatbuffer().unwrap(); let rq = content.data_as_request().unwrap(); let name = rq.name().unwrap(); println!("Result request: {:?}", name);}fn process_response(msg: &Message){ let content = msg.content_nested_flatbuffer().unwrap(); let rp = content.data_as_response().unwrap(); println!("Result response: {}.{}.{}", rp.x(), rp.y(), rp.z());}fn receive_buffer(buf: &[u8]){ // NOTE: no verification exists in Rust yet let msg = get_root_as_message(buf); let header = msg.header().unwrap(); let type_ = header.type_(); println!("Received Type: {:?}", type_); match type_ { Type::request => { process_request(&msg); } Type::response => { process_response(&msg); } }}fn main() { println!("Testing on Rust"); let mut builder = flatbuffers::FlatBufferBuilder::new_with_capacity(1024); { create_request(&mut builder); let buf = builder.finished_data();receive_buffer(&buf); } { builder.reset(); create_response(&mut builder); let buf = builder.finished_data();receive_buffer(&buf); }}
FIN
Атрибут
nested_flatbuffers
является очень полезным и
удобным способом для оптимизации передачи данных при использовании
протокола FlatBuffers. Другое дело, что не так просто понять сходу,
как именно его применять.Вообще документация и примеры от разработчиков тех или иных протоколов иногда оставляют желать лучшего. Синтаксис таких библиотек является довольно специфичным, и по сути на его понимание приходится часто тратить не меньше времени и сил, чем на изучение просто нового языка программирования. Особенно часто это случается, когда упоминаются какие-то редкие возможности.
Например
Похожим образом помимо
Так что если вы вдруг озадачитесь такой же проблемой, то наверняка наткнётесь на моё обсуждение с разработчиками на одном из форумов, жаль только, что их ответ был крайне развёрнутый, но почти бесполезный.
nested_flatbuffers
обстоит дело с использованием
собственных аллокаторов в C: Да, юзеры, вы
можете использовать свои аллокаторы аж двумя (2!) разными
способами, но примеров мы вам, конечно, не дадим. А зачем?! Курите
исходники *звуки trolololoТак что если вы вдруг озадачитесь такой же проблемой, то наверняка наткнётесь на моё обсуждение с разработчиками на одном из форумов, жаль только, что их ответ был крайне развёрнутый, но почти бесполезный.
Поэтому хочется просто пожелать вам поменьше сталкиваться с такими неприятностями, а ещё иногда не жалеть время на написание доков для других программистов мы же ведь коллеги по цеху, да?