Раздел 25 · Effect-TS

Config, секреты и platform: FileSystem, Path, Command, KeyValueStore

middle-senior~90 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Config, секреты и platform: FileSystem, Path, Command, KeyValueStore

Сцена · восклицательный знак, который однажды выстрелит

const API_URL = process.env.API_URL!;
const PORT = Number(process.env.PORT ?? 3000);
const TOKEN = process.env.API_TOKEN!;

Три строки, и в них три разных способа выстрелить себе в ногу.

API_URL! это обещание компилятору, которое ты не можешь сдержать. Если переменной нет, API_URL станет undefined, программа запустится как ни в чём не бывало и упадёт через двадцать минут в первом же запросе с сообщением Invalid URL: undefined.

Number(process.env.PORT) при опечатке PORT=80o0 вернёт NaN, и сервер поднимется на случайном порту.

TOKEN рано или поздно попадёт в лог. Не потому, что ты глупый, а потому, что кто-то напишет logger.info('config', config), и вся конфигурация уедет в Datadog вместе с боевым ключом.

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

Config в Effect решает все четыре. Заодно в этом уроке разберём платформенные сервисы: файлы, пути, запуск процессов и key-value хранилище, то есть всё, что обычно тащат из node:fs и node:child_process и тем самым прибивают код к Node.

Раньше это жило отдельным пакетом @effect/platform. Теперь пакета нет: FileSystem, Path, Terminal и Stdio переехали прямо в effect, процессы в effect/unstable/process, хранилище в effect/unstable/persistence. Остались только платформенные реализации вроде @effect/platform-node, и это правильно: интерфейс общий, а реализация у каждого рантайма своя.

Карта урока · что заберёшь домой

Восемь разделов:

  1. Раздел 1, Config как декларация: примитивы, all, nested, withDefault, option.
  2. Раздел 2, ошибки конфигурации: что в них видно, плюс проверки схемой и осмысленные типы.
  3. Раздел 3, Redacted, секрет, который не попадает в лог случайно.
  4. Раздел 4, ConfigProvider: окружение, .env, файлы секретов, подмена в тестах.
  5. Раздел 5, фича-флаги и выбор реализации слоем.
  6. Раздел 6, FileSystem и Path вместо node:fs.
  7. Раздел 7, ChildProcess, запуск внешних процессов.
  8. Раздел 8, KeyValueStore, в том числе типизированный схемой.

К концу урока в Pulse не остаётся ни одного process.env и ни одного прямого импорта из node:fs.

Раздел 1 · Config это декларация, а не чтение

Config<A> описывает как получить значение типа A из конфигурации. Само чтение произойдёт при запуске, а до тех пор это просто значение, которое можно комбинировать.

import { Config, Duration, Effect, Schema } from 'effect';

const port = Config.port('PORT');           // Config<number>, ещё и проверит диапазон
const url = Config.url('API_URL');          // Config<URL>, распарсит и провалидирует
const interval = Config.duration('PROBE_INTERVAL'); // Config<Duration>, поймёт "30 seconds"
const debug = Config.boolean('DEBUG');      // true, yes, on, 1 это true
const targets = Config.schema(Config.Array(Schema.String), 'TARGETS'); // строка через запятую

Обрати внимание: это не строки, которые ты потом парсишь руками. Config.port уже знает, что порт это число от 1 до 65535. Config.duration понимает человеческую запись. Config.url вернёт готовый URL, а не строку, которую ты забудешь проверить.

Набор готовых примитивов: string, nonEmptyString, number, finite, int, boolean, literal, literals, port, url, date, duration, logLevel, redacted.

Всё, что сложнее одного значения, читается через Config.schema(codec, ключ). Это второй по важности инструмент модуля, и на нём стоит задержаться: он берёт любую схему из 02 · Schema и делает из неё описание конфигурации. Два готовых кодека помогают с частыми случаями:

  • Config.Array(Schema.String) читает и настоящий массив, и плоскую строку "a,b,c";
  • Config.Record(Schema.String, Schema.String) читает и объект, и строку "key=val,key2=val2".

Разделитель настраивается опцией, по умолчанию запятая. Раньше под каждый такой случай был отдельный конструктор (Config.array, Config.hashMap, Config.branded), теперь один вход в мир схем, где ты и так умеешь всё.

Сборка в структуру

