golang

Собиратель конфигураций на Go

  • вторник, 13 августа 2024 г. в 00:00:10
https://habr.com/ru/articles/835264/

Начну с того, что я C#-разработчик, но Go мне очень нравится и один из проектов я решил для разнообразия и расширения знаний написать в связке Go + React.js + MongoDB. И тут я понял, что не могу найти библиотеку пакет для чтения единых настроек конфигурации из разных источников. Часть настроек была в .env, часть - в переменных окружения. Хотелось получить экземпляр одной структуры со всеми значениями, выполнив какую-то одну функцию. Возможно, плохо искал. Но, не найдя, решил написать своё. Тем более у меня уже был опыт работы над подобным open-source проектом, но для C#.

Я никогда ранее не писал пакетов для Go, поэтому просто начал писать код как для программы, чтобы просто смотреть, запускать, тестировать. Понять, насколько это реализуемо. А потом уже оформил в пакет.

Идея состояла в том, что надо было просто указать несколько источников настроек и они бы собрались в единый экземпляр структуры. По умолчанию каждый следующий источник данных должен переписывать аналогичные данные из предыдущего, как это делается в appsettings.json и appsettings.Development.json в C#. У нас есть настройки верхнего уровня и те, которые нам нужны в конкретном случае.
Берём самые популярные источники данных: переменные окружения, .env, .ini, .json, .yaml - и начинаем писать. (Yaml пока отложил в сторону.)

Так должно получиться на старте:

func main() {
    config := &Config{}
    cr := goconf.NewConfigReader(). // создаём экземлпляр
        RewriteValues(true).        // тут задаём основной параметр
        AddEnvironment().           // подключаем переменные окружения
        AddFile(".env").            // подключаем файл .env
        AddFile("config.ini").      // подключаем файл config.ini
        AddString("f5.sf1 = 10", goconf.FtEnv, "config 1"). // раз можно файл, то почему бы не добавить строку. возможно, прочитанную из другого источника ранее?
        AddFile("config.json").     // подключаем файл config.json, у него будет наивысший приоритет - перезапишет любые заданные ранее параметры, если будет их содержать
        EnsureHasNoErrors()         // убеждаемся, что используются поддерживаемые типы файлов
    err := cr.ReadConfig(config)
    if err != nil {
        panic(err)
    }

    ...
}

По ходу дела добавилось несколько возможностей. Работа со строкой, в целом, ничем не отличается от работы с файлом, поэтому в качестве одного из источников данных добавил строку. Указываем тип и название на случай отображения ошибок (чтобы понимать, где возникла).

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

Как в любой другой уважающей себя читалке конфигураций, должна быть возможность задать имя параметра в конфиге. А, значит, должна быть возможность проигнорировать параметр и указать значение по умолчанию. А также указать, что параметр обязателен (иначе как, например, без строки подключения мы подключимся к БД?).

Начал я с базовых типов:

type Config struct {
    Field_1 bool          `env:"f1" def:"true"` //поле с названием f1 и значением по умолчанию 'true'
    Field_2 int8          `env:"f2,required"`   //если f2 не встретится нигде в настройках, то будет выброшена ошибка
    Field_3 string        `env:"f3,required" def:"abc"` //если для обязательного указать значение по умолчанию, то ошибки не будет, а будет использовано это значение
    Field_4 time.Duration `env:"f4" def:"1h"`   //с базовыми также использовались Duration и Time
    Field_5 time.Time     `env:"f4" def:"now"`  //раз есть time, то должно быть и 'now', которое подставит текущее время
    Fiend_6 float32       // название не задано - будет использовано имя поля 'Field_6'
    Fiend_7 []string      `env:"-"` //этот параметр будет проигнорирован - с ним пока не умеем работать
}

Если имя параметра не указано, то будет взято имя поля. Все имена регистронезависимые.

На этом этапе в плане работы с типами всё было довольно просто. Основные сложности были в парсинге файлов, т.к. я решил, что лучшим вариантом будет парсинг вручную посимвольно. Такой код выглядит так себе, но зато я могу контролировать всё и добавить чуть больше возможностей, чем предоставляет формат согласно спецификации. Пока там нет никакой оптимизации.

