在 Day21-Day22 我們已經建立好 ConfigLive
、LoggerLive
、DatabaseLive
,並進一步組成應用層服務 MainLive
:
ConfigLive ─┐
├─(merge)→ AppConfigLive →(provide)→ DatabaseLive → MainLive
LoggerLive ─┘
MainLive → 提供給 HTTPServer(或你的主程式)
今天(Day23)我們延續這個架構,專注在「建構期」的可靠性:
有些時候建構 Layer 也可能失敗,這時可用錯誤處理 API 提供替代方案,讓程式持續運行。
import { Context, Effect, Layer } from "effect"
// 回憶一下我們有哪一些服務
class Config extends Context.Tag("Config")<
Config,
{
readonly getConfig: Effect.Effect<{
readonly logLevel: string
readonly connection: string
}>
}
>() {}
class Logger extends Context.Tag("Logger")<
Logger,
{
readonly log: (message: string) => Effect.Effect<void>
}
>() {}
class Database extends Context.Tag("Database")<
Database,
{ readonly query: (sql: string) => Effect.Effect<unknown> }
>() {}
// ┌─── Layer<Database, never, Logger | Config>
// ▼
const DatabaseLivePrimary = Layer.effect(
Database,
Effect.gen(function*() {
// 模擬主資料庫失敗,觸發 Layer.catchAll 的降級路徑
return yield* Effect.fail(new Error("Primary database connection failed (simulated)"))
})
)
// ┌─── Layer<Database, never, Logger>
// ▼
const InMemoryDatabaseLive = Layer.effect(
Database,
Effect.gen(function*() {
const logger = yield* Logger
const connection = "in-memory://db"
return {
query: (sql: string) =>
Effect.gen(function* () {
yield* logger.log(`Executing query (memory): ${sql}`)
return { result: `Results from ${connection}` }
})
}
})
)
// 失敗時回退到備援
// ┌─── Layer.Layer<Database, never, Config | Logger>
// ▼
const DatabaseLive = DatabaseLivePrimary.pipe(
Layer.catchAll((e) => {
console.log(`Recovering from error\n${String(e)}`)
return InMemoryDatabaseLive
})
)
還記得我們這個系列其中一個目的是學習產品級的軟體開發嗎?這邊我們用實際的例子來說明,當服務發生錯誤而被降級到 in-memory DB 這樣的處理方式有哪些好處與壞處:
我覺得用真實情境來探討這個議題會比較好理解,廢話不多說,我們直接看例子。
connect
/PING
逾時。X-Degraded: rate-limit-in-memory
)。為什麼可行?限流屬於「控制面」且可接受短暫不一致;跨實例不同步雖有誤差,但比全面停擺好。
為什麼可行?旗標多半是讀多寫少,短暫過期可接受;不涉及交易資料。
為什麼可行?業務是唯讀回應,時效性要求可容忍幾分鐘延遲。
重點:關鍵寫入不要回退到記憶體;要嘛拒絕,要嘛進持久佇列,確保可追溯與不丟單。
// ┌─── 備援 Layer
// ▼
const database = DatabaseLivePrimary.pipe(Layer.orElse(() => DatabaseFallbackLive))
Effect.runFork(Layer.launch(database))
catchAll
與 orElse
catchAll
:你想根據錯誤內容做不同處置(降級、調整設定、紀錄更多脈絡),就用 catchAll
取得錯誤值來決策。orElse
:只要「失敗就切換」備援方案,且備援不需要原始錯誤內容時,orElse
最簡潔。在產品級系統中,備援不要一次跳到「全域降級」。先局部、再邊界、最後才是全域,這樣影響面最小、回復也最快。
層級 1(局部/服務內) :某個依賴掛了 → 換同質的備援實作
層級 2(應用/邊界層) :某個業務功能受阻 → 提供 read-only 或簡化替代結果
層級 3(全域/系統級) :整體不可用 → 回應降級頁面/靜態 503 與明確告警
使用者在網站輸入關鍵字執行搜尋,系統需穩定回傳結果清單;若部分依賴故障,必須在不影響對外一致性的前提下降級服務:
Database
可用 → 回傳db:${q}
結果。Database
不可用 → 切換ReadOnly
,優先使用Cache
;命中回傳快取結果cache:${q}
,未命中回傳空清單。Cache
層亦不可用或沒有 cache 資料 → 回傳空清單。TestMode = "dbSuccess" | "cacheHit" | "cacheMiss"
驗證三種情境。讓我們來看看程式碼吧!😌
// 服務契約:快取(Read-only)。提供以 key 讀取字串值。
class Cache extends Context.Tag("Cache")<Cache, {
readonly get: (key: string) => Effect.Effect<string | undefined>
}>() {}
// 服務契約:資料庫。以查詢字串回傳結果清單。
class Database extends Context.Tag("Database")<Database, {
readonly find: (q: string) => Effect.Effect<ReadonlyArray<string>>
}>() {}
// 服務契約:搜尋。對外暴露單一 run 方法。
class Search extends Context.Tag("Search")<Search, {
readonly run: (q: string) => Effect.Effect<ReadonlyArray<string>>
}>() {}
// 主路徑:Search 直接委派給 Database(完整功能)
const SearchLivePrimary = Layer.effect(
Search,
Effect.gen(function*() {
const db = yield* Database
return {
run: (q: string) => db.find(q)
}
})
)
// 次級路徑(唯讀):Search 透過 Cache 回應。命中快取則回傳命中結果,否則回傳空陣列(對外一致)。
const SearchReadOnlyLive = Layer.effect(
Search,
Effect.gen(function*() {
const cache = yield* Cache
return {
run: (q: string) =>
Effect.gen(function*() {
const hit = yield* cache.get(`search:${q}`)
return hit ? [hit] : []
})
}
})
)
// 測試模式:控制各種情境
// - "dbSuccess": 資料庫可用(主路徑)
// - "cacheHit": 快取命中(唯讀降級)
// - "cacheMiss": 快取未命中(最終降級)
type TestMode = "cacheMiss" | "cacheHit" | "dbSuccess"
const TEST_MODE = "cacheHit" as TestMode
const QUERY = "hihi"
console.log("TEST_MODE", TEST_MODE)
// 小工具:將多個 Layer 依序串接,若前者失敗則切換到下一個(降級鏈)
function withFallbacks<S, E, R>(
first: Layer.Layer<S, E, R>,
...fallbacks: ReadonlyArray<Layer.Layer<S, E, R>>
): Layer.Layer<S, E, R> {
return fallbacks.reduce(
(acc, fb) => acc.pipe(Layer.catchAll(() => fb)),
first
)
}
function buildCacheLayer(mode: TestMode): Layer.Layer<Cache, unknown, never> {
// 情境:快取命中時,直接以快取提供資料
if (mode === "cacheHit") {
return Layer.effect(
Cache,
Effect.succeed({
get: (key: string) => Effect.succeed(key === `search:${QUERY}` ? `cache:${QUERY}` : undefined)
})
)
}
// 真實快取(Redis):此處故意失敗以模擬不可用
const CacheRedisLive = Layer.effect(
Cache,
Effect.fail(new Error("Redis unavailable (simulated)"))
)
// 後備快取(記憶體):永遠可用,但預設沒有資料
const CacheInMemoryLive = Layer.effect(
Cache,
Effect.succeed({
get: (_key: string) => Effect.succeed<string | undefined>(undefined)
})
)
// 先試 Redis,失敗則降級到記憶體快取
return CacheRedisLive.pipe(Layer.catchAll(() => CacheInMemoryLive))
}
function buildDatabaseLayer(mode: TestMode): Layer.Layer<Database, unknown, never> {
// 成功情境:資料庫可用
if (mode === "dbSuccess") {
return Layer.effect(Database, Effect.succeed({ find: (q: string) => Effect.succeed([`db:${q}`]) }))
}
// 失敗情境:模擬主資料庫連線失敗
return Layer.effect(Database, Effect.fail(new Error("Primary database connection failed (simulated)")))
}
// 依 TEST_MODE 建立對應的快取與資料庫 Layer
const CacheLayer = buildCacheLayer(TEST_MODE)
const DatabaseLayer = buildDatabaseLayer(TEST_MODE)
// 最終降級:回傳空清單,維持服務可用性
const DegradedSearchLive = Layer.succeed(Search, { run: (_q: string) => Effect.succeed([]) })
// 組合降級鏈:Primary -> ReadOnly -> Degraded
const SearchLive = withFallbacks(
SearchLivePrimary.pipe(Layer.provide(DatabaseLayer)),
SearchReadOnlyLive.pipe(Layer.provide(CacheLayer)),
DegradedSearchLive
)
// 系統注入:此例中只需提供 Search 即可
const MainLive = SearchLive
// 應用程式:呼叫 Search.run 並回傳結果
const program = Effect.gen(function*() {
const search = yield* Search
const results = yield* search.run(QUERY)
return results
})
// 提供 Layer 並執行
const runnable = Effect.provide(program, MainLive)
Effect.runPromise(runnable).then(console.log).catch(console.error)
SearchLivePrimary
依賴 DatabaseLayer
,成功回傳 db:${q}
。withFallbacks(Primary, ReadOnly, Degraded)
依序降級。SearchReadOnlyLive
依賴 CacheLayer
,命中回 cache:${q}
;未命中回 []
。DegradedSearchLive
回傳 []
;與 ReadOnly 未命中一致,滿足對外一致性。"dbSuccess" | "cacheHit" | "cacheMiss"
控制三種情境,便於示範與測試。示例使用 TEST_MODE = "cacheHit"
。QUERY
常數(例如 "hihi"
)搭配 cache:${QUERY}
模擬快取命中條件。關鍵要點:
orElse
;需根據錯誤決策用 catchAll
。