JSON с опциональными полями в Go +5


AliExpress RU&CIS

Перевод статьи подготовлен специально для будущих студентов курса "Golang Developer. Professional".


Один из наиболее распространенных видов данных, хранящихся в конфигурационных файлах, - это опции. В этой статье я расскажу о некоторых нюансах, которые необходимо учитывать при сохранении опций в JSON и их анмаршалинге в Go.

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

Основы - частичный анмаршалинг, omitempty и неизвестные поля

Начнем с основ. Рассмотрим следующую структуру, которая представляет из себя опции произвольной программы:

type Options struct {
  Id      string `json:"id,omitempty"`
  Verbose bool   `json:"verbose,omitempty"`
  Level   int    `json:"level,omitempty"`
  Power   int    `json:"power,omitempty"`
}

Эта структура содержит всего 4 опции, но в реальных программах их могут быть десятки.

Предположим, мы хотим указать эти опции в JSON-файле конфигурации. Полный список опций может выглядеть примерно так:

{
  "id": "foobar",
  "verbose": false,
  "level": 10,
  "power": 221
}

Если в ваших файлах конфигурации всегда указаны все опции, то говорить дальше особо не о чем. Просто вызовите json.Unmarshal, и дело с концом.

На практике же достаточно редко все бывает так просто. Нам следует обработать сразу несколько особых случаев:

  1. В JSON-конфиге могут отсутствовать некоторые поля, и мы хотим, чтобы наша структура в Go имела для них значения по умолчанию.

  2. JSON-конфиг может иметь дополнительные поля, которых в нашей структуре нет. В зависимости от обстоятельств мы можем либо проигнорировать их, либо отрапортовать об ошибке.

В случае (1) пакет json Go будет присваивать значения только полям, найденным в JSON; другие поля просто сохранят свои нулевые значения Go. Например, если бы в JSON вообще не было поля level, в анмаршаленной структуре Options Level будет равен 0. Если такое поведение нежелательно для вашей программы, переходите к следующему разделу.

В случае (2) пакет json по умолчанию поведет себя вполне терпимо и просто проигнорирует неизвестные поля. То есть предположим, что входной JSON:

{
  "id": "foobar",
  "bug": 42
}

json.Unmarshal без проблем распарсит это в Options, установив Id в значение "foobar", Level и Power в 0, а Verbose в false. Он проигнорирует поле bug.

В одних случаях такое поведение является желательным, в других - нет. К счастью, пакет json позволяет настроить это, предоставляя явную опцию для JSON-декодера в виде DisallowUnknownFields:

dec := json.NewDecoder(bytes.NewReader(jsonText))
dec.DisallowUnknownFields()

var opts Options
if err := dec.Decode(&opts2); err != nil {
  fmt.Println("Decode error:", err)
}

Теперь парсинг вышеупомянутого фрагмента JSON приведет к ошибке.

Наконец, вы можете заметить, что наша структура Options имеет тег omitempty, указанный для всех полей. Это означает, что поля с нулевыми значениями не будут внесены в JSON. Например:

opts := Options{
  Id:    "baz",
  Level: 0,
}
out, _ := json.MarshalIndent(opts, "", "  ")
fmt.Println(string(out))

Выведет:

{
  "id": "baz"
}

Потому что все остальные поля имеют нулевые значения. Если вы напротив хотите всегда вносить все поля, не указывайте omitempty.

Установка значений по умолчанию

В приведенном выше примере мы видели, что отсутствующие в JSON-представлении поля будут преобразованы в нулевые значения Go. Это нормально, если значения ваших параметров по умолчанию также являются их нулевыми значениями, что не всегда так. Что, если значение по умолчанию Power должно быть 10, а не 0? То есть, когда JSON не имеет поля «power», вы хотите установить Power равным 10, но вместо этого Unmarshal устанавливает его в ноль.

Вы можете подумать - это же элементарно! Я буду устанавливать Power в его значение умолчанию 10 всякий раз, когда он маршалится из JSON как 0! Но подождите. Что произойдет, если в JSON указано значение 0?

На самом деле, эта проблема решается наоборот. Мы установим значения по умолчанию сначала, а затем позволим json.Unmarshal перезаписать поля по мере необходимости:

func parseOptions(jsn []byte) Options {
  opts := Options{
    Verbose: false,
    Level:   0,
    Power:   10,
  }
  if err := json.Unmarshal(jsn, &opts); err != nil {
    log.Fatal(err)
  }
  return opts
}

Теперь вместо прямого вызова json.Unmarshal на Options, нам придется вызывать parseOptions.

В качестве альтернативы мы можем хитро спрятать эту логику в пользовательском методе UnmarshalJSON для Options:

