В прошлых постах вы могли прочитать о том, как сериализовать объекты в языке 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:
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 и соответствующей десериализации:
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:
В начало файла с объявлением типов дописываем:
package types
//go:generate codecgen -o types.gen.go types.go
// … дальше все как было раньше …
Заметьте, что пустая строчка перед package types
является обязательной. Как минимум, без нее будет ругаться go test
. Затем говорим:
go generate . / …
Появится файл types/types.gen.go, содержащий реализацию следующего интерфейса для всех наших типов:
CodecEncodeSelf ( * Encoder )
CodecDecodeSelf ( * Decoder )
}
Основной код приложения при этом остается прежним. При вызове методов Encode
и Decode
библиотека проверяет, реализован ли для типа интерфейс Selfer
. Если он реализован, то сериализация и десериализация осуществляются при помощи соответствующих методов.
Fun fact! По-видимому, интерфейс называется Selfer
, потому что реализующие его типы как бы умеют фотографировать сами себя. То есть, умеют делать селфи.
С одной стороны, это очень удобно, что можно просто сделать go generate
, и код становится быстрее. Но с другой, возникает опасность случайно забыть вызвать go generate
. В этом случае будет получен код, который работает и проходит тесты, но работает медленнее, чем мы думаем. Чтобы защититься от этого, следует добавить следующий тест:
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 . Вопросы и дополнения, как обычно, всячески приветствуются.