В конце прошлого урока башня обросла функциями: 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 {}
Как называть ошибки так, чтобы их можно было ловить по имени, все разом или по одной, и почему два одинаковых имени из разных модулей опаснее одной безымянной ошибки, разберём в следующем уроке.