在長時間運行的應用中,像是資料庫連線、檔案操作、網路請求,這些「使用後必須釋放或關閉」的資源若沒有妥善處理,系統就會發生資源外洩。常見的外洩種類有兩種:
Effect 內建完善的資源管理模型,能用一致、可合成、且有型別保證的方式,確保在成功、失敗,甚至中斷時都會正確釋放。
在多數語言中,我們會用 try/finally 來確保「無論成功或失敗,清理/收尾程式都會被呼叫」。Effect 也提供等價且更強大的能力,讓我們可以更細膩的管理程式的收尾行為。
注意: 收尾行為,不一定是單純的資源釋放。準確來說是處理「不論結果如何都要做」或「依結果分支處理」的收尾邏輯。
接下來我會用檔案上傳前處理的例子來帶大家瞭解如何使用 Effect 做資源管理。我們程式流程如下:
temp/
建立唯一暫存子資料夾(upload-xxxxxx)。content
)寫入該暫存夾中的 ${fileName}.txt
。這邊先列出會需要用到的 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")))
}
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)
Effect.onExit
(記錄成功/失敗)Effect.onExit
此處我們先定義一個 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)
Effect.onError
:只在錯誤時執行Effect.onError
和 onExit
相比,onError
不關心成功分支,專注在失敗與中斷的「後續處置」。典型用法是把告警、錯誤追蹤、重試排程或死信佇列(Dead Letter Queue)放在這裡,避免把這些「失敗路徑特有」的副作用混進主要流程。實務上可將 onExit
與 onError
並用:前者統一紀錄結果,後者聚焦於問題情境的告警與錯誤診斷的強化。
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)
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
做進一步的處理。