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

Пример того, как работает наследование в 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 систему типов.

Буду признателен за замечания, конструктивную критику, рекомендации и пожелания.

© Habrahabr.ru