const PulseConfig = Config.all({
  port: Config.port('PORT').pipe(Config.withDefault(3000)),
  baseUrl: Config.url('API_URL'),
  token: Config.redacted('API_TOKEN'),
  interval: Config.duration('PROBE_INTERVAL').pipe(Config.withDefault(Duration.seconds(30))),
  targets: Config.schema(Config.Array(Schema.String), 'TARGETS'),
  features: Config.all({
    sse: Config.boolean('SSE').pipe(Config.withDefault(false)),
  }).pipe(Config.nested('FEATURES')),
});

Config.all собирает объект из отдельных описаний. Config.nested('FEATURES') добавляет префикс: вложенный SSE будет искаться как FEATURES_SSE. Config.withDefault делает ключ необязательным.

Читается это как обычный эффект:

const program = Effect.gen(function* () {
  const config = yield* PulseConfig;
  console.log(config.port);          // 3000
  console.log(config.baseUrl.hostname); // api.pulse.dev
  console.log(String(config.interval)); // 30 seconds
  console.log(config.targets);       // [ 'a.com', 'b.com' ]
  console.log(config.features.sse);  // false
});

Тип config выведен полностью: port это number, baseUrl это URL, interval это Duration. Никаких as, никаких восклицательных знаков.

Ещё два полезных комбинатора:

  • Config.option(config) вместо падения возвращает Option<A>, если ключа нет;
  • Config.orElse(другой) берёт запасной источник, удобно при переименовании переменных с сохранением совместимости.

Раздел 2 · Ошибка называет ключ и причину

Запустим программу с пустой конфигурацией:

ConfigError: Missing key "API_URL"

Разница с ручным process.env.API_URL! не в количестве строк, а в моменте. Программа не стартует вообще, и ошибка называет конкретный ключ. С восклицательным знаком она бы стартовала, дожила до первого запроса и упала где-то в глубине с Invalid URL: undefined.

Одну вещь стоит знать заранее, чтобы не ждать от Config лишнего: Config.all останавливается на первой незакрытой позиции, а не собирает список всех сразу. Если хочется увидеть полную картину за один прогон, описывай конфигурацию одной схемой через Config.schema(Schema.Struct({...})): тогда разбор идёт по правилам схемы, и ошибки собираются так же, как в 02 · Schema.

Ошибка конфигурации это ConfigError, обычная типизированная ошибка в канале E. Внутри у неё поле cause, и там либо SourceError (источник не читается: файла нет, прав нет), либо SchemaError (значение нашлось, но не подошло). Разделение полезное: первое чинит инфраструктура, второе человек, который правил переменные. Ошибку можно поймать и вывести человеку, а можно оставить: программа не стартует, и это правильное поведение.

Проверки сверх типа

Правила, которые типом не выразишь (интервал не меньше секунды, порт не из привилегированного диапазона), навешиваются схемой:

import { Config, Duration, Schema } from 'effect';

const Interval = Schema.Duration.check(
  Schema.makeFilter((d) =>
    Duration.greaterThanOrEqualTo(d, Duration.seconds(1))
      ? undefined
      : 'интервал не может быть меньше секунды',
  ),
);

const interval = Config.schema(Interval, 'PROBE_INTERVAL');

Отдельного Config.validate в v4 нет, и это упрощение: проверка значения это работа схемы, а не конфигурации. Ты уже писал такие фильтры в 02 · Schema, теперь тот же навык работает и здесь.

Туда же уехал третий уровень строгости. Schema.brand навешивает brand прямо на этапе чтения, и дальше по коду DatabaseUrl уже не перепутаешь с RedisUrl, хотя обе строки.

Если проверка проще выражается кодом, а не схемой, есть Config.mapOrFail: он получает уже разобранное значение и возвращает эффект, который может упасть ConfigError.

Раздел 3 · Секреты: Redacted

import { Config, Effect, Redacted } from 'effect';

const program = Effect.gen(function* () {
  const token = yield* Config.redacted('API_TOKEN');

  console.log(String(token));        // <redacted>
  console.log(Redacted.value(token)); // super-secret
});

Redacted<string> это обёртка, у которой сломано строковое представление. Положишь её в лог, в JSON.stringify, в сообщение об ошибке, в трейс, везде будет <redacted>. Достать значение можно только явным Redacted.value, и этот вызов легко найти глазами на ревью.

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

С Redacted мы уже встречались в 19 · HttpClient и обвязка API: именно в нём middleware получает bearer-токен. Круг замкнулся.

Есть ещё Redacted.wipeUnsafe(secret), который затирает значение в памяти. Нужен редко (обычно для ключей шифрования с коротким временем жизни), но знать полезно.

Раздел 4 · Откуда берутся значения: ConfigProvider

Config описывает что прочитать, ConfigProvider отвечает откуда. По умолчанию это переменные окружения, но это лишь один из вариантов.

