Сериализация в языке 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:

// файл 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. Вопросы и дополнения, как обычно, всячески приветствуются.

Метки: .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.