The Mill Build Tool GitHub Blog API Issues Discuss

The Mill Build Engineering Blog

Edit this Page

Contents

Mill as a Direct Style Build Tool

Contents

Mill 是一个针对 Java/Scala/Kotlin 的 JVM 构建工具,并且有潜力服务于目前由 Bazel 服务的大型单代码仓库(monorepo)。 Mill 在其用户中拥有良好的吸引力,基准测试表明其构建速度比竞争对手快 3-6 倍,并且其独特的 "direct-style" 设计使其易于使用和扩展。本文讨论了 Mill 中一些最有趣的设计决策,以及它如何使 Mill 在市场上脱颖而出。

什么是构建工具?

构建工具是一个程序,它协调编译、打包、测试和运行代码库所需的各种任务:也许你需要运行编译器,下载一些依赖项,打包可执行文件或容器。虽然小型代码库可以使用 shell 脚本一次运行每个任务,但随着代码库的增长,这种幼稚的方法会越来越慢,并且构建任务必然变得更加繁多和复杂。

为了防止开发陷入停滞,你需要开始跳过任何时候都不需要的构建任务,并缓存和并行化你需要的任务。这通常从 shell 脚本中的一些临时的 if-else 语句开始,但是手动维护跳过/缓存/并行化逻辑既繁琐又容易出错。在某些时候,使用专门构建的工具来为你完成它变得有价值,这就是你转向像 Maven, Make, Mill, 或者 Bazel 这样的构建工具的时候。对于本文,我们将主要讨论 Mill。

什么是 Mill?

Mill 构建工具始于 2017 年,是我在学习使用 Google 的 Bazel 构建工具时发现的一些想法的探索。乍一看,Mill 看起来与其他你可能熟悉的构建工具类似,在项目的根目录中有一个 build.mill 文件,用于定义模块的依赖项和测试设置:

package build
import mill._, javalib._
object foo extends JavaModule {
 def mvnDeps = Seq(
  mvn"net.sourceforge.argparse4j:argparse4j:0.9.0",
  mvn"org.thymeleaf:thymeleaf:3.1.1.RELEASE"
 )
 object test extends JavaTests with TestModule.Junit4
}

scalacopy iconCopied! 语法可能有点陌生,但任何熟悉编程的人都可能猜到这个构建意味着什么:一个带有两个 ivy 依赖项 argparse4jthymeleafJavaModule,以及一个支持 Junit4test 子模块。然后可以从命令行编译、测试、运行或将此构建打包到程序集中:

> /mill foo.compile
compiling 1 Java source...

> /mill foo.run --text hello
<h1>hello</h1>

> ./mill foo.test
Test foo.FooTest.testEscaping finished, ...
Test foo.FooTest.testSimple finished, ...
0 failed, 0 ignored, 2 total, ...

> ./mill show foo.assembly
".../out/foo/assembly.dest/out.jar"

> ./out/foo/assembly.dest/out.jar --text hello
<h1>hello</h1>

copy iconCopied! Mill 最初是一个与 sbt 竞争的 Scala 构建工具,到 2023 年,它在 Scala 社区中达到了约 5-10% 的市场份额 (Jetbrains Survey, VirtusLabs Survey)。它最近增长了对 Java 的一流支持,展示了比现有的 Java 构建工具(如 Maven 或 Gradle)快 3-6 倍 的速度。 Mill 还获得了对 Java 邻近平台(如 KotlinAndroid)的实验性支持,并已展示出能够扩展到支持更远的工具链(如 TypescriptPython)的能力。

Mill 在大型构建方面也表现出色:它的构建逻辑可以拆分为多个文件夹,进行增量编译,延迟初始化,并自动缓存和并行化。这意味着即使是大型代码库也可以保持快速和响应:Mill 自己的构建可以轻松管理超过 400 个模块,并且该工具可能可以毫无问题地处理数千个模块。

