Mill Build for Java Devs
Mill has its origin in the Scala world. However, it is well suited to build Java projects. Recently the official Mill documentation gained a growing Java section with many examples. So, I’m keeping this post short, as the official documentation has more information than I can cover.
You might be scared that Mill builds are written in Scala. Luckily, Mill is conservative with its Scala use to constructs most Java developers are already familiar with.
When to Consider Mill?
As I mentioned in my first Mill build, I do not recommend to default to Mill for projects which build well with Maven. Start considering Mill when feel like you are working "against" Maven instead of with Maven. Usually this arises if there are multiple aspects in you build that do fit well into a Maven lifecycle:
Build (many) parts with other tools. Examples:
Compile a web front-end and package
Build native binaries
Niche tooling, company internal tooling which is part of the build
Build the same source against different combination of libraries.
Complex pipeline which generates more resources or source code.
Complex assembling of things way after the Java things are build.
You benefit from caching for many steps.
The build speed with Maven is an issue.
Of course you pay a price. The biggest drawbacks:
IDE support is weak. However, add some complex Maven plugin setup and your IDE also struggles.
Mill is not declarative at all.
Changing the build file requires compiling it.
You must learn some Scala. However, other build tools might also bring their own language. Ex. Groovy/Kotlin in Gradle
Scala of the Whirlwind Tour Explained
Here is a bit more Scala background for the example in the whirlwind tour:
import mill._ // These are imports. In Java this would be import mill.*
import scalalib._
// A object is an object, yes. Its a direct way to create a single object, similar to a Singleton.
// 'with' is the 'implements' in Java.
object demo extends RootModule with JavaModule{
// A 'def' declares a method. In Mill a method will be available as Task
// The 'Agg' is class from mill to aggregate things. Think `new Agg` in Java. The `new` is usually left out in Scala.
// The `ivy` is a String interpolator. It basically calls the `ivy` String processor to interpret the string as a dependency
override def ivyDeps = Agg(
ivy"org.json:json:20240303",
ivy"com.h2database:h2:2.2.224")
}
In general Mill
concepts are quite familiar for a Java developer:
Methods for tasks, which are declared with
def taskName = …
The inheritance is
extends JavaModule
. If more modules are mixed in, then it isextends JavaModule with MyExtra with FormatterSupport
Otherwise you can call methods etc like regular code. (Mostly, I’ll plan to explain more in the next blog post)
Otherwise, when you encounter a Scala constructs: You find their meaning after a quick search.
Less Plugins, Call Libraries/Tools Directly
If you come from the Maven world, you might search for a plugin to things beyond the basic Java build. There are plugins for Mill. However, often you do not a plugin. Instead you call the tool / library you need directly.
Example: Replacing frontend-maven-plugin
For example, you use NodeJS for the front-end part of your web app.
To integrate that with Maven, there is the frontend-maven-plugin
plugin.
Therefore, you maybe try to look for a similar plugin in Mill
.
However, calling external tools in Mill is easy and therefore you can get by without a plugin at all:
Here is an example of building a front-end with node for your Java project:
object `front-end` extends Module {
def nodeJsVersion = "v20.17.0"
// Important for Mill, specify all Source code, including config
// Otherwise Mill will skip build steps because it didn't see source changes.
def packageConfig = T.sources(millSourcePath / "package.json", millSourcePath / "package-lock.json")
def sources = T.sources(millSourcePath/"src",millSourcePath/"css")
// Most node commands expect that the dependencies are already installed.
def installPackages = T {
// Important: This task depends on the package description.
val packages = packageConfig()
val npm = nodeJsDist().path / "bin" / "npm"
os.proc(npm, "install").call(cwd = millSourcePath, stdout = Inherit)
packages
}
def buildFrontEnd = T {
// node build process expects packages to be installed
val _ = installPackages()
val _ = sources()
val npm = nodeJsDist().path / "bin" / "npm"
os.proc(npm, "run", "build-my-frontend").call(cwd = millSourcePath, stdout = Inherit)
PathRef(millSourcePath / "public")
}
// Download node first. (Linux only for this demo)
def nodeJsDist = T {
val downloadPath = s"https://nodejs.org/dist/$nodeJsVersion/node-$nodeJsVersion-linux-x64.tar.xz"
val tarFile = mill.util.Util.download(downloadPath, RelPath("node.tar.xz"))
os.proc("tar", "-xf", tarFile.path).call(cwd = T.dest)
PathRef(T.dest / "node-v20.17.0-linux-x64")
}
}
Example: Using a library to Render Markdown
Even further, you can include Java libraries into your build script. For example use a Markdown library to generate the documentation.
// The $ivy prefix will include this library into this build file
import $ivy.`org.commonmark:commonmark:0.22.0`
import org.commonmark.node._
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
object docs extends Module{
// regular instance for the markdown library
val parser = Parser.builder.build
val renderer = HtmlRenderer.builder.build
def source = T.source(millSourcePath)
def markdownFiles = T{
os.list(source().path,sort = true).filter(p=>p.lastOpt.exists(n=>n.endsWith(".md")))
}
def html = T{
for(file <- markdownFiles()){
val content = os.read(file)
val document = parser.parse(content)
val html = renderer.render(document)
val htmlFile = file.lastOpt.getOrElse(???) + ".html"
os.write(T.dest/htmlFile,html)
}
PathRef(T.dest)
}
}
Summary
Mill is a great build system for Java projects. Yes, you’ll have to learn some Scala instead of Kotlin/Groovy/CProgramming-via-XML. In return you get a build System which shines if you have various build steps which go beyond box standard Java builds.