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

Сервисы

middle~30 мин

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

* Первокурсники Академии пробуют командовать Сундуком, и Сундук их не слушает. Он приносит не то, что просят, а то, что в него положили. Старшекурсники это знают и с вечера кладут в него всё, что понадобится утром. Утром им остаётся только поднять крышку.

Сундук стоит у двери, как всегда с урока 02. Пондер в уроке 32 уже показал, что это за зверь: третий параметр квитанции, R, список того, что свиток просит у окружения. В Гексе ты сам складывал туда словарь. В EF4 R у башни всё время был never: всё, что ей нужно, приходило аргументом.

Этот урок о том, как перенести зависимости башни из аргументов в R. Функция перестаёт просить провод руками и говорит в типе: «мне нужен провод». Кто его положит, решают на краю программы, один раз.

Проводка

Вот цепочка из EF4, урезанная до сигнатур:

export const respond: (send: (line: string) => Promise<void>, frame: Frame) => ...
export const handleChecked: (tower: Tower, line: string) => ...
export const processNight: (tower: Tower, lines: readonly string[]) => ...

processNight получает tower и отдаёт его handleChecked, а та отдаёт tower.send в respond. Две функции из трёх провод не трогают, они только передают его дальше. Это проводка. Пока звеньев три, терпимо. Когда башне понадобятся журнал, часы и позывной, каждое звено получит ещё три аргумента, и каждый тест будет их собирать.

Второй способ, к которому тянется рука в городской конторе, это модуль с глобальным проводом: import { wire } from './wire'. Проводки нет, но провод один на всю программу. Тест не может дать свой, вторая башня не может взять другой, и по сигнатуре respond не видно, что она вообще куда-то пишет.

Effect предлагает третий путь. Функция объявляет зависимость в типе, а саму зависимость ей кладут снаружи, так же, как кладут вещи в Сундук: заранее и в одном месте.

Сервис

Сервис в Effect описывает что нужно, но не где это взять. Провод башни:

import { Context, type Effect } from 'effect';

import type { SendFailed } from './errors';

export class Wire extends Context.Service<
  Wire,
  { readonly send: (line: string) => Effect.Effect<void, SendFailed> }
>()('bashnya/Wire') {}

В объявлении три части.

  • Wire первым параметром это имя сервиса для компилятора. Класс ссылается сам на себя, чтобы в R стояло короткое Wire, а не длинный тип формы.
  • Форма вторым параметром: что умеет провод. Здесь одно умение, send, и оно возвращает квитанцию с SendFailed в ошибке. Промиса в форме нет, о нём ниже.
  • Ключ 'bashnya/Wire' в скобках после: строка, по которой окружение найдёт реализацию. В ключе путь пакета, по той же причине, что и в уроке 39 с тегами ошибок: два сервиса с ключом 'Wire' из разных библиотек будут путаться, а 'bashnya/Wire' с 'magistral/Wire' не перепутаешь.

Две пары скобок Service<...>()(...) выглядят странно, и у этого есть причина. TypeScript не умеет вывести часть параметров типа, а часть взять у тебя. Первый вызов берёт типы, которые ты пишешь сам, второй берёт ключ значением.

Класс пустой, и тело ему не нужно: всё нужное он получил от Context.Service. В том числе два помощника. Wire.of({ send }) собирает реализацию и проверяет её по форме: забудешь send или вернёшь из него промис, компилятор скажет. Wire['Service'] это тип формы, если он нужен отдельно, например для параметра функции, которая собирает провод.

Книга отправленных устроена так же:

export class Ledger extends Context.Service<
  Ledger,
  { readonly wasSent: (id: number) => Effect.Effect<boolean> }
>()('bashnya/Ledger') {}

wasSent возвращает квитанцию, а не boolean, хотя в EF4 хватало Set.has. Книга сегодня лежит в памяти, а завтра может лежать на диске или в другой башне. Форма сервиса не должна знать, где лежит книга, поэтому любое умение сервиса это квитанция.

