О чем пойдет речь

Думаю, не у меня одного возникает потребность удобно конфигурировать проект. И существует много готовых решений разной степени сложности и свежести. В этом топике хочу продемонстрировать модуль для определения настроек проекта и управления ими as code. Deno использую не так давно, но ряд фич, о которых написано уже много статей (сравнение с Node.js и Bun), оказались весьма удобными, а сообщество Deno еще не развито достаточно и модулей в наличии имеется тоже немного.

Начало работы

Создадим в корне проекта папку /config, вся работа с конфигом и его обработкой будет здесь. Tuner автоматически найдет папку именно с этим названием и соберет config-объект оттуда. Файл самого конфига должен заканчиваться на .tuner.ts

// config/myConfig.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune(
  {
    config: {
      field1: 'value1',
      field2: 100,
      field3: true,
      field4: ['минималистично', 'удобно', 'не правда ли?'],
    },
  },
);

Статический анализатор внутри функции tune заботливо подскажем вам, что конфиг состоит из двух полей: env и config. В файле, путь до которого в deno.json можно привязать алиасом “$global”, например, создаем объект конфига:

// main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
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 '<https://deno.land/x/tuner/mod.ts>';
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 '<https://deno.land/x/tuner/mod.ts>';
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 '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
  config: { a: 400, b: 401, c: 402 },
});


//config/a.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
child: Tuner.Load.local.configDir('b.tuner.ts'),
  config: {
    b: 200,
    e: 201,
  },
});


//config/b.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
  config: { a: 100, d: 101 },
});


//main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
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 "<https://deno.land/x/zod/mod.ts>";
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<typeof configSchema>;
//├─ config
//│  ├─ a
//│  ├─ b
//│  ├─ c
//│  ├─ e
//│  └─ d
//└─ env
//

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

// main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
import {Config} from "config/configSchema.ts"
export const tuner = (await Tuner.use.loadConfig()) as Config;

Заключение

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

Уже сейчас готова фича в виде наблюдателя изменений (Изменили какое-то поле в одном из конфигов - сработал ивент или колбэк). Правда в этой версии Tuner (ее пока не релизил) есть утечка памяти, как поборю ее, выкачу в deno land.

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

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