スコープ
長寿命のアプリケーションを開発する文脈において、リソース管理は重要な役割を果たします。大規模アプリケーションを構築する際には、効果的なリソース管理が不可欠です。アプリケーションがリソース効率的であり、リソースリークを引き起こさないことが重要です。
未処理のソケット接続、データベース接続、またはファイルディスクリプタなどのリソースリークは、Web アプリケーションにおいて許容されません。Effect は、この懸念に対処するための堅牢な構造を提供します。
リソースを安全に管理するアプリケーションを作成するには、リソースを開くたびに、それを閉じるメカニズムを持っていることを確認する必要があります。これは、リソースを完全に使用する場合でも、使用中に例外が発生した場合でも適用されます。
次のセクションでは、Effect がどのようにリソース管理を簡素化し、アプリケーションのリソースの安全性を確保するかを詳しく解説します。
Scope
Scopeデータ型は、Effect においてリソースを安全かつ合成的に管理するための基本的な構成要素です。
簡単に言うと、スコープは 1 つ以上のリソースのライフタイムを表しています。スコープが閉じられると、そのスコープに関連付けられたリソースはリリースされることが保証されます。
Scopeデータ型を使用すると、次の操作が可能です。
- ファイナライザの追加:これは、リソースを解放することを表します。
- スコープの閉鎖:すべての取得されたリソースを解放し、追加されたファイナライザを実行します。
この概念をよりよく理解するために、どのように機能するかを示す例を見ていきましょう。 通常の Effect の使用では、スコープの管理のためにこれらの低レベル API を直接操作することは少ないです。
import { Scope, Effect, Console, Exit } from "effect";
const program = // 新しいスコープを作成 Scope.make().pipe( // ファイナライザ1を追加 Effect.tap((scope) => Scope.addFinalizer(scope, Console.log("ファイナライザ 1")) ), // ファイナライザ2を追加 Effect.tap((scope) => Scope.addFinalizer(scope, Console.log("ファイナライザ 2")) ), // スコープを閉じる Effect.andThen((scope) => Scope.close(scope, Exit.succeed("スコープは正常に閉じられました")) ) );
Effect.runPromise(program);/*出力:ファイナライザ 2 <-- ファイナライザは追加された逆順で閉じられますファイナライザ 1*/デフォルトでは、Scopeが閉じられると、そのスコープに追加されたすべてのファイナライザが、追加された逆の順序で実行されます。このアプローチは、リソースを取得した逆の順でリリースすることが、リソースが適切に閉じられることを保証するため、理にかなっています。
たとえば、ネットワーク接続を開いた後、リモートサーバー上のファイルにアクセスする場合、ネットワーク接続を閉じる前にファイルを閉じる必要があります。この順序は、リモートサーバーとインタラクションを維持するために重要です。
addFinalizer
次に、Effect.addFinalizer関数について見ていきましょう。これは、Effectの値のスコープにファイナライザを追加するための高レベル API を提供します。これらのファイナライザは、関連するスコープが閉じられたときに実行されることが保証されており、その動作はスコープが閉じられた際のExit値に依存する場合があります。
これをより理解するために、いくつかの例を見てみましょう。
成功した場合の動作を観察してみましょう:
import { Effect, Console } from "effect";
const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`ファイナライザの後: ${exit._tag}`) ); return 1;});
const runnable = Effect.scoped(program);
Effect.runPromise(runnable).then(console.log, console.error);/*出力:ファイナライザの後: Success1*/import { Effect, Console } from "effect";
const program = Effect.addFinalizer((exit) => Console.log(`ファイナライザの後: ${exit._tag}`)).pipe(Effect.andThen(Effect.succeed(1)));
const runnable = Effect.scoped(program);
Effect.runPromise(runnable).then(console.log, console.error);/*出力:ファイナライザの後: Success1*/ここでは、Effect.addFinalizer演算子がワークフローに必要なスコープをコンテキストに追加します。これは次のように示されます:
Effect<void, never, Scope>;これは、ワークフローが実行するためにスコープを必要とし、このスコープを提供するためにEffect.scoped演算子を使用することを示しています。これにより、新しいスコープが作成され、ワークフローに供給され、ワークフローが完了するとスコープが閉じられます。
Effect.scoped演算子は、スコープをコンテキストから削除し、そのワークフローがもはやスコープに関連するリソースを必要としないことを示します。
次に、失敗した場合の動作を見てみましょう:
import { Effect, Console } from "effect";
const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`ファイナライザの後: ${exit._tag}`) ); return yield* Effect.fail("あちゃ!");});
const runnable = Effect.scoped(program);
Effect.runPromiseExit(runnable).then(console.log);/*出力:ファイナライザの後: Failure{ _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'あちゃ!' }}*/import { Effect, Console } from "effect";
const program = Effect.addFinalizer((exit) => Console.log(`ファイナライザの後: ${exit._tag}`)).pipe(Effect.andThen(Effect.fail("あちゃ!")));
const runnable = Effect.scoped(program);
Effect.runPromiseExit(runnable).then(console.log);/*出力:ファイナライザの後: Failure{ id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'あちゃ!' }}*/最後に、介入が発生した場合の動作を見てみましょう:
import { Effect, Console } from "effect";
const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`ファイナライザの後: ${exit._tag}`) ); return yield* Effect.interrupt;});
const runnable = Effect.scoped(program);
Effect.runPromiseExit(runnable).then(console.log);/*出力:ファイナライザの後: Failure{ _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } }}*/import { Effect, Console } from "effect";
const program = Effect.addFinalizer((exit) => Console.log(`ファイナライザの後: ${exit._tag}`)).pipe(Effect.andThen(Effect.interrupt));
const runnable = Effect.scoped(program);
Effect.runPromiseExit(runnable).then(console.log);/*出力:ファイナライザの後: Failure{ _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } }}*/スコープの手動作成と閉鎖
単一の操作内で複数のスコープ付きリソースを扱う際には、それぞれのスコープがどのように相互作用するかを理解することが重要です。デフォルトでは、これらのスコープは 1 つに統合されますが、スコープの閉鎖のタイミングをより細かく制御することもできます。
まず、デフォルトでスコープがどのように統合されるかを見てみましょう。このコード例を見てください:
import { Effect, Console } from "effect";
const task1 = Effect.gen(function* () { console.log("タスク 1"); yield* Effect.addFinalizer(() => Console.log("タスク 1の後のファイナライザ"));});
const task2 = Effect.gen(function* () { console.log("タスク 2"); yield* Effect.addFinalizer(() => Console.log("タスク 2の後のファイナライザ"));});
const program = Effect.gen(function* () { // これらの2つのスコープは1つに統合されます yield* task1; yield* task2;});
Effect.runPromise(program.pipe(Effect.scoped));/*出力:タスク 1タスク 2タスク 2の後のファイナライザタスク 1の後のファイナライザ*/import { Effect, Console } from "effect";
const task1 = Console.log("タスク 1").pipe( Effect.tap(() => Effect.addFinalizer(() => Console.log("タスク 1の後のファイナライザ")) ));
const task2 = Console.log("タスク 2").pipe( Effect.tap(() => Effect.addFinalizer(() => Console.log("タスク 2の後のファイナライザ")) ));
const program = // これらの2つのスコープは1つに統合されます Effect.all([task1, task2], { discard: true });
Effect.runPromise(program.pipe(Effect.scoped));/*出力:タスク 1タスク 2タスク 2の後のファイナライザタスク 1の後のファイナライザ*/この場合、task1とtask2のスコープは単一のスコープに統合され、プログラムが実行されると、特定の順序でタスクとそのファイナライザが出力されます。
各スコープの閉鎖タイミングをより制御したい場合は、次の例のように手動で作成して閉じることができます:
import { Console, Effect, Exit, Scope } from "effect";
const task1 = Effect.gen(function* () { console.log("タスク 1"); yield* Effect.addFinalizer(() => Console.log("タスク 1の後のファイナライザ"));});
const task2 = Effect.gen(function* () { console.log("タスク 2"); yield* Effect.addFinalizer(() => Console.log("タスク 2の後のファイナライザ"));});
const program = Effect.gen(function* () { const scope1 = yield* Scope.make(); const scope2 = yield* Scope.make();
// task1のスコープをscope1に拡張 yield* task1.pipe(Scope.extend(scope1));
// task2のスコープをscope2に拡張 yield* task2.pipe(Scope.extend(scope2));
// scope1とscope2を手動で閉じる yield* Scope.close(scope1, Exit.void); yield* Console.log("他の処理を実行"); yield* Scope.close(scope2, Exit.void);});
Effect.runPromise(program);/*出力:タスク 1タスク 2タスク 1の後のファイナライザ他の処理を実行タスク 2の後のファイナライザ*/import { Console, Effect, Exit, Scope } from "effect";
const task1 = Console.log("タスク 1").pipe( Effect.tap(() => Effect.addFinalizer(() => Console.log("タスク 1の後のファイナライザ")) ));
const task2 = Console.log("タスク 2").pipe( Effect.tap(() => Effect.addFinalizer(() => Console.log("タスク 2の後のファイナライザ")) ));
const program = Effect.all([Scope.make(), Scope.make()]).pipe( Effect.andThen(([scope1, scope2]) => Scope.extend(task1, scope1).pipe( Effect.andThen(Scope.extend(task2, scope2)), Effect.andThen(Scope.close(scope1, Exit.void)), Effect.andThen(Console.log("他の処理を実行")), Effect.andThen(Scope.close(scope2, Exit.void)) ) ));
Effect.runPromise(program);/*出力:タスク 1タスク 2タスク 1の後のファイナライザ他の処理を実行タスク 2の後のファイナライザ*/この例では、scope1とscope2という 2 つの別々のスコープを作成し、各タスクのスコープをそれぞれのスコープに拡張します。プログラムを実行すると、異なる順序でタスクとそのファイナライザが出力されます。
Scope.extend関数によって、スコープを必要とするEffectワークフローを別のスコープに拡張し、ワークフローが実行された際にスコープを閉じることなく、スコープを拡張できます。これにより、スコープされた値をより大きなスコープに拡張することが可能になります。
スコープが閉じられると、そのスコープ内のタスクがまだ完了していない場合に何が起こるか、気になるかもしれません。重要な点は、スコープの閉鎖がタスクを中断させるわけではないことです。タスクは引き続き実行され、ファイナライザは登録された際に即座に実行されます。closeへの呼び出しは、すでに登録されているファイナライザを待つだけです。
したがって、ファイナライザはスコープが閉じられたときに実行されますが、必ずしもエフェクトの実行が完了したときに実行されるわけではありません。Effect.scopedを使用している場合、スコープは自動的に管理され、ファイナライザはそれに応じて実行されます。ただし、スコープを手動で管理すると、ファイナライザが実行されるタイミングを制御できます。
リソースの定義
Effect.acquireRelease(acquire, release)のような演算子を使用してリソースを定義できます。これにより、acquireとreleaseのワークフローからスコープされた値を作成することができます。
すべての取得リリースには 3 つのアクションが必要です:
- リソースの取得:リソースの取得を説明するエフェクト。たとえば、ファイルを開くことです。
- リソースの使用:結果を生成するための実際のプロセスを説明するエフェクト。たとえば、ファイル内の行数をカウントすることです。
- リソースの解放:リソースを解放またはクリーンアップするための最終ステップを説明するエフェクト。たとえば、ファイルを閉じることです。
Effect.acquireRelease演算子は、acquireワークフローを中断不可で実行します。
これは重要です。なぜなら、リソース取得中に中断を許可すると、リソースが部分的に取得されているときに中断される可能性があるからです。
Effect.acquireRelease演算子の保証は、acquireワークフローが正常に実行を完了した場合、releaseワークフローがスコープが閉じられたときに実行されることです。
たとえば、シンプルなリソースを定義してみましょう:
import { Effect } from "effect"
// リソースのインターフェースを定義export interface MyResource { readonly contents: string readonly close: () => Promise<void>}
// リソースを取得するシミュレーションconst getMyResource = (): Promise<MyResource> => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("リソースは解放されました") resolve() }) })
// エラーハンドリング付きでリソースの取得を定義export const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("リソースが取得されました") return res }), catch: () => new Error("getMyResourceError")})
// リソースの解放を定義export const release = (res: MyResource) => Effect.promise(() => res.close())
export const resource = Effect.acquireRelease(acquire, release)// @include: resourceEffect.acquireRelease演算子がワークフローに必要なスコープをコンテキストに追加したことに注意してください:
Effect<MyResource, Error, Scope>;これは、ワークフローがスコープを必要とし、スコープが閉じられたときにリソースを閉じるファイナライザが追加されることを示しています。
Effect.andThenや他の Effect 演算子を使用することで、リソースとともに作業を続けることができます。たとえば、次のようにコンテンツを読み取る方法があります:
// @include: resource
// @filename: index.ts// ---cut---import { Effect } from "effect";import { resource } from "./resource";
const program = Effect.gen(function* () { const res = yield* resource; console.log(`コンテンツ: ${res.contents}`);});// @include: resource
// @filename: index.ts// ---cut---import { Effect, Console } from "effect";import { resource } from "./resource";
const program = resource.pipe( Effect.andThen((res) => Console.log(`コンテンツ: ${res.contents}`)));リソースの作業が完了したら、Effect.scoped演算子を使用してスコープを閉じることができます。これにより、新しいスコープが作成され、ワークフローに提供され、ワークフローが終了するとスコープが閉じられます。
// @include: resource
// @filename: index.ts// ---cut---import { Effect } from "effect";import { resource } from "./resource";
const program = Effect.scoped( Effect.gen(function* () { const res = yield* resource; console.log(`コンテンツ: ${res.contents}`); }));// @include: resource
// @filename: index.ts// ---cut---import { Effect, Console } from "effect";import { resource } from "./resource";
const program = Effect.scoped( resource.pipe( Effect.andThen((res) => Console.log(`コンテンツ: ${res.contents}`)) ));Effect.scoped演算子は、コンテキストからスコープを削除し、このワークフローによって使用されるリソースがもはやスコープを必要としないことを示します。
これで、実行する準備が整ったワークフローができました:
Effect.runPromise(program);/*リソースが取得されましたコンテンツ: lorem ipsumリソースは解放されました*/acquireUseRelease
Effect.acquireUseRelease(acquire, use, release)関数は、リソース管理を簡素化するために、リソースのスコープを自動的に処理する特化版のEffect.acquireRelease関数です。
主な違いは、acquireUseReleaseがリソースのスコープを管理するために手動でEffect.scopedを呼び出す必要を排除する点です。これにより、acquireステップで作成されたリソースの使用が完了したときに、いつ実行すべきかを自動的に判断するための知識が得られます。これは、取得したリソースで動作する関数を表すuse引数を提供することで達成されます。その結果、acquireUseReleaseは、リリースステップを実行するタイミングを自動的に決定できます。
以下の例では、acquireUseReleaseの使用方法を示します:
// @include: resource
// @filename: index.ts// ---cut---import { Effect, Console } from "effect";import { MyResource, acquire, release } from "./resource";
const use = (res: MyResource) => Console.log(`コンテンツ: ${res.contents}`);
const program = Effect.acquireUseRelease(acquire, use, release);
Effect.runPromise(program);/*出力:リソースが取得されましたコンテンツ: lorem ipsumリソースは解放されました*/