Когда вам нужно выжать каждый последний бит или цикл процессора из вашего протокола, одним из лучших инструментов является пакет encoding / binary. Он работает на самом низком уровне и предназначен для работы с бинарными протоколами. Мы ранее рассматривали использование encoding / json для текстовых протоколов, но двоичные протоколы могут быть намного лучше, когда двум машинам необходимо обмениваться данными быстро и эффективно.

Написание двоичных протоколов кажется сложной задачей, но она оказывается на удивление простой, если вы понимаете несколько концепций. Мы рассмотрим различные способы кодирования двоичных данных, различные компромиссы и, надеюсь, развеем любую загадку, связанную с двоичным пакетом.

Этот пост является частью серии пошаговых руководств, которые помогут вам лучше понять стандартную библиотеку. Хотя сгенерированная документация предоставляет обширную информацию, может быть сложно понять пакеты в реальном контексте. Эта серия статей призвана показать контекст того, как стандартные пакеты библиотек используются в повседневных приложениях. Если у вас есть вопросы или комментарии, вы можете связаться со мной по адресу @benbjohnson в Twitter.

Что такое бинарный протокол?

Различие между текстовыми протоколами и двоичными протоколами может показаться глупым, поскольку оба они взаимодействуют с использованием байтов. Однако важное различие заключается в том, какие байты они могут использовать и как они их структурируют.

Текстовые протоколы, такие как JSON, для связи используют только печатаемый набор символов в ASCII или Unicode. Например, число «26» представлено байтами «2» и «6», потому что это печатаемые символы. Это отлично подходит для чтения людьми, но медленно для чтения компьютерами.

В двоичном протоколе число «26» может быть представлено одним байтом - 0x1A в шестнадцатеричном формате. Это на 50% меньше места, и он уже находится в собственном двоичном формате компьютера, поэтому его не нужно анализировать. Эта разница в производительности выглядит незначительной для одного числа, но она складывается при обработке миллионов или миллиардов чисел.

Эта странная вещь, называемая "порядком следования байтов"

Когда вы сохраняете двоичные данные, вам нужно выбрать что-то, называемое порядком байтов. Слово звучит сложно, но все, что оно означает, - это «порядок, в котором вы пишете свои байты».

Это звучит странно, поскольку мы, люди, всегда пишем свои числа одинаково. Когда мы пишем число 5 273, сначала идет 5, затем 2, затем 7 и, наконец, 3. В этом примере цифра «5» называется наиболее значимой цифрой, а 3 - наименее значимой цифрой. Никогда бы не подумал написать это число в обратном порядке как 3,725. Это было бы смешно.

Однако в двоичном кодировании этот выбор нужно делать в порядке записи байтов. Существует два вида порядка байтов - прямой и прямой. Порядок байтов с прямым порядком байтов - это когда вы записываете первым свой старший байт, а с обратным порядком байтов - когда первым записываете младший байт.

Например, возьмем десятичное число 287,454,020, которое равно 0x11223344 в шестнадцатеричной системе счисления. Самый старший байт - 0x11, а младший значащий байт - 0x44.

Кодирование этого с прямым порядком байтов выглядит так:

11 22 33 44

и кодирование с прямым порядком байтов выглядит так:

44 33 22 11

Странно, правда?

Так зачем вам вообще использовать прямой порядок байтов? Что ж, метод прямого порядка байтов используется большинством современных процессоров для внутреннего хранения чисел. Преимущество этого, казалось бы, обратного подхода состоит в том, что вы можете изменить размер своего числового типа, не перемещая байты.

Например, мы можем легко изменить это 4-байтовое число int32, которое мы указали выше, на 8-байтовое число int64, просто увеличив длину на четыре байта с дополнением нулями. Это делает так, что int32 и int64 указывают на один и тот же адрес памяти:

44 33 22 11
44 33 22 11 00 00 00 00

Этот вид операции полезен на уровне компилятора для расширения и сжатия целочисленных переменных, но не очень полезен на уровне протокола.

Сетевой порядок байтов

