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

Effect.fn: функции с именем

middle~30 мин

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

В конце прошлого урока башня обросла функциями: decodeFrame, acknowledge, и сегодня к ним добавятся ответ на кадр и обработчик целиком. Каждая отдаёт квитанцию, и каждую зовут из нескольких мест. Пока всё работает, имена не нужны. Нужны они в ту ночь, когда что-то упало, и надо понять, что упало и откуда его позвали.

Этот урок про Effect.fn: как превратить функцию, которая отдаёт рецепт, в функцию с именем, которое попадёт в след каждого провала. А заодно про то, как объявить тип такой функции и куда пристёгивать то, что должно случаться при каждом её вызове.

Журнал без имён

Вот обработчик кадра, записанный так, как ты уже умеешь: две функции, каждая отдаёт рецепт.

type Send = (line: string) => Promise<void>;

const respond = (send: Send, frame: Frame): Effect.Effect<string, SendFailed> =>
  Effect.gen(function* () {
    if (frame.message._tag === 'Ping') {
      yield* sendLine(send, encodeFrame({ ...frame, from: frame.to, to: frame.from }));
      return 'ponged';
    }
    yield* sendLine(send, ackLine(frame));
    return 'acked';
  });

const handleFrame = (send: Send, line: string): Effect.Effect<string, DecodeError | SendFailed> =>
  Effect.gen(function* () {
    const frame = yield* decodeFrame(line);
    return yield* respond(send, frame);
  });

Запустим его на проводе, который рвётся, и напечатаем причину провала. Cause.pretty печатает причину вместе со следом, подробно о ней в уроке 41. Пути в следе здесь и дальше сокращены до имени файла.

const exit = await Effect.runPromiseExit(handleFrame(failing, 'T-3->T-1|1|Ping|'));
if (Exit.isFailure(exit)) console.log(Cause.pretty(exit.cause));
SendFailed:
    at catch (tower.ts:26:23)
    at <anonymous> (effect.ts:1159:42)

Первая строка следа честная: ошибку создали в catch у sendLine. Вторая уже из рантайма. Где между ними respond и handleFrame? Их нет, хотя провал случился внутри них. Это и есть журнал Рыдкулла: ошибка видна, а кто колдовал, нет.

Под капотом

След ошибки в JavaScript это стек вызовов в момент, когда создали объект ошибки. У обычной функции стек и есть история: кто кого позвал. У квитанции не так. Когда ты зовёшь handleFrame(send, line), функция отдаёт рецепт и сразу возвращается, её на стеке уже нет. Исполняет рецепт цикл рантайма, а продолжения он держит в своём массиве, как наш муравей. В момент провала на настоящем стеке только цикл и колбэк промиса, поэтому в следе catch и <anonymous> из потрохов. Сто вложенных рецептов дадут тот же след из двух строк. Чтобы журнал знал имена, квитанция должна носить имя с собой.

Функция с именем

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

export const respond = Effect.fn('Tower.respond')(function* (
  send: (line: string) => Promise<void>,
  frame: Frame,
): Effect.fn.Return<Outcome, SendFailed> {
  switch (frame.message._tag) {
    case 'Text': {
      yield* sendLine(send, ackLine(frame));
      return 'acked';
    }
    case 'Ping': {
      yield* sendLine(send, encodeFrame({ ...frame, from: frame.to, to: frame.from }));
      return 'ponged';
    }
    case 'Ack': {
      return 'confirmed';
    }
    case 'Lost': {
      return 'noted';
    }
  }
});

export const handleFrame = Effect.fn('Tower.handleFrame')(function* (
  send: (line: string) => Promise<void>,
  line: string,
): Effect.fn.Return<Outcome, DecodeError | SendFailed> {
  const frame = yield* decodeFrame(line);
  return yield* respond(send, frame);
});

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

  • Effect.fn('Tower.respond') это имя. Принято писать Модуль.функция, как в стеке обычного кода: по имени сразу видно, в каком файле искать.
  • Дальше в скобках генератор, как в Effect.gen, только с параметрами: function* (send, frame) { ... }. Параметры становятся параметрами готовой функции, respond(send, frame).
  • Effect.fn.Return<Outcome, SendFailed> это тип результата генератора. Читается так же, как квитанция: успех Outcome, ошибка SendFailed, нужд нет. Из него компилятор выводит тип готовой функции: (send, frame) => Effect<Outcome, SendFailed>.
  • Outcome это объединение строк 'acked' | 'ponged' | 'confirmed' | 'noted': что башня сделала с кадром.

Вызов respond(send, frame) по-прежнему ничего не отправляет: он только выписывает квитанцию. Запускает её yield* в handleFrame, а саму handleFrame запустит край программы.

Тот же провод, тот же кадр, теперь с Effect.fn:

SendFailed:
    at catch (tower.ts:26:23)
    at <anonymous> (effect.ts:1159:42)
    at Tower.respond (tower.ts:82:17)
    at Tower.respond (definition) (tower.ts:55:31)
    at Tower.handleFrame (night.ts:9:42)
    at Tower.handleFrame (definition) (tower.ts:77:35)

У каждого заклинания две строки. Строка с именем говорит, откуда его позвали: respond позвали в строке 82 модуля башни, внутри handleFrame, а handleFrame позвали из ночной смены night.ts. Строка с пометкой (definition) говорит, где заклинание записано. По такому журналу Рыдкулл найдёт и зверя, и место, где тот вышел на тропу.

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

Перевод с Ук

Effect.fn.Return это не подсказка, а обещание, и компилятор держит тебя за слово. Пусть handleFrame обещает только ошибку провода:

import { Effect } from 'effect';

