+++ title = 'Coffee Break Clojure, Web Server' subtitle = 'Ring Ring Ring' slug = 'clj6' tags = ["Lisp","Clojure","编程","Web"] categories = '茶歇蔻芥' 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/) 没有关系。 ## 初识 Ring Ring 是对 Web 服务器的**低层**抽象,也就是说没有实现页面路由(router),和通常的后端框架(比如 Go 的 Gin、Python 的 Django、Java 的 Spring Web 等)有很大区别。 根据 [Ring Spec 1.6.1](https://github.com/ring-clojure/ring/blob/master/SPEC.md),Ring 主要有三个概念: 1. Handler(处理器) 2. Middleware(中间件) 3. Adapter(适配器) Ring 提供了三种 API: 同步 API、异步 API 和 WebSocket API。不同的 API 在概念上略有不同,我们先从最简单的同步 API 讲起。 Handler 就是一个函数,接收 HTTP 请求,返回 HTTP 响应。 ```clojure ;; 一个示例 Handler (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 服务器。 ```clojure ;; 调用一个 Adapter 的示例 (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/) 绘制") {.dark:invert} 上述组件其实都不必有代码依赖关系,因为函数可以作为数据传递,上图的虚线都表示数据流。Handler 先是经过 Middleware 被改装成了新的 Handler,然后被传入 Adapter;Adapter 在 Web 服务器有请求进来时调用 Handler 产生响应数据,把响应数据包装成响应体后传送给客户端。 ## 简单的 Web 服务器 先在 `project.clj` 里添加两个新依赖: ```clojure :dependencies [[org.clojure/clojure "1.10.1"] [ring/ring-core "1.8.2"] [ring/ring-jetty-adapter "1.8.2"]]) ``` 其中一个是 Ring 的核心库,另一个是 Jetty[^1] 的 Ring Adapter。`ring-core` 也提供了一些 Middleware,我们其实只需要写 Handler。 创建 `src/antfigher/handler.clj`,`antfigher` 是我用来演示的项目名。我们先让 Handler 只返回 `Hello!` 这一条信息。 ```clojure (ns antfighter.handler) (defn handler [request] {:status 200 :body "Hello!"}) ``` 接下来需要启动 Web 服务器,如果你安装了上一篇提到的 `lein-ring` 插件,就可以直接用命令行启动了。在 `project.clj` 中添加这些配置: ```clojure :plugins [[lein-ring "0.12.6"]] :ring {:handler antfighter.handler/handler} ``` `:plugins` 安装了 Ring 插件,`:ring` 则是这个插件的配置项,其中 `:handler` 指定了 `antfigher.handler` 命名空间下的 `handler` 符号,也就是我们刚才定义的 `handler` 函数。 在终端输入 `lein ring server` 启动服务器,之后工具会自动在浏览器里打开 `http://localhost:3000`,然后就能看到 `Hello!` 显示在浏览器页面里。 如果要打包成可以直接在 JVM 上执行的 `.jar` 文件,可以用 `lein ring uberjar` 编译。不过,我不建议依赖这个 Leiningen 插件来编译应用,而且不少应用其实除了启动 Ring 服务器,还需要启动别的东西,比如后台处理线程、消息队列等等。`lein ring uberjar` 只会把指定的 Handler 编译进去。 在 `core.clj` 文件的 `-main` 函数里编写启动逻辑: ```clojure {hl_lines=[3,4,9]} (ns antfighter.core (:gen-class) (:require [antfighter.handler :refer [handler]] [ring.adapter.jetty :refer [run-jetty]])) (defn -main "I start a Jetty server." [& args] (run-jetty handler {:port 3000})) ``` `run-jetty` 就是 Ring Jetty Adapter,它接收 `handler` 函数和作为配置项的映射,这里我们传入的配置是 `{:port 3000}`,也就是在 `3000` 端口上启动 Jetty 服务器。 这时运行 `lein run`,再手动打开 `localhost:3000`,也能看到 `Hello!`。如果用 `lein uberjar` 把项目编译成 `.jar`,然后用 `java -jar xxx.jar` 运行,也是一样的效果。[^2] 开发时建议用 `lein ring server`,因为自带热重载,保存代码后就能看到效果。 ## 处理 HTTP 请求 Handler 接收的参数 `request` 是个映射,结构如下: | Key | Type | Required | | ------------------- | ---------------------------------- | -------- | |`:body` |`java.io.InputStream` | | |`:headers` |`{String String}` | Yes | |`:protocol` |`String` | Yes | |`:query-string` |`String` | | |`:remote-addr` |`String` | Yes | |`:request-method` |`Keyword` | Yes | |`:scheme` |`Keyword` | Yes | |`:server-name` |`String` | Yes | |`:server-port` |`Integer` | Yes | |`:ssl-client-cert` |`java.security.cert.X509Certificate`| | |`:uri` |`String` | Yes | Handler 也应该返回一个映射,结构如下: | Key | Type | Required | | -------- | ------------------------------------------ | -------- | |`:body` |`ring.core.protocols/StreamableResponseBody`| | |`:headers`|`{String String}` or `{String [String]}` | Yes | |`:status` |`Integer` | Yes | 上述表格摘自 [Ring Spec](https://github.com/ring-clojure/ring/blob/master/SPEC.md)。大部分情况下我们都只需要使用 `:body` 和 `:headers`,也就是响应头(或请求头)以及响应体(或请求体),响应时还需要带上 `:status`,即 HTTP 状态码。 当然,如果你不用任何路由库,纯手动路由,那还要用到 `:request-method`(请求方法,如 `GET` `POST`)和 `:uri`(用来解析路径)。 Handler 做的事情其实就是对请求做些处理,然后构造响应。比方说,如果客户端用 `GET` 方法请求 `/hello` 目录才返回 `Hello!`,对其他路由都返回 `404` 和 `Not Found`,就可以这样写: ```clojure (ns antfighter.handler (:import [java.net URI])) (defn handler [request] (let [method (:request-method request) uri (:uri request) path (.getPath (URI. uri))] (if (and (= method :get) (= path "/hello")) {:status 200 :body "Hello!"} {:status 404 :body "Not Found"}))) ``` 不过,访问 `/hello` 会得到 `Hello!`,但访问 `/hello/` 却是 `Not Found`。我们的路由匹配方式有缺陷。处理这些细节太麻烦了,有没有别的办法? ## 沉着冷静地路由 [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` 请求。 ```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 演示了。 [^2]: 上篇文章提到了,如果要用 Java 运行,就要保留 `core.clj` 中的 `:gen-class`。