Clojure: compiling java source with tools.build


Recently I stumbled over an old Java project from 2011. I wanted to see if I could get it to run. However, the original program had a bunch of IDE related build files that I no longer understood. What if I used Clojure to build the project? The fruit of that journey is covered in this blog post.

Lets start with a top level overview of the project structure.

├── README.md
├── build.clj
├── deps.edn
├── java
│   └── greatings
│       └── Greater.java
└── src
    └── compiling_java
        └── core.clj

Java code

The Java source lives in the java directory.

package greatings;

public class Greater {
  public void great() {
      System.out.println("Hello, world!");
  }
}

Clojure code

The Clojure source lives in src.

(ns compiling-java.core
  (:gen-class)
  (:import [greatings Greater]))

(defn -main []
  (.great (Greater.)))

Worth pointing out (:gen-class) this will be important later when it comes to building an uberjar. When compiling, this generates .class file with a given package-qualified name. This will ensure we can reference this class as the main entry point for our uberjar.

deps.edn

We will be using tools.build to compile our java code. To do this we need to add it as a dependency.

{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.10.3"}}
 :aliases
 {:build {:deps {io.github.clojure/tools.build
                 {:git/tag "v0.6.8"
                  :git/sha "d79ae84"}}
          :ns-default build}
  :dev {:paths ["src" "target/classes"]}}}

We add target/classes as a dev dependency so that when using the repl the Java classes will be available.

Compiling Java with tools.build

build.clj is where we define our build tasks. I've added a jcompile task that will compile our Java source code.

(ns build
  (:require [clojure.tools.build.api :as b]))

(def lib 'compiling-java)
(def version "0.1.1")
(def class-dir "target/classes")
(def basis (b/create-basis {:project "deps.edn"}))

(defn clean [_]
  (b/delete {:path "target"}))

(defn jcompile [_]
  (clean nil)
  (b/javac {:src-dirs ["java"]
            :class-dir class-dir
            :basis basis
            :javac-opts ["-source" "8" "-target" "8"]}))

This task can be run with:

clj -T:build jcompile

Calling our java code at the relp

Start the repl using the dev profile:

clj -M:dev

Load the namespace and test the Java code:

(require 'compiling-java.core)
(in-ns 'compiling-java.core)

(.great (Greater.))

=>
Hello, world!

nil

Everything is working as expected.

Building and running an uberjar

Clojure/Java projects are often shipped/deployed as an uberjar. An uberjar is the program and all its dependencies compiled into a single executable. With tools.build making uberjars is trivial.

First we add the following uber task to our build.clj file:

(def uber-file (format "target/%s-%s.jar" (name lib) version))

(defn uber [_]
  (clean nil)
  (jcompile nil)
  (b/copy-dir {:src-dirs ["src" "resources"]
               :target-dir class-dir})
  (b/compile-clj {:basis basis
                  :src-dirs ["src"]
                  :class-dir class-dir})
  (b/uber {:class-dir class-dir
           :uber-file uber-file
           :basis basis
           :main 'compiling-java.core}))

This task can be run with:

clj -T:build uber

The resulting uberjar file can be run with:

java -jar target/compiling-java-0.1.1.jar

=>
Hello, world!

That's all there is to it.

tools.build is really simple to use. It's philosophy, that the project build is inherently a program, is really powerful. I'll be using it as my build tool for both Clojure and Java projects going forward.

The full example project can be found here.