Сериализация в языке Go на примере библиотеки codec
24 декабря 2018
В прошлых постах вы могли прочитать о том, как сериализовать объекты в языке 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. Вопросы и дополнения, как обычно, всячески приветствуются.
Метки: Go.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.