Clojure REST API 设计实战:reitit、coercion、错误模型与文档

深入 Clojure REST API 设计的工程实践:reitit 数据驱动路由与 coercion(Schema/Malli 参数校验)、REST 风格与资源建模、错误模型(结构化错误码/HTTP 状态)、分页与过滤排序、鉴权与权限(JWT/中间件)、API 版本化策略、契约测试与 OpenAPI 文档生成、性能与并发(异步/限流/缓存),帮你用 Clojure 设计出规范、可维护、可演进的高质量 API。

设计 API 和写 API 是两件事。Clojure 生态里,reitit + coercion + OpenAPI 让「设计」能落成「代码与文档」,而「错误模型」「分页」「鉴权」这些看似琐碎的决策,决定了 API 能否被团队长期维护、被调用方稳定消费。本文从资源建模讲到契约测试,覆盖 reitit 数据驱动路由、Schema/Malli coercion、结构化错误、分页过滤排序、JWT 鉴权、版本化、OpenAPI 文档与性能并发,帮你交付一套「规范即代码」的 REST API。

1. 资源建模:先设计再编码

1.1 REST 的核心心智

REST 不是「URL 好看」,而是**「名词 + 动词」的约束**——资源是名词(/orders),动作靠 HTTP 动词表达(GET/POST/PUT/PATCH/DELETE)。

动作语义幂等示例
GET读是GET /orders/42
POST创建/触发否POST /orders
PUT整体替换是PUT /orders/42
PATCH部分更新否*PATCH /orders/42
DELETE删除是DELETE /orders/42

幂等判断直接影响重试策略:网络抖动重发一个 POST 可能重复下单,所以 POST 要幂等键(Idempotency-Key)或服务端去重;PUT/DELETE 可放心重试。

1.2 资源命名与层级

集合:  /orders
单条:  /orders/42
子资源:/orders/42/items
操作(非资源):/orders/42/cancel   ← 用 POST 触发动作

命名约定:复数名词、小写、连字符;层级别超过 2~3 层(嵌套过深用查询参数表达关系)。

反模式:用动词拼 URL(/getOrder)、把动作写进 GET(GET /orders?action=cancel)。REST 的约束是「资源驱动」而非「操作驱动」。

2. reitit 数据驱动路由

2.1 路由即数据

reitit 的路由表是一份数据(vector),可被复用为文档、校验、中间件配置:

(require '[reitit.ring :as ring]
         '[reitit.coercion.malli :as malli])

(def app
  (ring/ring-handler
   (ring/router
    [["/api/orders" {:get  {:handler list-orders
                            :coercion malli/coercion
                            :parameters {:query [:map [:page int?] [:size int?]]}}
                     :post {:handler create-order
                            :coercion malli/coercion
                            :parameters {:body [:map [:items vector?]]}}}]
     ["/api/orders/:id" {:get  {:handler get-order
                                :parameters {:path [:map [:id int?]]}}
                         :put  {:handler replace-order
                                :parameters {:path [:map [:id int?]]
                                             :body [:map [:status keyword?]]}}}]])
   (ring/routes (ring/create-default-handler)))))

2.2 路由优先级与冲突

reitit 基于 Trie 匹配,静态段优先于参数段:

["/api/orders/latest" ...]   ← 静态,优先
["/api/orders/:id" ...]      ← 参数,兜底

排错:两个路由都匹配同一路径时,reitit 会报「conflicting route」启动错误——这是特性不是 bug,逼你在设计期消除歧义。

3. Coercion:让参数校验变成路由声明

3.1 为什么需要 coercion

Ring 请求里的参数全是字符串("42"、"2026-09-29"),不校验就会让「类型错误」蔓延到业务代码。coercion 把「校验 + 转型」收敛到路由声明:参数进来自动按 Schema 转型,不合法直接 400。

3.2 Malli vs Schema

方案风格亮点适用
clojure.spec谓词+生成属性测试一体生成式测试重度
Malli数据描述简单直观、错误消息友好新项目推荐
Schema数据描述生态老、集成广遗留项目
;; Malli schema 定义参数
(def OrderParams
  [:map {:closed true}
   [:id int?]
   [:status [:enum :created :paid :shipped]]
   [:total pos?]])

;; 校验结果:合法返回 coerced 值,非法返回错误详情
;; {:data {:id 42}}  → {:data {:id 42}}(id 从 "42" 转成 42)

3.3 四种参数位置

:parameters {:path   [:map [:id int?]]}    ;; 路径参数 /orders/42
             :query  [:map [:page int?]]    ;; 查询参数 ?page=2
             :body   [:map [:name string?]] ;; 请求体
             :header [:map [:x-api-key string?]]} ;; 请求头

