Bazel Java Starter Pack

Jun 19, 2020

Today is Juneteenth and what better way to celebrate than making a donation.

This article is a quick walk-through on getting up and running with Bazel on a new Java project from local dev to IDE. An overview of the steps we’ll go through:

  1. Install Bazel.
  2. Setup the project tree.
  3. Configure IntelliJ.

Install Bazel

Installing Bazel is pretty straightforward. Download the latest installer for your OS, execute with the --user flag to install into your home directory, and adjust your path as advised by the installer.

Setup the Project Tree

Aside from your source a new Bazel project requires a minimum of 2 things a root WORKSPACE file and BUILD or BUILD.bazel files. We’ll build a simple HelloWorld console app and the core of the folder structure will look as follows:

1
2
3
4
5
6
7
8
9
10
11
12
helloworld
 +- .gitignore
 +- WORKSPACE
 +- BUILD.bazel
 +- helloworld
    +- src
       +- main
          +- java
             +- ca/junctionbox/helloworld
                +- BUILD.bazel
                +- Printer.java
                +- Main.java

First up is the .gitignore file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Ignore backup files.
*~
# Ignore Vim swap files.
.*.sw?
# Ignore files generated by IDEs.
/.classpath
/.factorypath
/.idea/
/.ijwb/
/.project
/.settings
/.vscode/
/bazel.iml
# Ignore all bazel-* symlinks. There is no full list since this can change
# based on the name of the directory bazel is cloned into.
/bazel-*
# Ignore outputs generated during Bazel bootstrapping.
/output/
# User-specific .bazelrc
user.bazelrc

The above should keep your commits fairly devoid of unnecessary cruft. Adjust to your needs. Next up is the WORKSPACE file. This indicates to Bazel the root of your repository and is the entry-point for what people familiar with maven would refer to as plugins.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
workspace(name="helloworld")

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

RULES_JVM_EXTERNAL_TAG = "3.2"
RULES_JVM_EXTERNAL_SHA = "82262ff4223c5fda6fb7ff8bd63db8131b51b413d26eb49e3131037e79e324af"

http_archive(
    name = "rules_jvm_external",
    strip_prefix = "rules_jvm_external-%s" % RULES_JVM_EXTERNAL_TAG,
    sha256 = RULES_JVM_EXTERNAL_SHA,
    url = "https://github.com/bazelbuild/rules_jvm_external/archive/%s.zip" % RULES_JVM_EXTERNAL_TAG,
)

load("@rules_jvm_external//:defs.bzl", "maven_install")
# this is only required if you use the long form `maven.artifact()`
load("@rules_jvm_external//:specs.bzl", "maven")

# maven_install will resolve all transitive dependencies.
maven_install(
    # If you update a dependency below execute this command:
    # bazel run @unpinned_maven//:pin
    artifacts = [
        # compact string form
        "junit:junit:4.13",
        # long form dependency definition allows for exclusion of
        # transitive dependencies.
        maven.artifact("org.hamcrest", "hamcrest", "2.2"),
        
    ],
    repositories = [
        "https://repo1.maven.org/maven2",
    ],
    # after pinning your dependencies you can uncomment this
    # bazel run @maven//:pin
    maven_install_json = "@helloworld//:maven_install.json",
)

# used to provide the @unpinned_maven//:pin
load("@maven//:defs.bzl", "pinned_maven_install")
pinned_maven_install()