Кодирование с прямым порядком байтов - это соглашение при работе с двоичными числами по сетевому протоколу - настолько, что оно фактически называется сетевым порядком байтов.

У Big Endian также есть интересное свойство, заключающееся в том, что его можно лексикографически сортировать. Это означает, что вы можете сравнивать два двоичных числа, начиная с первого байта и переходя к последнему байту. Так работают байты. Равно () и байты. Сравнить (). Это связано с тем, что наиболее значимые байты идут первыми в кодировке с прямым порядком байтов.

Кодирование с фиксированной длиной

В Go мы привыкли к целочисленным типам определенного размера. Типы int8, int16, int32 и int64 имеют длину 1. , 2, 4, & 8 байта соответственно. Они называются типами фиксированной длины.

Если вы читали другие пошаговые руководства в этой серии, вы заметите общий шаблон, согласно которому обычно существует один способ обработки потоков байтов и другой способ обработки байтовых срезов в памяти. Кодирование и декодирование двоичных данных ничем не отличается.

Кодирование в байтовые срезы

Для чтения и записи двоичных данных из байтовых срезов мы будем использовать ByteOrder:

type ByteOrder interface {
        Uint16([]byte) uint16
        Uint32([]byte) uint32
        Uint64([]byte) uint64
        PutUint16([]byte, uint16)
        PutUint32([]byte, uint32)
        PutUint64([]byte, uint64)
        String() string
}

Этот интерфейс имеет две реализации: BigEndian и LittleEndian. Чтобы записать число фиксированной длины в байтовый фрагмент, мы выберем порядок байтов и просто вызовем соответствующий метод Put:

v := uint32(500)
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, v)

Обратите внимание, что методы Put вызовут панику, если вы предоставите буфер, который слишком мал для записи.

Затем вы можете прочитать свое значение из буфера, используя связанный метод получения:

x := binary.BigEndian.Uint32(buf)

Опять же, это вызовет панику, если ваш буфер слишком мал. Кроме того, если вы читаете из потока, важно использовать io. ReadFull (), чтобы убедиться, что вы не выполняете декодирование из частично прочитанного буфера.

Потоковая обработка

Бинарный пакет предоставляет две встроенные функции для чтения и записи значений фиксированной длины в потоки. Они названы соответствующим образом: Читать () и Написать ().

Функция Read () работает, проверяя тип данных и считывая и декодируя соответствующее количество байтов, используя порядок байтов, указанный в аргументе order:

func Read(r io.Reader, order ByteOrder, data interface{}) error

Будет возвращена любая ошибка, возникающая при чтении из r. Эта функция поддерживает все типы чисел с фиксированной длиной целочисленные, float и сложные с помощью внутреннего быстрого переключения типа. Для составных типов, таких как структуры и срезы, он возвращается к более медленному декодеру, основанному на отражении. Однако, если вы декодируете составные типы данных, вам может потребоваться более стандартизованные и эффективные протоколы, такие как Protocol Buffers.

На стороне кодирования функция Write () работает противоположным образом, проверяя тип данных и затем кодируя, используя порядок байтов, указанный в order, а затем записывая это данные в w:

func Write(w io.Writer, order ByteOrder, data interface{}) error

К этой функции применяются те же правила, что и к функции Читать ().

Кодирование переменной длины

Проблема с кодировкой фиксированной длины в том, что она может занимать много места. Если вам нужен диапазон типа int64, но большинство ваших значений состоит из небольших чисел, тогда в ваших данных будет много нулевых байтов. Один из способов обойти это ограничение - использовать кодирование переменной длины.

Как это работает

Идея кодирования переменной длины заключается в том, что маленькие числа должны занимать меньше места, чем большие числа. Существуют разные схемы для этого, но бинарный пакет использует ту же реализацию, что и протоколы буферов.

Он работает, используя первый бит каждого байта, чтобы указать, есть ли еще байты для чтения, и оставшиеся 7 бит для фактического хранения данных. Если начальный бит равен 1, продолжить чтение следующего бита, если он равен 0, прекратить чтение. Как только все байты будут прочитаны, объедините все 7-битные биты данных вместе, чтобы получить ваше значение.

