В прошлом уроке башня получила первые квитанции: разбор текста и отправку строки. Чтобы разобрать кадр целиком, их надо сцепить: разбить строку на поля, проверить маршрут, разобрать тег и данные. Каждый шаг может провалиться, и следующему нужны результаты прошлых.
Сцеплять квитанции ты умеешь: Effect.flatMap. Беда в том, что с каждым шагом код уезжает вправо. В котле эту лесенку разгладил генератор с адаптером (урок 28), в Гексе свиток сам пошёл в yield* (урок 34). У Effect есть оба способа писать без лесенки, Effect.gen и pipe, и этот урок о том, что под ними один и тот же flatMap.
Лесенка
Разбор кадра делится на три шага, и у каждого своя ошибка. partsOf режет строку по | и проваливается с GarbledFrame, если полей мало или номер не число. routeOf разбирает T-3->T-1 и проваливается с BadRoute. messageOf по тегу собирает сообщение: Text, Ack, Ping или Lost, и проваливается с UnknownTag или BadPayload. Сцепим их через flatMap:
const decodeFrame = (line: string) =>
Effect.flatMap(partsOf(line), ({ route, id, tag, payload }) =>
Effect.flatMap(routeOf(route), ({ from, to }) =>
Effect.map(messageOf(tag, payload), (message) => ({ from, to, id, message })),
),
);
Работает, и тип честный: Effect<Frame, GarbledFrame | BadRoute | UnknownTag | BadPayload>. Но последний шаг сидит на третьем уровне вложенности, и только потому, что ему нужны id из первого шага и from из второго. Каждая стрелка держит в замыкании значения всех прошлых шагов, поэтому их нельзя поставить подряд. Четыре поля, четыре ступеньки. У Магистрали кадры бывают и сложнее.
Рецепт сверху вниз
Тот же разбор на Effect.gen:
export const decodeFrame = (line: string): Effect.Effect<Frame, DecodeError> =>
Effect.gen(function* () {
const { route, id, tag, payload } = yield* partsOf(line);
const { from, to } = yield* routeOf(route);
const message = yield* messageOf(tag, payload);
return { from, to, id, message };
});
Шаги стоят подряд, каждый видит переменные прошлых, а последняя строка собирает кадр. yield* partsOf(line) читается как «дождись этой квитанции и дай мне её успех». Если шаг провалился, рецепт дальше не идёт: следующие строки не исполнятся, и вся квитанция провалится с ошибкой этого шага, как свиток Гекса.
Ты пишешь на async/await каждый день, так что код почти знакомый. Вот словарь:
async/await | Effect.gen |
|---|---|
async () => { ... } | Effect.gen(function* () { ... }) |
const x = await promise | const x = yield* effect |
return value | return value |
throw error | return yield* Effect.fail(error) |
try { ... } catch { ... } | Effect.catch, Effect.catchTag вокруг рецепта (урок 39) |
| вызов функции запускает работу | вызов отдаёт квитанцию, работу запускает Effect.run* |
тип Promise<A> | тип Effect<A, E, R> |
Две последние строки и есть вся разница. async-функция начинает работать, как только её позвали, а рецепт только описывает работу. И у промиса в типе один успех, а у квитанции ещё ошибки и нужды всех шагов.
Над каждым yield* растут ошибки
Кто собрал четыре ошибки в тип decodeFrame? Не ты: сотри аннотацию, и ошибки в типе останутся те же. Каждый yield* отдаёт рантайму квитанцию со своей E, и компилятор собирает из них объединение: GarbledFrame от первого шага, BadRoute от второго, UnknownTag | BadPayload от третьего. С нуждами R то же самое: каждый шаг добавляет свои. Это тот же вывод, что ты писал руками для IO.gen через ErrorOf и ContextOf.
Пройди по рецепту башни строку за строкой. Здесь у башни уже есть сервисы, провод и журнал, как будет в разделе EF5: посмотри, как вместе с ошибками растёт и R, и как provideService вычитает сервис, не трогая ошибки.
return yield*
В словаре throw превратился в return yield* Effect.fail(error). Зачем return? Рецепт и так остановится на провале. Посмотри, что будет без него:
const textOf = (frame: Frame) =>
Effect.gen(function* () {
if (frame.message._tag !== 'Text') {
yield* Effect.fail(new UnknownTag({ tag: frame.message._tag }));
}
return frame.message.body;
});
textof.ts(11,26): error TS2339: Property 'body' does not exist on type 'Message'.
Property 'body' does not exist on type '{ readonly _tag: "Ack"; readonly of: number; }'.
Рантайм знает, что после проваленного yield* рецепт не продолжится. Компилятор этого не знает: для него yield* это выражение, после которого код идёт дальше. Значит, ниже if сообщение может быть любым, и body есть не у всех. return говорит компилятору то, что знает рантайм: дальше этой строки ветка не идёт. Сужение из урока 06 снова работает, и frame.message.body собирается.
if (frame.message._tag !== 'Text') {
return yield* Effect.fail(new UnknownTag({ tag: frame.message._tag }));
}
return frame.message.body;
Тип успеха не портится: Effect.fail даёт Effect<never, UnknownTag>, и never из объединения успехов пропадает. Квитанция остаётся Effect<string, UnknownTag>.
Перевод с Ук
Самая частая опечатка в рецепте это забытая звёздочка:
import { Effect } from 'effect';
import { decodeFrame } from './frame';
const frameId = (line: string) =>
Effect.gen(function* () {
const frame = yield decodeFrame(line);
return frame.id;
});
frame-id.ts(8,18): error TS2339: Property 'id' does not exist on type 'never'.
Ошибка в восьмой строке, а причина строкой выше. Компилятор говорит: у frame тип never, и поля id у него нет. Откуда never? Значение выражения yield x это то, что пришлют назад в генератор через next. Effect.gen объявляет рецепт как Generator<Eff, AEff, never>: третий параметр, тип присланного назад, это never, потому что рецепт не должен полагаться на голый yield. Звёздочка меняет смысл: yield* effect передаёт управление итератору самой квитанции, а тот кончается её успехом, и тип выражения становится A. Два пальца Библиотекаря это yield и *.
Рантайм Effect 4.1 такой шаг исполнит и без звёздочки, поэтому строка, где значение не используется, сработает и не покраснеет. Типы этого не обещают, и пишут всегда yield*.
pipe
Вторая половина вопроса Рыдкулла: подтверждение в одну строку. Подтвердить кадр значит разобрать его, отправить обратно Ack с тем же номером и отдать разобранный кадр дальше. Это цепочка, где каждый шаг смотрит только на результат прошлого. Для таких цепочек есть pipe из котла (урок 21):
export const acknowledge = (send: (line: string) => Promise<void>, line: string) =>
pipe(
decodeFrame(line),
Effect.tap((frame) => sendLine(send, ackLine(frame))),
);
// Effect.Effect<Frame, DecodeError | SendFailed>
pipe(x, f, g) это g(f(x)), только слева направо. Effect.tap((frame) => ...) без первого аргумента отдаёт функцию «возьми квитанцию и пристегни к ней этот шаг», как dual из урока 22: у функций Effect два вида вызова, с квитанцией первым аргументом и без неё. У каждой квитанции есть ещё и метод .pipe, поэтому то же самое пишут так: decodeFrame(line).pipe(Effect.tap(...)).
Четыре функции встречаются в цепочках чаще других. Разница между ними в том, что функция получает и что остаётся успехом:
| Функция | Что отдаёшь | Успех цепочки | Ошибки |
|---|---|---|---|
Effect.map(f) | f: A => B, обычное значение | B | E |
Effect.flatMap(f) | f: A => Effect<B, E2> | B | E | E2 |
Effect.andThen(f) | то же, что flatMap, или сразу квитанцию | B | E | E2 |
Effect.tap(f) | f: A => Effect<X, E2> | прежний A | E | E2 |
tap исполняет свой шаг ради действия и оставляет успехом то, что было: подтверждение отправлено, а дальше по цепочке идёт кадр. andThen пригодится, когда прошлый успех не нужен: pipe(sendLine(send, ping), Effect.andThen(callSign)) отправит пинг и отдаст позывной.
Под капотом
Вот четыре записи подтверждения: лесенка, рецепт, pipe и метод .pipe.
const ladder = (line: string) =>
Effect.flatMap(decodeFrame(line), (frame) =>
Effect.map(sendLine(send, ackLine(frame)), () => frame),
);
const recipe = (line: string) =>
Effect.gen(function* () {
const frame = yield* decodeFrame(line);
yield* sendLine(send, ackLine(frame));
return frame;
});
const pipeline = (line: string) =>
pipe(
decodeFrame(line),
Effect.tap((frame) => sendLine(send, ackLine(frame))),
);
const method = (line: string) =>
decodeFrame(line).pipe(Effect.tap((frame) => sendLine(send, ackLine(frame))));
42 T-1->T-3|42|Ack|42
42 T-1->T-3|42|Ack|42
42 T-1->T-3|42|Ack|42
42 T-1->T-3|42|Ack|42
Ответ у всех четырёх один, и тип один. pipe вообще не знает про Effect: это вызов функций по очереди, и pipe(decodeFrame(line), Effect.tap(f)) сразу становится Effect.tap(decodeFrame(line), f), а tap внутри это flatMap, который возвращает прежний успех. Рецепт устроен как твой IO.gen: каждый yield* отдаёт квитанцию циклу, цикл исполняет её как шаг flatMap, а продолжение «что делать с успехом» это остаток генератора, который получит успех через next. В подвале обе записи превратились бы в одну и ту же стопку карточек.
Поэтому выбор между ними не про возможности, а про чтение. Шагам нужны значения многих прошлых шагов, как в разборе кадра? Рецепт. Цепочка, где каждый шаг смотрит только на прошлый, как в подтверждении? Труба. Внутри рецепта трубы тоже бывают: yield* decodeFrame(line).pipe(...).
Где тут дыра
const sent: string[] = [];
const send = async (line: string) => void sent.push(line);
const acknowledge = (line: string) =>
pipe(
decodeFrame(line),
Effect.map((frame) => sendLine(send, ackLine(frame))),
);
const main = async () => {
const result = await Effect.runPromise(acknowledge('T-3->T-1|42|Text|гроза'));
console.log(Effect.isEffect(result), sent);
};
void main();
true []
На примере Ринцвальда квитанция кончилась успехом, ошибок нет. На соседнем вопросе, «ушло ли подтверждение», ответ «нет»: sent пуст. map берёт обычное значение, а функция вернула квитанцию sendLine(...). Квитанция это тоже значение, поэтому map честно положил её в успех и ничего не запустил. Тип это говорит, если на него посмотреть: Effect<Effect<void, SendFailed>, DecodeError>, квитанция на квитанцию, и SendFailed не попал в ошибки цепочки. Ту же ловушку ты встречал в упражнении урока 30 на своём свитке.
Шаг, который сам квитанция, пристёгивают flatMap, andThen или tap, а в рецепте его отдают в yield*. Если успехом цепочки оказалась квитанция, где-то стоит map вместо одного из них.
Башня, этап 2
Башня учится читать провод целиком. В модуле кадров появляются сообщения, кадр, ошибки разбора и сам разбор на рецепте, а ещё обратное кодирование кадра в строку. Тесты проверяют порядок проверок и свойство на fast-check: любой кадр, закодированный и разобранный обратно, тот же. В модуле башни подтверждение трубой с tap. Сигнатуры и тексты в заказе Рыдкулла.
Практика
Дальше
Разбор и подтверждение готовы, и Рыдкулл запускает башню на ночь. Утром на столе журнал провалов. Один кадр не ушёл, провод оборвался, и в журнале вот что (пути сокращены до имени файла):
SendFailed:
at catch (tower.ts:26:23)
at <anonymous> (effect.ts:1159:42)
Как дать квитанции имя, которое попадёт в журнал, и почему переиспользуемые функции башни пишут через Effect.fn, разберём в следующем уроке.