Позже была добавлена поддержка указателей на базовые типы и тут началось "веселье". "Оказывается", нельзя просто взять и использовать int64 для int8, int16 и int32. Ведь это указатель и для каждого типа нужно своё преобразование, т.к. не можем мы для int8 использовать указатель на int64. И таким образом вместо одного метода для целых знаковых чисел у меня появилось 4 почти идентичных и т.д.

Теперь можно было сделать так

type config struct {
    Field_1 *int32  `env:"f1" def:"*nil"`   // раз указатели могут принимать значение 'nil', то должна быть возможность его указать в конфиге и в значении по умолчанию. Либо даже не добавлять в конфиг, но при этом не указывать его как 'required'
}

В целом, неплохо, но как я читаю работаю с разными форматами, собирая всё в единый экземпляр структуры?

На старте я читаю данные структуры: атрибуты, названия полей, их типы. Дальше происходит чтение файла или строки и сохранение данных в промежуточный тип - своеобразное дерево значений. Представляет из себя словарь с названием параметра в качестве ключа и массивом значений, т.к может быть несколько из разных источников. В конце, когда все данные прочитаны, для каждого найденного ключа в соответствии с настройками и атрибутами подставляются необходимые значения в каждое поле структуры

Это данные по каждому полю структуры:

type structInfo struct {
	fieldName  string
	fieldType  reflect.Type
	field      reflect.Value
	keyName    string
	defValue   string
	isRequired bool
	isPointer  bool
    // === вы находитесь здесь ===
    useParser  bool
	separator  string
	separator2 string
	isSlice    bool
	isMap      bool
	append     bool
	size       int
}

А это дерево данных:

type intermediateTree map[string][]intermediateData
type intermediateData struct {
	source    int   // на случай, если в одном файле дважды указано одно и то же поле
	value     interface{}   // это пока у нас достаточно и string для занчения, но дальше будет больше
	valueType valueType // это появилось из-за .json
}
type valueType int
const (
	vtEmpty valueType = iota
	vtAny
	vtString
	vtNumber
	vtBool
	vtNull
)

Но этого явно мало! А как же вложенные структуры? Оказалось, не сложно. Просто добавляем ещё один поддерживаемый тип, читаем инфу по структуре рекурсивно и начинаем использовать точку в имени параметра, запрещая её использование внутри конфигов в качестве части имени (но не везде).

type config struct {
    SubConfig subConfig `env:"sub"` // тут можем указать название
}
type subConfig struct {
    Field_1 bool  `env:"f1"`    // тут автоматически раотает всё ранее написанное
}

Но какой конфиг без коллекций? И я начинаю добавлять коллекции одну за другой: слайсы, массивы, словари, слайсы на указатели, массивы на указатели. Это добавило ещё больше "веселья". С каждым разом код становился всё страшнее и массивнее, но трудно было с этим что-то сделать.

Теперь мы можем так:

type config struct {
    Field_1  []string           `env:"f1,append" def:"a|b" sep:"|"` // коллекции теперь можем собирать из всех источников данных сразу через 'append', а раз есть коллекция, то должен быть и разделитель 'sep'
	Field_2  [3]time.Time       `env:"f2" def:"now,now"`    // для массива мы не можем указать больше значений, чем его размер, но можем меньше. Разделительл по умолчанию - запятая
	Field_3  map[string]float64 `env:"f3" def:"a?1.1,b?2.2" sep2:"?"`   // для словаря нужно уже два разделителя, добавляю 'sep2'
    Field_4  []*int8            `env:"f4" def:"*nil,1,2,3,4,5"` // конечно, коллекции на указатели должны поддерживать nil
	FIell_5  [2]*float32        `env:"f5,required" def:"1.23"`	// хотя бы одно значение должно быть указано в конфиге илии в значении по умочанию
}

Соответственно, это как-то должно быть отображено в источниках конфигураций. Если с .json всё понятно, то для .env и .ini есть нюансы (к счастью, там в этом плане почти идентично):

