iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Modern Web

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

[學習 Effect Day21] Effect 服務管理(二)

  • 分享至 

  • xImage
  •  

在上一篇文章中,我們聊到如何用 Context 建立服務,並把服務提供給 Effect 使用。不過在文章的最後我們有提到服務依賴服務的問題。這會帶來一個設計上的挑戰:**怎麼在保持介面乾淨的同時,處理這些依賴?**這一篇我們就是要來講如何使用 Layer 來解決這個問題。

服務依賴服務的困境:需求外洩 (Requirement Leakage)

以一個常見的 Web 應用程式為例:

  • Config 服務:提供應用程式設定(例如 DB 連線字串、logLevel)。
  • Logger 服務:依賴 Config(例如讀取 logLevel 才能決定輸出層級)。
  • Database 服務:同時依賴 Config 與 Logger。

錯誤示範

如果我們不小心把依賴直接寫進 Database 服務的介面裡,就會變成這樣:

import { Context, Effect } from "effect"

// Config 與 Logger:僅作為示意
class Config extends Context.Tag("Config")<Config, object>() {}
class Logger extends Context.Tag("Logger")<Logger, object>() {}

// ❌ 錯誤示範:把依賴暴露在介面中
class Database extends Context.Tag("Database")<
  Database,
  {
    readonly query: (
      sql: string
    ) => Effect.Effect<unknown, never, Config | Logger>
  }
>() {}

在這個設計中,query 的回傳型別包含了 Config | Logger
這代表任何「使用 Database 的程式碼」都必須同時滿足 Config 與 Logger 的需求

為什麼這樣不好?

  1. 使用者被迫知道內部細節
    Database 的介面不應該暴露它內部是怎麼實作的。
    例如,它需要設定或需要記錄 log,本來是內部細節,卻被使用者看得一清二楚。這在閱讀上會是一個雜訊,增加理解成本。
  2. 測試變得複雜
    在測試裡,我們通常會用一個假的 Database 來取代真實的版本:
import * as assert from "node:assert"

// 測試替身 Test Double(假資料庫)
const DatabaseTest = Database.of({
  query: (_sql) => Effect.succeed([]) // 假裝查詢回傳 []
})

這樣就能單純測試邏輯,例如確認「呼叫 query 會得到陣列」。

const test = Effect.gen(function*() {
  const database = yield* Database
  const result = yield* database.query("SELECT * FROM users")
  assert.deepStrictEqual(result, [])
})

//          ┌── Effect<void, never, Config | Logger>
//          ▼
const incompleteTestSetup = test.pipe(
  Effect.provideService(Database, DatabaseTest)
)

//                 ┌── ❌  ERROR:Missing 'Config | Logger' in the expected Effect context.
//                 ▼
Effect.runSync(incompleteTestSetup)

但是因為介面設計把 ConfigLogger 寫死在型別裡。即便在測試中根本不會用到,TypeScript 也會強迫我們「提供 ConfigLogger」。這就是所謂的 需求外洩 (Requirement Leakage) 問題。

改善方法:用 Layer 管理依賴

理想狀況下,服務的方法不應再要求任何外部依賴。服務方法的型別應該是:Effect<Success, Error, never>。所以我們目標就是將依賴在建構階段處理掉,使用者只需要「取得服務」並「呼叫方法」即可。

使用 Layer 管理依賴

Layer 會在「建構階段」負責把依賴串起來,以產生我們要的服務。

型別結構如下:

             ┌─── 產生的服務(RequirementsOut)
             │             ┌─── 可能發生的錯誤(Error)
             │             │        ┌─── 建構該服務所需的依賴(RequirementsIn)
             ▼             ▼        ▼
Layer<RequirementsOut, Error, RequirementsIn>

也就是說:Layer 是「如何產生一個服務」的藍圖,其中包含最後會創建什麼服務建構服務過程中可能發生的錯誤,以及建構該服務所需的依賴

我們會需要哪些 Layer 建構 Database 服務?

Layer 名稱 依賴 型別
ConfigLive Layer<Config>
LoggerLive 需要 Config Layer<Logger, never, Config>
DatabaseLive 需要 Config 與 Logger Layer<Database, never, Config | Logger>

命名慣例:Layer 名稱的 suffix 用 Live 表示正式環境的實作,Test 表示測試用的實作。

Config 服務建立