func (o *Options) UnmarshalJSON(text []byte) error {
  type options Options
  opts := options{
    Power: 10,
  }
  if err := json.Unmarshal(text, &opts); err != nil {
    return err
  }
  *o = Options(opts)
  return nil
}

В этом методе любой вызов json.Unmarshal для типа Options будет заполнять значение по умолчанию Power правильно. Обратите внимание на использование псевдонимного типа options - это нужно для предотвращения бесконечной рекурсии в UnmarshalJSON.

Этот подход прост и понятен, но у него есть некоторые недостатки. Во-первых, он прочно связывает значения полей по умолчанию с логикой парсинга. Вполне возможно, что мы хотим, чтобы пользовательский код устанавливал значения по умолчанию позже; прямо сейчас значения по умолчанию нужно установить перед анмаршалингом.

Второй недостаток заключается в том, что это работает только для простых случаев. Если наша структура Options содержит срез или мапу других структур, мы не сможем проставить значения по умолчанию таким образом. Примите во внимание:

type Region struct {
  Name  string `json:"name,omitempty"`
  Power int    `json:"power,omitempty"`
}

type Options struct {
  Id      string `json:"id,omitempty"`
  Verbose bool   `json:"verbose,omitempty"`
  Level   int    `json:"level,omitempty"`
  Power   int    `json:"power,omitempty"`

  Regions []Region `json:"regions,omitempty"`
}

Если мы хотим заполнить значения по умолчанию для Power каждой Region, мы не сможем сделать это на уровне Options. Мы должны написать собственный метод анмаршалинга для Region. Это сложно масштабировать для произвольно вложенных структур - распространение нашей логики значений по умолчанию на несколько методов UnmarshalJSON не оптимально.

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

Значения по умолчанию и поля-указатели

Мы можем определить нашу структуру Options как:

type Options struct {
  Id      *string `json:"id,omitempty"`
  Verbose *bool   `json:"verbose,omitempty"`
  Level   *int    `json:"level,omitempty"`
  Power   *int    `json:"power,omitempty"`
}

Это очень напоминает исходное определение, за исключением того, что все поля теперь являются указателями. Предположим, у нас есть следующий текст JSON:

{
  "id": "foobar",
  "verbose": false,
  "level": 10
}

Обратите внимание, что указаны все поля, кроме "power". Мы можем анмаршалить это как обычно:

var opts Options
if err := json.Unmarshal(jsonText, &opts); err != nil {
  log.Fatal(err)
}

Но теперь мы можем четко различить поля, которые не были указаны вообще (они будут анмаршалены в nil указатель), и поля, которые были указаны с нулевыми значениями (они будут анмаршалены в валидные указатели на значения с нулевыми значениями). Например, мы можем написать следующую обертку парсера для анмаршалинга Options и установки значений по умолчанию по мере необходимости:\

func parseOptions(jsn []byte) Options {
  var opts Options
  if err := json.Unmarshal(jsonText, &opts); err != nil {
    log.Fatal(err)
  }

  if opts.Power == nil {
    var v int = 10
    opts.Power = &v
  }

  return opts
}

Обратите внимание, как мы устанавливаем opts.Power; это одно из неудобств работы с указателями, потому что в Go нет синтаксиса, позволяющего принимать адреса литералов встроенных типов, таких как int. Однако это не слишком большая проблема, поскольку существуют простые вспомогательные функции, которые могут сделать нашу жизнь чуть более приятной:

func Bool(v bool) *bool       { return &v }
func Int(v int) *int          { return &v }
func String(v string) *string { return &v }
// и т.д. ...

Имея это под рукой, мы могли бы просто написать opts.Power = Int(10).

Самая полезная особенность этого подхода заключается в том, что он не заставляет нас назначать значения по умолчанию в месте, где парсится JSON. Мы можем передать Options в пользовательский код, и пусть уже он разбирается со значениями по умолчанию, когда ему встречаются поля с nil.

Так являются ли указатели волшебным решением нашей проблемы - «отличить неопределенные значения от нулевых значений»? Вроде того. Указатели, безусловно, являются жизнеспособным решением, которое должно хорошо работать. Официальный пакет Protobuf использует тот же подход для protobuf-ов proto2, проводящих различие между необходимыми и опциональными полями. Так что этот метод прошел проверку боем!

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

Узнать подробнее о курсе.

Теги:

go, json



Комментарии (2):

  1. evnuh
    /#22337994

    А ещё можно использовать https://github.com/creasty/defaults и писать дефолтные значения прямо в тэгах рядом с json тэгами

  2. QtRoS
    /#22339612

    Хорошая статья, только не раскрыт маршаллинг пустых slice и map. Обсуждение по теме: GitHub.