golang

Я сделал самую удобную либу для Go-конфига

  • воскресенье, 4 мая 2025 г. в 00:00:07
https://habr.com/ru/articles/906636/

Если я говорю "самая удобная", значит в сложившемся годами подходе должны быть проблемы, и я их выделил и как-то решил. Давайте разбираться.

Проблемы стандартного подхода к конфигурации в Go

Стандартный подход к управлению конфигурацией в Go обычно выглядит примерно одинаково, будь то популярный viper, env или менее известные решения типа confita :

  • Создаём структуру кофигурации

  • Для иерархичности конфигурации вкладываем одну структуру в другую

  • Добавляем теги на поля наших структур, как минимум там всегда будет название используемое в источнике конфигурации по типу yaml:"token"

  • В проекте живет Parse метод, который простенько написан и может дополнительно заниматься выставление дефолтных значений и валидацией

Звучит знакомо, правда? И вот в чем я вижу проблемы.

Бойлерплейт и три источника правды

Каждый раз, когда мы хотим добавить новую опцию конфигурации, приходится:

  • Добавить поле в структуру c тегом, а иногда и добавить новую структуру для обеспечения иерархии

  • В другом месте указать дефолтное значение

  • В третьем месте прокинуть конфиг для использования значения

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

1. Ошибки в тегах и опечатки

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

Паникующий тикер с time.Duration(0) хотя бы сразу себя обнаружит, а у меня на практике бывали кейсы, когда опечатки живут годами и ни к чему хорошему не приводят.

Или ещё хуже – переименовали ключ в одном месте, а в другом забыли. В итоге конфиг ломается в проде. Очень неприятный сюрприз.

2. Количество телодвижений для добавления опции

Тут все просто, мы все ленивые, и вероятность вынесения переменной в конфиг обратно пропорциональна метрике в заголовке этого абзаца.

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

3. Разрастание конфигурации

В этот эффект вносят вклад два героя:

  • неиспользуемые опции, которые забыли убрать

  • отсутствие дефолтных значений, которые забыли применить

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

Критикуешь - предлагай! И я предложу...

В противовес этому мне всегда нравилось, как лаконично и элегантно решается аналогичная задача в стандартном пакете flag:

  • Одна опция = Одна строчка кода = Одна точка правды (ключ, дефолтное значение, описание).

  • Один вызов Parse в main.go

Это выглядит вот так:

var dbHost = flag.String("db_host", "localhost", "адрес базы данных")

func main() {
    flag.Parse()
  
    fmt.Println(*dbHost)
}

Минимум кода, максимум понятности.

Мне захотелось использовать эту простоту и элегантность, но с YAML- и ENV-конфигами, чего, на удивление, не оказалось в существующих решениях. И тогда я решил сделать это сам.

Представляю zerocfg — конфигурацию без лишних усилий

Я взял за основу философию пакета 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 (адрес базы данных)
}

Что получаем из коробки?

1. Единую точку правды

Определение конфигурации занимает ровно одну строку. Дефолтное значение, ключ и описание — всё в одном месте. Легко читать, легко поддерживать.

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

  • Общие опции для приложения в main.go

  • Частые опции для сервиса приложения в единственном файле пакета. Например вверху файла internal/kafka/client.go

2. Источники конфигурации с приоритезацией

zfg.Parse(yaml.New(highPriority), yaml.New(lowPriority))

Мы можем комбинировать различные источники конфигурации (в текущий момент поддержка только env, yaml, cli-flags) и использовать их с различным приоритетом.

Хочу отметить, что источник конфигурации из аргументов-cli включен всегда по-умолчанию с самым высоким приоритетом.

3. Раннее обнаружение ошибок и опечаток

Команда 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 также сообщает.

4. Полуавтоматическое документирование

  • При чтении кода, у нас есть поле текстового описания у каждой опции

  • А вызов метода zfg.Show() отдает форматированное состояние текущего конфига с этим самым описанием опции

5. Модификаторы опций

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

password = zfg.Str("db.password", "qwerty", "пароль базы", zfg.Secret())

И еще несколько модификаторов, но не все:

  • Required - значение по умолчанию должно быть перезаписано

  • Alias - альтернативный ключ конфигурации

6. Удобное расширение

  • Если нам нужно подгружать опции из альтернативного источника конфигурации, специфичного для нас или просто не поддержанного в текущий момент, мы можем это сделать!

  • Если нам нужен новый тип опции, специфичной для нашего проекта, поддержка этого также есть!

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

Зачем все это?

Я прекрасно понимаю, что многие привыкли к старым подходам и вполне счастливы. И всё же хочется чего-то простого, лаконичного при работе с конфигураций и рад поделиться с сообществом своим видением.

Zerocfg — попытка принести удобство и лаконичность в то место, где для меня был бойлерплейт, лишние ошибки и ненужные сложности.

Приглашаю всех попробовать, покритиковать, внести свои предложения и, конечно же, присоединиться к контрибьюторам!

Буду рад любому фидбэку!

PS: Уверен, что в других языках этих проблем нет и всё уже давно есть из коробки. Но раз уж мы пишем на Go, то почему бы не сделать нашу жизнь чуть удобнее?