ConfigLive 無相依,可以用 Layer.succeed 直接提供一個常數實作:

// 服務定義:提供讀取設定的方法
class Config extends Context.Tag("Config")<
  Config, 
  {
    readonly getConfig: Effect.Effect<{
      readonly logLevel: string
      readonly connection: string
    }>
  }
>() {}

//        ┌─── Layer<Config, never, never>
//        ▼
const ConfigLive = Layer.succeed(Config, {
  getConfig: Effect.succeed({
    logLevel: "INFO",
    connection: "mysql://username:password@hostname:3306/database_name"
  })
})
  • 這段程式碼示範介面與實作分離:Config 定義「能做什麼」,ConfigLive 則把實作寫好並注入 Effect 的環境。
  • ConfigLiveLayer.succeed 建立,因為它沒有依賴、建置不會失敗、也不做 I/O,所以直接用常數把 getConfig 實作好,並註冊為可用的 Config 服務功能。
  • 型別 Layer<Config, never, never> 表示:提供 Config、建置不會失敗、且沒有依賴。

Logger 服務建立

Logger 需要 Config(讀 logLevel),所以在建構服務時就需要知道 Config 服務的實作。這時候我們就需要用到 Layer.effect。先從 Effect 的環境把 Config 拿出來,再把 log 的實作「註冊成」 Logger 服務。

  • Layer.effect(Tag, initEffect)
    • Tag:服務識別與型別資訊的載體。可把它視為「服務的鍵」,同時攜帶該服務的 TypeScript 型別。其實就是我們一直在定義的 "class" 本身。
    • initEffect:在建置時執行的 Effect。在這裡可讀取其他已提供的服務(依賴)、執行必要的初始化,並在最後回傳「此服務的實作物件」。這個回傳值會被註冊為對應 Tag 的服務實例。
class Logger extends Context.Tag("Logger")<
  Logger,
  {
    readonly log: (message: string) => Effect.Effect<void> 
  }
>() {}

//        ┌─── Layer<Logger, never, Config>
//        ▼
const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function*() {
    const config = yield* Config
    const { logLevel } = yield* config.getConfig
    return {
      log(message) {
        return Effect.sync(() => {
          console.log(`[${logLevel}] ${message}`)
        })
      }
    }
  })
)

建立 Logger 服務介面跟之前大同小異,我們來講一下 LoggerLive 的實作:

  1. 建立一個 Layer,提供的服務是 Logger(以 Tag Logger 為鍵)。
  2. 在建置時,先從 Effect 的環境把 Config 拿出來,再讀取 logLevel
  3. 最後回傳 Logger 服務實例。

從最後的回傳型別可以看到 RequirementsIn 的部分是 Config,表示在建構 Logger 服務時,Config 服務是必要的依賴。

Database 服務建立

我們的 Database 服務需要「對資料庫發出查詢」。它同時需要:

  • Config:取得 connection 字串
  • Logger:在每次查詢前後做紀錄

因此它屬於「有依賴、在建置時需要讀取環境」的服務,適合用 Layer.effect 來建置。

class Database extends Context.Tag("Database")<
  Database,
  { readonly query: (sql: string) => Effect.Effect<unknown> }
>() {}

// Layer<Database, never, Config | Logger>
const DatabaseLive = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    const logger = yield* Logger
    return {
      query: (sql: string) =>
        Effect.gen(function* () {
          yield* logger.log(`Executing query: ${sql}`)
          const { connection } = yield* config.getConfig
          return { result: `Results from ${connection}` }
        })
    }
  })
)

整個建制流程跟 Logger 服務幾乎一樣,區別只在於 query 方法的實作有用到 Logger 與 Config。這也讓 DatabaseLive RequirementsIn 的型別是 Config | Logger,表示在建構 Database 服務時,Config 與 Logger 服務是必要的依賴。

總結

  • 需求外洩的問題是因為服務的介面暴露了內部細節,導致使用者被迫知道內部細節。這會增加理解成本,並且測試變得複雜。
  • Layer 是建構服務的藍圖:把依賴解析與初始化放在建置階段完成,對外僅保留 Tag/介面。這樣服務更好測試、更好替換、更好使用。
  • Layer.succeed(常數)、Layer.effect(從 Effect 建構)等方式,把每個服務的依賴銜接好。

參考資料


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

尚未有邦友留言

立即登入留言