iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Modern Web

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

[學習 Effect Day22] Effect 服務管理(三)

  • 分享至 

  • xImage
  •  

在上一篇文章中,我們學會了如何用 Layer 來解決服務依賴服務的問題,避免需求外洩(Requirement Leakage)。我們建立了 ConfigLiveLoggerLiveDatabaseLive 這三個 Layer,每個都有各自的依賴需求。

但是,光有這些個別的 Layer 還不夠。在真實的應用程式中,我們需要把這些 Layer 組合起來,形成一個完整的應用服務的程式架構。

這一篇我們要學習的是 Layer 的組合與管理:如何用 Layer.mergeLayer.provide 來組合多個 Layer,如何處理 Layer 建構時的錯誤,以及如何用 Effect.Service 來簡化服務定義。這些技巧將幫助我們建立更完整、更可維護的應用程式架構。

合併 Layer

合併 Layer 有兩種方法,一種是 Layer.merge,另一種是 Layer.provide

Layer.merge:把多個服務「並排合併」

延續上一篇的例子,我們把 ConfigLiveLoggerLive 合併成 AppConfigLive

//         ┌─── Layer<Config | Logger, never, Config>
//         ▼
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive)

型別上可以這樣讀:

  • ConfigLive: Layer<Config, never, never>
  • LoggerLive: Layer<Logger, never, Config>
  • Layer.merge(ConfigLive, LoggerLive) → 回傳 Layer<Config | Logger, never, Config>

這代表 AppConfigLive 會「提供」ConfigLogger 兩個服務,但由於 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)

型別上可以這樣讀:

  • inner: Layer<OutInner, never, InInner>
  • outer: Layer<InInner, never, InOuter>
  • Layer.provide(inner, outer) → 回傳 Layer<OutInner, never, InOuter>
    也就是說,outer 會把自己「提供」的 InInner,餵給 inner 的「需求」InInner,結果就是「把 inner 的需求消掉」。而最後留下的需求就只剩下 outer 的 InOuter。

回到我們的例子裡,先把 ConfigLogger 提供給 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)
)
  • 第一個 provide:消掉 DatabaseLive 對 Config | Logger 的需求,但留下 AppConfigLive 的需求(Config)
  • 第二個 provide:用 ConfigLive 消掉剩下的 Config 需求

最終得到完全解決依賴的 MainLive:Layer<Database, never, never>
結果:MainLive 是一個完全解決依賴的 Layer,輸出 Database,不再需要額外輸入(RequirementsIn = never)。

兩種方法的目的與使用時機比較

  • 目的不同
    • Layer.merge:把多個服務「放在一起」,輸出一起提供,需求一起保留
    • Layer.provide:把一個 Layer 的輸出「接給」另一個 Layer 的輸入,用來「消掉依賴」
  • 使用時機
    • 想把多個可用的服務打包成一包(例如 Config 與 Logger 一包)→ 用 merge
    • 想一步步把需求解掉,最後得到一個完全可啟動的 Layer → 連續用 provide

同時回傳多個服務(provideMerge)

若希望最終 Layer 同時輸出 ConfigDatabase

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)
)

ConfigLiveLoggerLive 的型別分別是:

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 會「提供」ConfigLogger 兩個服務,但由於 LoggerLive 需要 Config 才能運作,所以整個合併的 Layer 仍然需要 Config 服務。

組合(provide)

把一個 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 同時輸出 ConfigDatabase,這時候就可以使用 Layer.provideMerge

// Layer<Config | Database, never, never>
const MainLive = DatabaseLive.pipe(
  Layer.provide(AppConfigLive),
  Layer.provideMerge(ConfigLive)
)

小結:

  • merge:把多個服務「裝成一包」,所有服務的都提供,所有需求都保留
  • provide:把一個服務的輸出(RequirementsOut)「餵給」另一個服務的輸入(RequirementsIn),消掉最終 Layer 的輸入,只留下被餵食者的輸出
  • provideMerge:除了消掉輸入外,還把「餵食者的輸出」也一起保留在最終結果裡

