Config, секреты и platform: FileSystem, Path, Command, KeyValueStore
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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,
Configкак декларация: примитивы,all,nested,withDefault,option. - Раздел 2, ошибки конфигурации: что в них видно, плюс проверки схемой и осмысленные типы.
- Раздел 3,
Redacted, секрет, который не попадает в лог случайно. - Раздел 4,
ConfigProvider: окружение,.env, файлы секретов, подмена в тестах. - Раздел 5, фича-флаги и выбор реализации слоем.
- Раздел 6,
FileSystemиPathвместоnode:fs. - Раздел 7,
ChildProcess, запуск внешних процессов. - Раздел 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это ровно та схема с примонтированными файлами, а не с переменными окружения.