Конфигурация программ на Go
Вступление
Всем привет! После пяти лет программирования на Go я обнаружил себя достаточно ярым приверженцем определенного подхода к конфигурации программ. В этой статье я попытаюсь раскрыть его основные идеи, а также поделюсь небольшой библиотекой, которая является реализацией этих идей.
Очевидно, что статья весьма субъективна и не претендует на звание объективной истины. Однако, надеюсь, она может быть полезна сообществу и поможет сократить время, затраченное на такую тривиальную задачу.
О чем речь?
Совсем в общем, конфигурация, на мой взгляд, это определение переменных нашей программы, значения которых мы можем получить извне уже во время выполнения. Это могут быть аргументы или параметры командной строки, переменные окружения, конфигурационные файлы, хранящиеся на диске или где-либо в сети, таблицы базы данных и так далее.
Поскольку Go это язык со строгой статической типизацией, нам бы хотелось определять и получать значения для таких переменных с учетом их типа.
Существует большое количество open-source библиотек или даже фреймворков, решающих подобные задачи. Большинство из них представляют собственное видение того, как это делать.
Я хотел бы поговорить о менее распространенном подходе к конфигурации программ. Тем более, что этот подход мне кажется наиболее простым.
Пакет flag
Да, это не шутка и я действительно хочу обратить ваше внимание на всем известный пакет стандартной библиотеки Go.
На первый взгляд, flag
это инструмент для работы с параметрами командной
строки и не более. Но этот пакет также может быть использован как интерфейс
определения параметров нашей программы. И в контексте обсуждаемого подхода
flag
прежде всего используется именно так.
Как было сказано выше, нам бы хотелось иметь типизированные параметры.
Пакет flag
предоставляет возможность делать это для большинства базовых типов
– flag.String()
, flag.Int()
и даже flag.Duration()
.
Для более сложных типов, вида []string
или time.Time
существует интерфейс
flag.Value
, который позволяет описать получение значения параметра из его
строкового представления.
Например, параметр типа time.Time
можно реализовать так:
// TimeValue is an implementation of flag.Value interface.
type TimeValue struct {
P *time.Time
Layout string
}
func (t *TimeValue) Set(s string) error {
v, err := time.Parse(t.Layout, s)
if err == nil {
(*t.P) = v
}
return err
}
func (t *TimeValue) String() string {
return t.P.Format(t.Layout)
}
Важное свойство пакета – его наличие в стандартной библиотеке – flag
это
стандартный способ конфигурации программ, а значит вероятность его
использования между различными проектами и библиотеками выше, чем у других
библиотек в сообществе.
Почему не используют flag
?
Как мне кажется, другие библиотеки используются и существуют по двум причинам:
- Параметры читаются не только из командной строки
- Параметры хочется структурировать
Если про чтение параметров, например, из файлов все более-менее понятно (об этом чуть позже), то о структурных параметрах стоит сказать пару слов прямо сейчас.
Существует, на мой взгляд, не самый лучший способ определения конфигурации программы как структуры, полями которой могли бы быть другие структуры и так далее:
type AppConfig struct {
Port int
Database struct {
Endpoint string
Timeout time.Duration
}
...
}
И мне кажется, именно поэтому используется и существуют библиотеки и фреймворки, которые позволяют работать с конфигурацией именно так.
Я думаю, flag
не должен предоставлять возможности структурной конфигурации.
Этого легко можно достичь с помощью нескольких строк кода (или библиотеки
flagutil
, речь о которой чуть ниже).
Более того, если задуматься, существование такой структуры приводит к сильной связанности между используемыми компонентами.
Структурная конфигурация
Идея заключается в том, чтобы определять параметры независимо от структуры программы и максимально близко к тому месту, где они используются – то есть непосредственно на уровне пакета.
Предположим, у нас есть реализация клиента к какому-то сервису (базе данных,
API или чему-либо еще), который называется yoogle
:
package yoogle
type Config struct {
Endpoint string
Timeout time.Duration
}
func New(c *Config) *Client {
// ...
}
Чтобы заполнить структуру yoogle.Config
, нам понадобится функция, которая
регистрирует поля структуры в полученном *flag.FlagSet
.
Такая функция может быть объявлена на уровне пакета yoogle
или в пакете
yooglecfg
(в случае сторонней библиотеки, мы можем написать такую функцию
где-либо еще):
package yooglecfg
import (
"flag"
"app/yoogle"
)
func Export(flag *flag.FlagSet) *yoogle.Config {
var c yoogle.Config
flag.StringVar(&c.Endpoint,
"endpoint", "https://example.com",
"endpoint for our API",
)
flag.DurationVar(&c.Timeout,
"timeout", time.Second,
"timeout for operations",
)
return &c
}
Чтобы исключить зависимость от пакета flag
можно определить интерфейс с
нужными методами flag.FlagSet
:
package yooglecfg
import "app/yoogle"
type FlagSet interface {
StringVar(p *string, name, value, desc string)
}
func Export(flag FlagSet) *yoogle.Config {
var c yoogle.Config
flag.StringVar(&c.Endpoint,
"endpoint", "https://example.com",
"endpoint for our API",
)
return &c
}
А если конфигурация зависит от значений параметров (например, среди параметров
указывается алгоритм чего-либо), функция yooglecfg.Export()
может возвращать
функцию-конструктор, которую нужно вызвать после парсинга всех значений
конфигурации:
package yooglecfg
import "app/yoogle"
type FlagSet interface {
StringVar(p *string, name, value, desc string)
}
func Export(flag FlagSet) func() *yoogle.Config {
var algorithm string
flag.StringVar(&algorithm,
"algorithm", "quick",
"algorithm used to do something",
)
var c yoogle.Config
return func() *yoogle.Config {
switch algorithm {
case "quick":
c.Impl = quick.New()
case "merge":
c.Impl = merge.New()
case "bubble":
panic(...)
}
return c
}
}
Функции экспорта позволяют определять параметры пакета без знания структуры конфигурации программы и способа получения их значений.
github.com/gobwas/flagutil
Мы разобрались с большой структурой конфигурации и сделали наши параметры независимым, но пока не совсем понятно, как собирать их всех вместе и получать значения.
Именно для решения этой задачи и был написан пакет flagutil
.
Собираем параметры вместе
Все параметры программы, ее пакетов и сторонних библиотек получают свой префикс
и собираются на уровне пакета main
:
package main
import (
"flag"
"app/yoogle"
"app/yooglecfg"
"github.com/gobwas/flagutil"
)
func main() {
flags := flag.NewFlagSet("my-app", flag.ExitOnError)
var port int
flag.IntVar(&port,
"port", 4050,
"port to bind to",
)
var config *yoogle.Config
flagutil.Subset(flags, "yoogle", func(sub *flag.FlagSet) {
config = yooglecfg.Export(sub)
})
}
Функция flagutil.Subset()
делает простую вещь: она добавляет префикс
("yoogle"
) ко всем параметрам, зарегистрированным в sub
внутри колбека.
Запуск программы теперь может выглядеть так:
app -port 4050 -yoogle.endpoint https://example.com -yoogle.timeout 10s
Получаем значения параметров
Все параметры внутри flag.FlagSet
содержат в себе реализацию flag.Value
,
которая имеет метод Set(string) error
– то есть предоставляет возможность
установки строкового представления значения.
Остается прочитать из какого-либо источника значения в виде пар ключ-значение и
сделать вызов flag.Set(key, value)
.
Это дает нам возможность даже не использовать синтаксис параметров командной строки, описанный в пакете
flag
. Можно разбирать аргументы, любым способом, например, как posix program arguments.
package main
func main() {
flags := flag.NewFlagSet("my-app", flag.ExitOnError)
// ...
flags.String(
"config", "/etc/app/config.json",
"path to configuration file",
)
flagutil.Parse(flags,
// First, use posix arguments syntax instead of `flag`.
// Just to illustrate that it is possible.
flagutil.WithParser(&pargs.Parser{
Args: os.Args[1:],
}),
// Then lookup for "config" flag value and try to
// parse its value as a json configuration file.
flagutil.WithParser(&file.Parser{
PathFlag: "config",
Syntax: &json.Syntax{},
}),
)
}
Соответственно файл config.json
может выглядеть так:
{
"port": 4050,
"yoogle": {
"endpoint": "https://example.com",
"timeout": "10s"
...
}
}
Заключение
Безусловно, я далеко не первый, кто говорит о подобном подходе. Многие из описанных выше идей так или иначе уже использовались несколько лет назад, когда я работал в MailRu.
Итак, чтобы упростить конфигурацию нашего приложения и не тратить время на изучение (или даже написание) очередного фреймворка конфигурации предлагается следующее:
- Использовать
flag
в качестве интерфейса определения параметров программы - Экспортировать параметры каждого пакета отдельно, без знания о структуре и способе последующего получения значений
- Oпределять способ чтения значений, префиксы и структуру конфигурации в
main
На создание библиотеки flagutil
меня вдохновило знакомство с библиотекой
peterbourgon/ff – и я бы не стал писать flagutil
, если бы не некоторые
расхождения в способах использования.
Спасибо за внимание!