心法:coercion 声明了「这个接口长什么样」,校验失败自动 400,参数自动转型——业务 handler 拿到的就是干净的、已校验的数据,不用再写一堆 if (nil? x)。

4. 错误模型:结构化错误码

4.1 别让错误只有状态码

调用方需要「程序可识别 + 人可读」的错误:

;; 统一错误响应结构
{:error {:code    :order-not-found
         :message "订单 42 不存在"
         :status  404
         :fields  nil}}          ;; 校验错误时的字段详情

;; 对应 HTTP 状态码
;; 400 参数不合法 → {:error {:code :bad-request ...}}
;; 404 资源不存在 → {:error {:code :not-found ...}}
;; 422 业务冲突   → {:error {:code :insufficient-stock ...}}

4.2 错误分类

类别状态码语义示例
参数错误400客户端请求畸形缺字段/类型错
未认证401没带/带错凭证JWT 过期
无权限403凭证有效但不够格只读账号写操作
不存在404资源找不到订单不存在
业务冲突409/422状态不允许已支付订单再支付
服务错误500服务器内部数据库连接失败
;; 统一错误响应中间件:把业务异常映射成结构化错误
(defn error-middleware [handler]
  (fn [req]
    (try (handler req)
         (catch ExceptionInfo e
           (let [{:keys [code status message]} (ex-data e)]
             {:status (or status 500)
              :body   {:error {:code code :message message}}}))
         (catch Exception _        ;; 未预期异常,记录并返回 500
           {:status 500 :body {:error {:code :internal :message "internal error"}}})))))

4.3 用 ExceptionInfo 传递业务错误

(throw (ex-info "订单已支付,不能重复支付"
                {:code :already-paid :status 409}))

心法:用 ex-info + ex-data 携带结构化错误,中间件统一兜底——业务代码只管「抛出带语义的错误」,HTTP 映射与日志交给中间件,错误风格全局一致。

5. 分页、过滤与排序

5.1 分页策略对比

方案机制优点缺点
offset/limit?page=2&size=20简单、可跳页大数据偏移慢、并发写入漂移
cursor/keyset?after=<cursor>稳定、快不能跳页
混合列表用 cursor、管理台用 offset兼顾两套实现
;; cursor 分页:按 id 游标,天然稳定
;; 请求  ?after=41&size=20
;; 查询  WHERE id > 41 ORDER BY id LIMIT 21   (多取 1 条判断有没有下一页)
;; 响应  {:data [...] :next "/api/orders?after=61&size=20"}

5.2 过滤与排序白名单

查询参数设计要「白名单化」——允许哪些字段过滤/排序必须受控,否则注入式查询与超大结果集找上门:

;; 白名单
(def ^:private sortable #{:created-at :total :status})
(def ^:private filterable #{:status :user-id})

;; 组装查询前先校验参数在白名单内,超界返回 400

心法:列表接口的三件套 = 分页(cursor 优先)+ 过滤白名单 + 排序白名单。别让调用方自由传 SQL 片段——那不是灵活,是事故入口。

6. 鉴权与权限:JWT 中间件

6.1 认证 vs 授权

  • 认证(Authentication):你是谁 → 校验 JWT 签名/过期;
  • 授权(Authorization):你能干什么 → 基于角色/资源校验。

6.2 JWT 校验中间件

;; 解析 Authorization: Bearer <jwt>
(defn jwt-middleware [secret]
  (fn [handler]
    (fn [req]
      (if-let [token (some-> (get-in req [:headers "authorization"])
                             (string/replace #"^Bearer " ""))]
        (try
          (let [claims (jwt/verify token secret)]
            (handler (assoc req :claims claims)))
          (catch Exception _
            {:status 401 :body {:error {:code :unauthorized
                                        :message "token invalid or expired"}}}))
        {:status 401 :body {:error {:code :missing-token :message "missing token"}}}))))

;; 权限校验:基于 claims 里的角色
(defn require-role [role handler]
  (fn [req]
    (if (contains? (set (:roles (:claims req))) role)
      (handler req)
      {:status 403 :body {:error {:code :forbidden :message "insufficient role"}}})))

心法:认证放「最外层」(谁都能先验),授权放「路由层」(按资源细粒度控制)。JWT 无状态适合分布式,但吊销困难——敏感操作建议叠加短 TTL + 黑名单。

7. API 版本化策略

7.1 三种版本化方式

方式实现适用
URL 路径/api/v1/orders显式、最简单,推荐
请求头Accept: application/vnd.orders.v2+json优雅但难发现
查询参数?version=2不推荐(污染参数)

7.2 演进而非破坏

兼容策略:
  1. 加字段不删字段(客户端忽略未知字段)
  2. 语义不变则原地更新(改文档不动 URL)
  3. 破坏性变更 → 新版本 + 弃用期(Deprecation 头)

reitit 里多版本路由只是前缀差异:

(ring/router
 [["/api/v1/orders" {:get ...}]
  ["/api/v2/orders" {:get ...}]])

心法:默认「加字段」而不是「开新版本」——版本是最后的手段。真到需要 v2,就在弃用期内同时服务 v1/v2,用 Deprecation: true 头提醒调用方迁移。

8. OpenAPI 文档与契约测试

8.1 文档从路由生成

reitit 的 data-driven 特性让 OpenAPI 文档从路由声明自动生成——文档不是手写的,是代码的投影:

;; 集成 swagger-ui
(require '[reitit.openapi :as openapi])
(ring/router [["/api/orders" {:get {...} :post {...}}]
              ["/openapi.json" {:get {:handler (openapi/create-openapi-handler)}}]]
             {:data {:openapi {:info {:title "Orders API" :version "1.0.0"}}}})

8.2 契约测试的意义

文档生成 ≠ 契约正确。契约测试用生成的数据打真实 handler,验证「文档说的」与「代码做的」一致:

;; 用 Malli/spec 生成合法参数 → 打 handler → 断言响应也符合 schema
(deftest order-api-contract-test
  (testing "POST /api/orders 契约"
    (let [body (gen/generate (s/gen OrderSchema))]
      (let [res (app {:uri "/api/orders" :request-method :post
                      :body (json/write-str body)})]
        (is (= 201 (:status res)))))))

心法:「文档自动生成 + 契约测试」双保险——生成保证「文档永远最新」,契约测试保证「最新不等于正确」。团队演进 API 时,契约测试是防回归的网。

9. 性能与并发

9.1 慢路径与超时

API 的性能事故多是「下游慢」:数据库、第三方、消息队列。超时与限流是 API 的保命索:

;; 超时:对下游调用设超时(网络库参数)
;; http-kit 请求超时 5s,超时即 504

;; 限流:令牌桶/滑动窗口
;; 简单实现:原子计数器 + 窗口时间
(defonce ^{:doc "per-user rate limiter"}
  limits (atom {}))

9.2 异步处理长任务

计算密集或依赖多下游的请求,别阻塞请求线程:

;; 异步 handler:立即返回 202 + 任务 ID,结果轮询/回调
(defn async-create-report [req]
  (let [task-id (submit-task! (:body (:data req)))]
    {:status 202
     :body   {:task-id task-id :status "queued"}
     :headers {"Location" (str "/api/tasks/" task-id)}}))

9.3 响应缓存

对读多写少的资源,加 Cache-Control + ETag:

;; ETag:内容 hash,客户端 If-None-Match 命中 → 304
{:status 200
 :headers {"ETag" (etag-for data)
           "Cache-Control" "private, max-age=60"}
 :body data}

心法:API 性能三板斧 = 超时(保护自己)+ 异步(长任务 202 化)+ 缓存(读多写少上 ETag)。先把「不会拖垮别人、不会被别人拖垮」做好,再谈吞吐优化。

10. 常见陷阱与速查

10.1 陷阱清单

陷阱现象规避
不校验参数字符串类型错误蔓延路由级 coercion
错误只有状态码调用方靠猜结构化错误码
无限 offset 分页大数据变慢cursor 分页
自由过滤字段注入/慢查询白名单
无幂等键 POST重试重复下单Idempotency-Key
无超时下游慢拖垮 API显式超时
手写文档文档与代码漂移自动生成 + 契约测试

10.2 速查表

问题一句话答案
资源建模名词复数 + HTTP 动词
路由reitit 数据驱动,静态优先
参数校验coercion(Malli),路由声明
错误ex-info + 结构化错误码 + 中间件兜底
分页cursor 优先,offset 兜底
鉴权JWT 认证外层 + 角色授权路由层
版本加字段优先,v2 才开新版本
文档reitit OpenAPI 自动生成
契约生成参数打 handler 断言响应
性能超时 + 异步 202 + ETag 缓存

一句话记忆:REST API 设计 = 资源建模(名词复数 + 动词)→ reitit 数据驱动路由 → coercion 参数校验(Malli)→ 结构化错误码(ex-info + 中间件兜底)→ cursor 分页 + 过滤白名单 → JWT 认证外层/角色授权路由层 → 加字段优先、v2 才开新版本 → OpenAPI 自动生成 + 契约测试 → 超时/异步/缓存三件套——「规范即代码」让设计、实现、文档、测试共享同一份路由数据,API 才能长期演进而不腐化。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「clojure」更多文章

  1. Clojure 函数式错误处理:Result、异常与结构化错误
  2. Clojure GraphQL API 实战:lacinia、Schema、Resolver 与权限
  3. Clojure REPL 驱动开发:nREPL、热重载与交互式工作流