Coffee Break Clojure, Vol.5
在 第一篇 我们安装了 Leiningen,当时只是用它启动了 REPL 和 nREPL 服务器,现在我们要用它来干点重活儿了。顺带一提,Leiningen 的名字取自短篇故事《 莱宁根大战蚂蚁 》,故事情节…… 就是一个叫莱宁根的男人大战蚂蚁。
创建项目
在终端输入 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 的应用。
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 ,和 Clojure 一样。如果是写着玩儿的应用,可以直接删掉;如果要发布的话,建议考虑更换授权协议。
除了 .md 文档,剩下的目录就和典型的 Java 项目非常像了。
src/:源代码,其中每一个目录下的文件都对应一个 Clojure 命名空间test/:测试代码,可以用lein test执行里面的测试resources/:代码使用的静态资源,包括 Web 资源,也可以是嵌入到软件里的其他程序,比如编译好的 WebAssembly
说实话,我觉得这是 Clojure 从 Java 那里学来的陋习,不如 Go 的 标准布局 。src/ 目录只能让项目的最外层看起来整洁。除非是包含多个项目的 Monorepo,否则我不觉得 src/ 这类目录有存在的必要。
值得注意的是 project.clj,这是项目描述文件:
(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 。
余下的是可变长度的参数,要求是偶数个且以键值对的形式出现,就是映射的结构。除去 :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 。
命名空间与符号
之前我们简单略过了命名空间(namespace)的概念,可以理解为符号(symbol)的集合,一个命名空间下的符号不能有重合,命名空间可以相互引用对方的符号。
在 Clojure 里,用 ns 进入一个命名空间。如果你在 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 文件:
(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,之后会谈到。
(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。稍后再来看测试怎么写。
(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 或 :alias,第三个元素根据第二个元素而定。向量也可以只有一个元素。
[project.namespace]- 可以在当前命名空间使用
project.namespace中的符号。假设project.namespace中有say符号,可以用project.namespace/say引用。
- 可以在当前命名空间使用
[project.namespace :alias 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 的 讨论 ,现在已经不推荐使用 :use,全部换成 :require 是最好的。
至于 :import,这是用来引入 Java 库的,这个我们留到之后讲 Clojure 如何与 Java 联动的时候再说。
引入依赖
Clojure 有多种声明依赖的方式,被原生支持的是 deps.edn,写法如下:
{:deps {org.clojure/clojure {:mvn/version "1.10.3"}}
;; ...
}
不过我们在用 Leiningen,所以可以直接在 project.clj 里声明:
(defproject antfighter "0.1.0-SNAPSHOT"
:dependencies [[org.clojure/clojure "1.12.2"]]
;; ...
)
Leiningen 的写法干净不少。Leiningen 和 deps.edn 都默认用 Maven Central 和 Clojars 作为依赖的来源。Clojars 是个专门用来发布和存放 Clojure 依赖的 Maven 仓库 ,与 Maven 相关的工具兼容。不过我们用的是 Leiningen,不需要考虑太多细节。
假设我们要解析 JSON,需要引入 Cheshire 库。可以先去这个仓库的 README 里找到依赖字符串 [cheshire "6.2.0"],然后复制到 project.clj 里。也可以直接在 Clojars 上直接搜索依赖。
(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 数据试试。
(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 (用于检验数据有效性的库,比如 URL 是否合法),将依赖名和版本号写进 project.clj 即可。格式上,Java 的依赖和 Clojure 的依赖没有什么区别,它们实际上也都只是 .jar 文件而已。
(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 可以这么用:
(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 这个包里引用了 UrlValidator 类。
顺带一提,Clojure 做的一大改变就是把类和命名空间解耦了。在 Java 中,类既是数据类型,又是命名空间。在 Clojure 创造者 Rich Hickey 的演讲《 Simple Made Easy 》就提到了这一点。Clojure 的命名空间就只是命名空间而已。
除了在 project.clj 里声明的外部 Java 库,也可以直接用 Java 标准库里的类。
(import [java.net URI URL])
;; 上述代码引入了 java.net 的包
;; 里的 URI 和 URL 类
你可能注意到了,我们引入的是 Java 类,不是 Clojure 符号,处理起来会有点困难。Clojure 提供了点语法。. 是 Clojure 的特殊形式,和 Java 的 . 类似。
import java.util.Date
Date date = new Date()
date.getMonth()
上述 Java 代码相当于:
(import [java.util Date])
(def date (new Date))
(. date getMonth)
上述 Clojure 代码还可以进一步简写:
(import [java.util Date])
(def date (Date.))
(.getMonth (Date.))
new Object 相当于 Object.,这是 Clojure 提供的语法糖。如果要用点语法调用对象的方法,可以在方法名前面加上 .,当成函数来用,函数的第一个参数就是对象本身,像这样:(.method object args...)。
还可以进一步简写:
(.getMonth (java.util.Date.))
;; => 10
如果你熟悉 Java,你应该知道除了必须把类实例化为对象才能调用的方法,还有静态方法。静态方法可以直接在类上调用,不需要创建对象。对于静态方法,只需要把 Java 类当成 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 写,这样性能会更好。
开发依赖和 Leiningen 插件
之前引入的依赖都是代码依赖,会被一起打包到 Uberjar 里。还有一类依赖是开发依赖,比如测试工具、代码格式化工具、Mock 工具、依赖管理工具等等。
还记得 project.clj 里的 :profiles 吗?其实每个 :profiles 都可以声明自己的依赖项。比如说 :test 就针对 lein test,只有在运行测试时才会加载里面声明的依赖。下面的代码引用了 ring-mock,这是用来测试 Ring 项目的 Mock 工具。
:profiles {:uberjar {:aot :all}
:test {:dependencies [[ring/ring-mock "0.6.2"]]}}
上述依赖不写在 :profiles 里也能用,但运行和编译代码时,都会把实际上并没有在生产代码里用到的依赖打包进去,会消耗额外的资源,因此应该分离。
此外还有 Leiningen 插件,他们和代码以及测试代码都没有关系,而是用来辅助开发的,写在 :plugins 设置项里,格式与依赖相同。下面是我常用的一些插件。
: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 :可以运行
lein ancient命令检查依赖是否有新版本。 - lein-ring :可以用
lein ring运行基于 Ring 的 Web 服务,支持热重载,而且不需要在代码里编写运行服务器的逻辑,也不需要gen-class,只需要编写 Handler。 - lein-cloverage :用
lein cloverage运行测试并统计测试覆盖率,支持生成多种格式的报告,可以查看各个命名空间的形式覆盖率(forms coverage)和代码行覆盖率。还可以在生成的 HTML 页面里查看具体哪些语句没有被完全覆盖。 - lein-cljfmt :用
lein cljfmt格式化代码。
最后
除了命名空间的概念,Clojure 项目的结构和管理方法与其他软件项目并无太多差异。不过我这里说的「管理」并不是针对人力和时间分配的项目管理,而是指对软件的结构和依赖项的管理。
Clojure 项目比较特殊的是,尽管其他非 Lisp 编程语言可能也有热重载能力,但他们不像 Lisp 的 REPL 那样灵活。REPL 不只能做到热重载,还允许程序员直接与程序对话,快速上手调试。在项目根目录使用 lein repl 启动 REPL 和 nREPL,项目里所有的命名空间都会被加载,程序员可以在 REPL 里用 ns 在命名空间之间移动,探索软件的内部结构。
既然我们已经掌握了不少 Clojure 的基础知识和项目管理工具,那么是时候来点实操了。下一期我们将会接触到 Ring 及相关的工具,开发简单的 Web 后端。
-
ns实际上只是把全局变量*ns*设置成了指定的命名空间。 - Cheshire 指《爱丽丝梦游仙境》里的「柴郡猫」。
cheshire库用的是 Java 库 Jackson。 - 是的,Java 的包名都又臭又长。
- 不是说 Java 性能有多好,是因为 Clojure 运行在 Java 之上,自然会有性能损失。
- Ring 是 Clojure 的 Web 服务器抽象库;Mock 是指用来测试、演示的假组件,比方说前端需要调用后端 API 进行开发,但后端还没开发完,这时前端就可以用假的 Mock API。