iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Modern Web

用 Effect 實現產品級軟體系列 第 28

[學習 Effect Day28] Effect 資源管理(二)

  • 分享至 

  • xImage
  •  

延續前一篇「Effect 資源管理(一)」,本篇聚焦在 Scope 與相關的 API:Effect.addFinalizerEffect.scopedEffect.acquireReleaseEffect.acquireUseRelease。我們直接開始~

Scope 是什麼?

Scope 想成一個「會在結束時自動幫你收尾」的容器。你可以在過程中不斷把清理動作丟進去。等到這個 Scope 結束,系統會自動照順序幫你執行。

手動建立 Scope 並加入 finalizer

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(資源生命週期容器)。
  • 依序註冊兩個清理動作(finalizer)。
  • 主動關閉該 Scope,系統在關閉時自動執行所有 finalizer。

重點

  • Finalizer 執行順序為後進先出(LIFO):先加入「Finalizer 1」、再加入「Finalizer 2」,關閉時會先執行「Finalizer 2」,再執行「Finalizer 1」。
  • Scope.close(scope, Exit.succeed("scope closed")) 需要一個 Exit 告知關閉原因(成功或失敗)。
  • 此段示範 Scope 是一個可不斷追加清理工作的容器,關閉時統一收尾。

我個人實務上除非必須,不然盡量不直接用 Scope.make() 建立 Scope 來手動管理資源的生命週期。我更加 prefer 宣告式 (declarative) 寫法,你也是對吧?🙂

Effect.addFinalizerEffect.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 更合適。

常見坑:過早關閉 Scope

下方對照兩種寫法,關鍵在「資源取得(acquire)與使用(use)的生命週期是否落在同一個 Scope」:

  • 安全寫法:把「取得與使用」放在同一個 Effect.scopedScope 關閉時 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 中的並行執行!😀

參考資料


上一篇
[學習 Effect Day27] Effect 資源管理(一)
下一篇
[學習 Effect Day29] Effect 並行執行(一)
系列文
用 Effect 實現產品級軟體29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言