この記事はウェブクルー Advent Calendar 2023 21日目の記事です。
昨日は @wc-fukuda さんの「個人的に最近好きなUI/UX3選」でした。
はじめに
何処にでも居る、しがないバックエンドエンジニアです。JavaやScalaといったJVM系言語を長くやってきた身ですが、近年はTypescriptを使う機会も増えてきました。
できる限り実行時エラーが起きない世界で生きていきたい。そんな思いで過ごしていたら、Effectというライブラリに出会いましたので、今回はそれを紹介したいと思います。
紹介と言っても、公式のドキュメントが充実しているので、そちらを見れば事済んでしまうのですが……それで終わってしまうのも寂しいので、ざっとでもどんな感じかをお伝えできればと思います。興味がある方は、雑文ですがお目通しいただければ幸いです。
Effect とは?
同期・非同期プログラミングをサポートするTypeScriptライブラリです。
Typescriptでの開発における 現実的な問題を解決 と掲げており、その内容は多岐に渡ります。
- FPのイディオムとTypescriptの型システムを活かしたタイプセーフな実装
- エラーハンドリング
- 同期処理・非同期処理のモデル化
- リソース管理(確実性のあるリソース開放)
- 並行処理
- 依存性注入
- テスタビリティ(トレース機能)
- etc...
今回はこれらの一部を取り上げたいと思います。
コンセプト
ScalaやHaskell、effect systemとしてのZIOから影響を受けておりますが、ただ単純にインスパイアしている訳ではなく、Effectは実用的なツールキットの姿を目指しているようです。この点は公式ドキュメントでも示されています。
今年に入って fp-tsの作者もJOIN していますが、FPがゴールではなく、あくまで目的はメンテナンス性の良いコードを書けるようにする事だと作者のMichael Arnaldiさんは主張しています。(Discord内で)
Eitherがright-biasでなかったり、EitherやOptionがEffect(副作用を含む型)を継承していたりと、一般的なFPユーザーからは違和感を抱きそうな仕様もありますが、FPの文脈に偏らず分かりやすさ・利便性とのバランスを取っているポリシーが伺えます。
Effectの作成と実行
Effectでは Effect<R, E, A>
型が提供され、処理の正常・エラーを型で表現できます。
ZIOでいう ZIO[R, E, A]
ですね。
Effect<R, E, A> // R:実行時の要件, E:エラー値, A:正常値
早速、Effectを作りながら例を見てみましょう。
factoryメソッドが様々用意されていますので、詳細は公式ドキュメントをご確認ください。
例として、渡された文字列を JSON.parse
する関数を作成します。JSON.parse
はデシリアライズに失敗すると例外が発生しますので、例外が発生しうる処理からEffectを作るのに適した Effect.try
を使用しています。
import { Effect } from "effect";
// type: (s: string) => Effect<never, UnknownException, any>
const parse = (s: string) => {
return Effect.try(() => JSON.parse(s))
};
戻り値が Effect<never, UnknownException, any>
となりました。
正常だと any
、エラーだと UnknownException
が返却される事が型から判断できます。
作成したEffectを実行してみましょう。
同期実行の runSync
、非同期実行の runPromise
が用意されています。
// 同期実行
const obj = Effect.runSync(parse('{ "foo": "bar" }'));
console.log(obj); // { "foo": "bar" }
(async () => {
// 非同期実行
const obj = await Effect.runPromise(parse('{ "foo": "bar" }'));
console.log(obj); // { "foo": "bar" }
})();
失敗した場合は UnknownException
が例外として投げられます。
UnknownExceptionはEffectの独自エラーで、Errorオブジェクトを継承しています。
const obj = Effect.runSync(parse('{'));
console.log(obj); // throw UnknownException
Effect.async
や Effect.tryPromise
といった非同期Effectを Effect.runSync
で非同期実行すると、例外 AsyncFiberException が発生します。作成されたEffectが同期Effectなのか非同期Effectなのかを判別する事はできません。原則として Effect.runPromise
で実行する事が推奨されています。
参考:https://www.effect.website/docs/faq#sync--async-behavior-in-effects
エラーハンドリング
先程の UnknownException
ではエラーの詳細が判りません。
エラー時にはエラーオブジェクトを返却するようにすると、発生しうるエラーが型で表されるようになり、利用側でのエラーハンドリングやエラーケースの把握が容易になります。
class Over20Error {
readonly _tag = "Over20Error";
}
class NotEvenError {
readonly _tag = "NotEvenError";
}
const program = (n: number): Effect.Effect<never, Over20Error | NotEvenError, number> => {
if (n > 20) return Effect.fail(new Over20Error());
if (n % 2) return Effect.fail(new NotEvenError());
return Effect.succeed(n);
}
異常時の型を Over20Error | NotEvenError
とUnionで表す事ができました。
Effect.catchTags
で各エラーに対するハンドリングを実装できます。
// type: Effect.Effect<never, string, number>
program(21).pipe(
Effect.catchTags({
Over20Error: (_) => Effect.fail('数字が20を超えている'),
NotEvenError: (_) => Effect.fail('数字が奇数になっている'),
// UnknownError: (_) => ... 存在しないエラーの場合、コンパイルエラーになる
})
);
Effectの外で例外ハンドリングする場合、煩雑な実装になってしまいます。
const result = Effect.runSyncExit(program(21));
if (Exit.isFailure(result)) {
const c = Cause.squash(result.cause);
if (c instanceof Over20Error) {
// ...
}
if (c instanceof NotEvenError) {
// ...
}
}
原則として、エラーハンドリングはEffectの文脈内で行い、Effect外との接続点になる箇所において一度だけrunする事をオススメされています。
リトライ処理
システムは外的要因(各種リソース不足・ネットワークエラー等)を受けて、一時的なエラーに遭遇する事があります。それに対する一般的なリカバリー策として、リトライ処理が挙げられます。
Effectではリトライ処理が簡単に組み込めるように設計されています。
Effect.retry
でリトライ戦略を設定できます。
let count = 0;
const program = Effect.try(() => {
count++;
if (count <= 2) {
console.log("exec: error");
throw new Error();
}
console.log("exec: success");
return;
});
const policy = Schedule.addDelay(
Schedule.recurs(3), // 最大3回リトライ
() => "1000 millis", // リトライの間は1000msのディレイを挟む
);
(async () => {
const program = Effect.retry(program, policy);
await Effect.runPromise(program);
})();
// Output:
// exec: error
// exec: error
// exec: success
単純にリトライ回数を指定するだけであれば、Effect.retryN
を使用できます。
(async () => {
const program = Effect.retryN(program, 3); // 最大3回リトライ
await Effect.runPromise(program);
})();
並行処理
アプリケーションでは、処理に時間がかかるタスクを複数同時に実行し、処理時間の短縮を図るケースがあります。Effectでは並行処理が簡単に実行できるように設計されています。
複数のEffectを制御する Effect.all
や Effect.forEach
に対して、並行処理オプションを設定する事で実現できます。
import { Duration, Effect } from "effect";
const createTask = (name: string, delay: number, timer: Date) =>
Effect.promise(() => {
return new Promise<void>((resolve) => {
console.log(`${name}: start | ${new Date().getTime() - timer.getTime()} ms`);
setTimeout(() => {
console.log(`${name}: done | ${new Date().getTime() - timer.getTime()} ms`);
resolve();
}, delay);
});
});
const timer = new Date();
const tasks = [
createTask("task1", Duration.toMillis("1 seconds"), timer),
createTask("task2", Duration.toMillis("2 seconds"), timer),
createTask("task3", Duration.toMillis("3 seconds"), timer),
createTask("task4", Duration.toMillis("1 seconds"), timer),
createTask("task5", Duration.toMillis("2 seconds"), timer),
];
(async () => {
const program = Effect.all(tasks, { concurrency: 2 }); // 並行処理数: 2
await Effect.runPromise(program);
})();
// Output:
// task1: start | 2 ms
// task2: start | 3 ms
// task1: done | 1004 ms
// task3: start | 1007 ms
// task2: done | 2005 ms
// task4: start | 2006 ms
// task4: done | 3007 ms
// task5: start | 3009 ms
// task3: done | 4009 ms
// task5: done | 5009 ms
//
// Timeline:
// [task1==>[task3====================>
// [task2===========>[task4==>[task5===========>
// 0 1 2 3 4 5 (s)
順次処理と並行処理を組み合わせる事もできます。
const timer = new Date();
const task1 = createTask("task1", Duration.toMillis("1 seconds"), timer);
const task2 = createTask("task2", Duration.toMillis("2 seconds"), timer);
const task3 = createTask("task3", Duration.toMillis("3 seconds"), timer);
const task4 = createTask("task4", Duration.toMillis("1 seconds"), timer);
const task5 = createTask("task5", Duration.toMillis("2 seconds"), timer);
(async () => {
const seq = Effect.all([task1, task2]); // 順次処理
const par = Effect.all([task3, task4, task5], { concurrency: 2 }); // 並行処理
const program = Effect.all([seq, par]);
await Effect.runPromise(program);
})();
// Output:
// task1: start | 2 ms
// task1: done | 1004 ms
// task2: start | 1006 ms
// task2: done | 3007 ms
// task3: start | 3014 ms
// task4: start | 3014 ms
// task4: done | 4015 ms
// task5: start | 4018 ms
// task3: done | 6016 ms
// task5: done | 6019 ms
//
// Timeline:
// [task1==>[task2===========>[task3====================>
// [task4==>[task5===========>
// 0 1 2 3 4 5 6 (s)
おわりに
同期処理と非同期処理を同じコードベースで管理できる事、実行時のリトライ性や並行性が簡単に適用できる事が良い点に感じました。また、今回は紹介できておりませんが Either や Option 等も組み込みで用意されており、FP的に実装をしていけるのも、Scalaをやっている身としては馴染みやすい所がありました。
一方で、その良さを最大限活かそうとするとEffectをプロジェクトの大部分で引き回す事になり、特定の外部ライブラリにガッツリ依存してしまうのが懸念点となりそうです。
以上、Effectのご紹介でした。
明日は、@Hideto-Kiyoshima-wc さんの投稿です。よろしくお願いします。