# данные для слайсов и массивов можно записать по отдельности
f1[] = 1
f1[] = 2
# а можно записать их вместе
f1 = 3,4,5
# в данном примере в результате получим: f1 = [1,2,3,4,5]

# тут пример для словаря 'f2 map[string]float64'
f2[key_1] = 1.23
f2[key_2] = '32.1'  ; значение в любом случае будет парситься из строкового, если конечный тип - не строка

Функция назначения полю значения для bool стала выглядеть так:

Код страшный, спрячу под спойлер
func (cr *configReader) setBoolFieldValue(info structInfo, str string, strSlice []string, strMap map[string]string, vType valueType) error {
	if vType != vtBool && vType != vtAny {	// если json и прочитано не bool
		return getValueIsNotTypeError(info.fieldName, -1, boolName)
	}
	if info.isSlice && info.size == 0 {	// для слайсов
		if !info.isPointer {
			bSlice := []bool{}
			for index, s := range strSlice {
				b, err := strconv.ParseBool(s)
				if err != nil {
					return getValueIsNotTypeError(info.fieldName, index, boolName)
				}
				bSlice = append(bSlice, b)
			}
			info.field.Set(reflect.ValueOf(bSlice))
		} else {	//для слайсов указателей
			bSlice := []*bool{}
			for index, s := range strSlice {
				if s == nilDefault {
					bSlice = append(bSlice, nil)
				} else {
					b, err := strconv.ParseBool(s)
					if err != nil {
						return getValueIsNotTypeError(info.fieldName, index, boolName)
					}
					bSlice = append(bSlice, &b)
				}
			}
			info.field.Set(reflect.ValueOf(bSlice))
		}
	} else if info.isSlice && info.size > 0 {	// для массивов
		for index, s := range strSlice {
			if info.isPointer && s == nilDefault {
				info.field.Index(index).SetZero()
			} else {
				b, err := strconv.ParseBool(s)
				if err != nil {
					return getValueIsNotTypeError(info.fieldName, index, boolName)
				}
				if info.isPointer {
					info.field.Index(index).Set(reflect.ValueOf(&b))
				} else {
					info.field.Index(index).SetBool(b)
				}
			}
		}
	} else if info.isMap {	// для словаря
		bMap := map[string]bool{}
		for key, value := range strMap {
			b, err := strconv.ParseBool(value)
			if err != nil {
				return getValueIsNotTypeErrorByKey(info.fieldName, key, intName)
			}
			bMap[key] = b
		}
		info.field.Set(reflect.ValueOf(bMap))
	} else {	// для базовых типов и указателей на них
		b, err := strconv.ParseBool(str)
		if err != nil {
			return getValueIsNotTypeError(info.fieldName, -1, boolName)
		}
		if info.isPointer {
			info.field.Set(reflect.ValueOf(&b))
		} else {
			info.field.SetBool(b)
		}
	}
	return nil
}

Выглядит жутко, но работает идеально. И так для каждого поддерживаемого базового типа.

С поддерживаемыми разобрались, но что, если пользователь захочет больше? Например, хранить в параметрах хост, который должен парситься в пользовательский тип Host, который будет хранить в себе url и порт? Соответственно, в настройках будет записано как "127.0.0.1:5000". Тем более поддержку подобных типов мы делали в проекте, над которым я ранее работал.

Всё очень просто! Добавляем возможность указать, что данное поле будет парситься с помощью пользовательской функции и, соответственно, надо где-то указать эту функцию.

Получаем такой пример:

type Config struct {
    Field_1 SubConfig  `env:"f1,useparser" def:"f1_1"`  // добавляю ключевое слово 'useparser'
    Field_2 SubConfig  `env:"f2,useparser" def:"f2_1"`
}
type SubConfig struct {
    Field_1 string
    Field_2 int
}

func main() {
    config := &Config{}
    cr := goconf.NewConfigReader().
        WithParser("f1", ParseSubConfig).   // добавляю в настройках уазание парсера для конкретного поля
        WithParser("f2", ParseSubConfig).
        AddString("f1 = f1_2", goconf.FtEnv, "config 1").
        EnsureHasNoErrors()
    err := cr.ReadConfig(config)
    if err != nil {
        panic(err)
    }

    ...
}

