Unmarshalling yaml/json конфигурации с динамической структурой в GO
Довольно часто в приложениях мы используем для конфигурации json или yaml файлы. Это довольно удобный и простой способ задать огромное количество параметров при запуске, не передавая их через аргументы или не вводя вручную. Пока параметров немного, файлы выглядят очень понятно и аккуратно. Но как показывает практика, со временем они обрастают огромным количеством дублирующегося кода или сложных структур, вй которых заполнено только одно поле. В этой статье мы рассмотрим пример, как это исправить и дать пользователю возможность более гибко и просто использовать конфигурацию.
JSON
И так, для примера представим, что у нас есть вот такая структура, описывающая какой-то скрипт:
type Script struct {
Commands []string `json:"commands"`
Env map[string]string `json:"env"`
Dir string `json:"dir"`
}
И давайте просто распарсим json в эту структуру c помощью стандартного пакета encoding/json
:
package main
import (
"encoding/json"
"fmt"
)
type Script struct {
Commands []string `json:"commands"`
Env map[string]string `json:"env"`
Dir string `json:"dir"`
}
func main() {
src := `{
"commands":[
"command1",
"command2"
],
"env": {
"demo1":"values",
"demo2":"value1"
},
"dir": "/usr/test"
}`
var script Script
err := json.Unmarshal([]byte(src), &script)
if err != nil {
panic(err)
}
fmt.Print(script)
}
Если посмотреть немного внимательнее, то основные данные, которые мы хочем отсюда извлечь, это содержимое массива commands. И таким образом, при использовании этой структуры напрямую мы в 80% будем получать нечто такое в json файле:
{
"script1": {
"commands":[ "command1", "command2" ]
},
"script2": {
"commands":[ "command1", "command2" ]
},
"script3": {
"commands":["command1"]
},
"script4": {
"commands":[
"command1",
"command2",
"command3",
"command4"
]
},
"script5": {
"commands":[
"command1",
"command2"
],
"env": {
"demo1":"values",
"demo2":"value1"
},
"dir": "/usr/test"
},
}
Большинство скриптов просто не используют поля env
и dir
, следовательно, было бы логично дать пользователю возможность сокращенной записи,скажем, вот так:
{
"script1": [ "command1", "command2" ],
"script2": [ "command1", "command2" ],
"script3": "command1",
"script4": [
"command1",
"command2",
"command3",
"command4"
],
"script5": {
"commands":[
"command1",
"command2"
],
"env": {
"demo1":"values",
"demo2":"value1"
},
"dir": "/usr/test"
}
}
Такая конфигурация более читаема, и она не потеряла своей информативности и гибкости. Так давайте же это реализуем.
Во-первых, давайте напишем тесты на кейсы, которые мы хочем обрабатывать. Первый - это парсинг полной записи со всеми полями:
func TestFullForm(t *testing.T) {
src := `{
"commands":[
"command1",
"command2"
],
"env": {
"demo1":"values",
"demo2":"value1"
},
"dir": "/usr/test"
}`
var script Script
err := json.Unmarshal([]byte(src), &script)
assert.NoError(t, err)
assert.ObjectsAreEqualValues(script, Script{
Commands: []string{"command1", "command2"},
Env: map[string]string{
"demo1": "values",
"demo2": "value1",
},
Dir: "/usr/test",
})
}
Для удобной проверки результатов я использую пакет testify/assert.
Второй тест- это заполнение структуры из массива:
func TestParseFromArray(t *testing.T) {
src := `["command1", "command2"]`
var script Script
err := json.Unmarshal([]byte(src), &script)
assert.NoError(t, err)
assert.ObjectsAreEqualValues(script, Script{
Commands: []string{"command1", "command2"},
})
}
И последний кейс - строка:
func TestParseFromString(t *testing.T) {
src := `"command1"`
var script Script
err := json.Unmarshal([]byte(src), &script)
assert.NoError(t, err)
assert.ObjectsAreEqualValues(script, Script{
Commands: []string{"command1"},
})
}
Если сейчас мы запустим тесты, то получим следующий вывод:
=== RUN TestFullForm
--- PASS: TestFullForm (0.00s)
=== RUN TestParseFromArray
prog.go:49:
Error Trace: prog.go:49
Error: Received unexpected error:
json: cannot unmarshal array into Go value of type main.Script
Test: TestParseFromArray
--- FAIL: TestParseFromArray (0.00s)
=== RUN TestParseFromString
prog.go:61:
Error Trace: prog.go:61
Error: Received unexpected error:
json: cannot unmarshal string into Go value of type main.Script
Test: TestParseFromString
--- FAIL: TestParseFromString (0.00s)
FAIL
Пакет encoding/json
реализует возможность переопределить парсинг для вашего типа. Для этого необходимо реализовать интерфейс json.Unmarshaler
он достаточно прост:
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
Суть метода UnmarshalJSON
заключается в том ,чтобы заполнить структуру на основе переданных байт или , если это невозможно, вернуть ошибку.
Давайте начнем со строки. Для этого вызовем опять метод json.Unmarshal
, передадим туда полученные данные и заготовленную строку , затем на основе данных заполним нашу структуру
func (script *Script) UnmarshalJSON(data []byte) error {
var singleCommand string
err := json.Unmarshal(data, &singleCommand);
if err == nil {
script = &Script{
Commands: []string{singleCommand},
}
}
return err
}
Теперь, если мы запустим тест TestParseFromString
, он пройдет успешно:
=== RUN TestParseFromString
--- PASS: TestParseFromString (0.00s)
PASS
Аналогичным образам поступаем и с массивом строк, только делаем это в том случае, если попытка распарсить из строки не увенчалась успехом.
func (script *Script) UnmarshalJSON(data []byte) error {
var singleCommand string
err := json.Unmarshal(data, &singleCommand);
if err == nil {
script = &Script{
Commands: []string{singleCommand},
}
return nil
}
var multiCommand []string
err = json.Unmarshal(data, &multiCommand)
if err == nil {
script = &Script{
Commands: multiCommand,
}
}
return err
}
Теперь, казалось бы, осталось только распарсить полную структуру вот таким образом и все будет отлично:
func (script *Script) UnmarshalJSON(data []byte) error {
var singleCommand string
err := json.Unmarshal(data, &singleCommand);
if err == nil {
script = &Script{
Commands: []string{singleCommand},
}
return nil
}
var multiCommand []string
if err := json.Unmarshal(data, &multiCommand); err == nil {
script = &Script{
Commands: multiCommand,
}
return nil
}
return json.Unmarshal(data, script)
}
Но при запуске тесты покажут:
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc020161360 stack=[0xc020160000, 0xc040160000]
fatal error: stack overflow
runtime stack:
runtime.throw(0x10ce0fe, 0xe)
C:/Go/src/runtime/panic.go:1116 +0x79
runtime.newstack()
C:/Go/src/runtime/stack.go:1067 +0x791
runtime.morestack()
C:/Go/src/runtime/asm_amd64.s:449 +0x97
goroutine 6 [running]:
......
......
......
exit status 2
FAIL main 4.940s
Это происходит из-за того, что при попытке анмаршалинга в последней строке, парсер снова входит в нашу функцию UnmarshalJSON
и порождает бесконечную рекурсию. К счастью, существует решение этого всего в 2 строки: это объявить вспомогательный тип эквивалентный требуемому.
func (script *Script) UnmarshalJSON(data []byte) error {
// other code
type plain Script
return json.Unmarshal(data, (*plain)(script))
}
Это работает, потому что мы приводим указатель типа Script к указателю типа plain. Так как структуры полностью одинаковые, получается, что мы можем через указатели разных типов заполнять одну структуру. Так как для типа plain
нету определенного метода UnmarshalJSON
,а значит пакет легко распарсить все поля сам. А поскольку указатель ссылается на ту же память, что и script *Script
, то и наша основная структура будет заполнена.
И все тесты будут пройдены:
=== RUN TestFullForm
--- PASS: TestFullForm (0.00s)
=== RUN TestParseFromArray
--- PASS: TestParseFromArray (0.00s)
=== RUN TestParseFromString
--- PASS: TestParseFromString (0.00s)
PASS
Полный код примера можно найти по этой ссылке. Или посмотреть живой пример на goplay.space
YAML
Для парсинга yaml используется пакет gopkg.in/yaml.v3. Там же используется полностью аналогичный подход. Единственное отличие состоит в том, что метод UnmarshalYAML
принимает не байты данных, а функцию замыкание, которая парсит переданную структуру либо возвращает ошибку. Итоговая функция имеет следующий вид:
func (script *Script) UnmarshalYAML(unmarshal func(interface{}) error) error {
var singleCommand string
if err := unmarshal(&singleCommand); err == nil {
script = &Script{
Commands: []string{singleCommand},
}
return nil
}
var multiCommand []string
if err := unmarshal(&multiCommand); err == nil {
script = &Script{
Commands: multiCommand,
}
return nil
}
type plain Script
return unmarshal((*plain)(script))
}
Полный код можно найти по этой ссылке, или посмотреть живой пример на goplay.space.