The above is a lot to unpack but let’s walk through it:

  1. workspace(name="helloworld") defines the workspace name and is most often used when composing multiple namespaces together.
  2. load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") load statement that imports http_archive from @bazel_tools//tools/build_defs/repo:http.bzl. The import can be interpreted as bazel_tools workspace // always denotes the root of a given workspace. tools/build_defs/repo is the path. Finally http.bzl is the target with load this is a file however this pattern of path specification is common in bazel and in some cases it can be a rule. More on that later. Note: bazel_tools is a built-in workspace included with Bazel.
  3. Next is http_archive. This is a call the function loaded in the previous load command which retrieves an archive and makes it available in a workspace as defined by the name, in this case rules_jvm_external. It is advised to use the SHA to ensure reproducible builds and increase security.
  4. load("@rules_jvm_external//:defs.bzl", "maven_install") this load command loads the maven_install command from our newly downloaded rules_jvm_external workspace.
  5. load("@rules_jvm_external//:specs.bzl", "maven") this is an optional load and only required if you need to exclude particular transitive dependencies from one or more of your imports.
  6. maven_install() this is where you’ll specify your direct dependencies. The plugin will take care of resolving the transitive dependencies from there. I strongly recommend using the maven_install_json property as it pins you dependency graph providing 2 core benefits: reproducibility and speed. The first I’ll assume needs no explaination the second is worthy of a brief description. With most dependency resolution tools such as maven a clean checkout will need to walk the graph of dependencies which can result in recursive queries for each dependency encountered direct or transitive. The json file in effect resolves that graph once and any clean checkout only need download a list of dependencies which can be done with maximum concurrency.
  7. pinned_maven_install() is the final function call in our WORKSPACE. This allows updates of the pinned maven_install_json file without having to comment and then uncomment the parameter.

Phew that was a lot to ingest. Hopefully you’re still with me. Up next is the helloworld/BUILD.bazel file. You can organise your project similar to maven by putting this file in the root of your sub-module. This is easy to get started however over the life of your project if it gets large you’ll lose some of the speed benefits that Bazel can achieve by having more fine grained build targets at the package level. A simple BUILD.bazel file looks as follows:

1
2
3
4
5
6
7
8
9
10
11
12
java_library (
    name = 'lib',
    srcs = glob(['*.java']),
)

java_binary(
    name = 'helloworld',
    main_class = 'ca.junctionbox.helloworld.Main',
    runtime_deps = [
        ':lib',
    ],
)

In the above build file there are two Java rules:

  1. java_library is a java rule to build shared bundles of libraries. I’ve used a single rule specification here but you can split it up as appropriate for your project.
  2. java_binary is a java rule to build an executable JAR file.

There are a number of other parameters you can specify for each rule depending on your requirements which can include resources, direct dependencies, exports, visibility, etc. I’ll make note on visibility now, by default all rules are “private” which means rules outside of the current scope/build file cannot reference the rules we’ve just created without some adjustments to the visibility. I’ll cover some examples of visibility in a follow-up post. The root BUILD.bazel can be an empty file.

At this point you’ll be able to build your project assuming the code was already in place. A handful of commands you’ll find useful are:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# build all targets in the workspace
bazel build //...

# update the maven_install dependencies
bazel run @unpinned_maven//:pin

# list all of the maven dependency rules
bazel query @maven//:all --output=build

# clean the build output (this should be rare)
bazel clean //...

# build an uberjar
bazel build //helloworld/src/main/java/ca/junctionbox/helloworld:helloworld_deploy.jar

# run the java_binary target
bazel run //helloworld/src/main/java/ca/junctionbox/helloworld:helloworld

# run the java_binary target passing arguments into the app 
bazel run //helloworld/src/main/java/ca/junctionbox/helloworld:helloworld -- arg0 arg1

Configure IntelliJ

Let’s continue with getting your IDE configured. The best IDE to use with Bazel is arguably IntelliJ. The plugin doesn’t offer a lot in terms of functionality. Its primary focus is providing project Import and Run/Debug Configurations. If you’re running the latest version of IntelliJ you’ll need to downgrade to a version the plugin is compatible with. Currently that is the 2019.3.x series. You can see the compatible versions as specified on the plugin versions page. The second column indicates the compatibility range. As an example for Release 2020.06.01.1.0 you can use IntelliJ 2019.3.3 to 2019.3.5 inclusive.

If you use multiple JDK’s as I do ensure your login profiles JAVA_HOME matches the version you want to build with in IntelliJ. Any of the above commands can be added as build configurations to IntelliJ to ease execution.

That’s it your up and running with a minimal Java setup for IntelliJ. In a follow-up article I’ll demonstrate how to add resources, tests, and CI using Github Actions.

tags: [ bazel java ]