func ParseSubConfig(s string) (interface{}, error) {    // парсер должен иметь такую структуру
    split := strings.Split(s, "_")
    i, err := strconv.Atoi(split[1])
    if err != nil {
        return nil, err
    }

    return SubConfig{Field_1: split[0], Field_2: i}, nil
}

Вроде выглядит всё довольно просто. И при этом значительно расширяет возможности чтения конфигураций! Идём дальше.

Т.к. я вручную разбираю файлик, то позволяю больше, чем допускают спецификации. Ниже пример валидного json'а для моего парсера:

/* Тут можно написать,
зачем нужен этот файл. */
{
    "key_1":"value","key_2":"va\"lue",
    "/**/key_2[]": "value", // абсолюютно валидное имя ключа
    
    "key_3"//:
    :/*false*/
    true//,
    ,   // тут ещё комментарий

    "key_4": NuLl, // 'null' теперь регистронезависимый

    "key_5": [1,2,3,],   // возможны завершающие запятые ВЕЗДЕ (в новой спецификации уже можно, но не все ещё это умеют)
    
    "key_6": {
        "k1": TRUE, // 'true' и 'false' тоже регистронезависимые
        "k2": FALSE,	// тут ничего страшного не произойдёт
    },  // запятая в конце ничего не сломает

    // "key_7": [1, "2"] - но запрещены разные типы, даже если один может быть преобразован в другой
}
// тут можно попрощаться с читателем

По итогу у нас имеется поддержка переменных окружения, файлов .env, .ini, .json. Поддержка типов:

  • базовых,

  • указателей на них,

  • коллекций этих же типов,

  • коллекций на указатели,

  • словарь на те же типы, но обязательно со строковым ключом,

  • вложенные типы

  • и вообще любые типы при использовании пользовательского парсера.

Также имеем очень страшный, но работающий код. Куча тестов, в которых я пытался учесть все возможные случаи, это подтверждают.

Собственно, сам код лежит тут: https://github.com/Mansiper/GoConfiguration
Там же представлено достаточно подробное описание.

Если будет желание привести код в порядок, добавить типов или форматов файлов, можете смело создавать pull requests.

Теоретически можно добавить ещё чтение из online-источников данных, на случай, если захочется прочитать что-то вроде этого: https://api.nuget.org/v3/index.json. Или даже добавить возможность указания своих источников данных, но уже со своим способом их чтения и, возможно, парсинга.

Фантазии:

func main() {
    config := &Config{}
    cr := goconf.NewConfigReader().
        // тут источник удалённый, но с существующим парсером, в параметрах можно указать headers и пр.
        AddOnline("POST https://secret-vault.com/company/secret/config", goconf.FtEnv, someParams)
        // тут источник удалённый, но с пользовательским парсером
        AddOnline("GET https://example.com/robots.txt", myParser, someParams)
        // пользовательский формат с пользовательским парсером
        AddFile("config.secret", myAnotherParser).
        // тут сначала происходят пользовательские преобразования, а потом вызывается уже имеющийся парсер
        AddFile("config.encoded", myDecoder, goconf.Ini).
        // тут пользователь сам получает данные откуда хочет, но потом отдаёт их существующему парсеру
        AddSouce(userReader, goconf.Env).
        // аналогично, но с пользовательским парсером
        AddSouce(userReader, myParser);

    ...
}

Конечно, для кого-то, особенно тем, кто давно и глубоко в мире Go, такой проект может показаться абсолютно бесполезным. Но иногда стоит написать проект, чтобы написать. Ради исследования и новых знаний. А кому-то он действительно может пригодиться.


Если вам знакомы подобные пакеты/библиотеки для других языков программирования, поделитесь ими. А то вдруг кому-то надо, а не знают.

Я мог бы оставить тут ссылку на свой Телеграм-бложиг, но я не такой. И там очень мало про ИТ. В основном про жизнь и танго.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вам когда-либо пригодился бы подобный пакет?
10% Да, нужен был1
40% Нет, никогда4
50% Возможно, в будущем5
Проголосовали 10 пользователей. Воздержались 4 пользователя.