Как быстро, без боли и страданий организовать хранение структурированных данных в бинарном формате. А затем и их передачу при необходимости. А потом, немного подумав, ещё их обнаружение в «замусоренном» потоке.
Наверняка вы сталкивались с таким типом проектов, который задумывался (или заказывался) как нечто очень маленькое и компактное, а затем как-то неожиданно начинал разрастаться. Для таких вот «малюток» обычно стараешься ничего особенно не выдумывать и ограничиваешься минималистичным стеком.
Вот и я как-то работал над таким «вроде» небольшим и несложным проектом. Что это был за проект в контексте данной статьи — совсем неважно. Важно то, что в какой-то момент времени мне понадобилось сохранять данные с нескольких потоков. Логи. Дело нехитрое — берём файл, да записываем.
Но прилетает новый запрос. Хотелось бы логи по каждому потоку видеть.
Да, не вопрос… Теперь у нас несколько файлов. Потоки, кстати, непростые и дают не просто логи, а содержат ещё и структурированную информацию. Это могут быть метрики, это могут быть какие-то перечисления. В чём-то данные потоков (по структуре) совпадают, а в чём-то — нет, и поток даёт уникальные по своей сути данные. Однако сохранять в виде текста было приемлемо для целей, повторюсь, «да, это небольшой проект, там дел-то на пару часов».
Сделано, отдано, нехай работает.
Но ненадолго… «А можно нам какой-нибудь фронт-енд, чтобы вывод смотреть, мышкой, а то Зинаиде Федоровне неудобно с терминалом. А ещё, пожалуйста, фильтрацию по потокам и фильтрацию по ряду критериев».
А файлики-то за сессию пухнут до 1–2 ГБ и останавливаться не хотят. И тут несколько проблем сразу в полный рост встают:
данные в текстовом формате, строк там миллионы, в браузер такое просто так не закинешь — нужно что-то вроде виртуальной прокрутки встраивать. Но это решаемо относительно безболезненно.
а вот бэкенд требует более серьёзных доработок. Во-первых, надо думать, как давать из каждого файла какой-то сегмент по запросу клиента, а во-вторых — поиск, опять же с привязкой к тому самому фрейму, который надо будет отдать на клиента.
да и поиск сам по себе уже проблема: если искать нужно по какому-то критерию, а данные у тебя в тексте, то условие поиска может «отлавливать» как то, что пользователь ищет, так и что-то «левое». То есть в текст надо вводить дополнительные данные. Например, не просто температура, а температура с указанием ID или типа датчика.
В общем, как там всё разрешилось с этим проектом — не суть важно. Разрешилось там всё хорошо, и на этом, кстати, сервис свой рост остановил и больше не расширяется, полностью закрыв потребности пользователя.
Но это был уже не первый случай, когда я встречался с ситуацией, где мне надо хранить данные «немного» в структурированном виде, иметь возможность примитивного поиска и лёгкий (а главное, быстрый) доступ к фрагментам данных. И тебе вроде бы нужна какая-то очень лёгкая база данных, но, с другой стороны, тебе очень не хочется с ней заморачиваться.
Ну а дальше — классика… Хочу себе такой инструмент, который и не база данных, и чуть больше, чем хранилище, и ещё чтобы типизированное хранилось, и прочие приятные мелочи.
И вот после такого долгого предисловия — к делу. Речь пойдёт о наборе инструментов для создания своего протокола данных для их хранения или же передачи. Знакомьтесь — brec (BinaryRECord).
Суть проста до безобразия. Единица данных — это пакет. Можете считать это записью в логах, можно думать об этом как о сообщении в системе — неважно. Пакет состоит из блоков (от 0 до 255) и полезной нагрузки (опционально). Выглядит так:
Block
…
Block (up to 255)
Payload (Optional)
Сами пакеты, блоки внутри них, да и полезную нагрузку надо распознавать и контролировать целостность, а это и подписи какие-то, и расчёт хэш-сумм. Со всем этим морочиться не хочется, поэтому автоматизируем.
Всё, что нужно в коде — это с помощью макроса указать, что некая структура — это блок, а иная структура — это полезная нагрузка.
#[block]
pub struct BlockA {
pub field_u8: u8,
pub field_u16: u16,
pub field_u32: u32,
pub field_u64: u64,
pub field_u128: u128,
pub field_i8: i8,
pub field_i16: i16,
pub field_i32: i32,
pub field_i64: i64,
pub field_i128: i128,
pub field_f32: f32,
pub field_f64: f64,
pub field_bool: bool,
}
#[block]
pub struct BlockB {
pub data: [u8;100],
}
#[payload(bincode)]
#[derive(serde::Deserialize, serde::Serialize)]
pub struct PayloadA {
pub field_u8: u8,
pub field_u16: u16,
pub field_u32: u32,
pub field_u64: u64,
pub field_u128: u128,
pub field_struct_a: StructA,
pub field_struct_b: Option,
pub field_struct_c: Vec,
pub field_enum: EnumA,
pub vec_enum: Vec,
}
Вот и всё. Можно создать пакет.
let packet = Packet::new(
vec![
Block::BlockA(BlockA::default()),
Block::BlockB(BlockB::default()),
],
Some(Payload::PayloadA(PayloadA::default()))
);
И как бы протокол готов к использованию :) Да, вот так вот просто. Но какая магия произойдёт «под капотом»?
пакет будет оснащён уникальной подписью для распознавания;
у пакета появится заголовок с длинами данных внутри и хэш-суммой пакета;
у блоков появятся подписи и хэш-суммы;
у полезной нагрузки появится свой заголовок с подписью, хэшем и длинами.
Иными словами, после упаковки блоков и полезной нагрузки в пакет, его легко распознать даже в «замусоренном» потоке.
Инициализированный пакет даст необходимые методы для записи:
packet.write(&mut dest)
packet.write_all(&mut dest)
packet.write_vectored(&mut dest)
Ну и чтение, куда без него
Packet::read(&mut source)
Но вы справедливо заметите, что всего этого можно было бы добиться, не изобретая очередной велосипед, а используя, да хотя бы, bincode
(который, как вы заметили выше, используется в brec
). И вы будете правы! Всё это может делать bincode
и подобные замечательные крейты (кстати, низкий поклон разработчикам за стабильные и прекрасные решения).
Но моя задача была шире, а значит, и задача brec. Это, в первую очередь, набор инструментов, которых «всего» два, но довольно прикладных.
Первый — это ридер буфера PacketBufReader
. Его главные особенности можно свести к следующему:
во-первых, он способен читать «замусоренный» поток, то есть данные, содержащие не только пакеты
brec
, но и любые другие. При этом он не теряет «отторгнутые» данные, а даёт возможность пользователю их получить;во-вторых — и это частая головная боль — у него есть собственный внутренний буфер, что позволяет
PacketBufReader
подгружать данные в случае их недостатка. То есть париться вот с этой головной болью, когда «немного не хватило», совершенно не нужно;в-третьих, он умеет фильтровать
базарпакеты, но об этом — чуть ниже.
Второй инструмент — это хранилище Storage
, и здесь ещё интереснее:
сохраняет пакеты в слотах, что даёт возможность перехода по порядковому номеру пакета. Скажем, нужен вам 100-й пакет — вы его и получаете; или, например, с 200 по 250 пакеты — пожалуйста. Помните, вначале я упоминал про виртуальную перемотку на фронт-енде? Вот это оно (ну или для этого);
каждый слот при этом имеет свою хэш-сумму, что позволяет избежать ситуации с неверными данными. Иными словами, если хранилище будет повреждено, то вероятность выдачи скомпрометированных данных крайне мала — вместо этого хранилище «выплюнет» ошибку.
При этом, даже если хранилище было «повреждено» и не может больше быть прочитано, то можно использовать PacketBufReader
и прочитать содержимое, минуя и метаданные хранилища, и повреждённые фрагменты. Да, извлечение данных по диапазону больше невозможно, но и пакеты при этом не утеряны.
Но главное — и PacketBufReader
, и Storage
умеют фильтровать данные. Парсинг пакетов происходит последовательно, в пару этапов:
частичный парсинг блоков. Парсер читает все примитивные поля блоков, но не трогает слайсы (не копирует данные). Получается
BlockReferred
— то есть ссылочная репрезентация вашего блока;выявление области полезной нагрузки (на данном этапе полезная нагрузка определена в виде слайса);
парсинг полезной нагрузки; самое дорогое удовольствие
и, наконец, формирование пакета (здесь произойдёт полное копирование данных из потока).
На каждом из этих этапов можно фильтровать данные. Например, вы ставите фильтр по блокам и решаете, нужен вам пакет, содержащий переданные вам блоки, или нет. Если нужен — парсинг продолжается; если нет — парсинг самой тяжёлой части просто пропускается. Кроме того, вы можете заглянуть в полезную нагрузку в виде слайса байтов и также решить — а надо ли дальше обрабатывать этот пакет или можно пропустить. Например, ваша полезная нагрузка — это строка; тогда вы можете сделать поиск по &str
, избежав копирования (да, конечно, при условии, что это валидная UTF-строка).
Безусловно — это даёт существенный прирост производительности. В документации есть результаты тестов, где brec
в режиме фильтрации обходит пресловутый JSON
в два раза в режиме потокового чтения (то есть используя PacketBufReader
), а в режиме хранилища (Storage
) их производительность фактически одинаковая. Почему сравнил с JSON
? Да просто потому, что он безумно распространён и оптимизирован вдоль и поперёк по самое не балуйся. Отдельно замечу, что мы говорим о производительности со включённой проверкой CRC, которую ещё и отключить можно.
А вот теперь, пожалуй, и всё. По существу, публичный API brec
сводится всего к 4-м ключевым элементам — это два макроса #[block]
и #[payload]
, ридер PacketBufReader
и хранилище Storage
. Всё! Остальное — мелочи.
И вот с помощью этих 4-х элементов (эх, не дотянул до пятого) можно избавиться от массы головной боли, получив инструмент, лежащий где-то между файловым хранилищем, примитивной базой данных и транспортным протоколом. Он не претендует на роль «швейцарского ножа», напротив, он говорит: «бро, если тебе нужно получать, сохранять, быстро искать и быть устойчивым к помехам и повреждениям — это ко мне; если что-то большее, то прости, тут надо что-то более тяжёлое». В какой-то мере brec
ломает и общую парадигму, что сообщения должны быть предопределены (как, например, в protobuf
и ему подобных, где присутствует схема), но, согласитесь, brec
делает это элегантно. Да, сообщений нет, но есть предопределённые blocks
и payload
, которые можно комбинировать, оставаясь при этом строго типизированным. То есть в отсутствие фиксированных сообщений присутствует главное — строгая типизация данных.
Опираясь на уже состоявшиеся обсуждения топика с коллегами и предвосхищая ряд вопросов, попробую ответить сразу. Тем более нередко так бывает, что вопрос есть, а спрашивать лень... Я попробую угадать :)
Тестирование, как подтверждение надёжности. Отдельная и тяжёлая история. Тяжёлая, потому что макросы, а их тестировать — дело неблагодарное. Пришлось написать (с помощью proptest) небольшой генератор блоков и структур (для payload). Тест проходит так: генерируется случайная коллекция блоков и структур для payload, затем каждому присваиваются какие-то случайные значения, из этого всего формируются пакеты, которые затем записываются и прочитываются. Каждый такой кейс — это отдельный крейт, запускаемый самостоятельно. Второй слой теста — это парсинг. Здесь проще с точки зрения реализации, но сложнее с точки зрения исполнения. Использовал всё тот же proptest, который в общей сложности даёт более 40 ГБ (не опечатка) данных, которые и обрабатываются самыми разными методами. Понятное дело, что такие стресс-тесты — это не то, что будешь пытаться запустить на CI. Там как раз облегчённая версия. Так же допускаю, что на данном этапе разработки мог упустить какой-то сценарий теста.
Где-то уже внедрялось? На публичных проектах — нет. Сам brec только-только вышел в стабильную бету. Я внедрил его в один небольшой закрытый проект на продакшене, а во второй включил на стадии разработки. Пока полёт нормальный.
Где сравнение хотя бы с
protobuf
? Тупо не было времени. Если будет такой запрос — постараюсь добавить. JSON взял по указанным выше в статье причинам и потому, что его было проще реализовать для целей сравнения.Что насчёт кросс-языковой поддержки? Отсутствие схемы протокола — это как раз то, что делает возможным такую поддержку (см.
protobuf
), а здесь её нет (в смысле схемы)… Схемы нет, да, как я уже упоминал. Да, такая поддержка будет нужна, но при условии, что хотя бы эта статья найдёт отклик и выразится хотя бы «звёздочкой» на GitHub ;) Если серьёзно, то это про то, когда спрос рождает предложение — если будет запрос на это, то будет и реализация от автора. На данный момент могу сказать, что связка с NodeJS или Web идёт почти «из коробки» с высокой производительностью. Я говорю о wasm. Да, протокол можно определить отдельным трейтом, и «рядом» создать трейт-«обёртку», скомпилированную в wasm, который можно успешно цеплять к NodeJS или браузеру и спокойно себе парсить пакеты или же получать их в виде байт. При этом всём — сохраняя высокую производительность. Конечно, хранилище так реализовать (через wasm) весьма и весьма тяжело… А вот буфер прикрутить будет проще. Пример wasm есть в репозитории, кстати.Что насчёт использования для обмена данными (по сети, например)? Конечно! Берём
PacketBufReader
— и в добрый путь, ему всё равно, что «кушать», главное, чтобыstd::io::Read
реализовало.Как насчёт компрессии данных? Мысль такая есть. Там, где я уже внедрил brec — это не нужно, так что «в коробку» я не добавил. Самостоятельная реализация возможна, и она довольно тривиальна, если мы говорим о компрессии payload, но не пакета целиком (для payload достаточно самостоятельно реализовать пару трейтов — и компрессия готова). Что касается компрессии пакета, то именно её имеет смысл добавить «в коробку», но давайте по необходимости — будет запрос, буду добавлять :)
Контроль версий? Из коробки пока нет такого. Ручная самостоятельная реализация через отдельный блок — очень легко. Но из коробки пока нет. Если нужно — пожалуйста, оставьте запрос на GitHub.
Использовал ли ChatGPT? Да, и очень интенсивно. Де-факто для меня это первый проект, сделанный с ним в роли ассистента.
Есть ли в решении его код? Нет, нету. Изначально я собирался делегировать на него тестирование, но очень быстро выяснилось, что в части чуть более сложных стратегий proptest он дико тупит и скорее вреден, чем даже бесполезен. Но вот где он оказал существенную поддержку — так это в разного рода «обобщениях». Я даже не знаю, как объяснить… Ну например, «я покрыл этот и этот интерфейсы… я ничего не упускаю, может, забыл что?» — а в ответ получаю советы по тем интерфейсам, которые имеет смысл поддержать. Кроме того, он лихо может проверить код на предмет ошибок «красных глаз» — это когда у тебя за окном второй час ночи и какой-то
offset
где-то принимает неверное значение, потому что ты забылoffset += blocks_len;
. Ну и, конечно, первичная помощь по API: вначале к нему за наводкой, а потом по наводке — в официальную документацию.
У меня по существу всё. Разве что добавлю, что ваша поддержка в виде клика по звёздочке на странице GitHub подарит мне улыбку, а вам — хорошее настроение :) Правда! Вы же и сами знаете, как для разработчика важна такая обратная связь. Да, блин, хоть какая-то обратная связь :) Эх, был бы я девушкой… нет, не буду продолжать… не те времена, чтобы с этим шутки шутить.
Кроме того, если вы хоть немного заинтересовались проектом, но вам чего-то не хватает, пожалуйста, загляните в репозиторий и оставьте feature request. Я работал над brec исходя из своих повседневных нужд и реализовал то, что в сухом остатке было нужно мне. Поэтому, конечно, я упустил какой-то важный API из виду. В том числе по этой же причине я оставил проект в бете, потому что рассчитываю получить от вас запросы на новые фишки и полезный API.
Что касается планов, то они будут зависеть от отклика, но что добавлю точно в ближайшее время — это дополнительные сценарии тестирования.
Спасибо, что прочитали, надеюсь, немного отвлеклись от рутины. Хорошего дня!