From 2b7d57776ec1cfbda858cec0a09cfaccc418f0c6 Mon Sep 17 00:00:00 2001 From: eltrac Date: Tue, 6 Oct 2026 00:19:06 +0800 Subject: [publish] /article/clj6/ --- content/article/clj6.md | 241 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 235 insertions(+), 6 deletions(-) (limited to 'content/article') 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/) 没有关系。 @@ -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 演示了。 -- cgit v1.2.3