Раздел 46 · Effect: Тихая Академия

Подвал Почтамта: платформа

middle~20 мин

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

Липвиг попросил две вещи, и обе про то, что лежит вне программы: файлы на диске и значения, которые переживают перезапуск. В уроке 94 база стала сервисом SqlClient, а драйвер слоем. С диском, путями и процессами так же: в Effect это сервисы платформы. Код говорит, что ему нужно, а Node, Bun или тест подают реализацию.

Что даёт платформа

Сервисы лежат в самом пакете effect:

  • FileSystem: читать, писать, создавать папки, проверять, есть ли файл;
  • Path: собирать и разбирать пути;
  • Terminal и Stdio: терминал и стандартные потоки, они понадобятся в следующем уроке;
  • ChildProcessSpawner из effect/process: запускать другие программы.

Реализации живут в пакетах платформ. Для Node это @effect/platform-node, и его NodeServices.layer даёт разом FileSystem, Path, Stdio, Terminal и ChildProcessSpawner. Для Bun есть @effect/platform-bun, для браузера @effect/platform-browser. Почтамт про них не знает: модуль archive.ts импортирует только effect, а слой ему подают снаружи. Так же, как store.ts не знает про PGlite.

Зачем сервис, если в Node есть node:fs? Ровно затем, зачем HttpClient вместо fetch в уроке 90. Код на node:fs работает только в Node: в браузере, где идут задачи курса, диска нет, и тест такого кода пишет настоящие файлы. Код на FileSystem работает везде, где ему дали слой, и в тесте слой это карта в памяти. Ошибки тоже честнее: вместо исключения с кодом ENOENT эффект падает значением PlatformError в канале ошибок.

Пути

Путь склеивают не плюсом, а сервисом Path:

const path = yield* Path.Path;
path.join('/archive/', 'dead', 'letters.jsonl'); // '/archive/dead/letters.jsonl'
path.dirname('/archive/dead/letters.jsonl'); // '/archive/dead'

join ставит разделитель сам и убирает лишний: папку '/archive/' со слэшем на конце и '/archive' без него он склеит одинаково. dirname отдаёт папку файла. Path.layer в самом effect это пути в стиле POSIX, через /; на Node пути даёт NodeServices.layer, со своими правилами для своей системы.

Файлы

Полка мёртвых писем это файл, в котором каждое письмо занимает одну строку JSON. Такой формат зовут JSON Lines: дописать в конец можно, не читая и не переписывая файл.

const ArchiveLine = Schema.fromJsonString(Letter);

export const archiveDead = (dir: string, letters: ReadonlyArray<Letter>) =>
  Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem;
    const path = yield* Path.Path;
    const file = path.join(dir, 'dead', 'letters.jsonl');
    yield* fs.makeDirectory(path.dirname(file), { recursive: true });
    const lines = yield* Effect.forEach(letters, (letter) => Schema.encodeEffect(ArchiveLine)(letter));
    if (lines.length > 0) yield* fs.writeFileString(file, lines.map((line) => `${line}\n`).join(''), { flag: 'a' });
    return file;
  });

Разберём по строкам.

  • Schema.fromJsonString(Letter) из урока 79: письмо в строку JSON и обратно одной схемой. Кодирование тоже идёт через схему, а не через JSON.stringify: бренд LetterId и литералы статуса проверяются на выходе так же, как на входе.
  • makeDirectory с recursive: true создаёт папку вместе с родителями и не падает, если папка уже есть. Как mkdir -p.
  • writeFileString с flag: 'a' дописывает в конец. Без флага файл переписывается целиком: вчерашняя полка исчезает, и остаётся только сегодняшняя пачка. Пустую пачку не пишут вовсе: нет писем, нет и записи.

Полку читают обратно тремя вызовами: fs.exists(file) скажет, есть ли файл, fs.readFileString(file) отдаст текст, а Schema.decodeEffect(ArchiveLine) разберёт каждую строку. Файла нет, значит, мёртвых писем ещё не было, и это пустой массив, а не беда. Если читать файл, которого нет, без проверки, эффект упадёт PlatformError с причиной NotFound:

NotFound: FileSystem.readFile (/archive/dead/letters.jsonl)

Диск без диска

В браузере диска нет, и тест его не трогает. FileSystem.layerNoop собирает файловую систему из тех методов, что ему дали; остальные падают как «нет такого файла»:

const files = new Map<string, string>();

