iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Modern Web

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

[學習 Effect Day24] Effect 服務管理(五)

  • 分享至 

  • xImage
  •  

用 Effect.Service 簡化服務定義

Effect.Service 是把「tag + 預設實作 + 對應的 Layer」合在一起的語法糖。
很適合應用程式層級的服務(有合理預設實作)。

為什麼要用 Effect.Service?

  • 它把你原本要分三步做的事「一次宣告」:
    • 建立 Tag(服務型別的識別)
    • 定義預設實作(如何建立這個服務)
    • 產生對應 Layer(自動處理依賴、可直接提供)
  • 好處:少樣板碼、依賴清楚、測試友善(有含依賴與不含依賴兩種 Layer)。

比較 Effect 兩種服務寫法:Context.Tag + Layer vs Effect.Service(循序漸進)

目標情境

  • 實作一個快取服務 Cache.lookup(key):讀取檔案內容;若檔案不存在則讓 Effect 失敗(不建立檔案);最後將結果輸出到 stdout。
  • 使用到的依賴:FileSystem、Path(均來自 @effect/platform),在 Node 環境使用 NodeFileSystem。

一、寫法 A:Context.Tag + Layer(先定義 API,Layer 中接上依賴)

步驟 1:定義服務契約(先定 API 形狀與錯誤型別)

  • 在宣告層就決定 Effect 的成功/錯誤型別,因此需要明確標註錯誤(此處選 PlatformError)。
  • 優點:契約邊界清晰、與實作解耦。
  • 代價:要自己決定錯誤型別並 import。
class Cache extends Context.Tag("Cache")<Cache, {
  readonly lookup: (key: string) => Effect.Effect<string, PlatformError>
}>() {}

為何需要手動標註 PlatformError?

  • 因為在 Context.Tag 的「契約宣告階段」你就必須決定錯誤型別。由於 lookup 的實作會呼叫 FileSystem API,其錯誤即為 PlatformError,所以契約中選擇 PlatformError 最吻合實際行為。

步驟 2:提供實作(在層內取得依賴並實作 lookup)

  • 依賴 FileSystem、Path;建立 cache 目錄;讀不到檔案就讓錯誤往上拋出。
// 建立快取服務的實作
const cacheLive = Effect.gen(function*() {
  const fs = yield* FileSystem.FileSystem
  const path = yield* Path.Path
  const cacheDir = path.join("src", "day24", "cache")
  const lookup = (key: string) => fs.readFileString(path.join(cacheDir, key))

  return { lookup }
})

步驟 3:組裝依賴(顯式依賴組裝)

  • 用 Layer.effect 建立服務層,逐一 provide 其外部依賴。
// 將實作包成 Layer,並提供 Node 檔案系統與 Path 依賴
const CacheLayer = Layer.effect(Cache, cacheLive).pipe(
  Layer.provide(NodeFileSystem.layer),
  Layer.provide(Path.layer)
)

步驟 4:使用與執行

  • 取出 Cache,呼叫 lookup,再輸出到 stdout;最後 provide 層並執行。
// 主程式:取得快取服務 → 讀取資料 → 確保結尾換行 → 輸出到 stdout
const program = Effect.gen(function*() {
  const cache = yield* Cache
  const data = yield* cache.lookup("my-key")
  const line = data.endsWith("\n") ? data : `${data}\n`
  process.stdout.write(line)
}).pipe(Effect.catchAllCause((cause) => Console.log(cause)))

const runnable = program.pipe(Effect.provide(CacheLayer))

// 執行程式
Effect.runFork(runnable)

適用場合

  • 需要明確的依賴組裝、自由替換實作、或設計多層降級(fallback)鏈時,顯式 Layer 會更直覺(例如在 day23 展示的降級機制)。

二、寫法 B:Effect.Service(整合式服務模組,型別由實作推斷)

步驟 1:定義服務 + 實作 + 依賴(在同一處)

  • 服務的介面由 effect 區塊的實作「自動推斷」。
  • dependencies 欄位直接聲明外部依賴,框架會幫你產生 Default / DefaultWithoutDependencies。
// 以 Effect.Service 定義快取服務,並在 effect 區塊中提供實作
class Cache extends Effect.Service<Cache>()("Cache", {
  effect: Effect.gen(function*() {
    // 取得檔案系統與路徑服務
    const fs = yield* FileSystem.FileSystem
    const path = yield* Path.Path
    // 快取目錄位置(專案內的 src/day24/cache)
    const cacheDir = path.join("src", "day24", "cache")
    // 確保目錄存在(必要時遞迴建立)
    const lookup = (key: string) => fs.readFileString(path.join(cacheDir, key))

    return { lookup }
  }),
  // 宣告此服務啟動時所需的外部依賴 Layer
  dependencies: [NodeFileSystem.layer, Path.layer]
}) {}

