iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Modern Web

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

[學習 Effect Day27] Effect 資源管理(一)

  • 分享至 

  • xImage
  •  

在長時間運行的應用中,像是資料庫連線、檔案操作、網路請求,這些「使用後必須釋放或關閉」的資源若沒有妥善處理,系統就會發生資源外洩。常見的外洩種類有兩種:

  • 記憶體外洩:配置了記憶體但沒有釋放,或遺失了引用,佔用量會持續上升,最後可能 OOM/當機。
  • 系統資源外洩:非記憶體的資源(例如開啟的檔案、網路/資料庫連線、執行緒、計時器)沒有關閉或歸還,累積到系統上限後就會失敗或卡住。

Effect 內建完善的資源管理模型,能用一致、可合成、且有型別保證的方式,確保在成功、失敗,甚至中斷時都會正確釋放。

程式的收尾行為:Finalization

在多數語言中,我們會用 try/finally 來確保「無論成功或失敗,清理/收尾程式都會被呼叫」。Effect 也提供等價且更強大的能力,讓我們可以更細膩的管理程式的收尾行為。

注意: 收尾行為,不一定是單純的資源釋放。準確來說是處理「不論結果如何都要做」或「依結果分支處理」的收尾邏輯。

接下來我會用檔案上傳前處理的例子來帶大家瞭解如何使用 Effect 做資源管理。我們程式流程如下:

  • 實務情境
    1. 使用者觸發腳本執行
    2. 程式在「腳本目錄」下的 temp/ 建立唯一暫存子資料夾(upload-xxxxxx)。
    3. 將使用者提供的檔案內容(content)寫入該暫存夾中的 ${fileName}.txt
    4. 檔案前處理:使用 3000ms 延遲模擬前處理(例如:驗證、轉檔、縮圖)。
    5. 成功:輸出完成訊息(目前為 Console.log),流程結束。
    6. 失敗:回傳錯誤(Effect.fail),流程結束。
    7. 清理:無論成功或失敗,皆會清理本次建立的暫存資料夾。

這邊先列出會需要用到的 helper functions。這些 functions 邏輯都很簡單,實作細節不是本文重點,主要是方便你直接複製貼上。😀


import { Console, Duration, Effect } from "effect"
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { fileURLToPath } from "node:url"

// 模擬前處理
function simulatePreprocessing(filePath: string, fileContents: string, delayMs: number = 3000) {
  return Effect.gen(function*() {
    yield* Effect.tryPromise({
      try: async () => {
        await fs.writeFile(filePath, fileContents)
      },
      catch: (e) => new Error(`write file failed: ${String(e)}`)
    })
    yield* Effect.sleep(Duration.millis(delayMs))
  })
}
// 實際情境的資源:上傳暫存基底目錄
function getScriptDirectory() {
  return path.dirname(fileURLToPath(import.meta.url))
}

// 建立唯一暫存資料夾
function createUniqueUploadTempDirectory(baseTmpDir: string) {
  return Effect.tryPromise({
    try: async () => {
      const tempBaseDir = path.join(baseTmpDir, "temp")
      await fs.mkdir(tempBaseDir, { recursive: true })
      return await fs.mkdtemp(path.join(tempBaseDir, "upload-"))
    },
    catch: (e) => new Error(`mkdtemp failed: ${String(e)}`)
  })
}

// 清理暫存資料夾
function cleanupTempBaseDirectory(baseTmpDir: string) {
  return Effect.tryPromise({
    try: async () => {
      await fs.rm(baseTmpDir, { recursive: true, force: true })
    },
    catch: (e) => new Error(`cleanup failed: ${String(e)}`)
  })
}

// 獲取資源
function acquireUploadResource() {
  return Effect.sync(() => {
    const scriptDir = getScriptDirectory()
    const fileName = "tmp-ensuring"
    const content = "隨便的文本,嘿嘿"
    return { fileName, baseDir: scriptDir, content }
  })
}

type UploadResource = {
  readonly fileName: string
  readonly baseDir: string
  readonly content: string
}

// 使用資源
function useUploadWork(resource: UploadResource) {
  return Effect.gen(function*() {
    const tmpDir = yield* createUniqueUploadTempDirectory(resource.baseDir)
    const rawPath = path.join(tmpDir, `${resource.fileName}.txt`)
    yield* simulatePreprocessing(rawPath, resource.content, 3000)

    const pass = true
    if (!pass) {
      return yield* Effect.fail(new Error(`${resource.content} failed`))
    }
    yield* Console.log(`${resource.content}: done`)
  })
}

// 釋放資源
function releaseUploadResource(resource: UploadResource) {
  return Effect.ignore(cleanupTempBaseDirectory(path.join(resource.baseDir, "temp")))
}