构建工具的 React.js

我们上面简要介绍了 Mill 是什么,但仍然存在一个问题:为什么是 Mill?为什么不是其他 100 个构建工具中的一个?

Mill 的独特之处在于它与流行的 Javascript UI 框架 React.js 共享许多核心设计决策。我是 React 的第一批外部用户之一,我在 2014 年将其引入 Dropbox,虽然今天人们抱怨它,但 React 确实是对 Javascript UI 实现方式的一场革命。过去需要数周的 UI 流程突然只需要几天,只需要以前实现所需的一小部分代码和复杂性

React 最重要的两项创新是:

  1. 让用户编写 "direct style" 代码来定义他们的 UI - 直接返回你想要的 HTML 结构的 Javascript 函数 - 而不是注册回调以响应事件来改变 UI 的 "code behind" 方法
  2. 为你的 UI 使用单一的 "通用" 编程语言,而不是将你的逻辑拆分为多个专用领域特定语言

虽然 React 做了很多聪明的事情 - 虚拟 DOM 差异, JSX, de/re-hydration,等等 - 所有这些都只是为了服务于这两个基本思想。例如,在 Dropbox,我们使用 React 多年,没有 JSX,并且许多受 React 启发的后来的框架提供了类似的体验,但使用其他技术来替换虚拟 DOM 差异。此外,React 不仅限于 HTML UI,相同的技术也被用于管理 移动应用 UI, 终端 UI,以及许多其他场景

构建工具和交互式 UI 一方面不同,但另一方面非常相似:你正在尝试将大型有状态系统(无论是 HTML 页面还是文件系统构建工件)更新到你所需的状态,以响应输入的变化(无论是用户点击还是源文件编辑)。就像 2014 年的 React 一样,这两个想法在今天的 2024 年在构建工具中并不普遍。但是许多相同的下游好处适用,并且这些想法赋予 Mill 作为构建工具的一些独特的属性。

Direct-Style 构建

React.js 的一个关键方面是,你编写代码以 "直接" 生成你的 Web UI:

在 React 之前,你总是需要权衡:你是重新渲染每次更新的整个 UI(这很容易天真地实现,但对用户来说是浪费和破坏性的),还是进行细粒度的 UI 更新(这很难实现,但高效且用户友好)。React 消除了这种权衡,让开发人员编写 "天真" 代码,就好像他们正在重新渲染整个 UI,同时自动对其进行优化以实现高性能并提供一流的用户体验。

Mill 作为构建工具的方法类似:

之前我们看到了一个使用内置模块类型(如 JavaModule)的 hello-world Mill 构建,但是如果我们删除这些内置类,我们可以看到 Mill 在幕后是如何工作的。考虑以下 Mill 任务,这些任务定义了一些源文件,使用 javac 可执行文件将它们编译成 classfile,然后使用 jar 可执行文件将它们打包到一个程序集中:

def mainClass: T[Option[String]] = Some("foo.Foo")
def sources = Task.Source("src")
def resources = Task.Source("resources")
def compile = Task {
 val allSources = os.walk(sources().path)
 os.proc("javac", allSources, "-d", Task.dest).call()
 PathRef(Task.dest)
}
def assembly = Task {
 for(p <- Seq(compile(), resources())) os.copy(p.path, Task.dest, mergeFolders = true)
 val mainFlags = mainClass().toSeq.flatMap(Seq("-e", _))
 os.proc("jar", "-c", mainFlags, "-f", Task.dest / "assembly.jar", ".")
  .call(cwd = Task.dest)
 PathRef(Task.dest / "assembly.jar")
}

scalacopy iconCopied! 此代码定义了以下任务图,其中框是任务,箭头表示它们之间的_数据流_: G sources sources compile compile sources->compile assembly assembly compile->assembly resources resources resources->assembly mainClass mainClass mainClass->assembly

