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

Слой как фабрика

middle~25 мин

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

provideService из прошлого урока кладёт в контекст готовое значение. Это годится, пока значение не меняется. У журнала есть состояние, массив строк, и если положить один и тот же журнал в две смены, они будут писать в одну тетрадь.

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

Журнал

Сначала сам журнал, обычный сервис из прошлого урока:

export class Journal extends Context.Service<
  Journal,
  {
    readonly write: (entry: string) => Effect.Effect<void>;
    readonly entries: Effect.Effect<readonly string[]>;
  }
>()('bashnya/Journal') {}

write дописывает строку, entries отдаёт все строки по порядку. Обе квитанции, хотя журнал пока живёт в памяти: форма не знает, где он лежит.

Слой

Слой это рецепт сервиса. У него, как у квитанции, три параметра: Layer<ROut, E, RIn>. ROut это сервисы, которые слой даёт. E это то, как сборка может упасть. RIn это то, что слою нужно для сборки. Слой ничего не строит, пока его не попросят, так же как квитанция ничего не делает, пока её не запустят.

Два основных способа сделать слой.

Layer.succeed кладёт готовое значение. Это provideService, записанный рецептом:

export class Ledger extends Context.Service<
  Ledger,
  { readonly wasSent: (id: number) => Effect.Effect<boolean> }
>()('bashnya/Ledger') {
  static readonly layer = (sent: Iterable<number>) => {
    const ids = new Set(sent);
    return Layer.succeed(Ledger, Ledger.of({ wasSent: (id) => Effect.succeed(ids.has(id)) }));
  };
}

Книга это функция от номеров: какие кадры башня отправляла, знает тот, кто собирает башню. Получился слой Layer<Ledger>: даёт книгу, не падает, ничего не просит.

Layer.effect создаёт сервис квитанцией. Квитанция исполняется при каждой сборке слоя:

export class Journal extends Context.Service<Journal, { /* как выше */ }>()('bashnya/Journal') {
  static readonly layer = Layer.effect(
    Journal,
    Effect.sync(() => {
      const entries: string[] = [];
      return Journal.of({
        write: (entry) => Effect.sync(() => void entries.push(entry)),
        entries: Effect.sync(() => [...entries]),
      });
    }),
  );
}

Массив entries создаётся внутри Effect.sync, то есть заново при каждой сборке. Слой это фабрика, а не значение: две сборки дают два чистых журнала.

Слою тоже что-то нужно

Провод для тестов не шлёт строки по Магистрали, а пишет их в журнал, чтобы тест мог их прочитать:

export class Wire extends Context.Service<
  Wire,
  { readonly send: (line: string) => Effect.Effect<void, SendFailed> }
>()('bashnya/Wire') {
  static readonly layerTest = Layer.effect(
    Wire,
    Effect.gen(function* () {
      const journal = yield* Journal;
      return Wire.of({ send: (line) => journal.write(`> ${line}`) });
    }),
  );
}

Квитанция, которая строит провод, сама берёт журнал: yield* Journal. Поэтому тип слоя Layer<Wire, never, Journal>: даёт провод, не падает, а для сборки просит журнал. Требования квитанции-фабрики стали требованиями слоя. Ссылка на Journal внутри Wire не мешает тому, что журнал объявлен ниже по файлу: тело генератора исполняется при сборке, когда все классы уже на месте.

Соглашение имён

Слои живут статическими полями класса сервиса, рядом с формой, и называются одинаково во всех библиотеках Effect:

  • layer для настоящей сборки;
  • layerTest для дублёра в тестах;
  • layerConfig для сборки из конфигурации, она придёт в уроке 51.

Если слою нужны параметры, как книге, layer становится функцией: Ledger.layer([1, 2]). Читатель кода видит Wire.layerTest и сразу знает, где искать и что это.

Effect.provide

Слой отдают квитанции так же, как значение: Effect.provide(слой). Вот ночная смена из нового модуля main.ts, ей нужны провод, книга и журнал:

export const nightShift = Effect.fn('Tower.nightShift')(function* (
  lines: readonly string[],
): Effect.fn.Return<NightReport, never, Wire | Ledger | Journal> {
  const report = yield* processNight(lines);
  yield* Journal.use((journal) => journal.write(summary(report)));
  return report;
});
const night = ['T-3->T-1|5|Text|%D0%B3', 'мусор', 'T-5->T-1|10|Ping|'];

