- 构建工具
- CLI
【免费下载链接】leiningen
Moved to Codeberg; this is a temporary convenience mirror
导读
本文以leiningen-core模块为核心,系统讲解 Leiningen 构建工具的底层实现机制:任务(task)如何被解析与调用、project.clj如何被读取并应用 profile、类路径如何计算,以及项目代码为何必须通过子进程隔离执行。读完本文,你将理解lein test、lein repl等命令背后从-main入口到项目 JVM 的完整调用链,掌握:eval-in、:prep-tasks、:java-cmd等关键配置项的真实语义,并学会如何为自己的 Leiningen 插件选择正确的执行模式。
leiningen-core 是什么
Leiningen 由两个仓库层级构成:主项目(leiningen)与核心库(leiningen-core)。主项目目录下的src/leiningen/存放内置任务(如compile.clj、test.clj、uberjar.clj)与启动脚本,而 leiningen-core 则是被主项目依赖的独立 Clojure 库,承载了 Leiningen 的"引擎"部分——即任务执行实现、项目配置解析和各类辅助函数。
从 leiningen-core/project.clj 可以看到它的依赖边界:它依赖clojure、bultitude(类路径上的命名空间扫描)、classlojure(类加载器隔离)、robert/hooke(hook 机制)、pomegranate(Maven/Aether 依赖解析)与wagon-http等。其中clj-commons/pomegranate 1.2.23是核心:所有 Maven 依赖的解析、下载与本地仓库管理都经由它与 Aether 交互。
核心库的五个命名空间构成其骨架,各自职责如下(对应 src/leiningen/core 目录):
| 命名空间 | 职责 |
|---|---|
leiningen.core.main | -main入口点,以及apply-task、resolve-task等任务处理函数 |
leiningen.core.project | 提供read与defproject,从project.clj读取项目 map;负责应用 profile、加载插件 |
leiningen.core.classpath | 计算项目类路径,处理 Maven 依赖与 checkout 依赖 |
leiningen.core.eval | 提供eval-in-project,实现项目代码与 Leiningen 自身代码的隔离 |
leiningen.core.user | 处理用户级配置(~/.lein目录、用户 profile、gpg 凭证等) |
任务调度的完整调用链
启动流程:从-main到任务分发
当在终端敲下lein <task>时,实际执行的是 main.clj 中的-main。其流程为:
- 初始化动态类加载器并注册 Wagon 工厂(
init-dynamic),同时加载用户配置(user/init); - 判断当前目录是否存在
project.clj:存在则调用project/read读取项目 map,不存在则通过default-project生成一个不依赖项目的默认 map(此时:eval-in为:leiningen、:prep-tasks为空); - 若项目声明了
:exact-lein-version或:min-lein-version,则校验 Leiningen 版本(见verify-exact-version与verify-min-version); - 配置 HTTP 代理(
configure-http); - 调用
resolve-and-apply完成最终的任务分发。
任务即命名空间中的函数
Leiningen 的任务本质上就是以任务名命名的函数,定义在leiningen.<task-name>命名空间中,通常接收项目 map 作为第一个参数,但也可以通过^:no-project-needed元数据声明"无需项目也可运行"(如help、version)。
resolve-task通过lookup-task-var依次尝试解析leiningen.plugin.<name>与leiningen.<name>命名空间中的同名函数(见 main.clj)。随后apply-task(main.clj)完成三件事:
- 检查任务所需的项目是否存在(否则
abort); - 用
matching-arity?校验参数个数与函数的 arglist 是否匹配(含可变参数&的处理); - 将项目 map 与命令行参数
apply到任务函数上。
resolve-and-apply还会先做参数预处理:task-args处理内置别名(如-o展开为with-profile +offline、cp指向classpath,完整别名表见 main.clj),parse-options将--foo/:foo风格参数解析为关键字键值 map(例如--beef rare变为{:--beef "rare"})。
拼写纠错与任务发现
tasks函数利用 bultitude 扫描类路径上所有leiningen.*命名空间,与内置任务清单(stock-tasks)合并去重;当用户敲错任务名时,task-not-found会基于 Damerau–Levenshtein 编辑距离(见distance实现)给出 "Did you mean this?" 的纠错建议。这也解释了为何第三方插件只需把命名空间命名为leiningen.xxx即可被 Leiningen 当作任务发现。
别名递归解析
lookup-alias会递归展开别名:项目:aliases、内置别名表、以及非项目场景下的用户 profile 别名都会被依次解析,直到找到一个真实任务名。别名向量还可以包含:project/<key>形式的占位符,在splice-into-args阶段被替换为项目 map 中对应的值。
项目配置:defproject与read
project.clj 如何变成项目 map
一个典型的project.clj顶部调用defproject宏。该宏(project.clj)把参数列表转成关键字参数 map,通过unquote-project支持在配置中直接使用~求值(例如:twelve ~(+ 6 2 4),见测试资源 p1.clj),然后调用make构造项目 map 并def到project符号上——项目根目录取自*file*的父目录。
read(project.clj)则读取文件后调用init-project完成全套初始化;若只想读原始 map 而跳过插件加载与 profile 应用,可使用read-raw。defproject与read两条路径覆盖了"用户手写配置"与"程序化读取"两种场景。
默认值与规范化
make首先将defaults(project.clj)与用户配置做带元数据的合并(meta-merge)。defaults中定义的默认值包括:
:source-paths ["src"]、:resource-paths ["resources"]、:test-paths ["test"];:compile-path "%s/classes"、:target-path "target"、:native-path "%s/native";:prep-tasks ["javac" "compile"];:eval-in :default(随后会被解析为:subprocess或:leiningen);:release-tasks的默认发布流水线(vcs assert-committed→ 改版本 → commit → tag → deploy → …);:offline?直接取自LEIN_OFFLINE环境变量;:uberjar-merge-with等打包相关默认值。
这些默认值大量使用了^:top-displace、^:replace、^:displace等元数据标记,它们共同构成了 Leiningen profile 合并系统的"优先级语言"(详见下文)。normalize-values还会把:repositories等键统一规范为[id {:url ...}]形式,并把旧的:eval-in-leiningen/:java-opts键迁移到:eval-in/:jvm-opts。
依赖的合并策略
defaults合并时,:dependencies、:plugins、:repositories等键使用带归约函数(:reduce)的空集合作为种子:依赖按 group/artifact 去重,同名依赖用meta-merge合并其选项(如:exclusions),仓库则按 id 归约合并。这让 profile 可以精准地"覆盖"或"增强"某个具体依赖,而不是整体替换列表。
Profile 系统:合并、优先级与元数据
Leiningen 的 profile 机制完全构建在meta-merge(project.clj)之上。它根据左右值携带的元数据决定合并策略:
^:replace:右值整体替换左值;^:displace:两者都标记时取靠右的值;^:top-displace:左侧标记时被右侧完全取代;^:prepend:右值拼接到左值之前(路径类默认值依赖此行为);^:reduce:调用元数据中绑定的归约函数;- map 递归合并、set 取并集、普通 coll 拼接,类型不匹配时给出警告并取右值。
默认激活的 profile 由default-profiles(project.clj)定义::default展开为[:base :system :user :provided :dev],因此:dev、:provided、:user默认生效。read-profiles的查找顺序是:Leiningen 内置默认 → 系统级/etc/leiningen/profiles.clj(Windows 为AllUsersProfile)→ 用户级~/.lein/profiles.clj与~/.lein/profiles.d/*.clj→ 项目根目录profiles.clj→ 项目 map 中的:profiles键。
set-profiles/init-profiles完成最终计算:展开 profile(复合 profile 可嵌套展开)、合并出新的项目 map、将:compile-path与:native-path重定位到 profile 作用域下的target/<profile-hash>子目录(profile-scope-target-path),并执行load-plugins、load-certificates、load-hooks、apply-middleware等副作用初始化(activate-middleware)。插件还可以通过profiles.clj资源声明自己的 profile 命名空间(plugin.<name>/<profile>)。
类路径计算:Maven 依赖与 checkout 依赖
leiningen.core.classpath的入口是get-classpath(classpath.clj),它按以下顺序拼接项目类路径:
:test-paths、:source-paths、:resource-paths;:compile-path;- checkout 依赖的路径(
checkout-deps-paths); - 解析后的 Maven 依赖 jar 绝对路径。
依赖解析经由resolve-managed-dependencies委托给 Pomegranate/Aether:get-dependencies将项目中的:repositories、:local-repo、:offline?、:update、:mirrors等键组装成 Aether 参数(default-aether-args),并挂载 pedantic 会话以支持:pedantic?依赖冲突检查。解析失败时会给出友好提示("可能是 :dependencies 拼写错误、文件权限或网络问题"),并在未离线时自动重试一次离线解析。
checkout 依赖是开发工作流的利器:把依赖项目 clone 到checkouts/目录后,其源码路径(默认共享:source-paths、:test-paths、:resource-paths、:compile-path)会直接进入当前项目类路径,无需先lein install依赖项目。实现上read-checkouts(project.clj)读取每个checkouts/子目录中的project.clj,checkout-deps-paths通过:checkout-deps-shares键决定共享哪些路径,并用*seen*集合防止循环依赖。仓库中的 sample/checkouts/sample2 就是一个可运行的真实示例。
此外,:java-agents依赖会被提取为-javaagent:参数,原生依赖(:native-prefix)会被解压到:native-path,仓库中的extract-native-dependencies通过outdated-swap!缓存比对避免重复解压。
项目隔离:eval-in-project与子进程机制
为什么必须隔离
Leiningen 自身运行在一个 JVM 与特定版本 Clojure 上,但目标项目可能依赖不同版本的 Clojure 或其他库。若不隔离,Leiningen 的类路径就会污染项目代码,项目也可能反向污染 Leiningen。因此任何需要在项目上下文中执行的代码(AOT 编译、测试运行、REPL)都必须经由eval-in-project。
启动前准备:prep
在启动子进程之前,prep(eval.clj)会依次完成:
- 创建
:compile-path、源码/资源/测试目录; - 写出
pom.properties(版本、groupId、artifactId、git revision); - 解析 managed dependencies;
- 运行
:prep-tasks中列出的所有任务(默认["javac" "compile"],项目可通过defproject或 profile 追加,如:prep-tasks ^:replace ["compile" "mytask"])。
:prep-tasks中的任务必须是"幂等且廉价"的——它们可能在每次执行项目代码前都被调用,因此设计上必须允许在无变更时快速跳过(这正是compile任务基于时间戳增量的原因)。
三种执行模式::eval-in的值语义
eval-in-project本身是一个基于:eval-in键分发的 multimethod(eval.clj),支持以下取值:
:eval-in值 | 执行方式 | 隔离强度 | 适用场景 |
|---|---|---|---|
:subprocess(默认) | 启动全新java子进程 | 最强,完全隔离 | 常规项目任务、测试、REPL |
:classloader | 在 Leiningen 进程内用 classlojure 新建类加载器 | 较强 | 需要进程内共享状态但隔离类路径时 |
:leiningen | 直接在 Leiningen 进程内eval | 无隔离 | Leiningen 插件(默认场景) |
:nrepl | 连接.nrepl-port上的 nREPL 服务求值 | 取决于外部进程 | 已运行 REPL 时复用 |
:trampoline | 延迟到启动脚本层执行,避免双 JVM 栈开销 | 强 | lein trampoline场景 |
:pprint | 仅打印 java 命令、类路径、JVM 参数与表单 | — | 调试用 |
当project.clj中设置:eval-in-leiningen true(旧写法,现已归一化为:eval-in :leiningen)时,代码直接在 Leiningen 自身进程内求值。这通常用于插件开发——插件本就要在 Leiningen 内部运行,强制隔离反而没有意义。仓库自身的 p1.clj 与 leiningen 主项目正是这样做的。
子进程的构造细节
默认的:subprocess模式通过shell-command(eval.clj)拼出完整命令:
<java-cmd> <classpath 参数> <JVM 参数> clojure.main -i <init-file>其中:
- java 可执行文件:优先取
:java-cmd项目键或JAVA_CMD环境变量,因此项目 JVM 可以与 Leiningen 自身 JVM 版本不同; - 类路径:由
classpath-arg依据classpath/get-classpath计算,:bootclasspath true时改用-Xbootclasspath/a:; - JVM 参数(
get-jvm-args):包括-Dfile.encoding、JVM_OPTS环境变量、项目:jvm-opts(旧键:java-opts已迁移)、-Dclojure.compile.path、<name>.version、-Dclojure.debug、:java.library.path(含原生库路径)以及 HTTP 代理设置; - init 文件:表单以
pr-str写入临时文件(或:target-path下的校验和缓存文件,配合:preserve-eval-meta true可保留元数据),由clojure.main -i加载执行。
子进程与 Leiningen 主进程只能通过文件系统、socket 与退出码通信(eval-in的sh会把子进程 stdout/stderr 实时泵回主进程,:nrepl模式则走 nREPL 协议)。测试 eval.clj 中的test-eval-in-project在:subprocess、:leiningen、:classloader三种模式下验证了同一表单的执行结果一致,test-get-jvm-args-with-proxy-settings则验证了代理参数被正确注入子进程 JVM。
为什么插件默认走:leiningen
Leiningen 插件(:plugins键中的库)在load-plugins阶段就被解析并直接加入 Leiningen 自身类路径(pomegranate/add-dependencies+:add-classpath?),因此插件的任务函数运行在 Leiningen 进程中,天然不需要子进程隔离。这也是:eval-in :leiningen成为插件标配的原因。
用户级配置:leiningen.core.user
leiningen.core.user处理所有用户级配置(user.clj):
- 配置目录解析:
leiningen-home依次检查LEIN_HOME环境变量、~/.lein、XDG 规范的~/.config/leiningen,两者同时存在时给出警告并优先使用~/.lein; - init.clj:
init在启动时加载~/.lein/init.clj(若存在); - 用户 profile:
profiles合并~/.lein/profiles.clj与profiles.d/*.clj,可用LEIN_NO_USER_PROFILES环境变量整体禁用;重名 profile 会报错; - gpg 凭证:
credentials解密~/.lein/credentials.clj.gpg(经 gpg--decrypt),resolve-credentials支持:env(从LEIN_<NAME>环境变量取值)、:gpg(从凭证文件取值)与字面量三种取值方式,并为:authprofile 提供:repository-auth按 URL(含正则匹配)注入仓库凭证; - gpg 程序:可用
LEIN_GPG覆盖,gpg-available?探测其存在性。
总结:一条命令的完整旅程
把以上各层串起来,一次lein test的完整执行路径是:
leiningen.core.main/-main初始化类加载器与用户配置;project/read读取project.clj→defproject宏构建项目 map →init-project应用默认/用户/系统/项目 profile,加载插件、证书、hook 与 middleware;resolve-and-apply解析别名、规范化参数、校验 arity,找到leiningen.test命名空间中的任务函数并调用;- 测试任务调用
eval-in-project→prep运行:prep-tasks(javac、compile)→ 按:eval-in模式启动子进程 → 子进程以项目自己的类路径与 JVM 参数执行测试代码 → 退出码回传主进程。
leiningen-core的价值正在于此:它把"构建工具的引擎"从"具体的构建命令"中剥离出来,任务、配置、类路径与隔离执行这四件事各司其职,使 Leiningen 内置任务与第三方插件得以共享同一套经过验证的底层基础设施。读者若要深入源码,建议从 main.clj 的-main出发,沿resolve-and-apply→eval-in-project→shell-command这条主线,再配合 project.clj 的meta-merge与 classpath.clj 的get-classpath展开横切面,即可完整掌握这套构建引擎的设计全貌。
延伸阅读
- Leiningen 官方 README:项目整体介绍与 profile 使用说明;
- 插件编写指南:任务函数的签名约定与插件打包规范;
- Profile 参考:
^:replace/^:displace元数据语法的完整说明; - 测试源码:
project.clj、eval.clj、classpath.clj等测试文件展示了各模块的可验证行为; - 测试资源 p1.clj:一个包含
~求值与:eval-in-leiningen的最小project.clj样例。
- 构建工具
- CLI
【免费下载链接】leiningen
Moved to Codeberg; this is a temporary convenience mirror
相关推荐
Nacos 任务执行引擎(Task Execution)深度解析:延迟任务、执行任务与领域调度机制
Nacos 任务执行引擎(Task Execution)深度解析:延迟任务、执行任务与领域调度机制 本文以 Nacos 官方设计规范 Foundation Ta
后端微服务配置中心服务注册发现云原生Winhance深度解析:C构建的Windows系统优化架构与实现技术
Winhance深度解析:C 构建的Windows系统优化架构与实现技术 Winhance是一款基于C 和WPF技术栈开发的Windows系统优化工具,专为技术
桌面应用系统优化Jenkins构建系统:任务调度与执行引擎
Jenkins构建系统:任务调度与执行引擎 Jenkins构建系统是一个高度可扩展的分布式持续集成平台,其核心功能围绕任务调度与执行引擎展开。本文深入解析了Je
后端CI/CDDevOps构建工具任务调度
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考