iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
Modern Web

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

[學習 Effect Day12] 用 Effect.gen 扁平化流程:結束巢狀地獄

  • 分享至 

  • xImage
  •  

前言

在上一篇,我們一起做了一個 pipeline,把一連串應收金額的產出步驟用 pipe 串起來。pipe 雖然很適合銜接簡單流程,但當需求變得複雜、需要插入條件、早退或錯誤處理時,就容易寫成巢狀的 pipe + callback樣式:括號變多、程式一路往右飄,中間值在閉包裡穿梭,閱讀與維護成本直線上升。這一篇要示範如何把這種巢狀地獄的 pipeline 改用 Effect.gen 重構。流程中會用 yield* 逐步取值並命名,並把巢狀流程「攤平」成敘事式的可讀樣式。你可以把它想成類似 async/await 的寫法。Effect 在設計 Effect.gen 時,就是用類似 async/await 的寫法來設計的。

什麼是巢狀 pipe + callback?很可怕嗎?😨

所謂「巢狀 pipe + callback」是指以 pipe/andThen(或類似方法)連續串接步驟,並以匿名函式承接每一步的輸出,例如:a.pipe(andThen(x => b(x).pipe(andThen(y => c(y)))))。這種寫法把中間值藏在多層閉包裡;隨著需求增加,括號與縮排會急速加深。一旦插入條件判斷、早退或錯誤處理,閱讀與重構成本就會直線上升。

巢狀 pipe 寫法(難閱讀與維護)

import { Console, Effect } from "effect"

/**
 * 以 Effect 形式取得目前時間(毫秒)。
 * @returns Effect<number, never> 代表目前時間戳(ms)的純同步 Effect
 */
const now = Effect.sync(() => Date.now())

/**
 * 簡單錯誤類別:執行超過門檻時拋出。
 */
class TooSlowError extends Error {
  readonly name = "TooSlowError"
  constructor(readonly elapsedMs: number, readonly thresholdMs: number) {
    super(`Too slow: ${elapsedMs}ms (> ${thresholdMs}ms)`)
  }
}

/**
 * 為傳入的 Effect 加上執行時間量測與日誌,並支援簡單錯誤分流。
 *
 * 行為:
 * - 執行前記錄起始時間。
 * - 先印出 "start running..."。
 * - 成功後印出耗時(毫秒)。
 * - 若提供 thresholdMs 且耗時超過門檻,回傳 TooSlowError(錯誤分流)。
 * - 原本的失敗錯誤不改變,直接向外拋出(維持語意)。
 *
 * @template A 成功值型別(與原 Effect 相同)
 * @template E 失敗錯誤型別(與原 Effect 相同)
 * @template R 需求環境(與原 Effect 相同)
 * @param self 要被量測與記錄日誌的 Effect
 * @param thresholdMs 門檻(毫秒);超過則以 TooSlowError 失敗
 * @returns Effect<A, E | TooSlowError, R>
 */
const elapsed = <A, E, R>(
  self: Effect.Effect<A, E, R>,
  thresholdMs?: number
): Effect.Effect<A, E | TooSlowError, R> =>
  now.pipe(
    Effect.andThen((start) =>
      Console.log("start running...").pipe(
        Effect.andThen(() => self),
        // 注意:這裡只處理成功分支;若 self 失敗,錯誤會直接外拋(維持語意)。
        Effect.andThen((value) =>
          now.pipe(
            Effect.andThen((end) =>
              Console.log(`elapsed: ${end - start}ms`).pipe(
                Effect.andThen(() =>
                  thresholdMs !== undefined && end - start > thresholdMs
                    ? Effect.fail(new TooSlowError(end - start, thresholdMs))
                    : Effect.succeed(value)
                )
              )
            )
          )
        )
      )
    )
  )

功能:包住一個 Effect,執行時印出兩則訊息,並可依門檻做錯誤分流

流程:

  1. 先記下現在時間 start
  2. 先印出「start running...」。
  3. 執行傳入的 self
  4. 成功後印出耗時 end - start(毫秒)。
  5. 若提供 thresholdMs 且 end - start > thresholdMs,直接以 TooSlowError 早退(錯誤分流)。
  6. 否則回傳 self 的原始結果;若 self 失敗則維持原錯誤向外拋出。

問題

