実装はここ https://jsr.io/@mizchi/[email protected]
あらすじ
使い方
npm と jsr に公開しておいた。 Node/Deno どっちでも使える。
基本的な発想として、ある program の実行とは AsyncGenerator
あるいは Generator
のイテレータで表現できるとする。
program 定義とは別に、エフェクトの宣言とは別に対応するハンドラ(EffectHandler)を注入する。それが同期か非同期かはそれ自身ではなく、実行方式で決まる。
import {
type EffectFor,
defineEffect,
performAsync,
} from "@mizchi/domain-types";
const log = defineEffect
"log",
[message: string],
void
>("log");
const val = defineEffect"val", [], number>("val");
type ProgramEffect = EffectFortypeof log> | EffectFortypeof val>;
async function* program(): AsyncGeneratorProgramEffect> {
const v = yield* val();
yield* log(`v:${v}`);
}
console.log(
await Array.fromAsync(
performAsync(program(), {
[log.t]: async (message) => {
console.log(`[log] ${message}`);
},
[val.t]: async () => {
return 42;
},
})
)
);
これの何が嬉しいか。
利点: 型による副作用の記述を強制する
AsyncGenerator
として使用するエフェクトを明示しないといけないので、型を見ると何を実装しないけといけないかわかる。
const log = defineEffect"log", [message: string], void>("log");
async function* program(): AsyncGeneratorEffectFortypeof log>> {
yield* log(`hello`);
}
これは型シグネチャを見ることで、この関数が内部で発行するエフェクトが静的にわかる。
型シグネチャにないもの は yield
できない。
また、実行時にはエフェクトに対してのハンドラを必ず要求する。
performAsync(program(), {
[log.t]: async (message) => {
console.log(`[log] ${message}`);
},
});
利点: 実行時にハンドラが同期か非同期かどうかを決定
実行時の perform
か performAsync
かでイベントハンドラの同期/非同期を決定できる。
performAsync
は常に AsyncGenerator になるが、 Generator は perform
で同期イテレータにできる。
function* program(): GeneratorProgramEffect> {
const v = yield* val();
yield* log(`v:${v}`);
}
console.log(
Array.from(
perform(program(), {
[log.t](message) => {
console.log(`[log] ${message}`);
},
[val.t]: () => {
return 42;
},
})
)
);
handler 側にすべての非同期があれば、 program 側は AsyncGenerator ではなく Generator になるはず。
何に使えるかと言うと、 program を分割した時に、同期実行部分を部分的にテストできる。
function* subProgram(): Generatortypeof val> {
const v = yield* val();
return v;
}
async function* program(): AsyncGeneratorProgramEffect> {
const v = yield* subProgram();
yield* log(`v:${v}`);
}
const xs = [
...perform(sub(), {
[val.t]: () => {
return 42;
},
}),
];
Generator から AsyncGenerator は呼べないが、AsyncGenerator から Generator の yield* generator
はできる。
利点: 内部トレーサビリティのあるテストを書ける
これを使うと、あるプログラムが内部的にどのような実行ステップを踏んだのかをデバッグできる。
import { none, performAsync, ResultStep, returns } from "@mizchi/domain-types";
import { query, log, ProgramEffect, read, timeout, write } from "./effects.ts";
import { program } from "./program.ts";
import { expect } from "@std/expect";
Deno.test("Sample App Program Test", async () => {
const steps = await Array.fromAsync(
performAsync(program(), {
[log.t]: none,
[read.t]: returns("mocked"),
[write.t]: none,
[timeout.t]: none,
[query.t]: returns([]),
})
);
const expected: ResultStepProgramEffect>[] = [
[log.t, ["Starting complex workflow..."], undefined],
[read.t, ["config.json"], "mocked"],
[log.t, ["Config loaded: mocked"], undefined],
[query.t, ["SELECT * FROM users"], []],
[log.t, ["Found 0 users"], undefined],
[timeout.t, [500], undefined],
[write.t, ["report.txt", "Report: 0 users processed"], undefined],
[timeout.t, [500], undefined],
];
expect(steps).toEqual(expected);
});
工夫せずに実行ステップをすべて保持すると全部の実行履歴をもってしまいメモリが溢れてしまうが、これはイテレータとして実装してるので、Array.fromAsync 等で明示的に保持しない限りメモリを占有しない。
単にログを捨てて実行するならこれだけ。
const g = performAsync(program(), {
[log.t]: none,
[read.t]: returns("mocked"),
[write.t]: none,
[timeout.t]: none,
[query.t]: returns([]),
});
for await (const _ of g) {
}
欠点
const a = defineEffect"a">("a");
const b = defineEffect"b">("b");
async function* program(): AsyncGeneratorEffectFortypeof a>> {
yield* a();
yield* b();
}
あくまで型で縛ってるだけで、それを無視して実行はできる。TS でこれを縛るのは型の表現力が足りない。
TODO
型パズルとして、やりこんでみたが、まだやりたいことがある。
実行中の Effect を明示的に拡張したい。
declare function extendProgram(...args: any[]): any;
const a = defineEffect"a">("a");
const b = defineEffect"b">("b");
const c = defineEffect"c">("c");
async function* extended(): AsyncGenerator
EffectFortypeof a> | EffectFortypeof b> | EffectFortypeof c>
> {
yield* a();
yield* b();
}
async function* program(): AsyncGeneratorEffectFortypeof a>> {
yield* a();
yield* extendProgram(extended(), {
[b.t]: () => {},
[c.t]: () => {},
});
}
Generator の実行時は this
自体が Generator インスタンスになってるので、これを引き回せばできそうな気はしてる。
が、型の表現が可能かが不明。また、その場合 yield * extendProgram(...)
が拡張された側をどう扱うかが自明でない。
async function* program(): AsyncGenerator
EffectFortypeof a>,
ExtendedEffectFortypeof extended>
> {}
参考
https://github.com/susisu/effectful を大いに参考にして、EffectRegistry を引いて AsyncGenerator 用に拡張した結果がこれです。
Views: 0