The Go Blog

Объёмы данных

Роб Пайк
24 марта 2011

Введение

Чтобы передать структуру данных по сети или сохранить её в файле, она должна быть закодирована, а затем снова раскодирована. Конечно, существует множество доступных кодировок: JSON, XML, Google’s протокол-буферы и другие. И теперь есть ещё один способ, предоставляемый пакетом gob в Go.

Почему стоит определять новую кодировку? Это много работы и избыточно. Почему бы не использовать одну из существующих форматов? Ну, во-первых, мы это делаем! Go имеет пакеты, поддерживающие все упомянутые выше кодировки (пакет протокол-буферов находится в отдельном репозитории, но он один из самых часто загружаемых). И для многих целей, включая связь с инструментами и системами, написанными на других языках, они являются правильным выбором.

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

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

Цели

Пакет gob был спроектирован с учётом ряда целей.

Первая, и самая очевидная, заключается в том, чтобы он был очень прост в использовании. Первым делом, поскольку Go имеет рефлексию, нет необходимости в отдельном языке определения интерфейсов или "компиляторе протоколов". Сама структура данных должна быть достаточной для того, чтобы пакет мог понять, как её закодировать и раскодировать. С другой стороны, этот подход означает, что gobs никогда не будут работать так хорошо с другими языками, но это нормально: gobs — это честно ориентированные исключительно на Go.

Эффективность также важна. Текстовые представления, представленные в XML и JSON, слишком медленны, чтобы быть центральным элементом эффективной сетевой связи. Необходима двоичная кодировка.

Потоки gob должны быть самодостаточными. Каждый поток gob, считываемый с начала, содержит достаточную информацию, чтобы весь поток мог быть проанализирован агентом, который ничего не знает заранее о его содержимом. Это свойство означает, что вы всегда сможете раскодировать поток gob, сохранённый в файле, даже спустя долгое время после того, как вы забудете, какие данные он представляет.

Также были изучены некоторые особенности Google protocol buffers.

Недостатки протокол-буферов

Протокол-буферы оказали значительное влияние на дизайн gobs, но имеют три функции, которые были намеренно избегнуты. (Оставляя в стороне тот факт, что протокол-буферы не являются самодостаточными: если вы не знаете определения данных, использованного для кодирования протокол-буфера, возможно, вы не сможете его разобрать.)

Во-первых, protocol buffers работают только с типом данных, который в Go называется структурой. Невозможно закодировать целое число или массив на верхнем уровне, можно закодировать только структуру с полями внутри. Кажется, это бессмысленное ограничение, по крайней мере в Go. Если всё, что вы хотите отправить — это массив целых чисел, почему вы должны сначала поместить его в структуру?

Во-вторых, определение protocol buffer может указывать, что поля T.x и T.y должны присутствовать всегда, когда значение типа T кодируется или декодируется. Хотя такие обязательные поля могут показаться хорошей идеей, их реализация требует затрат, потому что кодек должен поддерживать отдельную структуру данных во время кодирования и декодирования, чтобы иметь возможность сообщать о пропущенных обязательных полях. Это также проблема сопровождения. Со временем может возникнуть необходимость изменить определение данных, удалив обязательное поле, но это может привести к сбою существующих клиентов данных. Лучше вообще не использовать их в кодировке. (Protocol buffers также имеют необязательные поля. Но если у нас нет обязательных полей, все поля считаются необязательными, и всё. Позднее будет больше сказано о необязательных полях.)

Третье недостаточное свойство protocol buffer — это значения по умолчанию. Если protocol buffer опускает значение для поля с «значением по умолчанию», то декодированная структура ведёт себя так, как будто поле установлено в это значение. Эта идея хорошо работает, когда у вас есть методы получения и установки значений для управления доступом к полю, но сложнее реализовать чисто, если контейнер — это просто обычная структура. Обязательные поля также сложно реализовать: где определяются значения по умолчанию, какие типы они имеют (текст в кодировке UTF-8? неинтерпретированные байты? сколько бит в числе с плавающей точкой?), и несмотря на кажущуюся простоту, в проектировании и реализации protocol buffers возникло множество сложностей. Мы решили исключить их из gobs и вернуться к тривиальному, но эффективному правилу Go для значений по умолчанию: если вы не установили что-то другое, то оно имеет «нулевое значение» для этого типа — и его не нужно передавать.

Таким образом, gobs выглядят как обобщённый и упрощённый protocol buffer. Как они работают?

Значения

Закодированные данные gob не связаны с типами, такими как int8 и uint16. Вместо этого, аналогично константам в Go, целочисленные значения абстрактны, безразмерны, могут быть как знаковыми, так и беззнаковыми. При кодировании int8 его значение передаётся как безразмерное, переменной длины целое число. При кодировании int64 его значение также передаётся как безразмерное, переменной длины целое число. (Знаковые и беззнаковые значения обрабатываются отдельно, но то же самое безразмерное свойство применяется и к беззнаковым значениям.) Если оба имеют значение 7, биты, переданные по сети, будут идентичны. Когда получатель декодирует это значение, он помещает его в переменную получателя, которая может быть любого целочисленного типа. Таким образом, кодировщик может отправить 7, которое пришло из int8, но получатель может сохранить его в int64. Это допустимо: значение является целым числом, и пока оно помещается, всё работает. (Если оно не помещается, возникает ошибка.) Такое отсоединение от размера переменной даёт некоторую гибкость кодировке: мы можем расширять тип целочисленной переменной по мере развития программного обеспечения, но всё ещё сможем декодировать старые данные.

Эта гибкость также распространяется на указатели. Перед передачей все указатели сокращаются. Значения типа int8, *int8, **int8, ****int8 и так далее передаются как целочисленные значения, которые затем могут быть сохранены в int любого размера, или *int, или ******int и так далее. Опять же, это позволяет сохранить гибкость.