在這種巢狀寫法裡,讀者必須在多層閉包間來回追蹤上下文。startend 這類中間值仰賴函式巢狀來維持作用域並跨層傳遞;一旦插入條件判斷、早退或錯誤處理,巢狀會迅速加深,閱讀與維護成本直線上升。

Effect.gen 改寫(更可讀、更好維護)

const elapsedGen = <A, E, R>(
  self: Effect.Effect<A, E, R>,
  thresholdMs?: number
): Effect.Effect<A, E | TooSlowError, R> =>
  Effect.gen(function*() {
    const start = yield* now
    yield* Console.log("start running...")
    const value: A = yield* self
    const end = yield* now
    const elapsedMs = end - start
    yield* Console.log(`elapsed: ${elapsedMs}ms`)
    // 早退:若超過門檻,直接以 TooSlowError 失敗返回(錯誤分流)。
    if (thresholdMs !== undefined && elapsedMs > thresholdMs) {
      return yield* Effect.fail(new TooSlowError(elapsedMs, thresholdMs))
    }
    return value
  })

快速驗證

// 成功案例:不超過門檻(或不設定門檻)
Effect.all([
  elapsed(Effect.succeed("OK"), 1000),
  elapsedGen(Effect.succeed("OK"), 1000)
]).pipe(
  Effect.matchEffect({
    onSuccess: ([a, b]) => Console.log(`success: ${a}, ${b}`),
    onFailure: (err) => Console.log(String(err))
  }),
  Effect.runSync // 輸出:success: OK, OK
)
// 錯誤案例:超過門檻 → TooSlowError
function busyWait(ms: number): void {
  const start = Date.now()
  while (Date.now() - start < ms) void 0
}

const slowSelf = Effect.sync(() => {
  busyWait(30)
  return 42
})

Effect.all([
  elapsed(slowSelf, 5),
  elapsedGen(slowSelf, 5)
]).pipe(
  Effect.matchEffect({
    onSuccess: () => Console.log("unexpected success"),
     onFailure: (err) => Console.log(String(err))
  }),
  Effect.runSync // 輸出:TooSlowError: Too slow: 30ms (> 5ms)
)

實用技巧

  • 在以下情境,優先使用 Effect.gen

  • 需要上一個步驟的值(跨多步驟傳遞/命名中間值)。

  • 有條件分支、早退、錯誤分流或需要 try/catch(例如 catchAll)。

  • 想以「逐行敘事」插入/重排步驟,維持高可讀性。

  • 需要在同一作用域整合多個結果再計算。

  • 在以下情境,改用 andThen(含 tapmap):

    • 不依賴前一步的值,只是接續下一個動作。
    • 僅追加副作用(記錄、統計),不改變資料主軸。
    • 直線、無分支的小步驟鏈結,寫在 pipe 內更精簡。
  • 混用建議:

    • 大流程用 gen 管控分支與早退;在 gen 內用 tap/tapError 放置不改變值的副作用。
    • 外層若有多個彼此獨立的小步驟,可先以 pipe + andThen 組裝,再在需要的地方以 gen 取值。

補充:Do 風格也能攤平

const elapsedDo = <A, E, R>(
  self: Effect.Effect<A, E, R>,
  thresholdMs?: number
): Effect.Effect<A, E | TooSlowError, R> =>
  Effect.Do.pipe(
    Effect.bind("start", () => now),
    Effect.tap(() => Console.log("start running...")),
    Effect.bind("result", () => self),
    Effect.bind("end", () => now),
    Effect.tap(({ end, start }) => Console.log(`elapsed: ${end - start}ms`)),
    Effect.flatMap(({ end, result, start }) =>
      thresholdMs !== undefined && end - start > thresholdMs
        ? Effect.fail(new TooSlowError(end - start, thresholdMs))
        : Effect.succeed(result)
    )
  )
  • Do 可以逐步綁定命名欄位,最後再 map 出結果。
  • 但若牽涉條件、早退或錯誤流轉,Effect.gen 通常更直覺。

總結

  • 流程一旦複雜(條件、早退、錯誤、收尾),優先使用 Effect.gen
  • 它把每個步驟化為「單行敘事」,中間值命名清楚,以同步思維寫出正確的非同步程式。

參考資料


上一篇
[學習 Effect Day11] 實際建立一個 pipeline
下一篇
[學習 Effect Day13] Effect 錯誤管理 (一)
系列文
用 Effect 實現產品級軟體14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言