summaryrefslogtreecommitdiff
path: root/content/article/clj5.md
blob: f01a320cfe7f56fef55c47b255a2460dda0af391 (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
+++
title = 'Coffee Break Clojure, Namespace'
subtitle = '管理 Clojure 项目'
slug = 'clj5'
tags = ["Lisp","Clojure","编程"]
categories = '茶歇蔻芥'
date = 2026-10-03 18:50:02
draft = false
toc = true
fedi = 'https://akk.eltr.ac/objects/81d108fb-6962-44d9-8127-0f1c2698cddb'
+++

在[第一篇](/article/clj1/)我们安装了 Leiningen,当时只是用它启动了 REPL 和 nREPL 服务器,现在我们要用它来干点重活儿了。顺带一提,Leiningen 的名字取自短篇故事《[莱宁根大战蚂蚁](https://en.wikipedia.org/wiki/Leiningen_Versus_the_Ants)》,故事情节…… 就是一个叫莱宁根的男人大战蚂蚁。

## 创建项目

在终端输入 `lein new -h` 命令查看帮助,可以看到这些模板:

```
Subtasks available:
template   A meta-template for 'lein new' templates.
plugin     A leiningen plugin project template.
default    A general project template for libraries.
app        An application project template.
```

模板只是提供了预先配置好的项目描述文件和项目结构,选哪个都不影响开发。简单来说,如果要写个 Clojure 库就用 `default`,如果要写应用程序就用 `app`。下文用 `app` 做示范。

创建一个名叫 `antfighter` 的应用。

```sh
lein new app antfighter
cd antfighter
```

```
❯ tree
.
├── CHANGELOG.md
├── doc
│   └── intro.md
├── LICENSE
├── project.clj
├── README.md
├── resources
├── src
│   └── antfighter
│       └── core.clj
└── test
    └── antfighter
        └── core_test.clj

7 directories, 7 files
```

项目默认生成的 `LICENSE` 是 [Eclipse Public License](https://en.wikipedia.org/wiki/Eclipse_Public_License),和 Clojure 一样。如果是写着玩儿的应用,可以直接删掉;如果要发布的话,建议考虑更换授权协议。

除了 `.md` 文档,剩下的目录就和典型的 Java 项目非常像了。

- `src/`:源代码,其中每一个目录下的文件都对应一个 Clojure 命名空间
- `test/`:测试代码,可以用 `lein test` 执行里面的测试
- `resources/`:代码使用的静态资源,包括 Web 资源,也可以是嵌入到软件里的其他程序,比如编译好的 WebAssembly

说实话,我觉得这是 Clojure 从 Java 那里学来的*陋习*,不如 Go 的[标准布局](https://github.com/golang-standards/project-layout)。`src/` 目录只能让项目的最外层看起来整洁。除非是包含多个项目的 Monorepo,否则我不觉得 `src/` 这类目录有存在的必要。

值得注意的是 `project.clj`,这是项目描述文件:

```clojure 
(defproject antfighter "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "https://example.com/FIXME"
  :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
            :url "https://www.eclipse.org/legal/epl-2.0/"}
  :dependencies [[org.clojure/clojure "1.12.2"]]
  :main ^:skip-aot antfighter.core
  :target-path "target/%s"
  :profiles {:uberjar {:aot :all
                       :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}})
```

`defproject` 的第一个参数是项目名,第二个参数是版本号,`SNAPSHOT` 表示快照,正式发布时改成自己习惯的命名就好,比如 [Semantic Versioning](https://semver.org/)。

余下的是可变长度的参数,要求是偶数个且以键值对的形式出现,就是映射的结构。除去 `:description`、`:url` 和 `:license` 这些顾名思义的选项,上述选项的意思分别是:

- `:dependencies`:依赖项,应该是包含所有依赖声明的向量。向量的元素通常是长度为 `2` 的向量,第一个元素是依赖名,第二个元素是版本号。默认都应该加载 `org.clojure/clojure` 依赖项。
- `:main`:项目的入口,即运行项目时应该在哪个**命名空间**里找 `-main` 函数。
- `:target-path`:和 Java 一样,`target` 就是编译产物存放的目录。
- `:profiles`:可以为不同的运行环境和命令指定选项、参数和依赖项。
    - `:uberjar`:Uberjar 也叫 Fat Jar。Clojure 和 Java 一样,最终编译为 `.jar` 文件,而 Uberjar 就是把所有依赖项都打包在内,可以独立运行的 `.jar`。这个 Profile 是执行 `lein uberjar` 编译命令时会用到的参数。
    
更多的选项会在后文提及,也可以查看官方提供的 [sample.project.clj](https://codeberg.org/leiningen/leiningen/src/branch/stable/sample.project.clj)。
    
## 命名空间与符号

之前我们简单略过了命名空间(namespace)的概念,可以理解为符号(symbol)的集合,一个命名空间下的符号不能有重合,命名空间可以相互引用对方的符号。

在 Clojure 里,用 `ns` 进入一个命名空间[^1]。如果你在 REPL 里想要直接访问某个命名空间里的符号,可以输入 `(ns project.namespace)`;在文件开头写 `ns`,就可以在这个命名空间里定义符号。

`ns` 的第一个参数是命名空间的名字,与当前文档在 `src/` 的子目录对应。比如,如果文件被放在 `src/antfighter/core.clj`,这个文件的命名空间只能叫 `antfighter.core`。子目录同理,`src/antfighter/http/client.clj` 是 `antfighter.http.client`。Clojure 文件不应该包含 `-`,而是用 `_`,但是在命名空间的命名里要变成 `-`,比如 `src/antfighter/meat_proxy.clj` 应该是 `antfighter.meat-proxy`。

以下是我们刚才生成的示例代码中的 `core.clj` 文件:

```clojure 
(ns antfighter.core
  (:gen-class))

(defn -main
  "I don't do a whole lot ... yet."
  [& args]
  (println "Hello, World!"))
```

这个文件的 `ns` 还包含更多的参数,写在 `ns` 内部的 `:gen-class` 等价于直接写 `gen-class`。同理还有 `:require` 和 `:import`,之后会谈到。

```clojure
(ns antfighter.core)
(gen-class)
```

`gen-class` 的作用是生成 Java 类(Generate Class)。如果你需要把 Clojure 程序编译成 JVM 字节码(`.jar` 文件),然后直接在 Java 环境里执行,就需要给**主**命名空间生成 Java 类。其他命名空间是通过 Clojure 调用的,只有 `core.clj` 才需要 Java 类。

如果直接用 `clj` 或 `lein` 命令行,就自带 Clojure 运行环境,不需要 `gen-class`。

继续,看刚才一同生成的 `core_test.clj`,可以注意到 `test/` 目录下的文件也对应一个命名空间,`test/antfighter/core_test.clj` 对应 `antfighter.core-test`。稍后再来看测试怎么写。

```clojure 
(ns antfighter.core-test
  (:require [clojure.test :refer :all]
            [antfighter.core :refer :all]))

(deftest a-test
  (testing "FIXME, I fail."
    (is (= 0 1))))
```

此外还有 `:require`,用于引入另一个命名空间的符号。`require` 接收可变数量的参数,每个参数都是一个向量:第一个元素是命名空间名,第二元素是 `:refer` 或 `:as`,第三个元素根据第二个元素而定。向量也可以只有一个元素。

- `[project.namespace]`
    - 可以在当前命名空间使用 `project.namespace` 中的符号。假设 `project.namespace` 中有 `say` 符号,可以用 `project.namespace/say` 引用。
- `[project.namespace :as pn]`
    - 可以在当前命名空间用 `pn` 作为 `project.namespace` 的别名。
    - 例如,`project.namespace/say` 可以写作 `pn/say`。
- `[project.namespace :refer [symbol]]`
    - 可以在当前命名空间中直接使用 `project.namespace` 中的 `symbol`,不需要写 `/` 以及命名空间的名字。
    - 可以在 `:refer` 之后的向量中填写多个符号名。
    - 可以把 `:refer` 之后的向量替换成 `:all`,把命名空间中的所有符号引入当前命名空间。不推荐这种写法,需要注意命名冲突。
    
其实还有 `:use` 的写法,等同于 `:require [... :refer :all]`。`:use` 也可以加上 `:exclude`(排除)和 `:only`(只引入)等参数,但根据 [Clojure Style Guide](https://guide.clojure.style/) 的[讨论](https://github.com/bbatsov/clojure-style-guide/issues/5),现在已经不推荐使用 `:use`,全部换成 `:require` 是最好的。

至于 `:import`,这是用来引入 Java 库的,这个我们留到之后讲 Clojure 如何与 Java 联动的时候再说。

## 引入依赖

Clojure 有多种声明依赖的方式,被原生支持的是 `deps.edn`,写法如下:

```clojure 
{:deps {org.clojure/clojure {:mvn/version "1.10.3"}}
 ;; ...
 }
```

不过我们在用 Leiningen,所以可以直接在 `project.clj` 里声明:

```clojure 
(defproject antfighter "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure "1.12.2"]]
  ;; ...
  )
```

Leiningen 的写法干净不少。Leiningen 和 `deps.edn` 都默认用 [Maven Central](https://repo1.maven.org/maven2/) 和 [Clojars](https://clojars.org/) 作为依赖的来源。Clojars 是个专门用来发布和存放 Clojure 依赖的 [Maven 仓库](https://mvnrepository.com/repos/clojars),与 Maven 相关的工具兼容。不过我们用的是 Leiningen,不需要考虑太多细节。

假设我们要解析 JSON,需要引入 [Cheshire](https://github.com/dakrone/cheshire) 库[^2]。可以先去这个仓库的 `README` 里找到依赖字符串 `[cheshire "6.2.0"]`,然后复制到 `project.clj` 里。也可以直接在 Clojars 上直接搜索依赖。

```clojure 
(defproject antfighter "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure "1.12.2"]
                 [cheshire "6.2.0"]]
  ;; ...
  )
```

保存之后执行 `lein deps` 可以下载依赖。用 `lein repl` 启动 REPL,或者用 `lein run` 运行程序的时候也会自动下载依赖。如果添加了新的依赖,就要重启 REPL 或者重启应用,否则不会生效。

写个文件测试一下,把 Clojure 数据转换为 JSON 字符串,再把字符串转换为 Clojure 数据试试。

```clojure 
(ns antfighter.cat
  (:require [cheshire.core :refer [generate-string parse-string]]))

(generate-string {:foo "bar" :key "val"}) 
;; => "{\"foo\":\"bar\",\"key\":\"val\"}"

(parse-string "{\"foo\":\"bar\",\"key\":\"val\"}") 
;; => {"foo" "bar", "key" "val"}
```

好了,现在你已经会导包了,可以说已经跨进 Clojure 生态的大门了。如果想知道某个外部命名空间里有哪些符号,可以阅读对应的文档,或者翻阅源代码。

## 偷渡 Java 代码

Clojure 毕竟是门 Lisp,用的人相对较少,所以有时候会找不到想要的包。这时就可以从生态更成熟的 Java 那边偷来用。

还记得 Leiningen 也从 Maven Central 拉取依赖吗?Java 的各种 `.jar` 就放在 Maven Central 上,所以可以直接拿来用。

比方说,我想用 [Apache Commons Validator](https://mvnrepository.com/artifact/commons-validator/commons-validator)(用于检验数据有效性的库,比如 URL 是否合法),将依赖名和版本号写进 `project.clj` 即可。格式上,Java 的依赖和 Clojure 的依赖没有什么区别,它们实际上也都只是 `.jar` 文件而已。

```clojure 
(defproject antfighter "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure "1.12.2"]
                 [cheshire "6.2.0"]
                 [commons-validator "1.11.0"]]
  ;; ...
  )
```

Clojure 的 `import` 跟 Java 的差不了太多,`import` 的参数是 Java 的包名加上类名。比方说,刚才的 Apache Commons Validator 可以这么用:

```clojure
(ns antfighter.spider
  (:import [org.apache.commons.validator.routines UrlValidator]))

(defn valid-http-url? [url]
  (let [schemes (into-array String ["http" "https"])
        validator (UrlValidator. schemes)]
    (.isValid validator url)))
```

`import` 和 `require` 类似,也可以接收多个向量。每个向量的第一个元素是包名,后面的是可变数量的元素,是 Java 类名。上述代码从 `org.apache.commons.validator.routines` 这个包[^3]里引用了 `UrlValidator` 类。

顺带一提,Clojure 做的一大改变就是把类和命名空间解耦了。在 Java 中,类既是数据类型,又是命名空间。Clojure 的创造者 Rich Hickey 在演讲《[Simple Made Easy](https://redirect.invidious.io/watch?v=SxdOUGdseq4)》中就提到了这一点。Clojure 的命名空间就只是命名空间而已。

除了在 `project.clj` 里声明的外部 Java 库,也可以直接用 Java 标准库里的类。

```clojure 
(import [java.net URI URL])
;; 上述代码引入了 java.net 的包 
;; 里的 URI 和 URL 类
```

你可能注意到了,我们引入的是 Java 类,不是 Clojure 符号,处理起来会有点困难。Clojure 提供了点语法。`.` 是 Clojure 的特殊形式,和 Java 的 `.` 类似。

```java 
import java.util.Date

Date date = new Date()
date.getMonth()
```

上述 Java 代码相当于:

```clojure 
(import [java.util Date])

(def date (new Date))
(. date getMonth)
```

上述 Clojure 代码还可以进一步简写:

```clojure 
(import [java.util Date])

(def date (Date.))
(.getMonth (Date.))
```

`new Object` 相当于 `Object.`,这是 Clojure 提供的语法糖。如果要用点语法调用对象的方法,可以在方法名前面加上 `.`,当成函数来用,函数的第一个参数就是对象本身,像这样:`(.method object args...)`。

还可以进一步简写:

```clojure 
(.getMonth (java.util.Date.))
;; => 10
```

如果你熟悉 Java,你应该知道除了必须把类实例化为对象才能调用的方法,还有静态方法。静态方法可以直接在类上调用,不需要创建对象。对于静态方法,只需要把 Java 类当成 Clojure 命名空间,把方法当作命名空间里的函数,也就是用 `/` 语法。

```clojure 
(java.time.Instant/now)
;; => #object[java.time.Instant 0x1c2b8e81 "2026-10-02T16:26:17.401045Z"]
```

好了,现在你已经会偷 Java 代码用了。

在 Clojure 中写 Java,本质上是同时使用 Lisp 的语法和 Java 的语义(semantics),类的概念都来自 Java,和 Clojure 格格不入。所以,除非必要不建议大量使用 Java 库,如果要用,也建议把 Java 代码封装在 Clojure 函数里。其实不少 Clojure 库就是通过封装 Java 库开发的,有些 Clojure 库还会专门用 Java 写,这样性能会更好。[^4]

## 开发依赖和 Leiningen 插件

之前引入的依赖都是代码依赖,会被一起打包到 Uberjar 里。还有一类依赖是开发依赖,比如测试工具、代码格式化工具、Mock 工具、依赖管理工具等等。

还记得 `project.clj` 里的 `:profiles` 吗?其实每个 `:profiles` 都可以声明自己的依赖项。比如说 `:test` 就针对 `lein test`,只有在运行测试时才会加载里面声明的依赖。下面的代码引用了 `ring-mock`,这是用来测试 [Ring](https://github.com/ring-clojure/ring) 项目的 Mock 工具。[^5]

```clojure 
  :profiles {:uberjar {:aot :all}
             :test {:dependencies [[ring/ring-mock "0.6.2"]]}}
```

上述依赖不写在 `:profiles` 里也能用,但运行和编译代码时,都会把实际上并没有在生产代码里用到的依赖打包进去,会消耗额外的资源,因此应该分离。

此外还有 Leiningen 插件,插件和代码以及测试代码都没有关系,而是用来辅助开发的,写在 `:plugins` 设置项里,格式与依赖相同。下面是我常用的一些插件。

```clojure 
  :plugins [[lein-ancient "0.6.15"]
            [lein-ring "0.12.6"]
            [lein-cloverage "1.2.2"] 
            [dev.weavejester/lein-cljfmt "0.16.5"]]
```

- [lein-ancient](https://codeberg.org/xsc/lein-ancient):可以运行 `lein ancient` 命令检查依赖是否有新版本。
- [lein-ring](https://github.com/weavejester/lein-ring):可以用 `lein ring` 运行基于 Ring 的 Web 服务,支持热重载,而且不需要在代码里编写运行服务器的逻辑,也不需要 `gen-class`,只需要编写 Handler。
- [lein-cloverage](https://github.com/cloverage/cloverage):用 `lein cloverage` 运行测试并统计测试覆盖率,支持生成多种格式的报告,可以查看各个命名空间的形式覆盖率(forms coverage)和代码行覆盖率。还可以在生成的 HTML 页面里查看具体哪些语句没有被完全覆盖。
- [lein-cljfmt](https://github.com/weavejester/cljfmt):用 `lein cljfmt` 格式化代码。

## 最后 

除了命名空间的概念,Clojure 项目的结构和管理方法与其他软件项目并无太多差异。不过我这里说的「管理」并不是针对人力和时间分配的项目管理,而是指对软件的结构和依赖项的管理。

Clojure 项目比较特殊的是,尽管其他非 Lisp 编程语言可能也有热重载能力,但不像 Lisp 的 REPL 那样灵活。REPL 不只能做到热重载,还允许程序员直接与程序对话,快速上手调试。在项目根目录使用 `lein repl` 启动 REPL 和 nREPL,项目里所有的命名空间都会被加载,程序员可以在 REPL 里用 `ns` 在命名空间之间移动,探索软件的内部结构。

既然我们已经掌握了不少 Clojure 的基础知识和项目管理工具,那么是时候来点实操了。下一期我们将会接触到 Ring 及相关的工具,开发简单的 Web 后端。

[^1]: `ns` 实际上只是把全局变量 `*ns*` 设置成了指定的命名空间。

[^2]: Cheshire 指《爱丽丝梦游仙境》里的「柴郡猫」。`cheshire` 库用的是 Java 库 Jackson。

[^3]: ~~是的,Java 的包名都又臭又长。~~

[^4]: 不是说 Java 性能有多好,是因为 Clojure 运行在 Java 之上,自然会有性能损失。

[^5]: Ring 是 Clojure 的 Web 服务器抽象库;Mock 是指用来测试、演示的假组件,比方说前端需要调用后端 API 进行开发,但后端还没开发完,这时前端就可以用假的 Mock API。