Гибкость также возникает из-за того, что при декодировании структуры сохраняются только те поля, которые были отправлены кодировщиком. Учитывая значение

<code>type T struct{ X, Y, Z int } // Только экспортируемые поля кодируются и декодируются.
var t = T{X: 7, Y: 0, Z: 8}
</code>

кодирование t отправляет только 7 и 8. Поскольку значение Y равно нулю, оно даже не отправляется; незачем отправлять нулевое значение.

Получатель может вместо этого декодировать значение в следующую структуру:

<code>type U struct{ X, Y *int8 } // Примечание: указатели на int8
var u U
</code>

и получить значение u с установленным только полем X (в адрес переменной int8, установленной в 7); поле Z игнорируется — куда его поместить? При декодировании структур поля сопоставляются по имени и совместимому типу, и затрагиваются только те поля, которые существуют и в исходной, и в целевой структуре. Этот простой подход позволяет обойти проблему «необязательных полей»: по мере развития типа T добавлением новых полей, устаревшие получатели всё ещё смогут работать с тем, что они знают. Таким образом, gobs обеспечивают важный результат — необязательные поля — без каких-либо дополнительных механизмов или синтаксиса.

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

Одной из удобных особенностей gobs, которую позволяет реализовать Go, является возможность определить собственную кодировку, если тип реализует интерфейсы GobEncoder и GobDecoder, аналогично интерфейсам JSONMarshaler и Unmarshaler, а также интерфейсу Stringer из пакета fmt. Эта возможность позволяет представлять специфические особенности, обеспечивать ограничения или скрывать данные при передаче. Подробности см. в документации.

Типы в потоке данных

В первый раз, когда вы передаёте определённый тип, пакет gob включает в поток данных описание этого типа. На самом деле, происходит следующее: кодировщик используется для кодирования в стандартном формате gob внутренней структуры, описывающей тип, и присваивает ему уникальный номер. (Базовые типы, а также макет структуры описания типа предопределены программным обеспечением для инициализации.) После того как тип описан, его можно ссылаться по номеру типа.

Таким образом, когда мы отправляем первый тип T, кодировщик gob отправляет описание типа T и помечает его номером типа, например 127. Все значения, включая первое, предваряются этим номером, поэтому поток значений типа T выглядит следующим образом:

<code>("define type id" 127, definition of type T)(127, T value)(127, T value), ...
</code>

Эти номера типов позволяют описывать рекурсивные типы и отправлять значения этих типов. Таким образом, gobs может кодировать такие типы, как деревья:

<code>type Node struct {
  Value       int
  Left, Right *Node
}
</code>

(Упражнение для читателя — выяснить, как правило нулевого заполнения делает это возможным, даже несмотря на то, что gobs не представляет указатели.)

С информацией о типах поток gob полностью самодостаточен, за исключением набора начальных типов (bootstrap types), который представляет собой хорошо определенную начальную точку.

Компиляция машины

В первый раз, когда вы кодируете значение заданного типа, пакет gob строит небольшую интерпретируемую машину, специфичную для этого типа данных. Он использует рефлексию над типом для построения этой машины, но как только машина построена, она не зависит от рефлексии. Машина использует пакет unsafe и некоторые хитрости для преобразования данных в закодированные байты с высокой скоростью. Она могла бы использовать рефлексию и избежать unsafe, но была бы значительно медленнее. (Аналогичный высокоскоростной подход используется в поддержке protocol buffer для Go, чья архитектура была вдохновлена реализацией gobs.) Последующие значения того же типа используют уже скомпилированную машину, поэтому их можно кодировать немедленно.

[Обновление: начиная с Go 1.4, пакет unsafe больше не используется пакетом gob, с незначительным снижением производительности.]

Декодирование аналогично, но сложнее. Когда вы декодируете значение, пакет gob хранит срез байтов, представляющий значение заданного типом кодировщика, который нужно декодировать, плюс значение Go, в которое нужно декодировать. Пакет gob строит машину для этой пары: тип gob, отправленный по сети, пересечённый с типом Go, предоставленным для декодирования. Как только эта машина декодирования построена, она снова становится движком без рефлексии, который использует unsafe методы для достижения максимальной скорости.

Использование

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

<code>package main
import (
  "bytes"
  "encoding/gob"
  "fmt"
  "log"
)
type P struct {
  X, Y, Z int
  Name    string
}
type Q struct {
  X, Y *int32
  Name string
}
func main() {
  // Initialize the encoder and decoder.  Normally enc and dec would be
  // bound to network connections and the encoder and decoder would
  // run in different processes.
  var network bytes.Buffer        // Stand-in for a network connection
  enc := gob.NewEncoder(&network) // Will write to network.
  dec := gob.NewDecoder(&network) // Will read from network.
  // Encode (send) the value.
  err := enc.Encode(P{3, 4, 5, "Pythagoras"})
  if err != nil {
    log.Fatal("encode error:", err)
  }
  // Decode (receive) the value.
  var q Q
  err = dec.Decode(&q)
  if err != nil {
    log.Fatal("decode error:", err)
  }
  fmt.Printf("%q: {%d,%d}\n", q.Name, *q.X, *q.Y)
}
</code>

Вы можете скомпилировать и запустить этот пример кода в Go Playground.

Пакет rpc использует gobs для преобразования этой автоматизации кодирования/декодирования в транспорт для вызова методов через сеть. Это тема для другой статьи.

Подробности

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

Следующая статья: Godoc: документирование Go кода
Предыдущая статья: C? Go? Cgo!
Индекс блога

GoRu.dev Golang на русском

На сайте представлена адаптированная под русский язык документация языка программирования Golang