+++ 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。