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"))