← На главную

Сериализация в языке Go на примере библиотеки codec

В прошлых постах вы могли прочитать о том, как сериализовать объекты в языке C++, используя формат Protobuf, а также в языке Scala, используя Thrift. Была рассмотрена даже такая эзотерика, как формат MessagePack и работа с ним на языке Haskell. Давайте же теперь выясним, как делается сериализация в языке Go. Для этого мы воспользуемся форматом CBOR и библиотекой codec.

Concise Binary Object Representation или CBOR – это формат, придуманный с целью минимизации кода, отвечающего за сериализацию и десериализацию объектов. Это может быть не лишено смысла, например, при разработке встраиваемых систем. Само собой разумеется, сериализованные объекты при этом также получаются довольно компактными. В отличие от Thrift и Protobuf, CBOR не использует схемы. Можно думать о CBOR, как о чем-то вроде бинарного JSON. Этим формат схож с MessagePack. Но в отличие от MessagePack, CBOR стандартизован и описан в RFC 7049. CBOR является расширяемым в том смысле, что будущие RFC могут вводить поддержку новых типов. Из типов, отсутствующих в JSON, на данный момент CBOR поддерживает, например, даты и большие числа.

Библиотека codec разработана и поддерживается Ugorji Nwoke. Библиотека имеет лицензию MIT, и написана, естественно, на Go. В ней поддерживается несколько форматов – JSON, CBOR, MessagePack и Binc. В рамках этого поста речь пойдет исключительно о CBOR, но использовать библиотеку для работы с другими форматами ничем не сложнее.

Как и в предыдущих подобных постах, сериализовывать будем героев для какой-нибудь RPG:

// файл types/types.go package types type Spell int const ( FIREBALL Spell = iota THUNDERBOLT Spell = iota ) type Weapon int const ( SWORD Weapon = iota BOW Weapon = iota ) type WarriorInfo struct { Weapon Weapon `codec:"w"` ArrowsNumber int `codec:"a"` } type MageInfo struct { Spellbook []Spell `codec:"s"` Mana int `codec:"m"` } type Hero struct { Name string `codec:"n"` HP int `codec:"h"` XP int `codec:"x"` WarriorInfo *WarriorInfo `codec:"w"` MageInfo *MageInfo `codec:"m"` }

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

Заметьте также, как в структуре Hero было сделано подобие типов-сумм. Если бы не сериализация, мы могли бы хранить специфичную для конкретного класса информацию, как interface{}. Определить, какого именно класса является герой, нам помогли бы type switches. Библиотека codec даже способна успешно такое сериализовать. Однако для корректной десериализации нужно инициализировать поле с типом interface{} соответствующим zero value, а мы его заранее не знаем. Это не нерешаемая проблема. Например, можно записать информацию о классе героя перед сериализованным объектом. Но я решил не усложнять пример, и потому использовал тупо два указателя. Заинтересованным читателям предлагается реализовать описанное в качестве упражнения.

Пример сериализации в CBOR и соответствующей десериализации:

// файл main.go package main import ( "github.com/ugorji/go/codec" . "github.com/afiskon/golang-codec-example/types" "log" ) func main() { var ( cborHandle codec.CborHandle err error ) //v1 := Hero{ "Alex", 123, 456, &WarriorInfo{ BOW, 10 }, nil} v1 := Hero{ "Bob", 234, 567, nil, &MageInfo{ []Spell{FIREBALL, THUNDERBOLT}, 42 } } var bs []byte enc := codec.NewEncoderBytes(&bs, &cborHandle) err = enc.Encode(v1) if err != nil { log.Fatalf("enc.Encode() failed, err = %v", err) } log.Printf("bs = %q, len = %d, cap = %d", bs, len(bs), cap(bs)) // Decode bs to v2 var v2 Hero dec := codec.NewDecoderBytes(bs, &cborHandle) err = dec.Decode(&v2) if err != nil { log.Fatalf("dec.Decode() failed, err = %v", err) } log.Printf("v2 = %v", v2) if v2.WarriorInfo != nil{ log.Printf("WarriorInfo = %v", *v2.WarriorInfo) } if v2.MageInfo != nil { log.Printf("MageInfo = %v", *v2.MageInfo) } }

Запускаем и убеждаемся, что код отлично работает. Однако можно сделать еще лучше. Дело в том, что работа приведенного кода основана на рефлексии. Библиотека codec также умеет генерировать код сериализатора и десериализатора. Простой синтетический ничего на практике не означающий бенчмарк показал увеличение производительности при таком подходе на 30%.

Для генерации кода нам понадобится утилита codecgen:

go get -u github.com/ugorji/go/codec/codecgen

В начало файла с объявлением типов дописываем:

//+build !test generate package types //go:generate codecgen -o types.gen.go types.go // ... дальше все как было раньше ...

Заметьте, что пустая строчка перед package types является обязательной. Как минимум, без нее будет ругаться go test. Затем говорим:

# три точки в мире Go означают рекурсию по подкаталогам go generate ./...

Появится файл types/types.gen.go, содержащий реализацию следующего интерфейса для всех наших типов:

type Selfer interface { CodecEncodeSelf(*Encoder) CodecDecodeSelf(*Decoder) }

Основной код приложения при этом остается прежним. При вызове методов Encode и Decode библиотека проверяет, реализован ли для типа интерфейс Selfer. Если он реализован, то сериализация и десериализация осуществляются при помощи соответствующих методов.

Fun fact! По-видимому, интерфейс называется Selfer, потому что реализующие его типы как бы умеют фотографировать сами себя. То есть, умеют делать селфи.

С одной стороны, это очень удобно, что можно просто сделать go generate, и код становится быстрее. Но с другой, возникает опасность случайно забыть вызвать go generate. В этом случае будет получен код, который работает и проходит тесты, но работает медленнее, чем мы думаем. Чтобы защититься от этого, следует добавить следующий тест:

// часть файла types/types_test.go func implementsSelferInterface(obj codec.Selfer) bool { return true } // make sure user didn't forget to run `go generate ./...` // according to README.md func TestSerialization(t *testing.T) { hero := Hero{ "Alex", 123, 456, &WarriorInfo{ BOW, 10 }, nil} res := implementsSelferInterface(&hero) if !res { t.FailNow() } }

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

В контексте сериализации важно понимать, что происходит при изменении структур между версиями приложения. К примеру, подумать про случай, когда часть серверов обновилась, а часть еще нет, и в итоге две версии приложения обмениваются структурами разных версий. Или случай, когда мы сначала обновили приложение, потом решили откатить обновление, и теперь приложение читает с диска структуры как прошлых, так и будущих версий. Библиотека codec работает следующий образом. Лишние поля игнорируются, недостающим присваивается zero value. Дополнительно будет не самой плохой идеей включать в структуры их версии. Тогда версию не придется угадывать по наличию или отсутствию полей, а миграцию можно будет осуществить даже в случае, когда поля не менялись, но менялся их смысл и/или допустимые значения.

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