TL;DR: Мы перенесли битовый синтаксис Erlang в Go, чтобы парсить бинарные протоколы без боли. Получилась библиотека funbit — декларативный парсер с поддержкой не выровненных по байтам данных.
Предыстория
В процессе разработки funterm — мультиязыкового REPL, объединяющего Python, Lua, JavaScript и Go — мы столкнулись с необходимостью эффективной работы с бинарными данными. Нужно было парсить сетевые протоколы, обрабатывать структурированные данные и работать с битовыми полями на уровне отдельных битов.
Что не так с ручным парсингом
Представьте реальную задачу: распарсить пакет данных от IoT-устройства, где каждый бит на счету. Пакет занимает всего 28 бит (3.5 байта) и содержит несколько полей:
| device_id:4 | type:2 | battery:1 | error:1 | value:12 | battery_percent:7 | more:1 |
Традиционный подход на Go превращается в "ад битовых масок и сдвигов":
// data := []byte{...} deviceId := (data[0] >> 4) & 0x0F sensorType := (data[0] >> 2) & 0x03 batteryLow := (data[0] >> 1) & 0x01 errorFlag := data[0] & 0x01 value := uint16(data[1])<<4 | uint16(data[2]>>4) batteryPercent := (data[2] >> 1) & 0x7F moreData := data[2] & 0x01
Этот код не только трудно писать, но и практически невозможно читать и отлаживать.
С funbit эта же задача решается декларативно и понятно:
funbit.Integer(m, &deviceId, funbit.WithSize(4)) funbit.Integer(m, &sensorType, funbit.WithSize(2)) funbit.Integer(m, &batteryLow, funbit.WithSize(1)) funbit.Integer(m, &errorFlag, funbit.WithSize(1)) funbit.Integer(m, &value, funbit.WithSize(12)) funbit.Integer(m, &batteryPercent, funbit.WithSize(7)) funbit.Integer(m, &moreData, funbit.WithSize(1))
Go предоставляет отличные инструменты для работы с байтами, но когда дело доходит до битового уровня или сложного парсинга протоколов, код быстро становится громоздким:
// Типичный Go-код для парсинга TCP заголовка srcPort := binary.BigEndian.Uint16(data[0:2]) dstPort := binary.BigEndian.Uint16(data[2:4]) seq := binary.BigEndian.Uint32(data[4:8]) flags := data[13] urg := (flags >> 5) & 1 ack := (flags >> 4) & 1 // ... и так далее
В Erlang та же задача решается элегантно:
<<SrcPort:16, DstPort:16, Seq:32, _:64, URG:1, ACK:1, PSH:1, RST:1, SYN:1, FIN:1, _:2, Payload/binary>> = Data
Мы реализовали это в Go.
Почему не подошли готовые решения
Перед началом разработки мы изучили существующие библиотеки для работы с бинарными данными в Go:
encoding/binary — отлично для простых случаев, но требует много boilerplate-кода
Различные парсеры протоколов — узкоспециализированные, не универсальные
Сторонние библиотеки — либо неполные, либо не следуют семантике Erlang
Нам нужно было решение, которое:
Поддерживает битовые строки произвольной длины (не только байт-выровненные сегменты)
Совместимо со спецификацией Erlang/OTP
Имеет простой API для Go-разработчиков
Поддерживает типы: integer, float, binary, UTF-8/16/32
Умеет работать с динамическими размерами и выражениями
Архитектура funbit
Builder Pattern для конструирования
Мы выбрали builder pattern с отложенной проверкой ошибок. Все операции по добавлению данных выполняются через функции, которые принимают builder как аргумент. Ошибка проверяется один раз в конце, при вызове Build().
// Создаем builder builder := funbit.NewBuilder() // Добавляем сегменты funbit.AddInteger(builder, 42, funbit.WithSize(8)) funbit.AddBinary(builder, []byte("data")) funbit.AddFloat(builder, 3.14, funbit.WithSize(32)) // Собираем битстринг и проверяем ошибку bitstring, err := funbit.Build(builder) // Matcher работает по тому же принципу matcher := funbit.NewMatcher() var num int var data []byte var pi float32 funbit.Integer(matcher, &num, funbit.WithSize(8)) funbit.Binary(matcher, &data, funbit.WithSize(4)) funbit.Float(matcher, &pi, funbit.WithSize(32)) results, err := funbit.Match(matcher, bitstring)
Преимущества:
Чистый код без множественных
if err != nilПервая ошибка останавливает обработку
Последующие операции игнорируются при наличии ошибки
Matcher для паттерн-матчинга
matcher := funbit.NewMatcher() var srcPort, dstPort, seq int var payload []byte funbit.Integer(matcher, &srcPort, funbit.WithSize(16)) funbit.Integer(matcher, &dstPort, funbit.WithSize(16)) funbit.Integer(matcher, &seq, funbit.WithSize(32)) funbit.RestBinary(matcher, &payload) results, err := funbit.Match(matcher, bitstring)
Ключевые технические решения
1. Битовая точность
funbit работает на уровне отдельных битов:
builder := funbit.NewBuilder() funbit.AddInteger(builder, 0b101, funbit.WithSize(3)) // 3 бита funbit.AddInteger(builder, 0b1111, funbit.WithSize(4)) // 4 бита // Итого: 7 битов (не полный байт!)
2. Семантика размеров
Критическое различие между integer и binary сегментами:
// Для integer: WithSize(32) = 32 БИТА funbit.Integer(matcher, &val, funbit.WithSize(32)) // Для binary: WithSize(32) = 32 БАЙТА = 256 БИТОВ! funbit.Binary(matcher, &data, funbit.WithSize(32))
Это соответствует семантике Erlang, где:
<<Value:32>>— 32 бита<<Data:32/binary>>— 32 байта
3. Unit Multipliers
Для точного контроля размеров:
// Без WithUnit(1): size*8 интерпретируется как БАЙТЫ funbit.Binary(matcher, &data, funbit.WithDynamicSizeExpression("size*8")) // size=5 → 5*8=40, но binary интерпретирует как 40*8=320 битов! // С WithUnit(1): size*8 интерпретируется как точные БИТЫ funbit.Binary(matcher, &data, funbit.WithDynamicSizeExpression("size*8"), funbit.WithUnit(1)) // size=5 → 5*8=40 битов точно ✅
4. UTF поддержка
Полная поддержка UTF-8/16/32 с правильной семантикой:
// Кодирование строки funbit.AddUTF8(builder, "Hello 🚀") // Кодирование отдельного кодпоинта funbit.AddUTF8Codepoint(builder, 0x1F680) // 🚀 // Извлечение кодпоинта как INTEGER (по спецификации Erlang) var codepoint int funbit.UTF8(matcher, &codepoint)
5. Динамические размеры
Поддержка переменных и выражений:
// Регистрируем переменную funbit.RegisterVariable(matcher, "size", &size) // Используем в выражениях funbit.Binary(matcher, &data, funbit.WithDynamicSizeExpression("size*8"), funbit.WithUnit(1))
Пример: Вложенный протокол
funbit особенно хорош в разборе протоколов, где размер данных зависит от значения в заголовке.
// Структура пакета: [общий размер:8][тип:8][данные:размер-2/binary][crc:16] matcher := funbit.NewMatcher() var size, pktType int var data []byte var crc uint16 // 1. Извлекаем общий размер funbit.Integer(matcher, &size, funbit.WithSize(8)) // 2. Регистрируем его как переменную для использования в выражениях funbit.RegisterVariable(matcher, "size", &size) // 3. Извлекаем остальные поля, используя динамический размер funbit.Integer(matcher, &pktType, funbit.WithSize(8)) funbit.Binary(matcher, &data, funbit.WithDynamicSizeExpression("(size-2)*8"), // size-2 байта = (size-2)*8 бит funbit.WithUnit(1)) // Указываем, что размер в битах funbit.Integer(matcher, &crc, funbit.WithSize(16))
Интеграция с funterm
В контексте funterm библиотека используется для:
Парсинга протоколов в примерах:
# В funterm REPL lua.packet = <<0xDEADBEEF:32, "payload"/binary>> match lua.packet { <<header:32, data/binary>> -> lua.print("Header:", header) }
Межъязыкового обмена бинарными данными:
py.data = b"\xDE\xAD\xBE\xEF" # Конвертация в битстринг для обработки в Lua
Обработки IoT данных и сенсоров:
lua.sensor_data = <<temp:16/signed, humidity:8, battery:8>>
Архитектурные характеристики
Производительность
Удобство декларативного синтаксиса имеет свою цену в виде некоторых накладных расходов по сравнению со стандартным encoding/binary. Для большинства задач, где парсинг не является узким местом, это приемлемый компромисс, однако в критически важных для производительности участках кода рекомендуется проводить собственное профилирование.
Другие характеристики
Библиотека спроектирована с учетом:
Алгоритмическая сложность: O(n) для конструирования и матчинга, где n — количество сегментов
Память: Битстринги иммутабельны, что обеспечивает безопасность и предсказуемость
Потокобезопасность: Созданные битстринги (тип
*BitString) полностью потокобезопасны для чтения после создания. Однако экземплярыBuilderиMatcherне являются потокобезопасными и не должны использоваться одновременно в разных горутинах без внешней синхронизации.
Когда использовать funbit (и когда нет)
Идеальные сценарии:
Парсинг сетевых протоколов: TCP, UDP, DNS, или любые кастомные бинарные протоколы
Работа с IoT и embedded данными: Удобная обработка компактных, не выровненных по байту структур данных
Разбор файловых форматов: Работа со структурами PNG, MP3, GIF и других форматов на низком уровне
Исследование и документирование протоколов
Прототипирование парсеров
Обучение работе с бинарными протоколами
Парсинг сложных структур с динамическими размерами
Задачи, где важна корректность и читаемость
Когда encoding/binary может быть лучше:
Когда все данные идеально выровнены по байтам и имеют фиксированный размер
Для high-load систем с миллионами пакетов/сек
Для игровых серверов с жёсткими требованиями к latency
Для embedded систем с ограниченными ресурсами
// Пример правильного использования в горутинах var mu sync.Mutex // Операции с builder должны быть защищены mu.Lock() builder := funbit.NewBuilder() funbit.AddInteger(builder, 123, funbit.WithSize(8)) bitstring, err := funbit.Build(builder) mu.Unlock() // Созданный bitstring можно безопасно читать из разных горутин go processData(bitstring) go analyzeData(bitstring)
Читаемость vs производительность: Приоритет отдан читаемости кода и корректности
Вызовы разработки
1. Совместимость с Erlang семантикой
Самая сложная часть — точное воспроизведение поведения Erlang:
Различная интерпретация размеров для разных типов
Правильная обработка UTF кодпоинтов
Поведение при ошибках (эквивалент
badarg)
2. Go типизация vs Erlang динамика
Erlang — динамически типизированный язык, Go — статически типизированный. Это создавало множество проблем:
Проблема 1: Динамические типы в паттернах
% В Erlang переменная может быть любого типа <<Value/binary>> = Data % Value может быть строкой <<Value:32>> = Data % Value может быть числом
// В Go нужны разные переменные для разных типов var binaryValue []byte var intValue int funbit.Binary(matcher, &binaryValue) funbit.Integer(matcher, &intValue, funbit.WithSize(32))
Проблема 2: Универсальный интерфейс для значений
В Erlang все значения имеют общий тип. В Go пришлось использовать interface{}:
func AddInteger(b *Builder, value interface{}, options ...SegmentOption)
Но это требовало runtime проверок типов и приводило к потере безопасности компиляции.
Проблема 3: Размеры и единицы измерения
% В Erlang размер интерпретируется по-разному для разных типов <<Value:32>> % 32 бита для integer <<Data:32/binary>> % 32 БАЙТА для binary
// В Go пришлось делать явные проверки типов в runtime if segment.Type == TypeInteger { // size в битах } else if segment.Type == TypeBinary { // size в байтах (единицах) }
Проблема 4: Обработка ошибок
В Erlang ошибки типа badarg выбрасываются в runtime. В Go нужно было решить:
Паниковать (не Go-way)
Возвращать ошибки из каждой функции (verbose)
Накапливать ошибки в builder (наш выбор)
Решение: Компромиссы
Типобезопасность на уровне API — разные функции для разных типов
Runtime проверки внутри — неизбежное зло для совместимости с Erlang
Отложенная обработка ошибок — builder pattern с накоплением ошибок
Явная семантика размеров — четкое разделение битов и байтов в документации
3. Производительность битовых операций
Эффективная работа с небайт-выровненными данными требовала оптимизации алгоритмов битовых сдвигов и маскирования.
4. Семантика размеров — головная боль
Самая коварная проблема — различная интерпретация размеров:
% В Erlang: <<Data:4/binary>> % 4 БАЙТА <<Value:4>> % 4 БИТА <<Text:4/utf8>> % 4 КОДПОИНТА
Проблема: Один и тот же параметр 4 означает разные вещи!
Наше решение:
// Явное указание единиц измерения funbit.WithSize(4) // По умолчанию зависит от типа funbit.WithSize(4, WithUnit(8)) // 4 * 8 = 32 бита funbit.WithSize(4, WithUnit(1)) // Точно 4 бита
Почему это сложно?
Обратная совместимость — нужно точно воспроизвести Erlang поведение
Интуитивность — разработчик ожидает, что
WithSize(4)для binary означает 4 байтаВалидация — нужно проверять корректность комбинаций размер+тип+единица
Документирование — каждый случай требует подробного объяснения
Результат: Много времени ушло на тестирование краевых случаев и написание документации с примерами.
5. UTF кодирование — тонкости и подводные камни
UTF поддержка в Erlang очень гибкая, что создавало проблемы при портировании:
Проблема 1: Строки vs кодпоинты
% Erlang поддерживает оба варианта: <<"Hello"/utf8>> % Кодирует всю строку <<1024/utf8>> % Кодирует один кодпоинт
Наше решение:
// Разные функции для разных случаев funbit.AddUTF8(builder, "Hello") // Строка funbit.AddUTF8Codepoint(builder, 1024) // Кодпоинт
Проблема 2: Валидация кодпоинтов
Erlang выбрасывает badarg для невалидных кодпоинтов. Нужно было воспроизвести точно такое же поведение:
// Проверяем диапазоны Unicode if codepoint > 0x10FFFF || (codepoint >= 0xD800 && codepoint <= 0xDFFF) { return NewBitStringError(ErrInvalidUnicodeCodepoint, ...) }
Проблема 3: Endianness для UTF-16/32
UTF-16 и UTF-32 могут быть big-endian или little-endian, что усложняло API:
funbit.AddUTF16(builder, "text", funbit.WithEndianness("big"))
Время разработки: UTF поддержка заняла ~30% времени всего проекта из-за множества edge cases и необходимости полного соответствия Erlang поведению.
Планы развития
Оптимизация производительности для больших битстрингов
Расширение поддержки протоколов (HTTP/2, gRPC, etc.)
Интеграция с кодогенерацией для автоматического создания парсеров
Поддержка streaming для обработки больших потоков данных
Заключение
Создание funbit показало, что элегантные решения из одной экосистемы можно успешно адаптировать для другой, сохранив при этом идиоматичность целевого языка.
Ссылки:
Библиотека открыта для сообщества и ждет ваших отзывов, замечаний и предложений!
Практические примеры
Парсинг PNG заголовка
// Конструирование builder := funbit.NewBuilder() funbit.AddInteger(builder, 13, funbit.WithSize(32)) // Length funbit.AddBinary(builder, []byte("IHDR")) // Type funbit.AddInteger(builder, 1920, funbit.WithSize(32)) // Width funbit.AddInteger(builder, 1080, funbit.WithSize(32)) // Height funbit.AddInteger(builder, 8, funbit.WithSize(8)) // Bit depth bitstring, _ := funbit.Build(builder) // Паттерн-матчинг matcher := funbit.NewMatcher() var length, width, height, bitDepth int var chunkType []byte funbit.Integer(matcher, &length, funbit.WithSize(32)) funbit.Binary(matcher, &chunkType, funbit.WithSize(4)) // 4 байта funbit.Integer(matcher, &width, funbit.WithSize(32)) funbit.Integer(matcher, &height, funbit.WithSize(32)) funbit.Integer(matcher, &bitDepth, funbit.WithSize(8)) results, err := funbit.Match(matcher, bitstring) if err == nil && string(chunkType) == "IHDR" { fmt.Printf("PNG: %dx%d, %d-bit\n", width, height, bitDepth) }
TCP заголовок с флагами
builder := funbit.NewBuilder() funbit.AddInteger(builder, 0x1234, funbit.WithSize(16)) // Source port funbit.AddInteger(builder, 0x5678, funbit.WithSize(16)) // Dest port funbit.AddInteger(builder, 0x12345678, funbit.WithSize(32)) // Sequence funbit.AddInteger(builder, 0x87654321, funbit.WithSize(32)) // Ack funbit.AddInteger(builder, 5, funbit.WithSize(4)) // DataOffset (минимум 5) funbit.AddInteger(builder, 0, funbit.WithSize(6)) // Reserved // Флаги как отдельные биты funbit.AddInteger(builder, 1, funbit.WithSize(1)) // URG funbit.AddInteger(builder, 0, funbit.WithSize(1)) // ACK funbit.AddInteger(builder, 1, funbit.WithSize(1)) // PSH funbit.AddInteger(builder, 0, funbit.WithSize(1)) // RST funbit.AddInteger(builder, 1, funbit.WithSize(1)) // SYN funbit.AddInteger(builder, 0, funbit.WithSize(1)) // FIN funbit.AddInteger(builder, 8192, funbit.WithSize(16)) // Window size funbit.AddInteger(builder, 0, funbit.WithSize(16)) // Checksum funbit.AddInteger(builder, 0, funbit.WithSize(16)) // Urgent pointer funbit.AddBinary(builder, []byte("payload"))