import type { SendFailed } from './errors';
import { decodeFrame } from './frame';
import { type Outcome, respond } from './tower';

export const handleFrame = Effect.fn('Tower.handleFrame')(function* (
  send: (line: string) => Promise<void>,
  line: string,
): Effect.fn.Return<Outcome, SendFailed> {
  const frame = yield* decodeFrame(line);
  return yield* respond(send, frame);
});
handle-frame.ts(11,24): error TS2375: Type 'Effect<Frame, DecodeError, never>' is not assignable to type 'Effect<any, SendFailed, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Type 'DecodeError' is not assignable to type 'SendFailed' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
    Type 'BadPayload' is missing the following properties from type 'SendFailed': line, reason

Строка 11, колонка 24: квитанция decodeFrame(line) под yield*. Советы про undefined пропускаем, как в уроке 36: флаг тут ни при чём. Читаем снизу вверх. Нижняя строка: у BadPayload нет полей line и reason, которые есть у SendFailed. Средняя: DecodeError не подходит под SendFailed. Верхняя: квитанция с ошибкой DecodeError не может стоять в рецепте, который обещал только SendFailed. Компилятор взял из объединения одну ошибку, BadPayload, сравнил её с обещанной по полям и не нашёл двух.

Заметь, где он ругается: в теле функции, на строке шага, а не там, где handleFrame зовут. В этом польза аннотации. Без неё компилятор вывел бы тип сам, честно, со всеми ошибками, и новая ошибка внутри тихо расширила бы квитанцию у всех, кто её зовёт. С аннотацией расширить обещание можно только явно, в одной строке, и это видно в диффе.

Без следа: fnUntraced

Запомнить место вызова стоит времени: при каждом вызове Effect.fn снимает стек. Для заклинаний башни это копейки. Но у разбора есть мелкие помощники вроде messageOf, которые за ночь зовут сотни тысяч раз, и их имя в журнале мало кому нужно. Для них есть Effect.fnUntraced: тот же генератор с параметрами и тот же Effect.fn.Return, только без имени и без отметки.

const quick = Effect.fnUntraced(function* (
  send: Send,
  line: string,
): Effect.fn.Return<Outcome, DecodeError | SendFailed> {
  const frame = yield* decodeFrame(line);
  return yield* respond(send, frame);
});
SendFailed:
    at catch (tower.ts:26:23)
    at <anonymous> (effect.ts:1159:42)
    at Tower.respond (quick.ts:14:17)
    at Tower.respond (definition) (tower.ts:55:31)

respond в следе осталась, она подписана. А сам quick пропал: его в журнале нет. Правило такое: всё, что башня делает по делу и что придётся искать в журнале, через Effect.fn с именем; внутренние помощники, которых тысячи, через fnUntraced или обычные функции.

Что приделать ко всем вызовам

Рыдкулл хочет ещё одно: каждый разобранный кадр записывать в журнал смены, строка: что сделали. Запись в журнал это шаг после обработчика, Effect.tap из прошлого урока. Куда его пристегнуть?

У Effect.fn после генератора можно передать ещё функции. Каждая получает готовую квитанцию и те же аргументы, что пришли в вызов, и отдаёт новую квитанцию:

const journal: string[] = [];
const note = (line: string, outcome: Outcome) =>
  Effect.sync(() => void journal.push(`${line}: ${outcome}`));

export const handleFrame = Effect.fn('Tower.handleFrame')(
  function* (send: Send, line: string): Effect.fn.Return<Outcome, DecodeError | SendFailed> {
    const frame = yield* decodeFrame(line);
    return yield* respond(send, frame);
  },
  (effect, _send, line) => Effect.tap(effect, (outcome) => note(line, outcome)),
);
[ 'T-3->T-1|42|Text|гроза: acked', 'T-3->T-1|43|Ping|: ponged' ]

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

Где тут дыра

const nightShift = (line: string) =>
  handleFrame(send, line).pipe(Effect.tap((outcome) => note(line, outcome)));

const dayShift = (line: string) => handleFrame(send, line);

const main = async () => {
  await Effect.runPromise(nightShift('T-3->T-1|42|Text|гроза'));
  await Effect.runPromise(dayShift('T-3->T-1|43|Text|мост'));
  console.log(journal);
};
void main();
[ 'T-3->T-1|42|Text|гроза: acked' ]

На ночной смене Ринцвальда журнал пишется. На соседней, дневной, кадр мост разобран и подтверждён, а в журнале его нет: дневную смену писал другой маг, и про трубу у вызова он не знал. Цепочка .pipe у вызова живёт в одном месте вызова, а не в заклинании. Каждый новый вызов должен вспомнить её сам, и кто-нибудь обязательно забудет. Через полгода у башни пять смен и три разных цепочки, и никто не скажет, какая правильная.

То, что должно случаться при каждом вызове, пристёгивают в определении, аргументом Effect.fn. А .pipe у вызова оставляют для того, что нужно только здесь: например, одна смена хочет ограничить время ответа, а остальные нет.

Башня, этап 3

Башня учится отвечать. Разобранный кадр получает ответ: текст подтверждается, на пинг уходит пинг обратно, подтверждение и потерю башня отмечает. Ответ и обработчик целиком пишутся через Effect.fn с именами Tower.respond и Tower.handleFrame, а типы объявляются через Effect.fn.Return. Что отправляется на каждый вид кадра, написано в заказе Рыдкулла.

Практика

Дальше

Журнал за следующую ночь читается как охотничий дневник: у каждого обрыва имя и тропа. Рыдкулл доволен минуты две. Потом он снова кладёт на стол распечатку Магистрали и стучит пальцем по одной строке.

try {
  await relay(frame);
} catch {}

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