Тесты без переменных окружения

import { ConfigProvider, Effect } from 'effect';

const TestProvider = ConfigProvider.fromEnvRecord({
  API_URL: 'https://api.pulse.dev/v1',
  API_TOKEN: 'super-secret',
  TARGETS: 'a.com,b.com',
  FEATURES_SSE: 'true',
});

const test = program.pipe(Effect.provideService(ConfigProvider.ConfigProvider, TestProvider));

Провайдер это ссылка в контексте, как минимальный уровень логирования из 18 · Observability, поэтому подменяется он обычным Effect.provideService. Никакого process.env.X = ... в тестах, никакой утечки состояния между файлами, никаких гонок при параллельном запуске. Конфигурация стала обычной зависимостью, которую подменяют, как любой другой сервис.

Есть и второй конструктор, ConfigProvider.fromUnknown, который берёт вложенный объект. Он удобнее, когда конфигурация приехала цельным JSON, а не набором плоских ключей:

const TestProvider = ConfigProvider.fromUnknown({
  api: { url: 'https://api.pulse.dev/v1' },
  features: { sse: true },
});

.env и файлы секретов

Два источника закрывают почти весь прод, и оба теперь живут в самом ConfigProvider:

import { ConfigProvider, Layer } from 'effect';
import { NodeServices } from '@effect/platform-node';

const ConfigLive = ConfigProvider.layerAdd(ConfigProvider.fromDir({ rootPath: './secrets' })).pipe(
  Layer.provide(ConfigProvider.layerAdd(ConfigProvider.fromDotEnv({ path: '.env' }))),
  Layer.provide(NodeServices.layer),
);

fromDotEnv читает файл .env. fromDir читает дерево файлов, где имя файла это ключ, а содержимое это значение. Именно так монтируются секреты в Docker (/run/secrets/) и в Kubernetes: секрет лежит файлом, а не переменной окружения, и потому не виден в docker inspect и в списке процессов. Оба конструктора это эффекты (им нужен FileSystem), и layerAdd принимает эффект напрямую, разворачивать вручную не надо.

Одна ловушка, на которой легко потерять час. layerAdd значит “добавить к тому, что уже есть”, поэтому провайдеры выстраиваются цепочкой через Layer.provide, а не складываются Layer.mergeAll. Слитые через mergeAll два провайдера не сложатся: каждый возьмёт базовый и перезапишет результат другого, и один из источников молча пропадёт. Если нужно наоборот, заменить всё целиком, бери ConfigProvider.layer без Add.

Ещё из полезного: ConfigProvider.constantCase переименовывает ключи на лету (удобно, когда в коде apiUrl, а в окружении API_URL), ConfigProvider.nested добавляет общий префикс всему источнику, ConfigProvider.orElse выстраивает запасные источники в цепочку.

Раздел 5 · Фича-флаги

Фича-флаг это обычный Config.boolean, а интересное начинается там, где от флага зависит состав программы:

import { Config, Effect, Layer } from 'effect';

const StorageLive = Layer.unwrapEffect(
  Effect.gen(function* () {
    const usePostgres = yield* Config.boolean('FEATURES_POSTGRES').pipe(Config.withDefault(false));
    return usePostgres ? StoragePostgresLive : StorageJsonlLive;
  }),
);

Layer.unwrapEffect берёт эффект, возвращающий слой, и превращает его в слой. Так реализация выбирается один раз на старте, а не проверяется флагом в каждом вызове. Бизнес-код при этом не знает, что флаг вообще существует: он просит Storage и получает ту реализацию, которую собрали.

Это и есть правильное место для флага. Плохой флаг это if (features.newAlgorithm) в двадцати местах, хороший это одна развилка на уровне сборки графа зависимостей.

Раздел 6 · FileSystem и Path

Прямой import fs from 'node:fs/promises' прибивает код к Node: тот же модуль не заработает ни в Bun, ни в Deno, ни в воркере, и в тестах его придётся мокать. FileSystem в effect это тот же набор операций, но как сервис.

import { NodeServices } from '@effect/platform-node';
import { Effect, FileSystem, Path, Stream } from 'effect';

const program = Effect.gen(function* () {
  const fs = yield* FileSystem.FileSystem;
  const path = yield* Path.Path;

  const dir = yield* fs.makeTempDirectoryScoped();
  const file = path.join(dir, 'report.jsonl');

  yield* fs.writeFileString(file, '{"a":1}\n{"a":2}\n');
  const exists = yield* fs.exists(file);
  const info = yield* fs.stat(file);
  // exists = true, Number(info.size) = 16

  const lines = yield* fs.stream(file).pipe(
    Stream.decodeText(),
    Stream.splitLines,
    Stream.runCollect,
  );
  // две строки
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer));