把 Layer 提供給 Effect

當我們有了完整的 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 轉為 Effect

Layer.launch 會把一個 Layer 直接啟動成「長期運行」的 Effect。當整個應用可被視為服務時很適合用它,例如 HTTP Server、WebSocket、排程。下面用 Node 的 http 實作最小範例。

先釐清等一下範例會用到兩個關鍵方法:Layer.scopedEffect.acquireRelease

  • Layer.scoped(Tag, acquireRelease):用來建構「需要釋放」的長期資源(例如 Server、連線、訂閱)。它會在作用域開始時取得資源,作用域結束時自動釋放。
  • Effect.acquireRelease(acquire, release):把「取得」與「釋放」這兩件事綁在一起。
    • acquire:啟動資源並回傳可用的值(例如 { server })。
    • release:對應的清理動作(例如 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的時機

  • 需要啟動並持續運行(不會自己結束)的資源
  • 服務有啟動與關閉功能,關閉時需妥善釋放資源
  • 目的是持續提供服務,而非一次性回傳值

Tapping:在 Layer 建構時加上額外副作用

什麼時候用 Layer.tap / Layer.tapError

  • 啟動期間做「不改變服務本質」的額外紀錄或檢查
  • 成功時做告知(log/metrics/trace)、失敗時做告警(通知/回報)
  • 不用於資料轉換或備援切換(請用 Layer.mapLayer.catchAllLayer.orElse

常見情境

1. 啟動紀錄(結構化日誌)

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 }

2. 啟動健康檢查 / 預熱(不影響型別、不改變服務值)

實現外部服務預熱功能(對照 1/2/2a/3 標記)

這段程式分成四個小節來看:

    1. 先看「定義服務」。ExternalApi 是一個以 Context.Tag 建立的服務,對外提供 ping,實作上用 fetch 打到 https://httpbin.org/status/200ExternalApiLive 在建構時回傳 { 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
  })
)
    1. 接著是「withWarmup」。這是一個包裝器,將既有的 layerLayer.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))
}
  • 2a. 然後是「Soft 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 作為輸入。

    1. 最後看「使用方式」。主程式只是正常執行 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,請使用下方章節的 Layer.catchAllLayer.orElse

簡短對照:

// 只告警,不變更結果(仍失敗)
const onlyAlert = HTTPServerLive.pipe(
  Layer.tapError((e) => Console.error("init failed", e))
)

// 真的做備援(變更結果,改為成功)
const withFallback = HTTPServerLive.pipe(
  Layer.catchAll(() => FallbackHTTPServerLive)
)

小建議

  • tap 視為「hook(鉤子)- 在特定事件發生時執行自訂程式碼的機制或點」:只用來觀察成功/失敗或發通知,不做決策、不改資料。
  • 副作用要輕量(做的事少、快、少占資源)、可重入(可同時/重複執行而不互相影響)、最好具冪等(做一次或做很多次結果都一樣);這樣重試時不會把副作用一再累加。
  • 當錯誤發生,真的要改流程時,可以搭配catchAllorElse來建構Effect。這個我們留到下一篇再介紹。

總結

  • Layer 在「建構期」解決依賴,讓對外介面保持 Effect<_, _, never>
  • 組合 Layer:
    • merge:並排合併輸出服務且輸入需求也同時保留。
    • provide:用一方輸出滿足另一方輸入,用來逐步消掉輸入需求,保持外層服務介面沒有輸入需求,解決需求外洩問題。
    • provideMerge:跟 provide 類似,但在消輸入需求的同時保留提供者輸出,讓最終輸出包含提供者的輸出服務。
  • 執行程式:以 Effect.provide(program, MainLive) 注入「完全解決依賴」的 Layer,得到可直接執行的 Effect。
  • 長期服務:用 Layer.scoped + Effect.acquireRelease 管理資源生命週期;需要常駐時以 Layer.launch 啟動。
  • 建構期副作用:用 Layer.tap / Layer.tapError 做紀錄與告警(不改變結果)。

參考資料


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

尚未有邦友留言

立即登入留言