Я сделал самую удобную либу для Go-конфига
- воскресенье, 4 мая 2025 г. в 00:00:07
Если я говорю "самая удобная", значит в сложившемся годами подходе должны быть проблемы, и я их выделил и как-то решил. Давайте разбираться.
Стандартный подход к управлению конфигурацией в Go обычно выглядит примерно одинаково, будь то популярный viper, env или менее известные решения типа confita :
Создаём структуру кофигурации
Для иерархичности конфигурации вкладываем одну структуру в другую
Добавляем теги на поля наших структур, как минимум там всегда будет название используемое в источнике конфигурации по типу yaml:"token"
В проекте живет Parse
метод, который простенько написан и может дополнительно заниматься выставление дефолтных значений и валидацией
Звучит знакомо, правда? И вот в чем я вижу проблемы.
Каждый раз, когда мы хотим добавить новую опцию конфигурации, приходится:
Добавить поле в структуру c тегом, а иногда и добавить новую структуру для обеспечения иерархии
В другом месте указать дефолтное значение
В третьем месте прокинуть конфиг для использования значения
Такой подход, где нам нужно изменять строчки кода логически связанные, но расположенные далеко друг от друга, порождает кроме неудобства еще и потенциальный источник ошибок.
Ошибиться в написании тега легко, и потом мучительно долго искать, почему конфигурация ведёт себя непредсказуемо. Причем при наличии значений по-умолчанию это можно банально не заметить.
Паникующий тикер с time.Duration(0)
хотя бы сразу себя обнаружит, а у меня на практике бывали кейсы, когда опечатки живут годами и ни к чему хорошему не приводят.
Или ещё хуже – переименовали ключ в одном месте, а в другом забыли. В итоге конфиг ломается в проде. Очень неприятный сюрприз.
Тут все просто, мы все ленивые, и вероятность вынесения переменной в конфиг обратно пропорциональна метрике в заголовке этого абзаца.
Если это сделать просто и можно поставить дефолтное значение, то разработчик это сделает с большей вероятность, и мы получим более гибкую конфигурацию приложения без лишних издержек, однако стандартный подход усложняет процесс добавления, как мне кажется.
В этот эффект вносят вклад два героя:
неиспользуемые опции, которые забыли убрать
отсутствие дефолтных значений, которые забыли применить
И рубить это дерево нужно на корню! Эти проблемы должны решаться из коробки у хорошей библиотеки конфигурации, по моему мнению.
В противовес этому мне всегда нравилось, как лаконично и элегантно решается аналогичная задача в стандартном пакете flag:
Одна опция = Одна строчка кода = Одна точка правды (ключ, дефолтное значение, описание).
Один вызов Parse
в main.go
Это выглядит вот так:
var dbHost = flag.String("db_host", "localhost", "адрес базы данных")
func main() {
flag.Parse()
fmt.Println(*dbHost)
}
Минимум кода, максимум понятности.
Мне захотелось использовать эту простоту и элегантность, но с YAML- и ENV-конфигами, чего, на удивление, не оказалось в существующих решениях. И тогда я решил сделать это сам.
Я взял за основу философию пакета flag, добавил нужный функционал, подсыпал сахара, немного гибкости и получил zerocfg. Вот что вышло.
Сразу пример использования с корабля на прод:
package main
import (
"fmt"
zfg "github.com/chaindead/zerocfg"
"github.com/chaindead/zerocfg/env"
"github.com/chaindead/zerocfg/yaml"
)
var (
path = zfg.Str("config.path", "", "путь до конфигурации", zfg.Alias("c"))
host = zfg.Str("db.host", "localhost", "адрес базы данных")
)
func main() {
err := zfg.Parse(env.New(), yaml.New(path))
if err != nil {
panic(err)
}
fmt.Println("Текущая конфигурация:\n", zfg.Show())
// CMD: go run ./... -c config.yml
// OUTPUT:
// Текущая конфигурация:
// db.host = localhost (адрес базы данных)
}
Определение конфигурации занимает ровно одну строку. Дефолтное значение, ключ и описание — всё в одном месте. Легко читать, легко поддерживать.
Нужно сделать ремарку, что на юзера ложится ответственность в размещении опций конфигурации в коде. И хороший подход будет:
Общие опции для приложения в main.go
Частые опции для сервиса приложения в единственном файле пакета. Например вверху файла internal/kafka/client.go
zfg.Parse(yaml.New(highPriority), yaml.New(lowPriority))
Мы можем комбинировать различные источники конфигурации (в текущий момент поддержка только env, yaml, cli-flags) и использовать их с различным приоритетом.
Хочу отметить, что источник конфигурации из аргументов-cli включен всегда по-умолчанию с самым высоким приоритетом.
Команда Parse
возвратит ошибку при обнаружении неизвестных опций:
err := zfg.Parse(
env.New(),
yaml.New(path),
)
if unknown, ok := zfg.IsUnknown(err); !ok {
panic(err)
} else {
// unknown это мапа <source_name> к слайсу неизвестных опций
fmt.Println(unknown)
}
Также предусмотрены случаи ошибочного использования (например дублирование названия), о которых Parse
также сообщает.
При чтении кода, у нас есть поле текстового описания у каждой опции
А вызов метода zfg.Show()
отдает форматированное состояние текущего конфига с этим самым описанием опции
Например, можно указать, какие опции конфигурации секретны. При выводе значений пароли будут защищены от случайного попадания в логи:
password = zfg.Str("db.password", "qwerty", "пароль базы", zfg.Secret())
И еще несколько модификаторов, но не все:
Required
- значение по умолчанию должно быть перезаписано
Alias
- альтернативный ключ конфигурации
Если нам нужно подгружать опции из альтернативного источника конфигурации, специфичного для нас или просто не поддержанного в текущий момент, мы можем это сделать!
Если нам нужен новый тип опции, специфичной для нашего проекта, поддержка этого также есть!
Реализация и первого, и второго построена на простых интерфейсах (по сути один метод), давая необходимый уровень гибкости.
Я прекрасно понимаю, что многие привыкли к старым подходам и вполне счастливы. И всё же хочется чего-то простого, лаконичного при работе с конфигураций и рад поделиться с сообществом своим видением.
Zerocfg — попытка принести удобство и лаконичность в то место, где для меня был бойлерплейт, лишние ошибки и ненужные сложности.
Приглашаю всех попробовать, покритиковать, внести свои предложения и, конечно же, присоединиться к контрибьюторам!
Буду рад любому фидбэку!
PS: Уверен, что в других языках этих проблем нет и всё уже давно есть из коробки. Но раз уж мы пишем на Go, то почему бы не сделать нашу жизнь чуть удобнее?