Clojure 函数式错误处理:Result、异常与结构化错误

深入 Clojure 函数式错误处理的实践模式:异常(ex-info/ex-data)与纯函数值错误(Result/Either)的权衡、结构化错误携带语义(错误码/上下文/可追踪)、错误处理组合子(safe/either/try-then)、错误边界与中间件统一兜底、spec 校验输入拦截、恢复与重试策略、日志与可观测性,以及何时抛异常何时返回值,帮你设计优雅、可诊断、可恢复的错误处理体系。

函数式语言里「错误怎么表示」是个哲学题:抛异常是「控制流的一部分」,返回值是「数据的一部分」。Clojure 给了两条路——ex-info/ex-data 的结构化异常,与 Result/Either 风格的值化错误。没有唯一正确答案,只有「对场景合适」。本文把两种模型讲透:什么时候抛异常、什么时候返回值、错误如何携带语义、如何组合(safe/either)、如何统一兜底(中间件)、如何用 spec 拦截、如何恢复重试、如何记录。目标:你的代码「错误清晰、可诊断、可恢复」。

1. 两条错误模型:抛异常 vs 返回值

1.1 异常模型(Clojure 默认)

;; 抛:中断执行流,向上传播
(throw (ex-info "余额不足" {:code :insufficient-funds :balance 10}))

;; 接:try/catch 捕获,或向上冒泡
(try
  (withdraw! account 100)
  (catch ExceptionInfo e
    (handle-failure (ex-data e))))

1.2 值化模型(Result/Either)

;; 不抛,返回「结果值」
(defn withdraw! [account amount]
  (if (<= amount (:balance account))
    {:status :ok   :data (update account :balance - amount)}
    {:status :err  :error {:code :insufficient-funds :balance (:balance account)}}))

;; 调用方显式解构处理
(let [{:keys [status data error]} (withdraw! account 100)]
  (if (= status :ok) data (report error)))

1.3 对比与选型

维度异常模型值化模型
传播自动冒泡显式传递
忘记处理可能被顶层兜底可能被忽略(危险)
控制流中断正常数据流
可组合差(try/catch 嵌套丑)好(map/bind 组合)
性能异常创建有开销无
适用边界/意外/不可恢复业务/预期/可恢复

心法:铁律 = 「预期错误值化、意外错误抛出」——业务可预期的失败(余额不足、找不到)用返回值,让调用方显式处理;真正的意外(DB 连不上、bug)抛异常,让系统兜底。混用时要「在边界转换」。

2. ex-info / ex-data:结构化异常

2.1 用数据携带语义

;; 异常不只消息,还有 data
(ex-info "订单创建失败"
         {:code :order-failed
          :order-id 42
          :cause :db-timeout})
;; (ex-data e) → {:code :order-failed :order-id 42 :cause :db-timeout}

;; 带 cause(原始异常链)
(ex-info "上游服务失败"
         {:code :upstream-error :svc :payment}
         original-exception)

2.2 异常分层

;; 自定义异常类型:用 derive 建立层级
(derive ::business-error ::error)
(derive ::not-found ::business-error)
(derive ::insufficient-funds ::business-error)

;; 捕获时按层级判断
(catch clojure.lang.ExceptionInfo e
  (when (isa? (-> e ex-data :code) ::insufficient-funds)
    ...))

心法:ex-info + ex-data 让异常「结构化」——消息给人看,data 给程序看(错误码、上下文、cause 链)。再加 derive 建立错误层级,catch 就能按语义分支,而不是靠字符串匹配。

3. Result 组合子:把错误变成数据流

3.1 通用 Result 协议

;; 定义一个简单的 Result 容器
;; {:status :ok :data v}  /  {:status :err :error e}

(defn ok  [v]   {:status :ok  :data v})
(defn err [e]   {:status :err :error e})

;; bind:ok 继续,err 短路(Monad 的 bind)
(defn >>= [result f]
  (if (= :ok (:status result))
    (f (:data result))
    result))    ;; err 直接透传,f 不执行

