设计 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 现代 Web 全栈开发 — reitit/Ring 全栈骨架与 middleware 链
- Clojure spec 与测试 — spec 校验与生成式测试
- Clojure 网络服务深入 — 服务间通信与超时重试
- Clojure 微服务架构实战 — API 网关与 BFF 模式
- Clojure 测试与质量工程 — 契约测试与集成测试
- 系统架构专题 — API 网关与接口设计模式全景
- 安全专题 — JWT 与鉴权安全实践
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。