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"xDExADxBExEF"
# Конвертация в битстринг для обработки в 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-bitn", 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"))
Автор: oakulikov
