Leiningen 学习笔记:Clojure 项目构建与管理从入门到实战配置
目录
本文是学习 Leiningen — Complete Tutorial & Best Practices 过程中的笔记整理。全文以一个 greeting-api 项目为统一示例,从零开始演示 Leiningen 的完整工作流。
Leiningen 是什么
Leiningen(简称 lein)是 Clojure 的构建工具和项目管理器。如果你用过其他语言,可以这样类比:
- Node.js → npm
- Java → Maven
- Python → pip
- Clojure → Leiningen
它围绕 Clojure 的哲学设计,主要功能包括:
- 创建和脚手架项目
- 管理依赖(从 Clojars 和 Maven Central)
- 运行 REPL、测试和构建
- 编译打包应用(JAR/uberjar)
- 通过插件运行自定义任务
- 管理不同环境的配置(dev/test/prod)
安装
Leiningen 需要 JDK 8 或更高版本。安装步骤如下:
# 1. 下载 lein 脚本 curl -L -o ~/bin/lein https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein # 2. 赋予执行权限 chmod +x ~/bin/lein # 3. 首次运行会自动完成安装 lein version
我的环境输出如下:
Leiningen 2.10.0 on Java 21.0.10 OpenJDK 64-Bit Server VM
[注] 简单来说,Leiningen 就是 Clojure 世界里的"瑞士军刀",项目从创建到部署几乎都离不开它。
创建项目
使用 lein new app 命令创建一个应用项目:
lein new app greeting-api
实际输出:
Generating a project called greeting-api based on the 'app' template.
生成的目录结构
Leiningen 实际生成了如下文件:
greeting-api/
├── CHANGELOG.md
├── LICENSE
├── README.md
├── doc/
│ └── intro.md
├── project.clj ← 核心:依赖、配置、构建定义
├── resources/ ← 静态文件、配置、资源
├── src/
│ └── greeting_api/
│ └── core.clj ← 主命名空间
└── test/
└── greeting_api/
└── core_test.clj ← 测试文件
[注] 这里有个容易踩坑的地方:Clojure 命名空间用 - (连字符),但对应的文件夹和文件名用 _ (下划线)。比如命名空间 greeting-api.core 对应的文件路径是 src/greeting_api/core.clj 。这个转换规则是 Clojure 的惯例,Leiningen 会自动处理,但如果手动创建文件时搞混了就会找不到命名空间。
Leiningen 自动生成的初始文件
生成后 project.clj 的内容如下:
(defproject greeting-api "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://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.11.1"]]
:main ^:skip-aot greeting-api.core
:target-path "target/%s"
:profiles {:uberjar {:aot :all
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]}})
生成后 src/greeting_api/core.clj 的内容如下:
(ns greeting-api.core (:gen-class)) (defn -main "I don't do a whole lot ... yet." [& args] (println "Hello, World!"))
[注] 注意 (:gen-class) 声明——它告诉编译器为这个命名空间生成 Java 类。这是后面打包 uberjar 时能找到入口点的关键,如果忘了加,即使配置了 :aot ,JVM 也找不到入口。另外 :main ^:skip-aot 中的 ^:skip-aot 表示日常开发时不做 AOT 编译,只在打包 uberjar 时才编译。
第一次运行
在项目根目录下执行:
cd greeting-api
lein run
实际输出(首次运行会下载依赖,后续运行不再显示下载信息):
Retrieving nrepl/nrepl/1.0.0/nrepl-1.0.0.pom from clojars Retrieving nrepl/nrepl/1.0.0/nrepl-1.0.0.jar from clojars ... Hello, World!
project.clj 详解
project.clj 是整个项目的核心配置文件。现在我们在初始版本的基础上逐步添加功能,将其改造为一个完整的配置。
下面是 greeting-api 项目最终的 project.clj ,每个部分会在后续章节中详细演示:
(defproject greeting-api "0.1.0-SNAPSHOT"
:description "一个简单的问候 API 服务"
:url "https://github.com/you/greeting-api"
:license {:name "MIT"}
;; ===== 依赖 =====
;; 格式:[group/artifact "version"]
;; 实际上就是 Maven 坐标的简写形式
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.13.0"] ; HTTP 抽象层
[hiccup "1.0.5"]] ; HTML 生成
;; 入口命名空间(lein run 时调用其中的 -main 函数)
:main greeting-api.core
;; 源码路径(这些是默认值,通常不需要显式配置)
:source-paths ["src"]
:test-paths ["test"]
:resource-paths ["resources"]
;; ===== 插件(详见后文 Plugins 章节)=====
:plugins [[lein-ring "0.12.6"]]
;; Ring 插件配置
:ring {:handler greeting-api.core/app
:port 3000}
;; ===== Aliases(详见后文 Aliases 章节)=====
:aliases {"fmt" ["cljfmt" "fix"]
"lint" ["eastwood"]
"build" ["with-profile" "uberjar" "uberjar"]
"ci" ["do" "clean," "cljfmt" "check," "eastwood," "test," "uberjar"]}
;; ===== Profiles(详见后文 Profiles 章节)=====
:profiles {:dev {:dependencies [[ring/ring-mock "0.4.0"]]
:plugins [[lein-cljfmt "0.9.2"]
[jonase/eastwood "1.4.3"]]
:source-paths ["dev"]}
:uberjar {:aot :all
:omit-source true}})
[注] defproject 宏的第一个参数是项目名,第二个是版本号(遵循 语义化版本 规范)。=0.1.0-SNAPSHOT= 中的 SNAPSHOT 表示开发版本,这在 Java/Clojure 生态中是惯例。
编写核心代码
现在我们来编写 greeting-api 的核心代码,后续所有配置演示都围绕这个项目。
修改 src/greeting_api/core.clj
(ns greeting-api.core
(:require [ring.util.response :as resp]
[ring.middleware.params :refer [wrap-params]])
(:gen-class))
(defn greet
"返回问候字符串"
[name]
(str "Hello, " name "!"))
;; Ring handler:处理 HTTP 请求
(defn handler [request]
(let [name (get-in request [:query-params "name"] "World")]
(resp/response (greet name))))
;; 用 wrap-params 中间件自动解析查询参数,然后供 lein-ring 使用
(def app (wrap-params handler))
(defn -main [& args]
(println (greet "World")))
[注] 这里用到了 wrap-params 中间件,它的作用是将 URL 中的查询参数(如 ?name=Alice )解析到 :query-params 中。Ring 默认不解析查询参数,需要手动添加这个中间件。
编写测试 test/greeting_api/core_test.clj
(ns greeting-api.core-test
(:require [clojure.test :refer :all]
[greeting-api.core :refer :all]))
(deftest test-greet
(testing "按名字问候"
(is (= "Hello, Alice!" (greet "Alice"))))
(testing "默认问候"
(is (= "Hello, World!" (greet "World")))))
运行测试
lein test
实际输出:
lein test greeting-api.core-test Ran 1 tests containing 2 assertions. 0 failures, 0 errors.
[注] lein test 会自动运行 test/ 目录下所有命名空间中以 deftest 定义的测试。
运行项目
确保 project.clj 中配置了 :main greeting-api.core 后:
lein run
实际输出:
Hello, World!
也可以通过 -m 参数直接指定要运行的命名空间和函数:
lein run -m greeting-api.core/-main
Profiles(环境配置)
Profile 是 Leiningen 中非常实用的功能,它允许你为不同环境(开发、测试、生产)定义不同的配置,而这些配置会合并覆盖默认配置。
合并规则与默认激活
Profile 的合并规则是"后面的覆盖前面的"。Leiningen 有几个默认激活的 profile:
:user(来自~/.lein/profiles.clj)——总是激活:dev——在大多数命令中自动激活:test——在运行lein test时自动激活
这意味着你在 :dev 里配置的依赖和插件不需要手动指定就会生效。
在 greeting-api 中使用 Profiles
我们在 greeting-api 的 project.clj 中定义以下 profiles:
:profiles {:dev {:dependencies [[ring/ring-mock "0.4.0"]]
:plugins [[lein-cljfmt "0.9.2"]
[jonase/eastwood "1.4.3"]]
:source-paths ["dev"]}
:uberjar {:aot :all
:omit-source true}})
逐项说明:
:devprofile(开发时自动激活):- 添加
ring-mock依赖用于模拟 HTTP 请求 - 添加
lein-cljfmt插件用于代码格式化 - 添加
dev/作为额外源码目录,可以在dev/user.clj中放 REPL 辅助函数
- 添加
:uberjarprofile(打包时使用)::aot :all启用 AOT 编译所有命名空间:omit-source true不把源码打入 JAR
手动激活 Profile
lein with-profile uberjar uberjar # 用 :uberjar profile 打包 lein with-profile dev,test test # 同时激活 :dev 和 :test
#'leiningen.core.project/project Warning: The Main-Class specified does not exist within the jar. It may not be executable as expected. A gen-class directive may be missing in the namespace which contains the main method, or the namespace has not been AOT-compiled. Created /tmp/greeting-api/target/greeting-api-0.1.0-SNAPSHOT.jar Created /tmp/greeting-api/target/greeting-api-0.1.0-SNAPSHOT-standalone.jar #'leiningen.core.project/project
lein test user
Ran 0 tests containing 0 assertions. 0 failures, 0 errors.
常见使用模式
开发工具不打入生产包
把开发工具放在 :dev profile 里是个好习惯。如上面的配置所示, ring-mock 、 lein-cljfmt 都只在开发时生效,打包 uberjar 时这些依赖不会被包含进去,保持生产包干净。
额外的源码目录
在 :dev profile 中配置 :source-paths ["dev"] 后,Leiningen 会把项目根目录(即 project.clj 所在目录)下的 dev/ 也作为源码目录。这样就可以在 dev/user.clj 中放 REPL 辅助函数和启动代码,方便开发但不影响生产构建。
例如创建 dev/user.clj :
(ns user (:require [greeting-api.core :as core])) (defn start-dev [] (println "Dev mode started!") (core/greet "Developer"))
在 REPL 中,由于默认进入的是 :main 指定的命名空间(这里是 greeting-api.core ),且 :source-paths 只是让目录在 classpath 上可被找到,并不会自动加载其中的代码,所以需要先 require 加载 user 命名空间:
greeting-api.core=> (require 'user) nil greeting-api.core=> (user/start-dev) Dev mode started! "Hello, Developer!"
也可以切换到 user 命名空间后直接调用:
greeting-api.core=> (in-ns 'user) nil user=> (start-dev) Dev mode started! "Hello, Developer!"
Plugins(插件体系)
插件用来扩展 Leiningen 的功能。根据使用场景,插件有三种放置位置。
这里我们以 greeting-api 项目为例,演示如何安装和使用插件。
项目级插件(团队共享)
放在 project.clj 的顶层 :plugins 中,所有人 clone 后都能使用。
在 greeting-api 的 project.clj 中添加:
:plugins [[lein-ring "0.12.6"]]
启动 Ring 服务器:
lein ring server-headless
服务器启动后,用 curl 测试:
curl http://localhost:3000
实际输出:
Hello, World!
带参数访问:
curl "http://localhost:3000?name=Alice"
实际输出:
Hello, Alice!
按 Ctrl+C 停止服务器。
[注] lein-ring 的 :handler 配置指向 core.clj 中的 app 变量。注意我们的 app 使用了 wrap-params 中间件来解析查询参数。
Profile 内插件(按需激活)
放在特定 profile 中,只在激活该 profile 时生效。
在 greeting-api 的 project.clj 的 :dev profile 中添加:
:profiles {:dev {:plugins [[lein-cljfmt "0.9.2"]
[jonase/eastwood "1.4.3"]]}}
使用 lein-cljfmt 检查代码格式:
lein cljfmt check
实际输出(代码格式正确时):
All source files formatted correctly
如果有格式问题,用以下命令自动修复:
lein cljfmt fix
全局插件(个人工具)
放在 ~/.lein/profiles.clj 中,不进入项目仓库,适合个人常用工具:
{:user {:plugins [[lein-ancient "0.7.0"]
[lein-pprint "1.3.2"]]}}
使用 lein-ancient 检查依赖是否过时:
lein ancient :all
Retrieving lein-ancient/lein-ancient/0.7.0/lein-ancient-0.7.0.jar from clojars [org.clojure/clojure "1.12.4"] is available but we use "1.11.1" (use :check-clojure to upgrade) [ring/ring-core "1.15.4"] is available but we use "1.13.0" [hiccup "2.0.0"] is available but we use "1.0.5" (warn) [central] - Δ 13667ms - failure when checking ring/ring-mock: java.net.SocketTimeoutException: Read timed out [ring/ring-mock "0.6.2"] is available but we use "0.4.0"
lein-ring 配置
在上面的配置中,我们为 greeting-api 配置了 lein-ring 插件:
:ring {:handler greeting-api.core/app ; 指向 core.clj 中的 app 变量
:port 3000} ; 端口号
lein-ring 还支持更多选项:
:ring {:handler greeting-api.core/app
:port 3000
:auto-reload? true ; 自动重载(默认 true)
:reload-paths ["src"] ; 监听的目录
:open-browser? false} ; 是否自动打开浏览器
常用插件一览
| 类别 | 插件 | 用途 | 常用命令 |
|---|---|---|---|
| Web 开发 | lein-ring | 运行 Ring 应用,自动重载 | lein ring server |
| 代码格式 | lein-cljfmt | 格式化代码(类似 Prettier) | lein cljfmt fix |
| 静态分析 | jonase/eastwood | 发现常见 bug | lein eastwood |
| 惯用法 | lein-kibit | 建议更地道的写法 | lein kibit |
| 依赖管理 | lein-ancient | 检查过时的依赖 | lein ancient :all |
查找更多插件可以访问 Clojars(搜索 lein-* 前缀)或 Leiningen 官方插件列表 。
Aliases(自定义快捷命令)
Aliases 允许你在 project.clj 中定义快捷命令,避免每次输入一长串参数。
在 greeting-api 中配置 Aliases
:aliases {"fmt" ["cljfmt" "fix"]
"lint" ["eastwood"]
"build" ["with-profile" "uberjar" "uberjar"]}
使用简单别名
lein fmt # 等同于 lein cljfmt fix lein lint # 等同于 lein eastwood
链式任务(do)
用 do 将多个任务串联执行。注意逗号是 Leiningen do 任务的语法要求——它分隔 do 中的不同任务表达式,每个任务(除最后一个)后需要逗号。
例如配置一个 CI 流水线:
:aliases {"ci" ["do" "clean," "cljfmt" "check," "test," "uberjar"]}
运行:
lein ci # 依次执行:clean → cljfmt check → test → uberjar
Profile + 任务组合
"build" 别名演示了如何组合 profile 和任务:
"build" ["with-profile" "uberjar" "uberjar"]
等价于:
lein with-profile uberjar uberjar
即用 :uberjar profile 来执行 uberjar 任务(触发 AOT 编译)。
AOT 编译与打包
AOT 即 Ahead-Of-Time(提前编译)。正常情况下,Clojure 代码是在 JVM 启动时即时编译的。而 AOT 则是在构建阶段就把 Clojure 代码编译成 .class 文件。
为什么需要 AOT?
当你用 lein run 运行项目时,Leiningen 会启动 JVM、加载 Clojure 运行时、即时编译你的代码,然后调用 -main 函数——这个过程依赖 Leiningen 的运行时支持。
但当你想用 java -jar greeting-api.jar 直接运行一个 JAR 包时,JVM 需要一个标准的 Java 入口方法:
public static void main(String[] args)
Clojure 的 -main 函数本身不是这样的入口。AOT 编译会将 :aot 配置中指定的所有命名空间编译成 .class 文件,其中包含符合 Java 入口点规范的 main 方法,这样 JVM 就能直接运行了。
为什么只在 uberjar profile 中使用?
AOT 有几个缺点,不适合在开发时使用:
- 编译变慢:每次都要编译所有命名空间
- 过期的
.class文件:修改代码后如果旧的.class文件没清理干净,可能引发难以排查的 bug - 产物更大:
target/目录里多出一堆文件
因此标准做法是把 AOT 放在 :uberjar profile 中(这也是 lein new app 生成的默认配置):
:profiles {:uberjar {:aot :all
:omit-source true}}
打包 greeting-api
确保 src/greeting_api/core.clj 中有 (:gen-class) 声明(前面已经添加),然后执行:
lein build # 等价于: lein with-profile uberjar uberjar
实际输出:
Compiling greeting-api.core Created /tmp/greeting-api/target/greeting-api-0.1.0-SNAPSHOT.jar Created /tmp/greeting-api/target/greeting-api-0.1.0-SNAPSHOT-standalone.jar
验证打包结果:
java -jar target/greeting-api-0.1.0-SNAPSHOT-standalone.jar
实际输出:
Hello, World!
[注] standalone.jar 就是 uberjar——它把 Clojure 运行时和所有依赖都打进了同一个 JAR 文件,可以直接用 java -jar 运行,无需安装 Leiningen 或 Clojure。
总结
通过这篇笔记,我们用一个 greeting-api 项目演示了 Leiningen 的完整工作流:
- **安装与创建**:用
lein new app创建项目,注意命名空间-与文件路径_的转换 - **依赖与运行**:在
project.clj中声明依赖,用lein run和lein test运行和测试 - **Profiles**:用
:dev放开发工具,用:uberjar做 AOT 编译,保持生产包干净 - **Plugins**:通过插件扩展功能(如
lein-ring运行 Web 服务) - **Aliases**:用
:aliases定义快捷命令,统一团队工作流 - **打包**:用
lein uberjar生成可独立运行的 JAR 文件