Что тут стоит заметить.

fs.makeTempDirectoryScoped() создаёт временную директорию и регистрирует её удаление в Scope. Никаких забытых папок в /tmp после падения теста. Это тот же механизм, что в 05 · Resources, просто применённый к файловой системе.

fs.stream(file) возвращает Stream<Uint8Array>, а не строку целиком. В связке с Stream.decodeText и Stream.splitLines из 09 · Stream получается построчная обработка файла любого размера с готовым backpressure.

info.size это bigint, а не number. Мелочь, но при сравнении с числом компилятор поругается, и это правильно: размеры файлов действительно бывают больше, чем безопасно хранить в number.

Path.Path это отдельный сервис, а не импорт из node:path. Тот же join, resolve, basename, extname, но подменяемый и кроссплатформенный.

NodeServices.layer подаёт сразу весь набор платформенных сервисов: FileSystem, Path, Terminal, Stdio, ChildProcessSpawner. Для браузера или Bun подставляется свой слой, а код остаётся тем же. Если нужен только один сервис, есть и точечные слои вроде Path.layer.

Раздел 7 · ChildProcess, запуск процессов

import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;

  const output = yield* spawner.string(ChildProcess.make('echo', ['привет из процесса']));
  // "привет из процесса\n"

  const lines = yield* spawner.lines(ChildProcess.make('git', ['log', '--oneline', '-n', '5']));
  // массив из пяти строк
});

Тут две половины, и разделение осмысленное. ChildProcess.make(...) это описание команды, чистое значение, которое ничего не запускает. Запускает ChildProcessSpawner, платформенный сервис. Значит, описание команды можно собрать где угодно, а подменить сам запуск (в тестах, в песочнице) можно одним слоем.

Способ получить результат выбирается методом спавнера:

  • spawner.string, весь вывод строкой;
  • spawner.lines, массив строк;
  • spawner.streamLines, поток строк (для долгих процессов, чтобы не копить вывод в памяти);
  • spawner.exitCode, только код возврата;
  • spawner.spawn, дескриптор процесса, если нужен полный контроль. Он требует Scope, потому что процесс надо закрыть.

Настройки навешиваются комбинаторами на описание: ChildProcess.setEnv({...}), ChildProcess.setCwd(dir), ChildProcess.pipeTo(другая) для конвейера. Часть настроек (env, extendEnv, stdin) можно передать третьим аргументом прямо в make.

Два практических плюса по сравнению с child_process. Первый: прерывание работает само. Обернул вызов в Effect.timeout('5 seconds'), процесс будет убит вместе с отменой файбера, без ручного kill. Второй: spawner.spawn даёт дескриптор с полем all, а это обычный Stream с настоящим backpressure, поэтому болтливый процесс не съест память.

Раздел 8 · KeyValueStore

Маленькое состояние между запусками (когда последний раз проверяли, курсор пагинации, кеш токена) обычно живёт в файле, который каждый раз пишут заново руками. KeyValueStore даёт для этого один интерфейс и три реализации.

import { Effect } from 'effect';
import { KeyValueStore } from 'effect/unstable/persistence';

const program = Effect.gen(function* () {
  const kv = yield* KeyValueStore.KeyValueStore;
  yield* kv.set('last-run', new Date().toISOString());
  const value = yield* kv.get('last-run'); // Option<string>
}).pipe(Effect.provide(KeyValueStore.layerMemory));

Реализации: layerMemory (тесты), layerFileSystem(dir) (каждый ключ отдельным файлом), layerSql() (таблица в базе), layerStorage (браузерный localStorage). Меняется строка сборки, код не меняется вообще.

Интереснее типизированная версия:

import { Effect, Schema } from 'effect';
import { KeyValueStore } from 'effect/unstable/persistence';

const LastRun = Schema.Struct({
  at: Schema.Number,
  ok: Schema.Boolean,
});

const program = Effect.gen(function* () {
  const kv = yield* KeyValueStore.KeyValueStore;
  const store = KeyValueStore.toSchemaStore(kv, LastRun);

  yield* store.set('pulse', { at: 1000, ok: true });
  const value = yield* store.get('pulse');
  // Option.some({ at: 1000, ok: true }), уже разобранное схемой
}).pipe(Effect.provide(KeyValueStore.layerMemory));

