Whirlwind Tour of Mill Build
Lets start a whirlwind tour. Last time we stopped at a basic Java app. Yes, Mill does of course support building Scala projects as well, but Mill works well for other builds as well.
class HelloWorld{
public static void main(String[] args){
System.out.println("I'm built with Mill")
}
}
import mill._
import scalalib._
object demo extends RootModule with JavaModule{
}
Add Dependencies!
The JavaModule
supports adding Maven Dependencies.
The word 'ivy' comes from the Apache Ivy dependency manager.
However, Mill uses another library to fetch Maven dependencies meanwhile.
import mill._
import scalalib._
object demo extends RootModule with JavaModule{
override def ivyDeps = Agg(
ivy"org.json:json:20240303",
ivy"com.h2database:h2:2.2.224")
}
Splitting Code Into Modules
I said Mill makes sense for complicated builds.
In Mill you use modules to split things up.
A module is any instance of a Module
sub-class. For example, we split up our project into
an api
and backend
like this:
object demo extends RootModule{
object api extends JavaModule{
override def ivyDeps = Agg(ivy"org.json:json:20240303")
}
object backend extends JavaModule{
override def moduleDeps = Seq(demo.api)
override def ivyDeps = Agg(ivy"com.h2database:h2:2.2.224")
}
}
This creates two modules.
By default, the source code also moves to the sub-directory with the same name as the sub-module.
So, the api
module source will be in the 'api' directory, and the 'backend' module source
in the backend
directory.
This will also be reflected in the command line. Each module will get its own sub-commands.
In our example:
$ mill resolve _ # Show the top level tasks
api # our api module
backend # our backend module
clean # built-in
init # built-in
# more built-in tasks
$ mill resolve api._ # Show the tasks for our api module
api.allIvyDeps
api.allSourceFiles
# ...
$ mill resolve backend._ # Show the tasks for our backend module
backend.allIvyDeps
backend.allSourceFiles
# ...
Note: The RootModule
places its commands at the top level.
If we use another module type, commands would be under demo._
etc. Modules build a strict tree hierarchy:
object demo extends Module{
// the api and backend, as above
}
Then it does result in:
$ mill resolve _ # Show the top level tasks
clean # built-in
demo # our module
init # built-in
# more built-in tasks
$ mill resolve demo._ # Show the tasks for our demo module
demo.api
demo.backend
# ...
$ mill resolve demo.api._ # Show the tasks for our api module
demo.api.allIvyDeps
demo.api.allSourceFiles
# ...
You also can list things across module boundaries by using a double underscore:
$ mill resolve demo.__ # Show everything from the demo module and its sub-modules
demo
demo.api
demo.api.allIvyDeps
# long list of demo.api tasks.
demo.backend
demo.backend.allIvyDeps
demo.backend.allSourceFiles
# long list of demo.backend tasks.
Anyway, in the rest of the this blog post we got back to use a RootModule
Adding Tests
So far Mill looks quite similar to Maven (with Scala instead of XML) or SBT. Testing is where starts to Mill diverge, because Mill has no 'scope' concept. Missing scopes is a 'feature' [1] to keep the amount of concepts low.
What you do in Mill is to create a tests module:
import mill.scalalib.TestModule.Junit5 // Import JUnit 5 support
// ...existing stuff
object demo extends RootModule{
object api extends JavaModule{
override def ivyDeps = Agg(ivy"org.json:json:20240303")
object tests extends JavaModuleTests with Junit5{}
}
// The backend module as above
}
Where should the test source be? Again, we can ask Mill for that:
./mill show api.tests.sources
[
"ref:v0:c984eca8:/home/roman/dev/private-dev/mill-demo/api/tests/src"
]
But what if you migrate and maybe have a Maven directory structure? Then use the MavenModule module instead:
object demo extends RootModule{
object api extends MavenModule {
override def ivyDeps = Agg(ivy"org.json:json:20240303")
object tests extends MavenModuleTests with Junit5{}
}
// The backend module as above
}
./mill show api.tests.sources
[1/1] show > [1/1] api.tests.sources
[
"ref:v0:c984eca8:/home/roman/dev/private-dev/mill-demo/api/src/test/java"
]
Of course, you can overwrite it yourself for any other directory: .build.sc
object api extends JavaModule{
override def ivyDeps = Agg(ivy"org.json:json:20240303")
object tests extends JavaModuleTests with Junit5{
override def sources = T.sources(api.millSourcePath / "test-src")
}
}
How do you run the tests? You call the test
task on that module:
$ ./mill api.tests.test [62/62] api.tests.test Test run started (JUnit Jupiter) Test MyTest#myTest() started Test MyTest#myTest() finished, took 0.01 ....
For tests you might also want to use the --watch
flag.
That flag will start watching the source code, and re-run the specified task
if the source changes:
$ ./mill --watch api.tests.test
[62/62] api.tests.test
Test run started (JUnit Jupiter)
# ... test results ...
Test run finished: 0 failed, 0 ignored, 2 total, 0.052s
Watching for changes to 11 paths and 7 other values... (Enter to re-run, Ctrl-C to exit)
# ..this changed, then:
[55/62] api.tests.compile
# compiles changes stuff, reruns tests etc
Test run finished: 1 failed, 0 ignored, 2 total, 0.056s
1 targets failed
api.tests.test 1 tests failed:
MyTest myTest()
Watching for changes to 11 paths and 7 other values... (Enter to re-run, Ctrl-C to exit)
Adding a front end build
Let’s say we need a web front-end, based on NodeJS tooling.
We want to bundle it with our backend. First, we create a build for the front end, which runs the external tooling:
object `front-end` extends Module{
import os.Inherit
// Declare the sources etc, This will allow mill do skip the front-end build if nothing changes
def sources = T.sources(millSourcePath / "src") // The 'millSourcePath' is by default ./front-end, like the module name.
def nodePackages = T.sources(
millSourcePath / "package.json",
millSourcePath / "package-lock.json")
def installNodeModules = T{
// npm will implicitly use the package.json / package-lock.json. Therefore, it depends on these source files
val config = nodePackages()
os.proc("npm","ci").call(cwd=millSourcePath, stdout = Inherit)
millSourcePath/"node_modules"
}
/**
* Build our front end with our favorite framework of the month ;)
*/
def build = T{
val nodeModules = installNodeModules() // install node packages
val sourceCode = sources() // Depends on the source code
os.proc("npm","run","build-frontend").call(cwd=millSourcePath, stdout = Inherit)
PathRef(millSourcePath/"public")
}
}
This allows use to build the front-end with Mill:
$ ./mill front-end.build
[2/4] front-end.installNodeModules
added 1 package, and audited 2 packages in 532ms
[4/4] front-end.build
> build-frontend
> mkdir -p ./output && echo 'Building front end...' && sleep 10 && echo "building hi" > ./output/output.js && echo 'Front end done'
Building front end...
Front end build complete!
The best thing is that Mill will track changes to the source and our package definitions. So, if we run the front-end build again, it will be near instant!
$ time ./mill front-end.build
[3/4] front-end.sources
________________________________________________________
Executed in 358.69 millis fish external
usr time 321.47 millis 403.00 micros 321.06 millis
sys time 65.86 millis 206.00 micros 65.66 millis
Based on this, we then easily integrate it into the rest of our build. Let’s package the front-end as part of the backend, which will serve it as static resources:
object backend extends JavaModule{
override def moduleDeps = Seq(demo.api)
override def ivyDeps = Agg(ivy"com.h2database:h2:2.2.224")
override def resources = T{
val frontEndResult = `front-end`.build()
super.resources().appended(frontEndResult)
}
}
Now if you build your backend, the front-end will be built and packaged into it. Repeating a build step will without changing source code will be nearly instant, because the cached results from from previous steps are used.
IDE Support
Let’s get some IDE support. That is certainly where Mill is weak, compared to Maven. Anyway, there is some support via the BSP or direct support for IntelliJ:
# Generate a IntelliJ idea project $ ./mill mill.idea.GenIdea/idea # Or, create a BSP project, which can be imported into an IDE: ./mill mill.bsp.BSP/install
If you are using IntelliJ, I would try the mill.idea.GenIdea/idea
first.
However, I had to use the mill.bsp.BSP/install
on complex builds,
and ran into some trouble there with IntelliJ idea.
If you are using the BSP
import, I would consider changing the mill
start script
to fix the JDK used for the mill
command:
#!/usr/bin/env sh
# For example, using sdk man to enforce a JDK.
# Otherwise if BSP and your CLI commands use different SDK, you will invalide each others caches.
sdk env
# Original mill script from down below
Inspecting Things
./mill inspect <task>
will tell you details about the task.
It tells the task name, the source file and line where it is defined, and what its inputs are.
Some tasks have arguments, these show up as well. The explanation is the JavaDoc comment on the given task.
So, if you add JavaDoc on a task
, it will sho
$ ./mill inspect front-end.build front-end.build(build.sc:39) Build our front end with our favorite framework of the month ;) Inputs: front-end.installNodeModules front-end.sources $ ./mill inspect backend.ivyDepsTree Command to print the transitive dependency tree to STDOUT. --inverse Invert the tree representation, so that the root is on the bottom val inverse (will be forced when used with whatDependsOn) ... Inputs: backend.transitiveIvyDeps
There is also the ./mill show visualizePlan <task>
command, which generate the dependencies in your
build plan as image.
$ ./mill show visualizePlan front-end.build
# Will give you a paths to the task graph in different file formats
In our example:
Profile and Concurrent Builds
Your builds start to take longer in CI. How does Mill help with long builds?
Mill supports running the tasks concurrently. By default, only 1 task runs
at a time. You can specify with the -j <number>
flag how many tasks should run concurrently. Zero has the meaning
of running as many tasks as there are CPUs on your system:
Mill also gives you a profile you can analyze. There is a ./out/mill-chrome-profile.json
file, which you can analyze
with the Chrome profiler:
Open Chrome/Chromium
Type in
chrome://tracing/
Open that file
Here’s an example from a real build:
Run Mill on Build Servers
Last, if you run Mill on your build server, GitHub actions, whatever: Add --no-server
to the command line.
Mill spawns a background server task that keeps hanging around by default. You do not want that on a build server.
Summary:
So, we saw that with Mill, we can:
- Download dependencies easily
- Split things up easily into modules
- Tests are usually put into separate modules. --watch
helps you rerun a build step on each change.
- Integrating external tools for the build isn’t hard
- Build results are cached and reused
- There are command lines to give you information about your tasks, like inputs, arguments, task graphs, etc
- Concurrent builds and profiling are integrated
- Use --no-server on your CI system
Next time we take a step back and look at the fundamentals of Mill, tasks and modules.