此示例不使用 Mill 的任何内置支持来构建 Java 或 Scala 项目,而是使用 Mill 任务和 javac/jar 子进程 "从头开始" 构建管道。我们定义 Task.Source 文件夹和依赖于它们的普通 Task,完全在我们自己的代码中实现。

关于这段代码,有两件事值得注意:

  1. 它看起来几乎与你不使用构建工具编写的等效 "天真" 代码相同!如果你删除 Task{…​} 包装器,你可以运行该代码,它的行为就像一个天真脚本,每次都从上到下运行,并从头开始生成你的 assembly.jar。但是 Mill 允许你将这种天真的代码转换为具有并行性、缓存、失效等等的构建管道。
  2. 你根本看不到任何与并行性、缓存、代码中的失效相关的逻辑!没有 mtime 检查,没有计算缓存键,没有锁,没有磁盘上数据的序列化和反序列化。 Mill 会自动为你处理所有这些,因此你只需要编写你的 "天真" 代码,Mill 将免费提供所有 "构建工具的东西"。

这种 direct-style 代码有一些令人惊讶的好处:IDE 通常不了解注册的回调如何递归地触发彼此,但它们_确实_了解函数调用,因此它们应该能够通过简单地跟踪这些函数来无缝地上下导航你的构建图。下面,我们可以看到 IntelliJ 将 compile 解析为 build.foo 中的确切 def compile 定义,如果我们要查看它做什么,可以跳转到它: IntellijDefinition 在前面的 JavaModule 示例中,IntelliJ 能够看到 def mvnDeps 配置覆盖,并找到父类层次结构中的确切覆盖定义: IntellijOverride 这种 "direct style" 不仅使 IDE 可以轻松地导航你的构建:人类程序员_也_习惯于进出函数调用、上下类层次结构等等。因此,对于配置或维护其构建系统的开发人员来说,Mill 的 direct style 意味着他们更容易理解正在发生的事情,特别是与你可能期望从构建工具中获得的经典 "回调森林" 相比。但是,这两个好处都需要 IDE 和人类首先理解代码,这导致了第二个主要设计决策:

使用单一的通用语言

React.js 使使用者可以使用 Javascript 来实现他们的 HTML UI。虽然现在在 2024 年这是一种常见的方法,但很难夸大当时这个设计决策的争议性和不寻常性。

在 2014 年,Web UI 是用一些 HTML 模板语言 实现的,带有单独的 CSS 源文件,以及钩入的 "code behind" Javascript 逻辑。这允许关注点分离:图形设计师可以编辑 HTML 和 CSS,而无需了解 Javascript,程序员可以编辑 Javascript,而无需成为 HTML/CSS 方面的专家。因此,用三种语言在三个单独的文件中编写前端代码是最佳实践,并且自网络诞生以来的二十年来一直是这样。

React.js 颠覆了所有这些:一切都是 Javascript!UI 组件首先是 Javascript 对象,包含返回 HTML 代码片段(实际上_也_是 Javascript 对象)的 Javascript 函数。CSS 通常在使用站点内联,可能带有从 CSS-in-JS 库中获取的常量。这与之前二十年的 Web 开发最佳实践完全背道而驰。

虽然存在争议,但这种方法有两个巨大的优势:

  1. 它打破了 HTML/CSS/JS 之间的硬语言障碍,允许更灵活的方式来组织和分组代码,以便满足特定 UI 的需求。虽然看似微不足道,但拥有一个文件中的一种语言,其中包含你需要了解的关于 UI 组件的所有信息,而不是需要在三种不同语言的三个文件之间切换,这会产生巨大的差异。
  2. 它删除了单独的二级 "模板语言"。虽然 "柏拉图式的理想" 是人们编写 HTML/CSS/JS,但 HTML 通常最终成为 Jinja2, HAML, 或者 Mustache 模板,并且 CSS 通常最终被 SASS 或者 LESS 替换。虽然 Javascript 绝非完美,但拥有一种 "真正的" 编程语言中的所有内容,与在三种不同语言之间切换相比,每种语言都有自己半生不熟的语言特性(如 if-else、循环、函数等)来说,是一股新鲜空气。

