Конфигурация программ на Go

Декабрь, 2019  |  

Гофер держит большой флаг и стоит рядом с винтажным компьютером

Вступление

Всем привет! После пяти лет программирования на 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.

Итак, чтобы упростить конфигурацию нашего приложения и не тратить время на изучение (или даже написание) очередного фреймворка конфигурации предлагается следующее:

На создание библиотеки flagutil меня вдохновило знакомство с библиотекой peterbourgon/ff – и я бы не стал писать flagutil, если бы не некоторые расхождения в способах использования.

Спасибо за внимание!

Ссылки