Например, для числа 53 (, которое в двоичном виде 110101) требуется 6 бит памяти. Поскольку мы можем хранить 7 бит на байт, требуется только один байт:

00110101

Первый 0 указывает, что нет оставшихся байтов, а оставшийся 0110101 используется для хранения значения.

Однако для такого числа, как 1,732 (что составляет 11011000100 в двоичном формате), это требует 11 бит памяти. Поскольку мы можем хранить 7 бит на байт, нам нужно два байта. Чтобы построить это число, мы установим первый бит первого байта на 1, что указывает на то, что есть еще один байт, который нужно прочитать, и первый бит второго байта на 0, что указывает на то, что нет больше байтов:

10001101 01000100

Если мы удалим ведущие биты из каждого байта, мы получим:

0001101 1000100

Когда мы объединяем это вместе, мы получаем наше исходное значение 11011000100 (что составляет 1,732 в десятичном виде).

Это определенно может показаться запутанным в первые несколько раз, когда вы будете работать над этим. Однако вам не нужно знать внутреннее устройство кодирования переменной длины, чтобы использовать его. В основном это просто интересно понимать.

Кодирование в байтовые срезы

Есть две функции для записи значений переменной длины в байтовые срезы в памяти - PutVarint () и PutUvarint ():

func PutVarint(buf []byte, x int64) int
func PutUvarint(buf []byte, x uint64) int

Они работают, кодируя x в buf и возвращая количество записанных байтов. Если буфер слишком мал, функция запаникует, однако вы можете избежать этого, используя константы MaxVarintLen при выделении вашего буфера.

Также есть две дополнительные функции для обратного чтения данных - Varint () и Uvarint ():

func Varint(buf []byte) (int64, int)
func Uvarint(buf []byte) (uint64, int)

Эти функции декодируют данные в самые большие из доступных целочисленных типов со знаком и без знака - int64 и uint64. Число прочитанных байтов - второй возвращаемый аргумент.

Помните, что начальный бит каждого байта указывает, есть ли еще данные, поэтому вы можете попасть в две ситуации с ошибками. Во-первых, если ваш буфер заканчивается до того, как вы дойдете до байта с начальным нулевым битом. В этом случае количество прочитанных байтов вернет ноль. Вторая ситуация с ошибкой - это когда значение превышает 64 бита данных и не может быть возвращено в 64-битном возвращаемом типе. В этом случае количество прочитанных байтов будет отрицательным.

Декодирование из байтового потока

Двоичный пакет предоставляет способ чтения значений переменной длины из байтового потока, но, как ни странно, не предоставляет дополнительного способа записи значений переменной длины в байтовый поток.

Для декодирования из потока есть две функции - ReadVarint () и ReadUvarint ():

func ReadVarint(r io.ByteReader) (int64, error)
func ReadUvarint(r io.ByteReader) (uint64, error)

Они работают так же, как функции Varint () и Uvarint (), за исключением того, что они извлекают байты по одному, а не работают с байтовым срезом в памяти. Ошибки, возникающие при чтении из потока, будут переданы, а значения, вызывающие 64-битное переполнение, вернут ошибку переполнения.

Заключение

Двоичные протоколы - отличный способ обеспечить низкоуровневую скорость и эффективность вашего взаимодействия. Сначала они могут показаться устрашающими, но в конечном итоге состоят только из нескольких основных концепций. Мы узнали, как порядок байтов определяет порядок байтов, и увидели, как можно сжать значения, используя кодирование переменной длины.

Понимая пакет кодирование / двоичный, мы также открываем мир существующих стандартизированных двоичных протоколов. Все, от видеоформатов до файлов базы данных, использует двоичное кодирование. Я надеюсь, что этот пост проясняет, как работают эти двоичные форматы.

Если вам это понравилось, нажмите ниже, чтобы другие люди увидели это здесь, на Medium.