Effect 服務管理(二)
在上一篇文章中,我們聊到如何用 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 的需求。
為什麼這樣不好?
- 使用者被迫知道內部細節
Database 的介面不應該暴露它內部是怎麼實作的。
例如,它需要設定或需要記錄 log,本來是內部細節,卻被使用者看得一清二楚。這在閱讀上會是一個雜訊,增加理解成本。 - 測試變得複雜
在測試裡,我們通常會用一個假的 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)但是因為介面設計把 Config 與 Logger 寫死在型別裡。即便在測試中根本不會用到,TypeScript 也會強迫我們「提供 Config 跟 Logger」。這就是所謂的 需求外洩 (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的環境。 ConfigLive用Layer.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 的實作:
- 建立一個 Layer,提供的服務是 Logger(以 Tag Logger 為鍵)。
- 在建置時,先從 Effect 的環境把 Config 拿出來,再讀取 logLevel
- 最後回傳 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 建構)等方式,把每個服務的依賴銜接好。
參考資料
Last updated on