yield* сервиса

Теперь respond берёт провод из окружения:

export const respond = Effect.fn('Tower.respond')(function* (
  frame: Frame,
): Effect.fn.Return<Outcome, SendFailed, Wire> {
  const wire = yield* Wire;
  switch (frame.message._tag) {
    case 'Text': {
      yield* wire.send(ackLine(frame));
      return 'acked';
    }
    // ...
  }
});

yield* Wire значит «дай мне провод из окружения». Сервис сам по себе квитанция: успех это реализация, ошибки нет, а в R стоит сам сервис. Поэтому yield* над ним работает так же, как над любой квитанцией, и над respond в виджете урока 37 в R вырос бы Wire. Третий параметр Effect.fn.Return пишет это явно: успех Outcome, ошибка SendFailed, нужен Wire.

handleChecked теперь не трогает провод вообще:

export const handleChecked = Effect.fn('Tower.handleChecked')(function* (
  line: string,
): Effect.fn.Return<Outcome, TowerError, Wire | Ledger> {
  const frame = yield* decodeFrame(line);
  yield* checkFrame(frame);
  return yield* respond(frame);
});

Аргумента tower нет, а Wire | Ledger в R есть. Требования поднимаются наверх сами: checkFrame просит Ledger, respond просит Wire, и handleChecked честно говорит, что ей нужны оба. Компилятор собирает это объединение без твоей помощи, так же как собирал ошибки в E.

В стиле pipe сервис достают через use:

export const acknowledge = (line: string): Effect.Effect<Frame, DecodeError | SendFailed, Wire> =>
  pipe(
    decodeFrame(line),
    Effect.tap((frame) => Wire.use((wire) => wire.send(ackLine(frame)))),
  );

Wire.use(f) это короткая запись для «возьми провод и сделай с ним f».

Откуда провод берётся

R это список долгов. Квитанцию с непустым R запустить нельзя, сначала долги надо закрыть. Самый простой способ это Effect.provideService: положить в окружение готовую реализацию для одного сервиса.

import { Effect } from 'effect';

import { Ledger, Wire } from './services';
import { handleChecked } from './tower';

const wire = Wire.of({ send: (line) => Effect.sync(() => console.log('>', line)) });
const ledger = Ledger.of({ wasSent: (id) => Effect.succeed(id === 1) });

const program = handleChecked('T-3->T-1|5|Text|гроза').pipe(
  Effect.provideService(Wire, wire),
  Effect.provideService(Ledger, ledger),
);

console.log(await Effect.runPromise(program));
> T-1->T-3|5|Ack|5
acked

Каждый provideService вычёркивает из R один сервис. После двух вызовов у program тип Effect<Outcome, TowerError, never>, и runPromise её примет.

Перевод с Ук

Забудь книгу:

const outcome = Effect.runPromise(
  handleChecked('T-3->T-1|5|Text|гроза').pipe(Effect.provideService(Wire, wire)),
);
ex47a.ts(9,3): error TS2379: Argument of type 'Effect<Outcome, TowerError, Ledger>' is not assignable to parameter of type 'Effect<Outcome, TowerError, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Type 'Ledger' is not assignable to type 'never'.

Снизу вверх. Ledger не подходит к never. Строка выше говорит, где: аргумент runPromise имеет тип Effect<Outcome, TowerError, Ledger>, а runPromise ждёт never в третьем параметре. Провод вычеркнули, книгу нет, и в R остался один долг. Совет про undefined в конце первой строки к делу не относится, его разобрали в уроке 45.

Сравни с EF4. Тогда забытый провод был бы аргументом, который не передали, и ошибка стояла бы в вызове handleChecked. Теперь она стоит на краю, в runPromise: внутри программы долги можно копить сколько угодно, а запустить квитанцию с долгами нельзя.

Под капотом

