Tuner — гибкий конфигуратор проекта as code для Deno
О чем пойдет речь
Думаю, не у меня одного возникает потребность удобно конфигурировать проект. И существует много готовых решений разной степени сложности и свежести. В этом топике хочу продемонстрировать модуль для определения настроек проекта и управления ими as code. Deno использую не так давно, но ряд фич, о которых написано уже много статей (сравнение с Node.js и Bun), оказались весьма удобными, а сообщество Deno еще не развито достаточно и модулей в наличии имеется тоже немного.
Начало работы
Создадим в корне проекта папку /config, вся работа с конфигом и его обработкой будет здесь. Tuner автоматически найдет папку именно с этим названием и соберет config-объект оттуда. Файл самого конфига должен заканчиваться на .tuner.ts
// config/myConfig.tuner.ts
import Tuner from '';
export default Tuner.tune(
{
config: {
field1: 'value1',
field2: 100,
field3: true,
field4: ['минималистично', 'удобно', 'не правда ли?'],
},
},
);
Статический анализатор внутри функции tune заботливо подскажем вам, что конфиг состоит из двух полей: env и config. В файле, путь до которого в deno.json можно привязать алиасом »$global», например, создаем объект конфига:
// main.ts
import Tuner from '';
export const tuner = await Tuner.use.loadConfig();
console.log(tuner.config.field2); // 100
После чего можно импортировать import {tuner} from "$global”
в любом файле проекта и использовать.
При запуске обязательно наличие env переменной config, ее значение — название файла конфига до .tuner.ts, в данном примере это myConfig.
config=myConfig deno run --allow-all main.ts
Фактически, это единственная служебная env — переменная, которую нужно прокинуть в проект (Или настроить разные значение переменной config в Doppler, если вы используете его)
Тюним сикреты
В Tuner имеется возможность описать типы переменных окружения и поведения при их отсутствии:
значение по умолчанию
завершение процесса
генерация исключения
вычисление на лету
Приведу полный перечень поведений с разными ожидаемыми типами данных:
// config/myConfig.tuner.ts
import Tuner from '';
export default Tuner.tune(
{
env: {
// Использовать Значение по умолчанию
env1: Tuner.Env.getString.orDefault('defalut value1'),
env2: Tuner.Env.getNumber.orDefault(100),
env3: Tuner.Env.getBoolean.orDefault(true),
// Проигнорировать отсуствие переменной
env4: Tuner.Env.getString.orNothing(),
env5: Tuner.Env.getNumber.orNothing(),
env6: Tuner.Env.getBoolean.orNothing(),
// Завершенить процесс
env7: Tuner.Env.getString.orExit(
'сообщение об ошибке, необязательно',
),
env8: Tuner.Env.getNumber.orExit(
'выведет в консоль перед выходом',
),
env9: Tuner.Env.getBoolean.orExit(),
// Сгенерировать исключение
env10: Tuner.Env.getString.orThrow(new Error('ошибка')),
env11: Tuner.Env.getNumber.orThrow(new Error()),
env12: Tuner.Env.getBoolean.orThrow(new Error()),
// Вычисленить данных по переданному колбэку
//(может быть асинхронным, если данные нужно получить с диска или удаленно, например)
env13: Tuner.Env.getString.orCompute(() => 'computed value1'),
env14: Tuner.Env.getNumber.orAsyncCompute(() =>
new Promise(() => 100)
),
},
config: {
field1: 'value1',
field2: 100,
field3: true,
field4: ['минималистично', 'удобно', 'не правда ли?'],
},
},
);
Разумеется, можно просто указать значение-примитив, вроде env1: 100
Строим иерархию из конфигов
Бывает так, что конфиг представляет из себя не равные по значимости данные, обновлять которые придется с разной частотой. Чтобы отделить «базовые» данные от «вторичных», рекомендую разделить конфиг на несколько мини-конфигов, выстроив из них своего рода иерархию.
Tuner позволяет «собрать» конфиг, используя другие конфиги, нужно только выстроить из них цепочку:
Текущий конфиг дополнится всеми полями родительского, при этом сохранит свои значения
Текущий конфиг дополнится всеми полями дочернего, при этом совпадающие поля будут переписаны значениями из дочернего конфига
Значения-фукнции, используемые для описания env-переменных также подчиняются этим правилам
Пример того, как работает наследование в Tuner
Посмотрим, как сделать это в коде:
// config/develop.tuner.ts
import Tuner from '';
export default Tuner.tune({
child: Tuner.Load.local.configDir('a.tuner.ts'),
parent: Tuner.Load.local.configDir('base.tuner.ts'),
config: {
a: 300,
b: 301,
},
});
//config/base.tuner.ts
import Tuner from '';
export default Tuner.tune({
config: { a: 400, b: 401, c: 402 },
});
//config/a.tuner.ts
import Tuner from '';
export default Tuner.tune({
child: Tuner.Load.local.configDir('b.tuner.ts'),
config: {
b: 200,
e: 201,
},
});
//config/b.tuner.ts
import Tuner from '';
export default Tuner.tune({
config: { a: 100, d: 101 },
});
//main.ts
import Tuner from '';
export const tuner = await Tuner.use.loadConfig();
console.log(tuner);
//{ config: { a: 100, b: 200, c: 402, e: 201, d: 101 }, env: {} }
Tuner.Load предоставляет несколько вариантов определения источника конфига, можно подключать их локально, импортировать удаленно или запрашивать по переданному колбэку.
Все способы подгрузить дочерние/родительские конфиги
Tuner.Load предлагает локальный и удаленный вариант подключения конфига.
Tuner.Load.local
Функция | Вернет объект конфига из файла по … |
absolutePath (path: string) | …указанному полному пути до него |
configDir (path: string) | …пути, относительно директории с названием «config» |
cwd (path: string) | …относительному пути в директории проекта |
Tuner.Load.remote
Фукнция | Описание | Пример (пусть файл конфигурации лежит по адресу http://some_server/b.tuner.ts) |
import (path: string) | Работает, как обычный импорт | child: Tuner.Load.remote.import («http://some_server/b.tuner.ts») |
callbackReturnModule (cb: () ⇒ Promise<{default: ITunerConfig}>) | Принимает колбэк, который вернет промис с импортируемым модулем | child: Tuner.Load.remote.callbackReturnModule (() ⇒ import («http://some_server/b.tuner.ts»)) |
callbackReturnString ((cb: () => Promise)) | Принимает колбэк, который вернет промис с текстом модуля в виде строки (забираем код конфига из форм, блоков в Notion и т. д.) | child: Tuner.Load.remote.callbackReturnString (() ⇒ someFetchingFunctionStringReturned (options: {…})) |
Кроме того, Tuner.Load.remote имеет встроенные интеграции с различными сервисами через Tuner.Load.remote.providers:
notion (key: string, blockUrl: string) — отдаем ключ авторизации (Tuner.getEnv поможет найти env-переменную в окружении или .env файле) и ссылку на блок в Notion, в котором описан модуль конфигурации
github (key: string, owner: string, repo: string, filePath: string) — ключ, ник держателя репо, название репо и путь до файла
Кому-то может показаться идея конфигать проект через Notion странной…я его понимаю. Но в моем проекте так было удобнее, поэтому эта интеграция присутствует. Если есть пожелания, напишите, добавить несложно :)
Ну и вишенка на торте — схема конфига
Без схемы и выведенных типов работать с конфигом было бы неприятно.
Для удобной работы с объектом конфигурации во время разработки рекомендуется сгенерировать тип объекта.
Tuner.use.generateSchema (obj: ObjectType, variableName: string, filePath: string) сформирует файл по пути filePath со схемой объекта obj и экспортирует тип с названием variableName, переведя первую букву в заглавный регистр.
Запуск генерации схемы конфига можно вынести в отдельный task, чтобы
deno task schema
, допустим, занимался только обновлением схемы.
Пример:
const tuner = await Tuner.use.loadConfig();
Tuner.use.generateSchema(
tuner,
'config',
'config/configSchema.ts',
);
Получим:
//config/configSchema.ts
import { z } from "";
export const configSchema = z.object({
config: z.object({
a: z.number(),
b: z.number(),
c: z.number(),
e: z.number(),
d: z.number(),
}),
env: z.object({}),
});
export type Config = z.infer;
//├─ config
//│ ├─ a
//│ ├─ b
//│ ├─ c
//│ ├─ e
//│ └─ d
//└─ env
//
Теперь можно дополнить участок кода с инициализацией тюнера типов конфига и мы получим мощный и информативный способ общаться с нашим конфигом:
// main.ts
import Tuner from '';
import {Config} from "config/configSchema.ts"
export const tuner = (await Tuner.use.loadConfig()) as Config;
Заключение
Конечно, это всего лишь пример того, как можно организовать работу с относительно постоянными данными конфигурации. Есть куда расти и куда расширяться.
Уже сейчас готова фича в виде наблюдателя изменений (Изменили какое-то поле в одном из конфигов — сработал ивент или колбэк). Правда в этой версии Tuner (ее пока не релизил) есть утечка памяти, как поборю ее, выкачу в deno land.
Кроме того, генерация схемы выглядит не очень удобно и идеоматично, пробую решить эту задачу через гибку TS систему типов.
Буду признателен за замечания, конструктивную критику, рекомендации и пожелания.