步驟 2:使用與執行(更少樣板)

  • 直接 provide Cache.Default,錯誤由實作推斷為 PlatformError,無須顯式 import。
const program = Effect.gen(function*() {
  const cache = yield* Cache
  const data = yield* cache.lookup("my-key")
  const line = data.endsWith("\n") ? data : `${data}\n`
  process.stdout.write(line)
}).pipe(Effect.catchAllCause((cause) => Console.log(cause)))

const runnable = program.pipe(Effect.provide(Cache.Default))

Effect.runFork(runnable)

這裡為何不需要手動標註 PlatformError 呢?

  • 因為 lookup 的錯誤型別會從 FileSystem API 的型別自動推斷出來(就是 PlatformError),不需你在服務宣告時手動寫出。

適用場合

  • 中小型服務、一般應用、團隊想要統一與簡潔的服務模組寫法時,DX 極佳。

三、錯誤型別的來源與界線

  • FileSystem、Path 等平台能力在 @effect/platform 下,其 Effect 的錯誤型別為 PlatformError。
  • Context.Tag 寫法:契約層要先決定錯誤型別,所以你會 import 並標註 PlatformError。
  • Effect.Service 寫法:從 effect 實作內呼叫 FileSystem/Path,TypeScript 自動推斷出 lookup 的錯誤即 PlatformError。

四、Effect.Service 的測試方法

以下兩種策略都適用,請依需要選擇:

  1. 注入測試依賴(Injecting Test Dependencies): 替換 FileSystem
const FileSystemTest = FileSystem.layerNoop({
  readFileString: () => Effect.succeed("File Content...")
})

const TestLayer = Cache.DefaultWithoutDependencies.pipe(
  Layer.provide(FileSystemTest),
  Layer.provide(Path.layer)
)

const runnable = program.pipe(
  Effect.provide(TestLayer)
)

Effect.runFork(runnable)
  1. 直接 Mock 服務(覆蓋 Cache)
// 建立假的 Cache
const cache = new Cache({
  lookup: () => Effect.succeed("Cache Content...")
})

const runnable = program.pipe(Effect.provideService(Cache, cache))

Effect.runFork(runnable)

五、延伸:多實作 / 降級(fallback)何者更合適?

  • Context.Tag + Layer 更擅長做顯式依賴組合與降級串接(失敗時以 Layer.catchAll/Layer.orElse 切換至下一層)。像 day23 的示例可組出 Primary → ReadOnly → Degraded 的清晰路徑。
  • 用 Effect.Service 也能做降級,但通常是靠在不同地方提供/覆蓋服務來達成;當 fallback 鏈變長時,順序會分散在多個位置,閱讀起來不如用 Layer 在一條管線上明確串好來得直觀。

六、優缺點對照

Effect.Service

  • 優點:
    • 定義、實作、依賴聚合;樣板碼少、結構一致。
    • 自帶 Cache.Default / DefaultWithoutDependencies,提供/測試都方便。
    • 錯誤型別由實作推斷,無需手動 import/標註。
  • 缺點:
    • Layer 圖較隱性;複雜的依賴組裝、跨服務覆蓋時不如手寫 Layer 直觀。
    • 超大型服務可能想拆解定義/實作位置以維持邊界。

Context.Tag + Layer

  • 優點:
    • 契約(API/錯誤)在宣告層就清楚,實作可替換性強。
    • 依賴組裝清楚、降級/多實作切換更直覺。
  • 缺點:
    • 樣板碼偏多;需要自律維持一致結構與命名。
    • 需自己決定並標註錯誤型別(如 PlatformError)。

七、總結

  • 預設:選 Effect.Service(簡潔、DX 佳、結構一致)。
  • 若需要顯式依賴組裝/清楚的降級與多實作切換/嚴格鎖定服務 API 與錯誤邊界:選 Context.Tag + Layer。
  • 若實作必須由外部決定與替換:選 Context.Tag(在 Layer 組合處明確指定要用的實作與降級順序,切換點集中且一目了然)。
  • 使用 Effect.Service 時:類別本身即為 Tag;要切換實作可用 Effect.provideService(MyService, mock)

參考資料


上一篇
[學習 Effect Day23] Effect 服務管理(四)
系列文
用 Effect 實現產品級軟體24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言