Окружение квитанции в Effect называется контекст, модуль Context. Его можно собрать руками:

import { Context, Effect } from 'effect';

import { Ledger, Wire } from './services';
import { handleChecked } from './tower';

const services = Context.make(Wire, Wire.of({ send: () => Effect.void })).pipe(
  Context.add(Ledger, Ledger.of({ wasSent: () => Effect.succeed(true) })),
);

console.log(await Effect.runPromise(Effect.provide(handleChecked('T-5->T-1|1|Ping|'), services)));
ponged

Внутри это Map от строки ключа к реализации: 'bashnya/Wire' ведёт к проводу, 'bashnya/Ledger' к книге. yield* Wire при исполнении смотрит в словарь по ключу 'bashnya/Wire'. Поэтому ключ и должен быть уникальным во всей программе: два сервиса с одинаковым ключом займут одну ячейку, и один молча подменит другой. Тип Wire в R нужен только компилятору, на исполнении его уже нет.

Старый объект на краю

Сервисы не отменяют объект Tower из EF4: он теперь источник реализаций для обоих сервисов. Его разбирают в одном месте, на краю:

export const provideTower =
  (tower: Tower) =>
  <A, E>(self: Effect.Effect<A, E, Wire | Ledger>): Effect.Effect<A, E> =>
    self.pipe(
      Effect.provideService(Wire, Wire.of({ send: (line) => sendLine(tower.send, line) })),
      Effect.provideService(
        Ledger,
        Ledger.of({ wasSent: (id) => Effect.sync(() => tower.sent.has(id)) }),
      ),
    );

Промис провода превращается в квитанцию один раз, в sendLine из урока 36, и дальше в программе его нет. runNight из урока 45 сохраняет сигнатуру, а внутри зовёт provideTower(tower). Тот, кто зовёт башню снаружи, разницы не заметит.

Где тут дыра

import { Context, Effect, Exit } from 'effect';

class Wire extends Context.Service<Wire, { readonly send: (line: string) => Promise<void> }>()(
  'Wire',
) {}

// тип: Effect<void, never, Wire>
const ack = (line: string) =>
  Effect.gen(function* () {
    const wire = yield* Wire;
    yield* Effect.promise(() => wire.send(line));
  });

const broken = Wire.of({ send: async () => Promise.reject(new Error('обрыв')) });
const exit = await Effect.runPromiseExit(ack('T-1->T-3|5|Ack|5').pipe(Effect.provideService(Wire, broken)));
if (Exit.isFailure(exit)) console.log(exit.cause.reasons.map((reason) => reason._tag));
[ 'Die' ]

Пока провод не рвётся, всё работает. Оборви его, и вылезут две дыры. Первая: промис в форме сервиса заставляет каждого, кто зовёт send, решать заново, как превратить его в квитанцию. Ринцвальд выбрал Effect.promise, и обрыв провода стал дефектом, а в E у ack стоит never. Тот же catch {} Магистрали, только аккуратнее одет. Вторая: ключ 'Wire' без пакета. Магистраль тоже зовёт свой провод 'Wire', и если оба окажутся в одном контексте, один займёт ячейку другого.

Лечится тем, что промис превращают в квитанцию в реализации, один раз, а форма сервиса знает только квитанции с ошибкой в типе. Ключ получает путь пакета: 'bashnya/Wire'.

Башня, этап 12

Башня перестаёт таскать зависимости руками. Провод и книга отправленных становятся сервисами в новом модуле, функции башни теряют аргументы send и tower и берут всё из контекста, а старый объект Tower живёт только на краю. Это первый этап, где меняется код прошлых этапов, поэтому часть старых тестов уходит: их проверки переехали в тест этого этапа. Какие функции меняют сигнатуру, в карточке заказа.

Практика

Дальше

Провод и книгу ты положил в Сундук готовыми. Пондер смотрит на свой журнал опытов, исписанный за ночь.

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