3.2 错误短路链

;; 用 >>= 串起可能失败的步骤,任一失败即短路
(-> (validate input)          ;; {:status :ok ...} 或 err
    (>>= validate-user)
    (>>= place-order)
    (>>= charge))

;; 全部 :ok → 拿到最终 data
;; 任一步 :err → 直接返回那个 err,后续不跑

心法:值化错误的威力 = 可组合——>>= 让「多步可能失败的流程」变成一条短路链,错误在数据流里显式传递,调用方一眼看到所有失败分支,还能 map/filter 处理错误集合。

4. 错误边界:中间件统一兜底

4.1 边界在哪里转换

两条模型的转换边界:
  服务边界(REST/GraphQL handler):异常 → 结构化 HTTP 错误
  任务边界(消息消费/定时):异常 → 记录 + 重试/死信
  进程边界(REPL/脚本):异常 → 打印堆栈

原则:错误在「边界」从异常转成「响应/日志」,在内部保持单一模型

4.2 Ring 中间件兜底(Web 边界)

(defn error-middleware [handler]
  (fn [req]
    (try (handler req)
         (catch ExceptionInfo e
           (let [{:keys [status code message]} (ex-data e)]
             {:status (or status 500)
              :body {:error {:code code :message message}}}))
         (catch Exception e
           (log/error e "unhandled")    ;; 记录原始异常
           {:status 500
            :body {:error {:code :internal :message "internal error"}}}))))

4.3 任务边界

(defn safe-task [f]
  (try
    (f)
    (catch Exception e
      (log/error e "task failed")
      (retry-with-backoff! f))))   ;; 可恢复则重试

心法:边界统一兜底是「最后的防线」——Web 边界把业务异常映射成结构化响应,任务边界记录并重试/死信。内部随便用哪种模型,边界必须「收敛成统一的对外错误」,别让异常裸奔到用户面前。

5. 用 spec 拦截:错误不落地

5.1 校验前置

;; 输入校验放在函数最前,非法输入 → 结构化错误(不往下传)
(defn place-order [order]
  (let [spec-result (s/conform ::order-spec order)]
    (when (= ::s/invalid spec-result)
      (throw (ex-info "非法订单" {:code :invalid-input
                                  :problems (s/explain-data ::order-spec order)})))
    ...))

5.2 instrument 自动拦截

;; 开发期:instrument 让被校验的函数「参数不合法就抛」
;; (见 spec 篇:s/fdef + st/instrument)
;; 生产:合法参数照常,非法参数被 spec 拦截并报结构化错误

心法:spec 把错误拦截在「入口」而非「深处」——输入不合法在函数开头就显式报错,带 explain-data 说明哪里不合法。比起「深处 N 层后爆炸」,入口校验让错误可读、可定位、可预防。

6. 恢复与重试

6.1 何时重试

可重试的错误:
  网络抖动、临时超时、下游 503、锁竞争
不可重试的错误:
  参数非法(重试也没用)、权限不足、业务状态冲突
→ 判断标准:错误「会不会因为再试一次而消失」

6.2 指数退避

;; 简单指数退避:1s 2s 4s ... 上限 + 抖动
(defn retry [f {:keys [max-attempts max-delay-ms]}]
  (loop [attempt 1]
    (let [res (try {:status :ok :data (f)}
                   (catch Exception e {:status :err :error e}))]
      (if (or (= :ok (:status res)) (>= attempt max-attempts))
        res
        (do (Thread/sleep (min (* 1000 (Math/pow 2 (dec attempt)))
                               max-delay-ms))
            (recur (inc attempt)))))))

6.3 幂等是重试的前提

重试安全 = 操作幂等
  下单重发会重复下单 → 需要幂等键/服务端去重
  PUT 更新(整体替换)可安全重试
  扣款必须幂等(扣一次就是扣一次)
→ 凡是要重试的写操作,先问「重试会不会重复」

