Довольно часто в приложениях мы используем для конфигурации 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.