Tl;dr: Mill is a simplified and faster alternative to sbt
Mill is a build tool aimed at providing a better experience for Scala developers. If you’ve used Scala before, you likely used sbt for building your project. sbt is by far the most popular build tool in the Scala community, but if you’re like me, you may have quickly become frustrated with how long specific tasks tend to take. It’s not uncommon for sbt to take several seconds to start up. Those seconds add up throughout a workday.
Here is where Mill shines. Mill provides an intuitive command-line interface for compiling your project, running tests and packaging your code, and it does it with far less overhead than sbt.
[Scala][2] is a programming language intended to merge object-oriented and functional programming into one high-level language. It builds on top of the JVM and provides easy access to the entire ecosystem of JVM-based libraries.
It helps if you have some familiarity with Scala and sbt (or a similar build tool) when going through this tutorial. You should also feel comfortable working from the command-line and know how to create directories and files.
Getting Started
To follow along, you’ll need to install both Scala and Mill. Follow the instructions here and here to do so.
If everything went smoothly, you should be able to print the version info for both.
$ mill version
[1/1] version
0.2.6
$ scala -version
Scala code runner version 2.12.6-20180516-151519-unknown -- Copyright 2002-2018, LAMP/EPFL and Lightbend, Inc.
Now we’re ready to start using Mill. Every command in this tutorial will be issued from the command-line, so follow along from your terminal. If you use Intellij, I’ll show you at the end how to generate an Intellij project with Mill.
The Hello World of Mill
Let’s go ahead and create a directory for the project we’ll build throughout this tutorial. From your home directory, run:
~ $ mkdir milldemo && cd milldemo
We’ve created a folder on your machine named milldemo
, and our pwd
should now be ~/milldemo
.
Let’s see how easy it is to get off the ground with Mill. Inside the milldemo
directory, create a file named build.sc
and paste the following contents inside:
import mill._, scalalib._
object helloworld extends ScalaModule {
def scalaVersion = "2.12.6"
}
Rather than prescribe its own DSL or use something like XML for defining build configuration, Mill’s configuration is just Scala.
To demonstrate that this is all we need to start using Mill, let’s build out the remainder of the project structure. We’ll create a subdirectory named helloworld
to correspond with the name of the ScalaModule
we defined in build.sc
. Inside helloworld
, create another folder named src
and finally create a file named Main.scala
. Here’s a simple one-liner to do this in a single step:
$ mkdir -p helloworld/src && touch helloworld/src/Main.scala
Your directory should now look like this:
├── build.sc
├── helloworld
│ ├── src
│ │ └── Main.scala
One thing I’d like to point out is that this structure doesn’t follow the Scala Style Guide recommendations to follow Java package naming conventions. I’m using this structure here for simplicity.
Let’s write some Scala code now. Like every other beginning tutorial, we’ll start with the Hello World of Scala. Inside helloworld/src/Main.scala
, write the following:
package helloworld
object Main extends App {
println("Hello World")
}
Here we have a very minimal Scala application that prints Hello World
to the console. We’re extending the App
trait, so we don’t have to write a main
function. App
lets us write our code within the body of the Main
object, making things less verbose.
Let’s give Mill a run. Mill has various tasks that it can execute for a project. To see a list of all tasks Mill has defined for our helloworld
project, run:
$ mill resolve helloworld._
A whole bunch of tasks will be logged to your terminal. Feel free to explore the various tasks on your own, but for now, we’ll focus on the run
task. To run our Hello World project, execute:
This first run might take a few seconds but try it again. Do you notice how fast it is? Thanks to Mill’s cache strategy, every subsequent build should be much faster, and you’re not paying the cost of sbt’s startup time for every single task you run. Pretty great, right?
Congratulations. You’ve now built your first Scala project with Mill.
Time to Kick it Up a Notch
We’re now building a Scala project with Mill, but there’s a lot we haven’t covered yet. How about writing and running unit tests? Can we format our code automatically? Mill can do these things too.
Let’s build something a little cooler than Hello World. Something that gives us a chance to write a unit test or two and requires a bit more code so we can see what Mill’s reformat
task can do for us. Borrowing this example from “The Go Programming Language," we’re going to build a simple temperature conversion tool. Given a number, we’ll determine the conversion from both Celsius to Fahrenheit, and Fahrenheit to Celsius.
First, add another project definition to build.sc
. We could create an entirely new project in a new directory, but this way we can use a single build.sc
file to build multiple projects. Go ahead and add the following to build.sc
:
object tempconv extends ScalaModule {
def scalaVersion = "2.12.6"
}
This should look familiar. We now have two projects defined in build.sc
, and if you recall from the first time we did this, the next step is to create a directory with the same name as the new project. To do that, run the following:
$ mkdir -p tempconv/src && touch tempconv/src/Main.scala
With this single bash command, we’ve created the new directory we need and also added an empty Scala file inside it. Our updated directory structure should look like:
├── build.sc
├── helloworld
│ ├── src
│ │ └── Main.scala
├── tempconv
│ ├── src
│ │ └── Main.scala
Now for the implementation. Inside tempconv/src/Main.scala
, paste the following code.
package tempconv
import scala.util.{Try} // 1
object TempConv { // 2
type Celsius = Float // 3
type Fahrenheit = Float // 4
def toFahrenheit(c: Celsius): Fahrenheit = c * 9 / 5 + 32
def toCelsius(f: Fahrenheit): Celsius = (f - 32) * 5 / 9
}
object Main extends App {
import TempConv._ // 5
if (args.length < 1) { // 6
println("Usage: tempconv <number>")
}
for (arg <- args) Try(arg.toFloat).toOption match { // 7
case Some(t: Float) =>
println(s"$t°F = ${toCelsius(t)}°C, $t°C = ${toFahrenheit(t)}°F")
case None => println(s"Unable to parse $arg")
}
}
There’s a fair amount of code here so let’s go over what it does:
- We’re parsing arguments that users pass in, and for some values the parsing can fail. Therefore, we import
Try
so we can safely do the parsing.
- TempConv is an object that contains the logic needed for converting from Celsius to Fahrenheit and vice-versa.
- We declare type aliases for Celsius and Fahrenheit, both with underlying types of float.
- The methods for converting from one to another are defined here. Feel free to confirm for yourself the relevant formulas: http://allmeasures.com/temperature.html
- Inside of
Main
, we import the contents of the TempConv
object so we can call toCelsius
and toFahrenheit
directly.
- If the user doesn’t pass us any arguments, we print some usage instructions.
- For every argument the user passes, we try parsing to float, and then we pattern match to see if the parsing succeeded. If it did, we print both the conversion for Celsius to Fahrenheit and Fahrenheit to Celsius.
We have our temperature conversion tool now, so let’s run the thing. From your terminal, run:
$ mill tempconv.run 32 100
If everything went as planned, you should see the following logged to the console:
32.0°F = 0.0°C, 32.0°C = 89.6°F
100.0°F = 37.77778°C, 100.0°C = 212.0°F
Feel free to pass arguments of your own and see what happens.
One more thing I want to point out about the run
command is the -w
flag. If you pass -w
to mill run
, Mill automatically compiles and runs your code each time you make a change. Experiment with that now by changing one of the println
statements. Just run:
$ mill -w tempconv.run 32 100
The -w
flag is great for getting quick feedback as you develop.
All right, we’re nearing the end now. Before we wrap, let’s discuss testing, reformatting with the reformat
task, and building a fat JAR with the assembly
task.
Testing
We want to make sure we have a very accurate temperature conversion tool. One thing we can do to give us confidence is to write unit tests that assert our expected behavior. Configure Mill to enable unit testing by adding the following to the tempconv
project in build.sc
:
object test extends Tests {
def ivyDeps = Agg(
ivy"org.scalatest::scalatest:3.0.5",
)
def testFrameworks = Seq("org.scalatest.tools.Framework")
}
The complete build.sc
should now look like:
import mill._, scalalib._
object helloworld extends ScalaModule {
def scalaVersion = "2.12.6"
}
object tempconv extends ScalaModule {
def scalaVersion = "2.12.6"
def mainClass = Some("tempconv.Main")
object test extends Tests {
def ivyDeps = Agg(
ivy"org.scalatest::scalatest:3.0.5",
)
def testFrameworks = Seq("org.scalatest.tools.Framework")
}
}
Here we’re just telling Mill that we’d like to use the ScalaTest framework to write and run our tests.
We’re going to add just two simple tests that assert our temperature conversion is working as expected. Create a new file inside tempconv/test/src
and name it TempConvSpec.scala
. Paste the following code inside:
package tempconv
import org.scalatest.{ FlatSpec }
import tempconv.TempConv._
class TempConvSpec extends FlatSpec {
it should "convert 32°F to 0°C" in {
assert(toCelsius(32) == 0)
}
it should "convert 0°C to 32°F" in {
assert(toFahrenheit(0) == 32)
}
}
We have two simple tests here. The first tests our toCelsius
function and asserts that 32 degrees Fahrenheit is equal to 0 degrees Celsius. Our second test confirms that the opposite is true by calling toFahrenheit
with 0 and asserting the result is equal to 32.
We can run our tests now with:
Just like the run
command, we can pass -w
to the test
command to run our tests every time we make a change. Try running the tests with mill -w tempconv.test
and see if you can think of any other useful tests to add.
If you’ve been copying and pasting the code as you followed along with this tutorial, you probably haven’t been too concerned with how the code was formatted. I’d assume that when you pasted the code in, indentation looked pretty consistent. Formatting is one of those topics in programming that can elicit various opinions. Are tabs or spaces better? How long is too long for a line of code to be? However, consistency is more important in any software project, and often it’s better to adopt a set of standards agreed upon by the community so that others can jump into your code quickly. Scalafmt is a code formatter for Scala that comes with a set of rules most would consider reasonable defaults. Mill has built-in support for Scalafmt, so let’s format our code with it now.
To enable the reformat
task for a project, we need to make a couple of changes to build.sc
. First, import scalafmt._
and then add with ScalafmtModule
to each module you want to be able to reformat. Our updated build.sc
should look like the following:
import mill._, scalalib._, scalafmt._
object helloworld extends ScalaModule with ScalafmtModule {
def scalaVersion = "2.12.6"
}
object tempconv extends ScalaModule with ScalafmtModule {
def scalaVersion = "2.12.6"
def mainClass = Some("tempconv.Main")
object test extends Tests {
def ivyDeps = Agg(
ivy"org.scalatest::scalatest:3.0.5",
)
def testFrameworks = Seq("org.scalatest.tools.Framework")
}
}
To format the helloworld
project we wrote first, run:
$ mill helloworld.reformat
Do the same for the tempconv
project:
It’s a good habit to reformat your code before committing your changes. I won’t cover it here, but it’s possible even to configure most editors and IDEs to do the reformatting for you when you save a file.
Fat Jars with Assembly
Now for the final topic we’ll cover in this post; building what is affectionately called a “Fat JAR.” Sometimes a Scala developer sees a need to ship their code in a format that is executable by others without them needing the Scala toolchain. Many people have a JVM installed, but fewer have Scala installed. One case where I had to worry about this was when building an AWS Lambda function in Scala. AWS Lambda doesn’t support Scala, but it supports Java. So if we can produce a self-contained JAR capable of being run by the Java toolchain, our code can run in environments like AWS Lambda. The name “Fat JAR” comes from the fact that we’re bundling everything we need from the Scala standard library for our JAR to be executable by the Java runtime (JRE). Let’s take a look at this in action.
To build a fat JAR, run mill tempconv.assembly
. Mill places a jar in out/tempconv/assembly/dest/out.jar
. To see how you can run this with the java
command, run:
$ java -jar out/tempconv/assembly/dest/out.jar 32 100
Intellij Support
As promised, I’ll now show you how to generate an Intellij project using Mill. Throughout this tutorial, we’ve largely stuck with the command line, but I know many people prefer working in an IDE. To create the Intellij project files, run the following from the terminal:
$ mill mill.scalalib.GenIdea/idea
Where to Go From Here?
We now have a small, but real-world project using Mill as the build tool. Hopefully you come away from this post with an appreciation for Mill’s lightweight configuration and minimal overhead when performing tasks like compilation and testing. Think of all those precious seconds throughout the day you can have back just by swapping out build tools.
There’s a lot more Mill can do for you. I encourage you to read the docs to learn more.
The completed project can be found here.
If you got stuck at any point throughout the tutorial, feel free to leave a comment down below, or open an issue at the completed project on Github.