const journal = await Effect.runPromise(
  Effect.andThen(nightShift(night), Journal.use((journal) => journal.entries)).pipe(
    Effect.provide(Wire.layerTest),
    Effect.provide(Ledger.layer([1, 2])),
    Effect.provide(Journal.layer),
  ),
);
console.log(journal);
[
  '> T-1->T-3|5|Ack|5',
  '> T-1->T-5|10|Ping|',
  'обработано 2, ошибок 1, призраков 0, дублей 0, сбоев 0'
]

Читай pipe снизу вверх, как слои луковицы снаружи внутрь. Внешний provide(Journal.layer) собирает журнал. Средний кладёт книгу. Внутренний собирает провод, и ему нужен журнал: он берёт его у внешнего. Смена тоже берёт журнал у внешнего. Журнал один, и в нём и строки провода, и сводка смены.

Перевод с Ук

Поменяй порядок, журнал внутрь:

const report = Effect.runPromise(
  nightShift(['T-5->T-1|10|Ping|']).pipe(
    Effect.provide(Journal.layer),
    Effect.provide(Wire.layerTest),
    Effect.provide(Ledger.layer([])),
  ),
);
ex48b.ts(7,3): error TS2379: Argument of type 'Effect<NightReport, never, Journal>' is not assignable to parameter of type 'Effect<NightReport, never, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Type 'Journal' is not assignable to type 'never'.

Последняя строка: в R остался Journal. Откуда, если журнал положили первым же provide? Читай по шагам. После provide(Journal.layer) у смены в R остались Wire | Ledger, журнал закрыт. Следующий provide(Wire.layerTest) закрывает Wire, но приносит требования самого слоя: layerTest просит журнал. Тип стал Effect<NightReport, never, Ledger | Journal>. Последний provide закрыл книгу, а журнал для провода так никто и не положил.

Effect.provide(слой) вычитает из R то, что слой даёт, и прибавляет то, что слой просит. Поэтому слой, которому что-то нужно, кладут ближе к квитанции, а то, что ему нужно, дальше.

Под капотом

Если снять с Layer.effect обёртку, останется квитанция, которая возвращает кусок контекста: Effect<Context<Journal>, E, RIn>. Effect.provide исполняет её перед твоей программой, берёт полученный словарь и кладёт его в контекст программы. Поэтому сборка слоя может делать всё, что умеет квитанция: брать другие сервисы, падать с ошибкой в E, ждать.

Отсюда и разница между succeed и effect. Layer.succeed(Ledger, книга) получает готовую книгу в момент, когда ты написал эту строку, и при каждой сборке кладёт её же. Layer.effect(Journal, квитанция) получает рецепт и исполняет его при каждой сборке заново. Сколько раз слой собирается в одной программе, и что Effect делает, чтобы не собирать его лишний раз, разберём в уроке 50.

Где тут дыра

const makeJournal = () => {
  const entries: string[] = [];
  return Journal.of({
    write: (entry) => Effect.sync(() => void entries.push(entry)),
    entries: Effect.sync(() => [...entries]),
  });
};

// журнал создан один раз, когда модуль загрузился
const journalLayer = Layer.succeed(Journal, makeJournal());

const shift = (name: string) =>
  Effect.andThen(
    Journal.use((journal) => journal.write(name)),
    Journal.use((journal) => journal.entries),
  ).pipe(Effect.provide(journalLayer));

console.log(await Effect.runPromise(shift('ночь')));
console.log(await Effect.runPromise(shift('утро')));
[ 'ночь' ]
[ 'ночь', 'утро' ]

Одна смена работает. Вторая видит строку первой. makeJournal() вызвана один раз, когда модуль загрузился, и Layer.succeed кладёт этот единственный журнал в каждую сборку. Утренняя смена пишет в ночную тетрадь, а в тестах это хуже: тест, который прошёл первым, оставляет строки для всех следующих, и результат зависит от порядка тестов.

Лечится тем, что создание уезжает внутрь Layer.effect(Journal, Effect.sync(makeJournal)). Правило простое: если у сервиса есть состояние, слой его создаёт, а не кладёт.

Башня, этап 13

У башни теперь есть журнал, а у её сервисов слои. Книга кладётся готовой, журнал заводится заново при каждой сборке, провод для тестов пишет каждую строку в журнал. Ночная смена в новом модуле main.ts обрабатывает ночь и записывает в журнал сводку. Формат строк журнала в карточке заказа.

Практика

Дальше

Рыдкулл читает журнал первой смены и стучит пальцем по строкам с >.

Слои, которые собирают друг друга, ждут в следующем уроке.