summaryrefslogtreecommitdiff
path: root/content/article/clj6.md
blob: d8980bd660ebb6b19b1010a309bbbb2aaafe45d9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
+++
title = 'Coffee Break Clojure, Vol.6'
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/) 没有关系。<!--more-->

## 初识 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`。