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 