心法:重试是「给短暂错误第二次机会」,但只对「可恢复 + 幂等」的操作做。指数退避 + 最大次数 + 抖动是标准配置;写操作没幂等就别重试,宁可报错走人工。

7. 错误可观测性:记录与追踪

7.1 日志带上下文

;; 结构化日志:错误时带上错误码与上下文
(log/error {:event :order-failed
            :order-id 42
            :code :db-timeout
            :ex e})    ;; 堆栈保留,便于定位

7.2 错误指标

可观测的错误指标:
  - 错误率(按错误码分组)
  - 重试次数分布(退避是否生效)
  - 超时错误趋势(下游健康度)
  - 未处理异常计数(边界兜底命中数)
→ 错误要有「看得见的量」:率、分布、趋势

心法:错误处理的下半场是「可观测性」——错误码进日志、错误率进指标、追踪贯穿调用链(见 /clojure-microservices-architecture/ 观测篇)。一个「记录齐全」的错误比「静默吞掉」的错误好一万倍。

8. 实战:完整错误处理设计

;; 分层设计总结
;; ── 内部业务层:值化错误为主
(defn withdraw! [acct amount]
  (cond
    (nil? acct)        {:status :err :error {:code :not-found}}
    (> amount (:bal acct)) {:status :err :error {:code :insufficient}}
    :else              {:status :ok :data (update acct :bal - amount)}))

;; ── 编排层:短路链组合
(defn transfer! [from to amount]
  (-> (withdraw! from amount)
      (>>= #(deposit! to (:data %)))
      (>>= #(log-transfer! from to amount))))

;; ── 边界层:异常/错误统一转 HTTP
(defn transfer-handler [req]
  (let [result (transfer! ...)]
    (match result
      {:status :ok :data d}    {:status 200 :body d}
      {:status :err :error {:code :insufficient}} {:status 409 :body ...}
      {:status :err :error {:code :not-found}}    {:status 404 :body ...})))

心法:三层设计 = 内部值化(业务预期失败显式)→ 编排短路链(»= 组合)→ 边界映射(HTTP/任务)。业务逻辑纯函数、可组合、可测试;对外行为稳定、错误清晰;意外异常被边界兜底并记录——这是 Clojure 错误处理的完整闭环。

9. 陷阱清单

陷阱现象规避
全靠异常业务预期失败也抛预期失败值化
全靠值化意外错误被忽略意外抛异常
字符串匹配错误脆弱难改ex-data + 错误码
try/catch 嵌套可读性崩塌短路链/中间件
重试非幂等写重复副作用幂等键或放弃重试
吞掉异常静默失败边界必 log
错误不结构化难监控错误码 + 上下文

10. 速查表与一句话记忆

问题一句话答案
抛异常还是返回值意外抛、预期返
异常结构化ex-info + ex-data(码/上下文/cause)
错误层级derive 建 isa? 层级
值化组合»= 短路链(Monad bind)
边界统一中间件/任务兜底转 HTTP/日志
入口拦截spec 校验 + explain-data
重试指数退避 + 幂等前提
可观测错误码进日志、错误率进指标

一句话记忆:Clojure 错误处理 = 「意外抛异常、预期返回值」——业务可预期失败用 Result 值化({:status :ok/:err} + >>= 短路链,显式、可组合),意外用 ex-info/ex-data(消息给人、data 给程序:错误码/上下文/cause,derive 建层级供 catch 分支)→ 边界用中间件统一兜底(异常转结构化 HTTP/任务重试)→ spec 在入口拦截非法输入 → 重试只对「可恢复 + 幂等」操作做(指数退避)→ 错误码进日志、错误率进指标**——两条模型在边界转换、各司其职,错误清晰、可诊断、可恢复。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure GraphQL API 实战:lacinia、Schema、Resolver 与权限
  2. Clojure REPL 驱动开发:nREPL、热重载与交互式工作流
  3. Clojure 不可变数据结构:结构共享、持久化与 transient 优化