延續前一篇「Effect 資源管理(一)」,本篇聚焦在 Scope
與相關的 API:Effect.addFinalizer
、Effect.scoped
、Effect.acquireRelease
、Effect.acquireUseRelease
。我們直接開始~
Scope
是什麼?把 Scope
想成一個「會在結束時自動幫你收尾」的容器。你可以在過程中不斷把清理動作丟進去。等到這個 Scope 結束,系統會自動照順序幫你執行。
import { Console, Effect, Exit, Scope } from "effect";
const demoManualScope = Effect.gen(function*() {
const scope = yield* Scope.make()
yield* Scope.addFinalizer(scope, Console.log("Finalizer 1"))
yield* Scope.addFinalizer(scope, Console.log("Finalizer 2"))
yield* Scope.close(scope, Exit.succeed("scope closed"))
})
Effect.runFork(demoManualScope)
程式碼運作如下:
Scope
(資源生命週期容器)。Scope
,系統在關閉時自動執行所有 finalizer。Scope.close(scope, Exit.succeed("scope closed"))
需要一個 Exit
告知關閉原因(成功或失敗)。Scope
是一個可不斷追加清理工作的容器,關閉時統一收尾。我個人實務上除非必須,不然盡量不直接用 Scope.make()
建立 Scope 來手動管理資源的生命週期。我更加 prefer 宣告式 (declarative) 寫法,你也是對吧?🙂
Effect.addFinalizer
與 Effect.scoped
Effect.addFinalizer
用來註冊「結束時要做什麼」;Effect.scoped
則負責「這段範圍何時結束」。兩者合起來,就能把資源範疇界定好,讓收尾工作在結束時一次做完。
為什麼:例如串流處理任務(讀檔→解析→寫出),會在流程中不斷追加需要清理的「暫時資源」(暫存檔、開啟的 reader/writer、外部鎖)。這些清理動作的數量/順序會隨流程演進而變化。用 addFinalizer
將清理逐步掛上當前 Scope,最後由 Effect.scoped
統一在結束時執行,避免遺漏。
// 執行時註冊一個 finalizer
const withFinalizer = Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("Last!"))
yield* Console.log("First")
})
// 以 scoped 提供 Scope 包住的地方界定資源範疇
// ┌─── const scoped: <void, never, Scope>(effect: Effect.Effect<void, never, Scope>) => Effect.Effect<void, never, never>
// ▼
Effect.runFork(Effect.scoped(withFinalizer))
從 Effect.scoped
的型別我們可以知道,若未以 Effect.scoped
(或手動建立/關閉 Scope
)包住,直接執行需要 Scope
的 Effect 會因缺環境依賴而無法執行。這樣的設計很好的把資源範疇界定在 Effect.scoped
內,避免資源外洩。
我們也能把 Effect.scoped
放在內部而非運行邊界,將資源使用範圍限制在更小的範圍:
const fineGrained = Effect.gen(function*() {
// 在這裡就結束該 Scope
yield* Effect.scoped(Effect.addFinalizer(() => Console.log("Last!"))) // finalizer 註冊後,因 Scope 關閉而執行
yield* Console.log("First")
})
Effect.runFork(fineGrained)
Effect.acquireRelease
很多資源都有「取得」與「釋放」兩個階段(像檔案 handle、資料庫連線)。Effect.acquireRelease
能把這對行為綁在一起,並交給外層的 Effect.scoped
決定何時收尾。
在同一個請求/流程裡「開一次檔案→用同一個 fs.FileHandle
做多個步驟→最後再關一次」。Effect.acquireRelease
把「開啟/關閉」成對綁好,外層 Effect.scoped
決定收尾時機;就算失敗或中斷也會用 LIFO 正確清理。
特別注意:資源的取得(acquire)階段是不可中斷(uninterruptible)的,以確保不會因為僅部分取得資源而讓系統處於不一致狀態。
import * as fs from "node:fs/promises"
import type { FileHandle } from "node:fs/promises"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
// 取得運行腳本的路徑
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
// 檔案路徑
const targetPath = path.join(scriptDir, "1-what-is-a-program.ts")
// 資源取得(Acquire):以唯讀方式開啟檔案,成功時產生 FileHandle
const acquireReadOnlyFileHandle = Effect.tryPromise({
try: () => fs.open(targetPath, "r"),
catch: () => new Error("Failed to open file")
}).pipe(Effect.tap(() => Console.log("File opened")))
// 資源釋放(Release):接收與 acquire 相同的 FileHandle 並關閉
const closeFile = (fileHandle: FileHandle) =>
Effect.promise(() => fileHandle.close()).pipe(Effect.tap(() => Console.log("File closed")))
// 資源使用(Use):在作用域內使用同一個 FileHandle
const useFile = (fileHandle: FileHandle) => Console.log(`Using File: ${fileHandle.fd}`)
// Acquire → Use → Release 皆由 acquireRelease 與 scoped 自動串接
const program = Effect.scoped(
Effect.acquireRelease(acquireReadOnlyFileHandle, closeFile).pipe(
Effect.flatMap(useFile)
)
)
Effect.runFork(program)
// 輸出:
// File opened
// Using File: 28
// File closed
Effect.acquireUseRelease
:更簡潔的單一場景使用如果只是在單一區塊內用完(取得→使用→釋放),就用 Effect.acquireUseRelease
一次解決。程式碼最簡潔。我們可以將
const program = Effect.scoped(
Effect.acquireRelease(acquireReadOnlyFileHandle, closeFile).pipe(
Effect.flatMap(useFile)
)
)
修改成:
const program = Effect.acquireUseRelease(acquireReadOnlyFileHandle, useFile, closeFile)
acquireUseRelease
的時機與好處Effect.scoped
;同時強制提供 acquire/use/release,降低遺漏清理的風險。release
仍會執行,確保資源一定被釋放。Effect.acquireRelease
+ 外層 Effect.scoped
更合適。下方對照兩種寫法,關鍵在「資源取得(acquire)與使用(use)的生命週期是否落在同一個 Scope
」:
Effect.scoped
,Scope
關閉時 release
會按 LIFO 正確執行收尾程式。Effect.scoped
內取得資源、卻在外面繼續使用。型別不一定報錯,但語義已經錯誤,實際上FileHandle
早已失效。// 取得運行腳本的路徑
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
// 檔案路徑
const targetPath = path.join(scriptDir, "1-what-is-a-program.ts")
// 與 day27 命名對齊
const acquireReadOnlyFileHandle = Effect.tryPromise({
try: () => fs.open(targetPath, "r"),
catch: () => new Error("Failed to open file")
}).pipe(Effect.tap(() => Console.log("File opened")))
const closeFile = (fileHandle: FileHandle) =>
Effect.promise(() => fileHandle.close()).pipe(Effect.tap(() => Console.log("File closed")))
const fileHandle = Effect.acquireRelease(acquireReadOnlyFileHandle, closeFile)
// 安全:在同一個 Scope 內取得並使用(對應「請求內」拿到連線就用完)
const programSafe = Effect.scoped(
Effect.gen(function*() {
const handle = yield* fileHandle // 此處仍在 Scope 內
yield* Console.log("Using file (safe)")
const buf = yield* Effect.tryPromise(() => handle.readFile())
yield* Console.log(buf.toString())
})
)
Effect.runFork(programSafe)
// 輸出:
// File opened
// Using file (safe)
// const hello = "Hello, World!"
// console.log(hello)
// File closed
// 風險:Scope 已關閉,但之後還拿著 handle 使用(像把連線把手外洩到背景任務)
const programRisky = Effect.gen(function*() {
const handle = yield* Effect.scoped(fileHandle) // 在這裡 Scope 已關閉
yield* Console.log("Using file after scope closed (risky)")
// 直接觀察 scope 外的把手狀態(純展示用,不代表可用)
yield* Console.log(handle)
yield* Effect.tryPromise({
try: () => handle.readFile(),
catch: () => "readFile failed"
}).pipe(
Effect.tapError((e) => Console.log(e))
)
})
// 建議:不要這樣寫(範例僅為展示風險)
Effect.runFork(programRisky)
// 輸出:
// File opened
// File closed
// Using file after scope closed (risky)
// FileHandle {
// _events: [Object: null prototype] {},
// _eventsCount: 0,
// _maxListeners: undefined,
// close: [Function: close],
// [Symbol(shapeMode)]: false,
// [Symbol(kCapture)]: false,
// [Symbol(kHandle)]: FileHandle {},
// [Symbol(kFd)]: -1,
// [Symbol(kRefs)]: 0,
// [Symbol(kClosePromise)]: undefined
// }
根據流程,我們會先「File opened」接著「File closed」,之後才在 Scope
外使用 FileHandle
,但透過打印出來的結果可以看出 FileHandle
的 [Symbol(kFd)]
已是 -1
,代表資源已被關閉。所以程式會報錯。這就是「過早關閉 Scope 卻仍在範圍外持有資源的引用」的典型例子。
本篇聚焦於以 Scope
作為資源生命週期的容器:清理動作以 LIFO 執行,透過 Effect.scoped
界定邊界,並用 Effect.addFinalizer
動態累積收尾。當資源具備取得與釋放兩階段時,以 Effect.acquireRelease(acquire, release)
將其成對綁定並交由外層 scoped
在成功、失敗或中斷時一律會自動正確運行收尾程式(其中 acquire 為不可中斷的程式以確保系統資源的一致性與完整性)。
若僅在單一區塊就會使用完資源,則以 Effect.acquireUseRelease(acquire, use, release)
一氣呵成,在程式上最為簡潔。必要時也可將 scoped
放在流程內部以縮小影響面、提早釋放暫時性資源。
最後我們還提到容易不小心過早關閉 Scope 卻仍在範圍外持有資源的引用。這種情況不會在 Editor 做出提醒,但實際上卻會造成程式出錯。
Yes!終於講完了,下一篇我們繼續講 Effect 中的並行執行!😀