summaryrefslogtreecommitdiff
path: root/content/article
diff options
context:
space:
mode:
authoreltrac <hi@eltr.ac>2026-10-06 00:19:06 +0800
committereltrac <hi@eltr.ac>2026-10-06 00:19:06 +0800
commit2b7d57776ec1cfbda858cec0a09cfaccc418f0c6 (patch)
tree64bcca66c35810fa1db66f49acec57a915dca3c5 /content/article
parent7683c076a4270ad294b22e29385ca20f0d63a8e5 (diff)
[publish] /article/clj6/
Diffstat (limited to 'content/article')
-rw-r--r--content/article/clj6.md241
1 files changed, 235 insertions, 6 deletions
diff --git a/content/article/clj6.md b/content/article/clj6.md
index f59b34c9..75e1dda2 100644
--- a/content/article/clj6.md
+++ b/content/article/clj6.md
@@ -2,14 +2,15 @@
title = 'Coffee Break Clojure, Vol.6'
subtitle = 'Ring Ring Ring'
slug = 'clj6'
-tags = ["Lisp","Clojure","编程"]
+tags = ["Lisp","Clojure","编程","Web"]
categories = '茶歇蔻芥'
-date = 2026-10-03T20:05:00+08:00
-draft = true
+date = 2026-10-06 00:17:09
+draft = false
toc = true
endnotes = [
'本文副标题 *Ring Ring Ring*,来自 S.H.E. 的[同名歌曲](https://redirect.invidious.io/watch?v=nnQNiPAK-nY)。'
]
+fedi = 'https://akk.eltr.ac/objects/a0ebb4b3-ca36-4b8d-9095-3a09c7f30d65'
+++
[上一篇](/article/clj5/)讲解了如何用 Leiningen 管理 Clojure 项目的目录结构和依赖项,今天我们来了解 Clojure 生态中最常见的 Web 后端库 [Ring](https://github.com/ring-clojure/ring)。不对,和那个 [IndieWeb Ring](https://xn--sr8hvo.ws/) 没有关系。<!--more-->
@@ -33,6 +34,25 @@ Handler 就是一个函数,接收 HTTP 请求,返回 HTTP 响应。
(fn [request] response)
```
+异步 API 的区别不大,只不过,由于 Handler 的结果需要异步返回,如果出了错也要异步报错,所以要传入两个回调函数用来响应和报错。
+
+```clojure
+(fn [request respond raise]
+ (if (not exception)
+ (respond response)
+ (raise exception)))
+```
+
+一个 Handler 可以同时实现同步和异步 API。
+
+```clojure
+(fn
+ ([request]
+ response)
+ ([request respond raise]
+ (respond response)))
+```
+
Middleware 接收一个或多个 Handler,也可能接受一些参数,最后返回新的 Handler。可以理解为设计模式中的装饰器模式(decorator pattern),用来调整或增强 Handler 的行为,比如把 Handler 返回的响应体都解析成 JSON 字符串。
Adapter 接收一个 Handler 和其他配置参数,在调用时启动 HTTP 服务器。
@@ -42,6 +62,8 @@ Adapter 接收一个 Handler 和其他配置参数,在调用时启动 HTTP 服
(run-adapter handler options)
```
+把 `options` 设置成 `{:async? true}`,Adapter 就能以异步模式运行了。前提是 Handler 支持异步 API,Adapter 也要支持 `:async?` 才行。异步 API 可以让服务器并行处理多个请求,如果对并发度有要求可以用。
+
接收 HTTP 请求的就是 Adapter,他会把请求包装成 Request Map 传递给 Handler,随后把 Handler 返回的 Response Map 构造成 HTTP 响应发送给客户端。可以把 Adapter 理解为设计模式中的代理模式(proxy pattern),用户不直接调用 Handler,而是通过 Adapter 调用 Handler,Adapter 会处理 HTTP 协议相关的细节。
![](https://img.eltr.ac/geedeapro/article/clj6/ring-concepts.svg "图片由我使用 [Excalidraw](https://excalidraw.com/) 绘制")
@@ -82,7 +104,9 @@ Adapter 接收一个 Handler 和其他配置参数,在调用时启动 HTTP 服
在终端输入 `lein ring server` 启动服务器,之后工具会自动在浏览器里打开 `http://localhost:3000`,然后就能看到 `Hello!` 显示在浏览器页面里。
-当然,如果要打包成可以直接在 JVM 上运行的 `.jar` 文件,不自己调用 Adapter 是不行的。在 `core.clj` 文件的 `-main` 函数里编写启动逻辑:
+如果要打包成可以直接在 JVM 上执行的 `.jar` 文件,可以用 `lein ring uberjar` 编译。不过,我不建议依赖这个 Leiningen 插件来编译应用,而且不少应用其实除了启动 Ring 服务器,还需要启动别的东西,比如后台处理线程、消息队列等等。`lein ring uberjar` 只会把指定的 Handler 编译进去。
+
+在 `core.clj` 文件的 `-main` 函数里编写启动逻辑:
```clojure {hl_lines=[3,4,9]}
(ns antfighter.core
@@ -152,15 +176,220 @@ Handler 做的事情其实就是对请求做些处理,然后构造响应。比
不过,访问 `/hello` 会得到 `Hello!`,但访问 `/hello/` 却是 `Not Found`。我们的路由匹配方式有缺陷。处理这些细节太麻烦了,有没有别的办法?
-## Don't lose your composure
+## 沉着冷静地路由
+
+[Compojure](https://github.com/weavejester/compojure) 是 Ring 的主要贡献者 [weavejester](https://github.com/weavejester) 维护的路由库。这个名字显然来自 *Composure*,意思是「沉着冷静」。
+
+如果要新建项目的话,其实可以直接用 Leiningen 的 `compojure` 模板:
+
+```
+lein new compojure hello-world
+```
+
+不过我自己的感受是,不用脚手架从零搭建会减少很多认知负债,在一开始就把各种组件、依赖关系和架构搞清楚是最好的。熟悉之后再图方便使用模板。
+
+下文提及的符号基本都定义在 [`compojure.core`](https://weavejester.github.io/compojure/compojure.core.html) 命名空间里,读者应该会使用 `require` 引入符号(如果不会,就去读[上一篇](/article/clj5/)),我就不写 `require` 了。
+
+Compojure 的核心是 `routes` 函数,用来把多个 Handler 组合成一个。一般和 `GET`、`POST`、`PUT` 等路由宏一起使用,每个宏对应一个 HTTP 方法,可以用 `ANY` 宏匹配任意方法。这些宏展开之后,最终返回的是对应方法和路径的 Handler。
+
+```clojure
+(def api-routes
+ (routes
+ (GET "/hello" request (handle-hello request))
+ (POST "/receive" request (handle-receive request))))
+
+(run-jetty api-routes {:port 3000})
+```
+
+上述代码可以简写为:
+
+```clojure
+(defroutes api-routes
+ (GET "/hello" request (handle-hello request))
+ (POST "/receive" request (handle-receive request)))
+
+(run-jetty api-routes {:port 3000})
+```
+
+`request` 可以直接传给处理逻辑,也可以解构(destructure)之后再传递:
+
+```clojure
+(defroutes api-routes
+ (GET "/hello" [username] (say-hello username))
+ (POST "/receive" [data] (receive data)))
+```
+
+放在向量里的符号会被自动绑定到对应名称的 Query String 或 POST 参数上。
+
+```sh
+curl http://localhost:3000/hello?username=Alice
+# => Hello Alice!
+```
+
+Compojure 也可以用 Clojure 自带的[解构](https://clojure.org/reference/special_forms#binding-forms),比如把数据从 Session 里取出来:
+
+```clojure
+(GET "/user" {{:keys [user-id]} :session}
+ (str "The current user is " user-id))
+```
+
+关于解构,一般用向量取出 Query 和 POST 参数就够了,更多用法可以参考 [Compojure Wiki](https://github.com/weavejester/compojure/wiki/Destructuring-Syntax)。如果觉得解构的语法太麻烦,直接用 Request map 也没有问题。
+
+这些宏也天然支持动态路由:
+
+```clojure
+;; 路径中的 :username 会被绑定到 username 符号上
+;; 下面的路由会匹配所有形如 /hello/* 的 GET 路径
+(GET "/hello/:username" [username]
+ (say-hello username))
+
+;; 可以用正则表达式做更严格的匹配
+(GET ["/user/:id", :id #"[0-9]+"] [id]
+ (get-user id))
+```
+
+路由可以嵌套,要用到 `context` 函数,参考 [Wiki](https://github.com/weavejester/compojure/wiki/Nesting-routes) 里的例子:
+
+```clojure
+(defroutes user-routes
+ (context "/user/:user-id" [user-id]
+ (GET "/profile" [] ...)
+ (GET "/posts" [] ...)))
+```
+
+如果一个请求发过来,没有匹配上任何一条路由,那么 Compojure 会返回 `nil`。我们还没处理 Not Found 路径。可以用 [`compojure.route`](https://weavejester.github.io/compojure/compojure.route.html) 里的 `not-found` 函数处理 `404` 请求。
-[Compojure](https://github.com/weavejester/compojure) 是 Ring 的主要贡献者 [weavejester](https://github.com/weavejester) 维护的路由库。这个名字显然来自 *Composure*,意思是「沉着冷静」。其实还有更新更现代的 [reitit](https://github.com/metosin/reitit),不过我们还是先来看 Compojure 吧。
+```clojure
+(require '[compojure.route :as route])
+
+(defroutes api-routes
+ (GET "/hello" [username] (say-hello username))
+ (POST "/receive" [data] (receive data))
+ (route/not-found "Not Found"))
+```
+
+Compojure 不仅帮我们解构 `request`,还可以智能地处理 `response`。比如,我们可以把 `GET "/hello"` 这个路由的响应替换成字符串。
+
+```clojure
+(defroutes api-routes
+ (GET "/hello" [username] (str "Hello " username "!"))
+ (POST "/receive" [data] (receive data))
+ (route/not-found "Not Found"))
+```
+
+`"Hello Alice!"` 会被自动包装成:
+
+```clojure
+{:status 200
+ :headers {"Content-Type" "text/plain; charset=utf-8"}
+ :body "Hello Alice!"}
+```
+
+构造这种响应体可能很麻烦,除了让 Compojure 自动处理(说实话,我觉得自动处理有点令人捉摸不透,自己写清楚会好点),还可以用 [ring-http-response](https://github.com/metosin/ring-http-response) 工具库:
+
+```clojure
+(require '[ring.util.http-response :refer :all])
+
+(continue)
+; {:status 100, :headers {}, :body ""}
+
+(ok "body")
+; {:status 200, :headers {}, :body "body"}
+
+(created "url" "body")
+; {:status 201, :headers {"Location" "url"}, :body "body"}
+
+(found "url")
+; {:status 302, :headers {"Location" "url"}, :body ""}
+```
+
+## 自由组合中间件
+
+Compojure 能帮我们处理路由、请求体的解构和响应体的封装,但内部结构并不复杂,这些功能是通过 Middleware 实现的:
+
+```clojure
+(defn make-route
+ "Returns a function that will only call the handler if the method and path
+ match the request."
+ [method path handler]
+ (-> handler
+ (wrap-response)
+ (wrap-route-middleware)
+ (wrap-route-info [(or method :any) (str path)])
+ (wrap-route-matches method path)))
+```
+
+这些 `wrap-` 开头的函数就是 Middleware,最初的 `handler` 作为参数被传入这些函数,一层层被包装。`wrap-response` 给 Handler 增加了自动包装返回值(响应体)的能力,`wrap-route-matches` 则保证只有请求体里的方法和路径都匹配时才放行。
+
+还记得 `defroutes` 定义的符号也是个 Handler 吗?Compojure 的路由不过是把多个匹配不同路由的 Handler 组合成了一个大的 Handler。Handler 就是这样被组合、装饰才变得支持多路由和各种额外功能的。
+
+我们还可以继续包装 Handler,比如用 [`ring-json`](https://github.com/ring-clojure/ring-json) 库,把响应体 `:body` 里的 Clojure 数据结构自动解析为 JSON 字符串:
+
+```clojure
+ :dependencies [[org.clojure/clojure "1.10.1"]
+ [ring/ring-core "1.8.2"]
+ [ring/ring-jetty-adapter "1.8.2"]
+ [ring/ring-json "0.5.1"]]) ;; <- 新依赖
+```
+
+`ring.middleware.json/wrap-json-body` 会解析所有 `Content-Type` 为 JSON 的响应体。
+
+```clojure
+(require '[ring.middleware.json :refer [wrap-json-body]])
+
+(defroutes api-routes
+ (GET "/test" []
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body {:message "Cheshire just visited me."}}))
+
+(def app
+ (wrap-json-body api-routes))
+
+(run-jetty app {:port 3000})
+```
+
+```sh
+curl http://localhost:3000/test
+# => {"message": "Cheshire just visited me."}
+```
+
+如果需要对响应数据做处理,比起放在业务逻辑里增加复杂度,不如写一个 Middleware,对 Handler 进行装饰。如果是相对通用的需求,可以找找有没有已经实现自己需求的库。
---
## 其他参考资料
- 除了 Ring 默认的 Jetty,我个人比较推荐 [http-kit](https://github.com/http-kit/http-kit),它提供了简单且性能较好的 Web 服务器实现,可以用 `org.httpkit.server/run-server` 替代 `run-jetty`。这个库也提供了 HTTP 客户端实现。
+- 除了 Compojure,还有更新更现代的 [reitit](https://github.com/metosin/reitit),不过仍处于 `0.x.x` 版本阶段,不如 Compojure 稳定。
+
+ reitit 的主要亮点是数据驱动,路由不必写成宏形式,可以写成嵌套的向量:
+
+ ```clojure
+ (defn cqrs-routes [actions]
+ ["/api" {:interceptors [::api ::db]}
+ (for [[type interceptor] actions
+ :let [path (str "/" (name interceptor))
+ method (case type
+ :query :get
+ :command :post)]]
+ [path {method {:interceptors [interceptor]}}])])
+ ```
+
+ 上面这个示例来自 [reitit](https://cljdoc.org/d/metosin/reitit/0.11.0/doc/basics/route-syntax) 文档。说实话,用 `for` 循环(或者 `map`)构造路由看起来很酷,但用宏同样可以做到啊。Clojure/Lisp 代码本身就是数据,不需要什么「数据驱动」。
+
+ 不过 reitit 还有别的好处,比如可以在路由里写拦截器(interceptor)。
+- 你可能会想要 Swagger API 文档,有 [`ring-swagger`](https://github.com/metosin/ring-swagger) 这个库,本质上是在 API 路由的定义里添加了一些元数据和文档描述,然后在**运行时**用这些数据生成 `swagger.json`,还把 Swagger UI 也打包在内。
+
+ 还有专门为 Compojure 设计的 [`compojure-api`](https://github.com/metosin/compojure-api),用 `ring-swagger` 实现。我试了试,这个库的依赖有点大,打包后可能有十几二十兆。在我看来不如手写文档,除非 API 真的很复杂。
+
+---
+
+## 最后
+
+这其实也是我第一次这么仔细地阅读 Ring 和 Compojure 的架构。Ring 的 Handler、Middleware 和 Adapter 其实很优雅,尤其是 Middleware 的组合,以非常优雅清晰也非常函数式的方式拓展了 Handler 的能力。
+
+由于写文的过程中发现了不少之前不了解的细节,所以我打算稍后就去整理一下我项目里的 API 写法。那么,回见!
[^1]: Jetty 是个 Java Web 服务器;也可以换成别的,这里就用默认的 Jetty 演示了。