* В Анкморе говорят, что СМЕРТЬ никогда не опаздывает, и это правда. Реже говорят, что он и не приходит раньше. Он стоит в дверях с песочными часами и смотрит, как маг гасит свечи, возвращает книги в библиотеку и закрывает за собой окно. Торопить его бесполезно. Ждать он умеет лучше всех в городе.
У двери архива стоит кто-то очень высокий с песочными часами. Рыдкулл проходит мимо, не поднимая глаз: в Академии к этому гостю привыкли. Он не злой и никого не торопит. Он приходит, когда работа кончилась, и ждёт, пока закроют всё, что открыли.
Башня берёт на время то, что потом надо вернуть: линию Магистрали, файл архива, загрузку книги. Вернуть надо при любом исходе: смена прошла, смена упала с ошибкой, в смене сломалось то, чего быть не может, или смену прервали посередине. Этот урок о том, как Effect гарантирует возврат, не полагаясь на внимательность того, кто пишет смену.
Склад
Всё, что башня берёт на время, выдаёт склад Магистрали. Он умеет две вещи:
export class Depot extends Context.Service<
Depot,
{
readonly open: (name: string) => Effect.Effect<void, WireDown>;
readonly close: (name: string, how: string) => Effect.Effect<void>;
}
>()('bashnya/Depot') {}
open может не выйти: склад отвечает WireDown. close не падает никогда и получает словом, чем кончилась работа. Для тестов у склада есть дублёр: он пишет в массив открыт имя и закрыт имя: как.
Ринцвальд закрывает в finally
const shift = async () => {
await open('линия');
await open('архив');
try {
log.push('работа');
} finally {
await close('линия');
await close('архив');
}
};
Если архив сегодня не открылся:
[ 'открыт линия', 'упало: не открыть архив' ]
Линия открыта, и её никто не закрыл. try начинается после второго open, а падает второй open. Чтобы закрыть линию, нужен ещё один try вокруг архива, а на каждый новый ресурс ещё один уровень вложенности. Вторая беда тише: закрывают в том же порядке, в каком открывали. Если архив пишет через линию, его надо закрыть раньше линии, иначе последняя запись архива уйдёт в закрытую линию.
acquireRelease
Effect описывает ресурс одной квитанцией, в которой взятие и возврат стоят рядом:
export const resource = (name: string): Effect.Effect<string, WireDown, Depot | Scope.Scope> =>
Effect.acquireRelease(Effect.as(Depot.use((depot) => depot.open(name)), name), (opened, exit) =>
Depot.use((depot) => depot.close(opened, exitWord(exit))),
);
Effect.acquireRelease(взять, вернуть). Первая квитанция берёт ресурс и может упасть. Вторая возвращает и получает два аргумента: что взяли и Exit работы, которая шла с ресурсом. По Exit возврат знает, чем всё кончилось. exitWord превращает его в одно слово:
export const exitWord = (exit: Exit.Exit<unknown, unknown>): string => {
if (Exit.isSuccess(exit)) return 'ok';
const first = exit.cause.reasons[0];
return first === undefined ? 'interrupt' : first._tag.toLowerCase();
};
Гарантии acquireRelease три. Если взятие удалось, возврат будет вызван обязательно, при любом исходе. Если взятие упало, возвращать нечего, и возврат не зовут. Пока идёт взятие, квитанцию нельзя прервать: иначе ресурс мог бы оказаться наполовину взятым и без возврата.
Перевод с Ук
Попробуй взять линию и запустить квитанцию:
const log: string[] = [];
await Effect.runPromise(resource('линия').pipe(Effect.provide(Depot.layerTest(log))));
ex55a.ts(6,25): error TS2379: Argument of type 'Effect<string, WireDown, Scope>' is not assignable to parameter of type 'Effect<string, WireDown, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Type 'Scope' is not assignable to type 'never'.
Склад положили, а в R остался Scope. Это не забытый сервис, это вопрос «когда возвращать?». acquireRelease знает, как вернуть ресурс, но не знает, когда: сразу после взятия смысла нет, а после конца программы поздно. Ответ на «когда» даёт область.
Scope
Область, Scope, это список возвратов, которые надо исполнить, когда она закроется. acquireRelease кладёт свой возврат в ту область, что лежит в контексте. Поэтому Scope и стоит в R: ресурсу нужна область, куда положить возврат.
Закрывает область Effect.scoped. Он заводит новую область, отдаёт её квитанции, а когда квитанция кончилась, закрывает область с её исходом. Из R Scope уходит:
export const withResources = <A, E, R>(
names: readonly string[],
work: Effect.Effect<A, E, R>,
): Effect.Effect<A, E | WireDown, Depot | Exclude<R, Scope.Scope>> =>
Effect.scoped(Effect.andThen(Effect.forEach(names, resource), work));
Effect.forEach берёт ресурсы по порядку, работа идёт после, а scoped закрывает область, когда работа кончилась. Три исхода работы:
for (const work of [
Effect.sync(() => console.log(' работа')),
Effect.fail(new WireDown({ reason: 'обрыв' })),
Effect.interrupt,
]) {
const log: string[] = [];
await Effect.runPromiseExit(withResources(['линия', 'архив'], work).pipe(Effect.provide(Depot.layerTest(log))));
console.log(log);
}
работа
[
'открыт линия',
'открыт архив',
'закрыт архив: ok',
'закрыт линия: ok'
]
[
'открыт линия',
'открыт архив',
'закрыт архив: fail',
'закрыт линия: fail'
]
[
'открыт линия',
'открыт архив',
'закрыт архив: interrupt',
'закрыт линия: interrupt'
]
Закрывается в обратном порядке: последний взятый возвращается первым, как стопка тарелок. Каждый возврат знает исход. А если архив не откроется, линия всё равно закроется с fail: её возврат уже лежит в области, когда падает второе взятие. Того, на чём споткнулся Ринцвальд, тут не бывает по устройству.
Попробуй сам: выбери ресурсы, исход и то, что не откроется.
addFinalizer
Иногда возвращать нечего, но сделать что-то на выходе надо: записать «смена сдана», погасить фонарь. Для этого Effect.addFinalizer: он кладёт в область возврат без взятия.
const program = Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer((exit) => Effect.sync(() => console.log('первый финализатор:', exit._tag)));
yield* Effect.addFinalizer(() => Effect.sync(() => console.log('второй финализатор')));
console.log('работа');
return 'ночь';
}),
);
console.log(await Effect.runPromise(program));
работа
второй финализатор
первый финализатор: Success
ночь
Финализаторы исполняются при закрытии области, в обратном порядке, и до того, как runPromise отдаст результат.
Под капотом
Область это объект с двумя действиями: «положить финализатор» и «закрыть с таким-то Exit». Закрыть можно один раз. Effect.scoped создаёт область, кладёт её в контекст как сервис Scope, исполняет квитанцию и закрывает область её исходом. Потому Scope и выглядит как сервис в R: это он и есть. В следующем уроке окажется, что области бывают не только у куска программы, но и у слоя.
Башня, этап 20
Башня учится брать на время. Склад и его дублёр, ресурс на acquireRelease, работа с ресурсами, которая всё открытое закрывает в обратном порядке, и одно слово о том, чем кончилась работа.
Практика
Глубже в архиве
Та же мысль без Effect, через bracket в Haskell, разобрана в уроке «Исключения и ресурсы».
Дальше
Рыдкулл смотрит на записи склада за неделю.
Ресурс, который живёт столько же, сколько слой, ждёт в следующем уроке.