Перевод статьи подготовлен специально для будущих студентов курса "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
, и дело с концом.
На практике же достаточно редко все бывает так просто. Нам следует обработать сразу несколько особых случаев:
В JSON-конфиге могут отсутствовать некоторые поля, и мы хотим, чтобы наша структура в Go имела для них значения по умолчанию.
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 большую часть времени действительно хорошо скрывает дополнительную синтаксическую нагрузку при работе с указателями, в некоторых случаях немного возни все же просачивается (например, получение адреса встроенного литерала, как показано выше). Еще одна потенциальная проблема - производительность. Указатели часто означают динамическое выделение памяти и могут вызывать проблемы с производительностью в некоторых сценариях, хотя, если говорить о структурах параметров, это вряд ли станет проблемой.
Узнать подробнее о курсе.