toSchemaStore(store, schema) это чистая функция поверх уже полученного хранилища, а не отдельный слой: сначала берёшь любую физическую реализацию, потом навешиваешь на неё схему. Сериализация и разбор идут через схему, поэтому испорченный вручную файл даст SchemaError, а не тихо загруженный мусор.

Если типизированное хранилище нужно как отдельная зависимость, заверни его в свой Context.Service: тогда бизнес-код просит RunStore, а не KeyValueStore плюс схему.

Pulse · вклад этого урока

Файл src/config.ts:

// pulse-<nick>/src/config.ts
import { Config, Duration, Schema } from 'effect';

const Interval = Schema.Duration.check(
  Schema.makeFilter((d) =>
    Duration.greaterThanOrEqualTo(d, Duration.seconds(1))
      ? undefined
      : 'интервал пробы не может быть меньше секунды',
  ),
);

export const PulseConfig = Config.all({
  port: Config.port('PORT').pipe(Config.withDefault(3000)),
  targets: Config.schema(Config.Array(Schema.String), 'TARGETS'),
  interval: Config.schema(Interval, 'PROBE_INTERVAL').pipe(
    Config.withDefault(Duration.seconds(30)),
  ),
  timeout: Config.duration('PROBE_TIMEOUT').pipe(Config.withDefault(Duration.seconds(5))),
  token: Config.redacted('PULSE_TOKEN'),
  storagePath: Config.string('STORAGE_PATH').pipe(Config.withDefault('./data/events.jsonl')),
  telemetry: Config.all({
    otlpUrl: Config.option(Config.url('OTLP_URL')),
    prometheusPort: Config.port('PROMETHEUS_PORT').pipe(Config.withDefault(9464)),
  }).pipe(Config.nested('TELEMETRY')),
  features: Config.all({
    sse: Config.boolean('SSE').pipe(Config.withDefault(true)),
    postgres: Config.boolean('POSTGRES').pipe(Config.withDefault(false)),
  }).pipe(Config.nested('FEATURES')),
});

Дальше конфигурация расходится по слоям. Телеметрия из 18 · Observability больше не хардкодит адрес коллектора:

export const TelemetryLive = Layer.unwrapEffect(
  Effect.gen(function* () {
    const config = yield* PulseConfig;
    return Otlp.layerJson({
      baseUrl: String(config.telemetry.otlpUrl),
      resource: { serviceName: 'pulse' },
    }).pipe(Layer.provide(FetchHttpClient.layer));
  }),
);

Хранилище переезжает с node:fs на FileSystem, и заодно получает ротацию:

export class Storage extends Context.Service<Storage, StorageShape>()('Storage') {
  static readonly layer = Layer.effect(
    Storage,
    Effect.gen(function* () {
      const fs = yield* FileSystem.FileSystem;
      const path = yield* Path.Path;
      const config = yield* PulseConfig;

      yield* fs.makeDirectory(path.dirname(config.storagePath), { recursive: true });

      const append = (line: string) =>
        fs.writeFileString(config.storagePath, `${line}\n`, { flag: 'a' });

      const rotateIfNeeded = Effect.gen(function* () {
        const info = yield* fs.stat(config.storagePath);
        if (info.size > 5_000_000n) {
          const stamp = yield* Clock.currentTimeMillis;
          yield* fs.rename(config.storagePath, `${config.storagePath}.${stamp}`);
        }
      });

      return Storage.of({ append, rotateIfNeeded });
    }),
  ).pipe(Layer.provide(NodeServices.layer));
}

И новая подкоманда pulse doctor на ChildProcess: проверяет, что ping до цели проходит, что curl видит TLS-сертификат, и печатает таблицу. Двадцать строк вместо самописной обёртки над child_process.

Тесты меняются на строчку: вместо расстановки process.env в beforeEach теперь Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord({...})) внутри самого теста. Параллельный запуск больше не ломается.

Финал · чек-лист

Чек-листготово

ДЗ

Все задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-20.

Дальше

  • 21 · Schema, второй заход. Конфигурация это простой случай разбора внешних данных. Дальше рекурсивные структуры, асинхронная валидация и человеческие сообщения об ошибках.

Полезно перечитать:

  • 06-node · 03 Файлы и процессы, чтобы сравнить напрямую: FileSystem и ChildProcess это тот же набор операций, но подменяемый, кроссплатформенный и с отменой из коробки.
  • 14-infra · 02 DevOps и compose, про то, как секреты доезжают до контейнера. ConfigProvider.fromDir это ровно та схема с примонтированными файлами, а не с переменными окружения.