* В Анкморе говорят, что быстрее всего по городу расходятся три вещи: слух, пожар и заклинание, которое кто-то произнёс, не дочитав до конца. Первые две хотя бы можно потушить.
Ночь. Над рекой лежит туман, в котором вязнут фонари. По Семафорной Магистрали бегут кадры: башня открывает ставни, соседняя читает и открывает свои, и сообщение пересекает Анкмор быстрее любого гонца. На краю города стоит башня Тихой Академии, T-1. В полночь ей пришёл кадр для трёх соседей. До утра она не сказала ни слова.
Скоропись этой башни писал ты. Утром за тобой присылают из Академии, прямо в кабинет Аркканцлера.
Врут все трое понемногу, и никто не нарочно. Твой код не упал, не бросил исключение, не написал в журнал ни одной ошибки. Он отработал и доложил неправду. Упавшую программу видно сразу, а такую можно искать неделю.
В этом уроке ты найдёшь в скорописи башни пять мест, где она молчит, поймёшь, почему TypeScript ни одно из них не заметил, и увидишь, что Академия называет эффектом. А в конце будет карта всего курса: от теории типов до кластера башен.
Журнал, в котором пусто
Вот функция, которую башня T-1 выполнила в полночь. Ей дают кадр, список соседей и два инструмента: send открывает ставни в сторону башни, log пишет строку в журнал.
// Скоропись башни T-1: разослать кадр соседним башням и записать в журнал, сколько дошло.
export type Deps = {
readonly send: (tower: string, frame: string) => Promise<void>;
readonly log: (line: string) => void;
};
export async function relayFrame(
frame: string,
towers: readonly string[],
deps: Deps,
timeoutMs = 1000,
): Promise<number> {
let sent = 0;
towers.forEach(async (tower) => {
try {
await deps.send(tower, frame);
sent += 1;
} catch {
// башня потом догонит
}
});
deps.log(`relayed ${sent}/${towers.length}`);
void timeoutMs;
return sent;
}
Код выглядит честно. Для каждой башни отправка, счётчик удач, итог в журнал. Запустим его на ночных соседях: T-2 отвечает через пять миллисекунд, T-3 бросает new Error('ставни заклинило'), T-4 не отвечает никогда. Функция вернула 0, а в журнале одна строка:
relayed 0/3
Через пять миллисекунд T-2 получила кадр, и переменная sent стала единицей. Только функция к тому времени уже вернула ноль, а журнал уже записан. Единицу никто не прочитал.
Пять мест, где скоропись молчит
Чинить функцию будешь сам, в разминке ниже. Сейчас только найдём, где она врёт. Мест пять.
Первое: forEach не ждёт. forEach вызывает колбэк для каждой башни и выбрасывает то, что колбэк вернул. Колбэк async, значит, вернул он промис, и этот промис никто не ждёт. forEach закончился раньше, чем ответила хоть одна башня. В функции нет ни одного await на её собственном пути, поэтому она проходит от первой строки до return за один заход.
Второе: журнал пишется раньше, чем кончилась работа. Строка с deps.log идёт сразу за forEach. Счётчик в этот момент ноль, и в журнал уходит ноль. Это не ошибка счёта: функция честно записала то, что знала в ту секунду.
Третье: catch {} глотает ошибку. У T-3 заклинило ставни, send бросил исключение, catch его поймал и выбросил. Комментарий «башня потом догонит» обещает, что кто-то когда-то разберётся. В журнал не попало ничего, и утром некому даже сказать, что T-3 вообще пыталась.
Четвёртое: нет таймаута. Функция принимает timeoutMs и тут же выбрасывает его строкой void timeoutMs. T-4 молчит, и её промис не закончится никогда. Сейчас это незаметно, потому что функция ничего не ждёт. Но стоит починить первое место и начать ждать все отправки, как башня T-1 повиснет вместе с T-4 навсегда. Одна починка открывает следующую дыру.
Пятое: бросают не только Error. В JavaScript throw принимает что угодно: строку, число, undefined, объект без message. Когда ты начнёшь писать причину неудачи в журнал, error.message от брошенной строки окажется undefined, и журнал снова соврёт, только аккуратнее.
Почему TypeScript промолчал
Посмотри на сигнатуру ещё раз: Promise<number>. Тип говорит одно: когда-нибудь будет число. Он не говорит, что функция трогает чужие башни, пишет в журнал, ждёт время и может повиснуть. Не говорит, чем она падает и падает ли вообще: у Promise один параметр типа, для успеха, а для отказа места нет.
Компилятор проверяет то, что записано в типе. Всё остальное для него не существует. Твоя скоропись прошла проверку типов, потому что о провалах в типах не было ни слова.
Что такое эффект
Сравни две функции башни. Первая собирает строку для журнала:
const summary = (sent: number, total: number): string => `relayed ${sent}/${total}`;
Вызови её сто раз с одними аргументами, и сто раз получишь одну и ту же строку. Мир от этого не изменится: ни одна ставня не откроется, ни одна строка не появится в журнале. Вызов summary(1, 3) можно заменить его результатом, 'relayed 1/3', и программа не заметит разницы.
Вторая функция это relayFrame. Вызови её дважды, и соседи получат кадр дважды. Её результат зависит от погоды на ставнях, от часов и от того, проснулся ли смотритель T-4. Заменить вызов результатом нельзя: результат это меньшая часть того, что она делает.
Всё, что функция делает с миром, кроме возврата значения, называется побочным эффектом. В этом курсе слово шире: эффект это любая работа, которая трогает мир, занимает время, может провалиться или требует чего-то извне. Отправка кадра это эффект. Чтение часов тоже: результат зависит не от аргументов, а от того, когда спросили.
Эффекты не беда, без них программа бесполезна: башня, которая ничего не отправляет, никому не нужна. Беда в скорописи. В Анкморе заклинание срывается с языка в ту же секунду, как его произнесли: так ведёт себя Promise, который уже летит, когда ты его создал. Отменить его нельзя, повторить по-другому нельзя, что оно заденет по пути, неизвестно.
Тихая Академия учит обратному: сначала запиши заклинание целиком, потом произноси. Записанное заклинание можно прочитать, проверить, повторить, отменить, снабдить запасным планом и отдать другому магу. Библиотека Effect, ради которой собран этот курс, держит такую запись в значении типа Effect<A, E, R>. В нём три буквы, и у каждой своя работа:
Aэто то, что заклинание вернёт при успехе;Eэто то, чем оно может провалиться, причём каждым способом поимённо;Rэто то, что ему нужно для работы: башни, журнал, часы.
Читать такие сигнатуры свободно ты научишься к концу раздела EF1. Пока запомни разницу с Promise<number>: там записан только успех, здесь записано всё.
Взгляд вперёд: та же башня на Effect
Вот как могла бы выглядеть рассылка, если записать её как заклинание. Это не задача и не образец для подражания: синтаксис Effect начнётся в разделе EF4. Сейчас смотри не на слова, а на то, где оказались провалы.
import { Data, Effect, Result } from 'effect';
class SendFailed extends Data.TaggedError('SendFailed')<{
readonly tower: string;
readonly cause: unknown;
}> {}
declare const sendToTower: (tower: string, frame: string) => Promise<void>;
const send = (tower: string, frame: string) =>
Effect.tryPromise({
try: () => sendToTower(tower, frame),
catch: (cause) => new SendFailed({ tower, cause }),
}).pipe(
Effect.timeout('1 second'),
Effect.tapError((error) => Effect.logWarning(`failed ${tower}: ${error._tag}`)),
);
const relayFrame = Effect.fn('relayFrame')(function* (frame: string, towers: readonly string[]) {
const results = yield* Effect.forEach(towers, (tower) => Effect.result(send(tower, frame)), {
concurrency: 'unbounded',
});
const sent = results.filter(Result.isSuccess).length;
yield* Effect.log(`relayed ${sent}/${towers.length}`);
return sent;
});
Что тут происходит, по кусочкам:
Effect.tryPromiseпревращает отправку в запись. Всё, что броситsendToTower, что угодно, а не толькоError, ложится вSendFailedс именем башни.Effect.timeout('1 second')ограничивает отправку секундой и добавляет к возможным провалам ещё один,TimeoutError. Наведи курсор наsendв редакторе, и TypeScript покажет, что она возвращаетEffect<void, SendFailed | TimeoutError, never>. Оба провала записаны в типе, и компилятор не даст о них забыть.Effect.tapErrorпишет каждую неудачу в журнал и не мешает ей идти дальше.Effect.forEachсconcurrency: 'unbounded'шлёт кадр всем башням сразу и ждёт всех.Effect.resultпревращает исход каждой отправки в значение, поэтому сломаннаяT-3не мешаетT-2.yield*внутри генератора читается какawait: «дождись этой работы и дай её результат».
relayFrame возвращает Effect<number, never, never>: на месте E стоит never, пустота, потому что каждый провал уже разобран внутри и наружу не выходит ни один. И главное: вызов relayFrame('42', towers) ничего не отправляет. Он возвращает запись, а произносит её отдельная команда, Effect.runPromise. Почему это так ценно, разберём в третьем уроке.
С теми же соседями, что ночью, журнал теперь говорит:
[13:07:13.047] WARN (#5): failed T-3: SendFailed
[13:07:14.046] WARN (#8): failed T-4: TimeoutError
[13:07:14.047] INFO (#1): relayed 1/3
Три строки правды вместо одной строки неправды. Таймаут сработал через секунду, T-3 записана, итог посчитан после того, как отработали все.
Карта курса
До такого кода сто двадцать уроков, и в первых тридцати пяти Effect почти не встретится. Сначала ты научишься читать типы, потом думать функциями, потом соберёшь свой маленький Effect и только тогда откроешь настоящий. Иначе он покажется магией, а Академия магии без объяснений не терпит.
| Раздел | Уроки | О чём |
|---|---|---|
| EF0 Скорописец | 1-4 | зачем всё это и как читать ошибки TypeScript |
| EF1 Библиотека | 5-16 | теория типов, чтобы читать Effect<A, E, R>; протокол семафора shifr |
| EF2 Головология | 17-29 | функциональное программирование с нуля: действия и вычисления, pipe, Option, Result |
| EF3 Свой Гекс | 30-35 | свой эффект-рантайм на триста строк |
| EF4 Первые чары | 36-46 | ядро Effect: ошибки в типе, генераторы, первая версия башни |
| EF5 Сундук | 47-54 | сервисы и слои: откуда заклинание берёт то, что ему нужно |
| EF6 Смерть приходит вовремя | 55-60 | ресурсы, финализаторы, прерывание |
| EF7 Муравейник | 61-71 | файберы и конкурентность |
| EF8 Часы на башне | 72-75 | время: повторы, таймауты, расписания, тесты с часами |
| EF9 Печати | 76-84 | Schema: проверка и разбор данных с провода |
| EF10 Магистраль | 85-89 | стримы, батчи и повторы без дублей |
| EF11 Почтамт | 90-97 | HTTP API и база данных |
| EF12 Свет в окнах | 98-102 | логи, трассы, метрики |
| EF13 Витрина | 103-105 | Effect во фронтенде |
| EF14 Башни без хозяина | 106-112 | кластер, очереди, долгие процессы |
| EF15 Голем | 113-115 | Effect и языковые модели |
| EF16 Мастерство | 116-120 | финальный босс: всё вместе |
За боссов разделов Академия даёт ранги, от студента до профессора Тихих чар. А по дороге выяснится, почему Магистраль теряет сообщения и почему её отчёты всегда зелёные.
Где тут дыра
Пока ты разглядываешь карту, в кабинет заглядывает Ринцвальд и видит на столе твою скоропись. Шляпа с надписью «ВАЛШЕБНИК» съехала набок, в руке пирожок.
export async function relayFrame(
frame: string,
towers: readonly string[],
deps: Deps,
): Promise<number> {
await Promise.all(towers.map((tower) => deps.send(tower, frame)));
deps.log(`relayed ${towers.length}/${towers.length}`);
return towers.length;
}
На двух здоровых башнях, T-2 и T-6, всё сходится: функция ждёт обе отправки, возвращает 2, в журнале relayed 2/2. Ринцвальд доволен.
Добавь в список T-3 со сломанными ставнями. Promise.all отклоняется на первой же ошибке, функция бросает ставни заклинило, и до deps.log дело не доходит: в журнале снова пусто, а что стало с T-2 и T-6, из этой функции уже не узнать. Добавь T-4, которая молчит, и Promise.all будет ждать её вечно. Ринцвальд закрыл одну дыру из пяти и открыл новую: теперь одна сломанная башня роняет рассылку всем.
Ринцвальд не дурак. Ему всегда некогда, поэтому он и колдует скорописью.
Разминка
Почини relayFrame сам, не меняя сигнатуры. Тесты проверят все пять мест: функция ждёт все отправки, не прячет ошибки, отваливается по таймауту, пишет итог после работы и разбирает всё, что могли бросить.
Практика
Глубже в архиве
Почему forEach не ждёт и чем Promise.all отличается от Promise.allSettled, подробно разбирает урок «Promise, thenable и async/await». А урок «Контекст и модели» показывает Effect рядом с акторами и STM, если хочется заранее увидеть его место среди других подходов.
Дальше
Рыдкулл захлопывает журнал.
В следующем уроке ты будешь весь день дописывать к скорописи то, о чём она молчала, и смотреть, как функция растёт быстрее, чем польза от неё.