1. 先用 Effect.ensuring(總是執行收尾工作)

下方程式先取得資源 acquireUploadResource(),執行主要處理 useUploadWork(resource)(建立唯一暫存目錄、寫入檔案並以 sleep 模擬前處理),最後以 Effect.ensuring(releaseUploadResource(resource)) 保證無論成功、失敗或中斷都會執行清理(刪除本次建立的 temp/ 內容)。Effect.runFork 以非阻塞方式啟動程式。

import { Cause, Console, Duration, Effect, Exit } from "effect"
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { fileURLToPath } from "node:url"

const program = Effect.gen(function*() {
  const resource = yield* acquireUploadResource()
  const body = useUploadWork(resource)
  return yield* body.pipe(Effect.ensuring(releaseUploadResource(resource)))
})
Effect.runFork(program)

2. 疊加 Effect.onExit(記錄成功/失敗)

  • Effect.onExit
    • 什麼時候觸發:無論成功、失敗、或中斷都會在 Effect 結束後執行。
    • 帶什麼資料:收到 Exit,可分辨成功/失敗並取得 Cause。
    • 用途:統一收尾與紀錄結果(log/metrics),需要知道「結果是成功或失敗」時用。

此處我們先定義一個 exitHandler,利用 Exit.match 針對成功與失敗分支記錄不同訊息(也可在此上報 metrics/trace)。最後以 program.pipe(exitHandler) 的方式將它套在主程式外層,讓業務邏輯保持純淨,同時集中結果導向的收尾工作。不同於 Effect.ensuring 的「無條件清理」,onExit 著重在「依最終結果分支處理」。

const exitHandler = Effect.onExit((exit) =>
  Exit.match(exit, {
    onSuccess: () => Console.log("Cleanup completed: success"),
    onFailure: (cause) => Console.log(`Cleanup completed: ${Cause.pretty(cause)}`)
  })
)

const program = Effect.gen(function*() {
  const resource = yield* acquireUploadResource()
  const body = useUploadWork(resource)
  return yield* body.pipe(Effect.ensuring(releaseUploadResource(resource)))
}).pipe(exitHandler)

Effect.runFork(program)

3. Effect.onError:只在錯誤時執行

  • Effect.onError
    • 什麼時候觸發:只有在 Effect 失敗或中斷時才會執行;成功不會跑。
    • 帶什麼資料:收到 Cause(錯誤原因/堆疊)。
    • 用途:錯誤回報/告警/追蹤(反過來講就是在不關心成功時使用)。

onExit 相比,onError 不關心成功分支,專注在失敗與中斷的「後續處置」。典型用法是把告警、錯誤追蹤、重試排程或死信佇列(Dead Letter Queue)放在這裡,避免把這些「失敗路徑特有」的副作用混進主要流程。實務上可將 onExitonError 並用:前者統一紀錄結果,後者聚焦於問題情境的告警與錯誤診斷的強化。

const errorHandler = Effect.onError((cause) => Console.log(`Only on error: ${Cause.pretty(cause)}`))

const program = Effect.gen(function*() {
  const resource = yield* acquireUploadResource()
  const body = useUploadWork(resource)
  return yield* body.pipe(Effect.ensuring(releaseUploadResource(resource)))
}).pipe(exitHandler, errorHandler)

Effect.runFork(program)

4. Effect.acquireUseRelease:管理資源生命週期

前面的寫法以 ensuring 搭配 onExit/onError 來保證清理並記錄結果。當情境僅需在單一區塊內完成「取得→使用→釋放」時,可以將程式重構為 Effect.acquireUseRelease:把資源生命週期收斂在同一個表達式中;同時仍可在外層以 .pipe(exitHandler, errorHandler) 維持一致的結果紀錄與告警。

const program = Effect.acquireUseRelease(
  acquireUploadResource(),
  (resource) => useUploadWork(resource),
  releaseUploadResource
).pipe(exitHandler, errorHandler)

Effect.runFork(program)

總結

  • Effect.ensuring:無論成功、失敗或中斷,都會確保執行清理邏輯,適合放置「一定要做」的收尾工作。
  • Effect.onExit:依最終結果分支(成功/失敗)做紀錄或度量,維持業務程式純淨,將結果導向的處理集中在外層。
  • Effect.onError:專注失敗/中斷時的後續處置(告警、追蹤、重試、DLQ),與 onExit 並用能同時兼顧「統一紀錄」與「問題情境強化」。
  • Effect.acquireUseRelease:當需求可在單一區塊完成「取得→使用→釋放」,以此重構能把資源生命週期收斂在同一表達式,外層仍可串接 exitHandler/errorHandler做進一步的處理。

參考資料


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

尚未有邦友留言

立即登入留言