const MemoryFs = FileSystem.layerNoop({
  makeDirectory: () => Effect.void,
  exists: (path) => Effect.succeed(files.has(path)),
  readFileString: (path) => Effect.succeed(files.get(path) ?? ''),
  writeFileString: (path, data, options) =>
    Effect.sync(() => {
      files.set(path, (options?.flag === 'a' ? (files.get(path) ?? '') : '') + data);
    }),
});

Одна мелочь, на которой спотыкаются: layerNoop не выводит строковые методы из байтовых. Дали ему writeFile, а код зовёт writeFileString, и запись падает. Поэтому тестовая ФС задаёт ровно те методы, которые зовёт код. Это тот же приём, что подменный провод в уроке 90: слой знает столько, сколько нужно тесту, и ни байтом больше.

Ящик с номерками

Номерок должен пережить перезапуск, значит, хранить его в Ref нельзя. Для таких мелочей есть хранилище ключей KeyValueStore из effect/persistence: строка по ключу.

export const nextTicket = Effect.gen(function* () {
  const store = KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Schema.Int);
  const last = yield* store.get('ticket');
  const ticket = Option.getOrElse(last, () => 0) + 1;
  yield* store.set('ticket', ticket);
  return ticket;
});

Само хранилище знает только строки: get отдаёт строку или undefined. toSchemaStore надевает на него схему, и get отдаёт уже Option<number>, а set принимает число и кладёт его строкой '3'. Нет ключа, и это Option.none(), то есть первый номерок.

Где лежат номерки, решает слой: KeyValueStore.layerMemory в памяти, для тестов; layerFileSystem(dir) файлами в папке, ему нужны FileSystem и Path; layerStorage(() => localStorage) в браузере; layerSql() в той же базе, что письма. Код nextTicket от выбора не меняется.

Чужие программы

Иногда Почтамту надо запустить другую программу: узнать, сколько места занимает подвал, или отдать этикетку принтеру. Для этого есть effect/process:

const archiveSize = Effect.gen(function* () {
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
  return yield* spawner.string(ChildProcess.make('du', ['-s', '/srv/pochtamt/dead']));
});

ChildProcess.make только описывает запуск: программа, аргументы массивом. Строкой с пробелами аргументы не передают, по той же причине, по какой в уроке 94 не склеивали SQL. spawner.string запускает и собирает вывод строкой, spawner.lines по строкам, spawner.spawn отдаёт процесс с потоками вывода. Запуск процессов это тоже сервис: на Node его даёт NodeServices.layer, в тесте ChildProcessSpawner.make с ответом, который нужен тесту. В браузере процессов нет вовсе, поэтому в задаче их не будет.

Где тут дыра

const archive = (dir: string, letters: ReadonlyArray<Letter>) =>
  Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem;
    const file = dir + '/dead/letters.jsonl';
    yield* fs.writeFileString(file, letters.map((letter) => `${JSON.stringify(letter)}\n`).join(''));
  });

На его примере всё работало: одна пачка за ночь, папка '/archive'. Соседний пример: ночью мёртвые письма приходят двумя пачками. Вторая запись переписывает файл, и утром на полке только вторая пачка; о первой не знает никто, даже ошибка.

А через неделю папку настроили как '/archive/', со слэшем, и путь стал '/archive//dead/letters.jsonl'. Файловая система такое прощает, а сравнение путей и ключи кэша нет: архив раздвоился в отчётах. path.join и flag: 'a' закрывают обе дыры, а Schema.encodeEffect вместо JSON.stringify не пустит на полку письмо с номером ноль.

Нестабильно. FileSystem и Path в корне effect, а также effect/persistence и effect/process в 4.1 помечены @stability unstable: имена методов и настроек могут поменяться в минорной версии. Урок написан на effect@4.1.0.

Почтамт, этап 6

Почтамт спускается в подвал. Новый модуль src/archive.ts: путь к полке, мёртвые письма строками JSON в конец файла, чтение полки обратно по схеме и номерки, которые живут в хранилище. Тест подаёт файловую систему в памяти и считает, что именно легло в файл.

Практика

Глубже в архиве

Файлы и процессы на голом Node разобраны в уроке про файлы и процессы.

Дальше

К ночи в подвале новая полка, а ящик с номерками помнит, на чём остановился. Липвиг стучит по нему костяшкой, улыбается и тут же мрачнеет.

Ночная смена ждёт в следующем уроке.