构建工具的故事类似:传统智慧一直是在一些有限的 "构建语言" 中实现你的构建逻辑,过去通常是 XML(例如,Maven, MSBuild),现在通常是 JSON/TOML/YAML(例如,Cargo,逻辑拆分到单独的 shell 脚本或插件中。虽然这可行,但总是存在问题:

  1. 就像 Web 开发一样,构建工具_也_将逻辑拆分到多种语言之间。Yaml 中的模板化 Bash 是一种常见的结果,Bazel 使你编写 在伪 Python 中的 make-interpolated Bash,Maven 使你选择 XML+Java 来编写插件,或者在 XML 中编写 Bash Ant 脚本。大多数使用 "简单" 配置语言的构建工具不可避免地会发现逻辑被推送到构建中的 shell 脚本中,或者整个构建工具本身被包装在 shell 脚本中,以提供项目所需的灵活性
  2. 这些 "简单的构建语言" 总是以简单开始,但最终会增长真正的编程语言特性:不仅是 if-else、循环、函数、继承,还有包管理器、包存储库、分析器、调试器等等。这些总是临时的,以它们自己奇怪和特有的方式设计和实现,并且通常不如真正的编程语言提供的相同特性或工具。

“配置元数据变成模板语言变成通用语言” 是一个古老的故事。无论是使用 Jinja2 进行 HTML 模板化,使用 Github Actions Config Expressions 进行 CI 配置,还是像 Cloudformation FunctionsHelm Charts 这样的基础设施即代码系统。虽然使用 "简单" 配置语言的诱惑力很强,但许多系统最终会增长如此多的编程语言特性,以至于你最好从一开始就使用通用语言。

Mill 遵循 React.js 的 "一种通用语言" 方法:

  1. Mill 任务只是方法定义
  2. Mill 任务依赖项只是方法调用
  3. Mill 模块只是对象

虽然这并非完全正确 - Mill 任务和 Mill 模块有一些额外的逻辑,这些逻辑对于处理缓存并行化和其他构建工具必需品是必要的 - 但这足够真实,以至于这些细节通常对用户完全透明。

这具有与 React.js 从始终使用通用语言中获得的相同好处:

  1. 你可以直接编写代码来连接和执行你的构建逻辑,所有这些都使用一种语言,而无需使用当选择不够灵活的配置语言时常见的嵌套在 YAML 中的嵌套在 Mustache 模板中的 Bash 的怪物。
  2. 你_已经知道_编程语言是如何工作的:不仅是条件循环和函数,还有类、继承、覆盖、类型检查、IDE 导航、包存储库和库生态系统(在 Mill 的情况下,你可以使用 Java 的 Maven Central 存储库中的所有内容)。Mill 让你直接使用真实的东西,而不是处理这些特性半生不熟的版本,这些特性是专门语言不可避免地会增长的。

例如,在 Mill 中,你可能不熟悉捆绑的库和 API,但你的 IDE 可以帮助你理解它们: IntellijDocs 如果你犯了一个错误,例如,你将 resources 拼写为 reources,你的 IDE 会立即为你标记它,即使在你运行构建之前: IntellijError 虽然所有 IDE 都很好地支持理解 JSON/TOML/YAML/XML,但对理解_特定工具的模板化-yaml-bash方言_的支持要少得多。即使是黄金标准的 IntelliJ 通常也只能提供编辑模板化-yaml-bash 配置文件基本的帮助。相比之下,对广泛使用的通用编程语言的 IDE 支持要扎实得多。

作为另一个例子,如果你需要在构建系统中使用生产质量的模板引擎,你可以选择自助餐。常见的 Java Thymeleaf 模板引擎可以通过单个导入来使用,流行的 Scalatags 模板引擎也是如此。你不再局限于构建工具内置的或互联网上的某人发布的第三方插件,而是可以触手可及地使用 JVM 生态系统中的任何库,并且可以以与在任何 Java/Scala/Kotlin 应用程序中完全相同的方式使用它们。

其他构建工具怎么样?

有些现有的构建工具使用了上述一些想法,但也许没有一个同时拥有这两个想法,这对于充分利用这些想法是必要的:

Mill 的 direct style 代码和通用语言的使用使其在构建工具中独一无二,就像 React.js 在 2014 年首次发布时在 UI 框架中独一无二一样。凭借这两个关键设计特性,Mill 使理解和维护你的构建比传统工具容易一个数量级,从而使项目构建民主化,因此任何人都可以贡献而无需成为专家。

Mill 的发展方向?

上面,我们讨论了 Mill 的一些独特设计决策,以及它们为用户提供的价值。在本节中,我们将讨论 Mill 如何适应更大的构建工具生态系统。我认为 Mill 有潜力比今天增长 10 倍到 100 倍。我认为 Mill 可以发展到三个主要领域:

现代 Java/JVM 构建工具

Mill 是一个 JVM 构建工具,JVM 平台托管着许多丰富的社区和生态系统:Java 人员、Android 这样的分支、Kotlin 和 Scala 这样的其他语言。所有这些生态系统都依赖像 Maven 或 Gradle 这样的工具来构建它们的代码,我相信 Mill 可以提供更好的替代方案。即使在今天,与现有的构建工具相比,使用 Mill 已经有很多优势:

  1. Mill 今天运行的等效本地工作流程比 Maven 快 3-6 倍,比 Gradle 快 2-4 倍,并且为你的构建的每个部分自动并行化和缓存
  2. Mill 今天提供了比 Maven 或 Gradle 更好的易用性,具有用于导航你的构建图并可视化你的构建正在做什么的 IDE 支持
  3. Mill 今天使扩展你的构建比 Maven 或 Gradle 容易 10 倍,直接使用你已经知道的相同 JVM 库,而不必受第三方插件的束缚

JVM 是一个灵活的平台,虽然 Java/Kotlin/Scala/Android 在表面上有所不同,但在底层存在大量的相似之处。像 classfile、jar、程序集、classpath、依赖管理和发布工件、IDE、调试器、分析器、许多第三方库这样的概念,在各种 JVM 语言之间都是共享和相同的。Mill 提供了 Java 和 Scala 的一流体验,并增加了对 Kotlin 和 Android 的支持。 Mill 的易于扩展意味着将新工具集成到 Mill 中需要几个小时而不是几天或几周。

在过去的 15-20 年里,我们学到了很多关于构建工具的知识,并且该领域已经得到了显着发展:

但是 Java/JVM 生态系统中没有真正利用这些更新的设计和技术的构建工具:像拥有构建图、自动缓存、自动并行化、无副作用构建任务等等这样的想法。虽然 Maven(来自 2004 年)和 Gradle(2008 年)一直在慢慢地朝着这些方向发展,但它们也受到其二十年的遗产的限制,这限制了它们的发展速度。

Mill 可能是现代 Java/JVM 构建工具:与 Maven 或 Gradle 相比,提供 10 倍的速度提升,10 倍的易用性,10 倍的扩展性。今天,Mill 已经提供了引人注目的 Java 构建体验。通过一些集中的努力,我认为 Mill 不仅可以是一个_好_选择,而且是 Java 项目未来_更好_的选择!

更简单的 Monorepo 构建工具

许多公司今天正在使用 Bazel。在我从我的硅谷网络采访的公司中,30 家中有 25 家正在使用或尝试使用 Bazel。Bazel 是一种非常强大的工具:它提供了 [沙箱](https://mill-build.org/blog/<https:/bazel.build/