在上一篇文章中,我們學會了如何用 Layer
來解決服務依賴服務的問題,避免需求外洩(Requirement Leakage)。我們建立了 ConfigLive
、LoggerLive
和 DatabaseLive
這三個 Layer,每個都有各自的依賴需求。
但是,光有這些個別的 Layer 還不夠。在真實的應用程式中,我們需要把這些 Layer 組合起來,形成一個完整的應用服務的程式架構。
這一篇我們要學習的是 Layer 的組合與管理:如何用 Layer.merge
和 Layer.provide
來組合多個 Layer,如何處理 Layer 建構時的錯誤,以及如何用 Effect.Service
來簡化服務定義。這些技巧將幫助我們建立更完整、更可維護的應用程式架構。
合併 Layer 有兩種方法,一種是 Layer.merge
,另一種是 Layer.provide
。
Layer.merge
:把多個服務「並排合併」延續上一篇的例子,我們把 ConfigLive
與 LoggerLive
合併成 AppConfigLive
:
// ┌─── Layer<Config | Logger, never, Config>
// ▼
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)
型別上可以這樣讀:
這代表 AppConfigLive
會「提供」Config
與 Logger
兩個服務,但由於 LoggerLive
需要 Config
才能運作,所以整個合併的 Layer 仍然需要 Config
服務。
Layer.provide
:把輸出「接到」對方的輸入把一個 Layer 的輸出,提供給另一個 Layer 當作輸入,我們先看看語法長怎樣:
import { Layer } from "effect"
declare const inner: Layer.Layer<"OutInner", never, "InInner">
declare const outer: Layer.Layer<"InInner", never, "InOuter">
// Layer<"OutInner", never, "InOuter">
const composed = Layer.provide(inner, outer)
型別上可以這樣讀:
回到我們的例子裡,先把 Config
和 Logger
提供給 Database
,再把 Config
提供給前一層:
import { Layer } from "effect"
// Layer<Config | Logger, never, Config>
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)
// Layer<Database, never, never>
const MainLive = DatabaseLive.pipe(
// 提供 Logger 與(透過 Logger 的需求)Config
Layer.provide(AppConfigLive),
// 進一步把 Config 的來源補上
Layer.provide(ConfigLive)
)
最終得到完全解決依賴的 MainLive:Layer<Database, never, never>
結果:MainLive
是一個完全解決依賴的 Layer,輸出 Database
,不再需要額外輸入(RequirementsIn = never
)。
若希望最終 Layer 同時輸出 Config
與 Database
:
import { Layer } from "effect"
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)
// Layer<Config | Database, never, never>
const MainLive = DatabaseLive.pipe(
Layer.provide(AppConfigLive),
Layer.provideMerge(ConfigLive)
)
ConfigLive
和 LoggerLive
的型別分別是:
type ConfigLive = Layer<Config, never, never>
type LoggerLive = Layer<Logger, never, Config>
當我們用 Layer.merge
合併這兩個 Layer 時,AppConfigLive
的型別會如何變化呢?
合併規則:
輸出服務(RequirementsOut):兩個 Layer 輸出服務的聯集
ConfigLive
輸出:Config
LoggerLive
輸出:Logger
Config | Logger
輸入需求(RequirementsIn):兩個 Layer 輸入需求的聯集
ConfigLive
需要:never
(無需求)LoggerLive
需要:Config
Config
這代表 AppConfigLive
會「提供」Config
與 Logger
兩個服務,但由於 LoggerLive
需要 Config
才能運作,所以整個合併的 Layer 仍然需要 Config
服務。
把一個 Layer 的輸出,提供給另一個 Layer 當作輸入:
在我們的例子裡,先把 Config 和 Logger 提供給 Database,再把 Config 提供給前一層:
import { Layer } from "effect"
// ┌─── Layer<Config | Logger, never, Config>
// ▼
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)
// ┌─── Layer<Database, never, never>
// ▼
const MainLive = DatabaseLive.pipe(
// 提供 Logger 與(透過 Logger 的需求)Config
Layer.provide(AppConfigLive),
// 進一步把 Config 的來源補上
Layer.provide(ConfigLive)
)
結果:MainLive
就成為一個完全解決依賴的 Layer,輸出 Database
,且不再需要額外輸入(RequirementsIn = never
)。
Layer.provideMerge
:同時回傳多個服務(provideMerge)Layer.provideMerge 是在 provide 基礎上多做一步。除了原本消掉依賴的作用外,也把提供者的輸出也一併保留在最終輸出中。
若我們有一個情境是希望最終 Layer 同時輸出 Config
與 Database
,這時候就可以使用 Layer.provideMerge
:
// Layer<Config | Database, never, never>
const MainLive = DatabaseLive.pipe(
Layer.provide(AppConfigLive),
Layer.provideMerge(ConfigLive)
)
當我們有了完整的 MainLive
,就可以把它提供給需要 Database
的程式:
// ┌── Effect<unknown, never, Database>
// ▼
const program = Effect.gen(function*() {
const database = yield* Database
const result = yield* database.query("SELECT * FROM users")
return result
})
// ┌── Effect<unknown, never, never>
// ▼
const runnable = Effect.provide(program, MainLive)
Effect.runPromise(runnable).then(console.log)
/*
輸出:
[INFO] Executing query: SELECT * FROM users
{
result: 'Results from mysql://username:password@hostname:3306/database_name'
}
*/
runnable
的需求是 never
,表示可以直接執行了。
Layer.launch
:把 Layer 轉為 EffectLayer.launch
會把一個 Layer 直接啟動成「長期運行」的 Effect。當整個應用可被視為服務時很適合用它,例如 HTTP Server、WebSocket、排程。下面用 Node 的 http 實作最小範例。
Layer.scoped
與 Effect.acquireRelease
{ server }
)。server.close()
)。// 簡化的 HTTP 服務器服務定義(以服務形式暴露 server 實例)
class HTTPServer extends Context.Tag("HTTPServer")<
HTTPServer,
{ readonly server: http.Server }
>() {}
// 使用 Layer.scoped + acquireRelease 啟動並管理長期存活的服務
// ┌─── Layer<HTTPServer, never, never>
// ▼
const HTTPServerLive = Layer.scoped(
HTTPServer,
Effect.acquireRelease(
// acquire: 啟動 HTTP 服務器
Effect.async<{ readonly server: http.Server }>((resume) => {
console.log("🚀 啟動服務器...")
const server = http.createServer((req, res) => {
res.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8"
})
res.end("Hello from Effect Layer! 🌍")
})
server.listen(3000, () => {
console.log("🌐 服務器運行中: http://localhost:3000")
resume(Effect.succeed({ server }))
})
}),
// release: 優雅關閉
({ server }) =>
Effect.sync(() => {
server.close()
console.log("🛑 服務器已停止")
})
)
)
// 使用 Layer.launch 啟動長期運行的服務
// Layer.launch 會取得資源並保持作用域存活,直到外部終止
Effect.runFork(Layer.launch(HTTPServerLive))
/*
輸出:
🚀 啟動服務器...
🌐 服務器運行中: http://localhost:3300
*/
Layer.launch
的時機Layer.tap
/ Layer.tapError
Layer.map
、Layer.catchAll
、Layer.orElse
)const ServerLive = HTTPServerLive.pipe(
Layer.tap(() => Console.log({ event: "server:init", ok: true })),
Layer.tap(() => Console.log({ event: "server:ready", port: 3000 }))
)
// 輸出:
// 🚀 啟動服務器...
// 🌐 服務器運行中: http://localhost:3000
// { event: 'server:init', ok: true }
// { event: 'server:ready', port: 3000 }
實現外部服務預熱功能(對照 1/2/2a/3 標記)
這段程式分成四個小節來看:
ExternalApi
是一個以 Context.Tag
建立的服務,對外提供 ping
,實作上用 fetch
打到 https://httpbin.org/status/200
。ExternalApiLive
在建構時回傳 { ping }
,因此後續任何地方只要拿到此服務,就能呼叫 ping
來做健康檢查。import { Console, Context, Duration, Effect, Layer } from "effect"
// 1. 定義服務:提供一個可呼叫的 ping Effect
class ExternalApi extends Context.Tag("ExternalApi")<
ExternalApi,
{ readonly ping: Effect.Effect<void, Error, never> }
>() {}
const ExternalApiLive = Layer.effect(
ExternalApi,
Effect.sync(() => {
const ping = Effect.gen(function*() {
// call httpbin health endpoint
yield* Effect.tryPromise({
try: async () => {
const res = await fetch("https://httpbin.org/status/200")
if (!res.ok) {
throw new Error(`ExternalApi ping failed: ${res.status}`)
}
},
catch: (e) => (e instanceof Error ? e : new Error(String(e)))
})
})
return { ping } as const
})
)
layer
以 Layer.tap(warmup)
包起來,表示「當該 Layer 建構成功後,額外執行一次 warmup
副作用」。因為只是 tap,不會改變服務內容與型別,也不會新增輸入依賴,所以前後都還是同一個 Layer,只是多了建構期的 side effect。// 2. 抽出 withWarmup:不改變型別與結果,只在建構時做副作用
function withWarmup(
layer: Layer.Layer<ROut, E, RIn>,
warmup: (services: Context.Context<ROut>) => Effect.Effect<void>
) {
return layer.pipe(Layer.tap(warmup))
}
warmup
內先從 services
取出剛建好的 ExternalApi
,呼叫 api.ping
,並加上 timeout(1s)
避免卡住。catchAll
會把任何錯誤轉成記錄(例如印出 { ok: false }
),不讓錯誤往外拋,因此整個 Layer 仍然處於成功狀態。最後再記一筆 { ok: true }
標示 warmup 流程結束。這種做法適合「啟動不被預熱失敗阻擋」的場景。// 2a. Soft warmup:預熱失敗時只記錄,不讓 Layer 失敗
const ExternalApiLiveSoftWarmup = withWarmup(ExternalApiLive, (services) =>
Effect.gen(function*() {
const api = Context.get(services, ExternalApi)
yield* api.ping.pipe(
Effect.timeout(Duration.seconds(1)),
Effect.catchAll(() => Console.log({ event: "warmup:api", ok: false }))
)
yield* Console.log({ event: "warmup:api", ok: true })
}).pipe(
// 透過 provideService 消除服務輸入,確保 ExternalApiLiveSoftWarmup 沒有任何輸入
Effect.provideService(ExternalApi, Context.get(services, ExternalApi))
))
補充一下為何需要 provideService
:我們在 warmup
裡將建好的 ExternalApi
當場提供給 ExternalApiLiveSoftWarmup
,確保 warmup
不會再對外要求 ExternalApi
作為輸入。
program
,並以 Effect.provide(ExternalApiLiveSoftWarmup)
注入服務。預熱在 Layer 建構時自動執行,無須更動主邏輯;若外部服務暫時不穩,預熱只會記錄,不會阻擋應用啟動。// 3. 使用方式
const program = Effect.gen(function*() {
yield* Console.log("app started")
})
// Soft 預熱
Effect.runPromise(program.pipe(Effect.provide(ExternalApiLiveSoftWarmup)))
/*
輸出:
{ event: 'warmup:api', ok: true }
app started
*/
如果想改成「Hard warmup/fail fast」:把 catchAll
移除(或在其中拋出錯誤),讓 warmup 的錯誤冒泡,Layer.tap
會使該 Layer 直接建構失敗,達到啟動前就中止的效果。
tapError
不會「處理」錯誤,只會在錯誤發生時執行副作用,錯誤仍然會往外拋。Layer.catchAll
或 Layer.orElse
。簡短對照:
// 只告警,不變更結果(仍失敗)
const onlyAlert = HTTPServerLive.pipe(
Layer.tapError((e) => Console.error("init failed", e))
)
// 真的做備援(變更結果,改為成功)
const withFallback = HTTPServerLive.pipe(
Layer.catchAll(() => FallbackHTTPServerLive)
)
tap
視為「hook(鉤子)- 在特定事件發生時執行自訂程式碼的機制或點」:只用來觀察成功/失敗或發通知,不做決策、不改資料。catchAll
、orElse
來建構Effect。這個我們留到下一篇再介紹。Effect<_, _, never>
。merge
:並排合併輸出服務且輸入需求也同時保留。provide
:用一方輸出滿足另一方輸入,用來逐步消掉輸入需求,保持外層服務介面沒有輸入需求,解決需求外洩問題。provideMerge
:跟 provide
類似,但在消輸入需求的同時保留提供者輸出,讓最終輸出包含提供者的輸出服務。Effect.provide(program, MainLive)
注入「完全解決依賴」的 Layer,得到可直接執行的 Effect。Layer.scoped
+ Effect.acquireRelease
管理資源生命週期;需要常駐時以 Layer.launch
啟動。Layer.tap
/ Layer.tapError
做紀錄與告警(不改變結果)。