Gradle Builds
Introduction
When writing complex applications, there is potentially a large list of steps that need to be completed before we can deploy our software. We might need to:
- Download and import new versions of libraries that we’re using.
- Run a code analysis tool against your source code to check for suspicious code, formatting etc.
- Run a documentation tool to generate revised documentation.
- Build a directory structure containing images, fonts and other resources for our executable to use.
- Compile the code and run automated tests to ensure that its working correctly.
- Create an installer that you can use to deploy everything.
Performing these steps manually is error prone, and very time-consuming. Instead of doing this by-hand, we tend to rely on build systems: software that is used to build other software. Build systems provide consistency in how software is built, and let you automate much of the process. They addresses issues like:
- How do I make sure that all of my steps (above) are being handled properly?
- How do I ensure that everyone is building software the same way i.e. compiling with the same options?
- How do I know that I have the correct library versions?
- How do I ensure that tests are being run before changes are committed?
There are a number of build systems on the market that attempt to address these problems. They are often programming-language or toolchain dependent.
- C++: CMake, Scons, Premake
- Java: Ant, Maven, Gradle
Make is one of the most widely used build systems, which allows you to script your builds (by creating a makefile to describe how to build your project). Using make, you can ensure that the same steps are taken every time your software is built.
For small or relatively simple projects, make is a perfectly reasonable choice. It’s easy to setup, and is pre-installed on many systems. However, make has limitations and may not be the best choice for large or more complex projects.
-
Build dependencies must be explicitly defined. Libraries must be present on the build machine, manually maintained, and explicitly defined in your makefile.
-
Make is fragile and tied to the underlying environment of the build machine.
-
Performance is poor. Make doesn’t scale well to large projects.
-
Its language isn’t very expressive, and has a number of inconsistencies.
-
It’s very difficult to fully automate and integrate with other systems.
We’re going to use Gradle in this course:
- It handles all of our requirements (which is frankly, pretty impressive).
- It’s the official build tool for Android builds, so you will need it for Android applications.
- It fits nicely into the Kotlin and JVM ecosystem.
- It’s cross-platform and language agnostic.
You write Gradle build scripts in a DSL (Groovy or Kotlin). You describe tasks, and Gradle figures out how to perform them. Gradle handles dependency management and manages complex dependencies automatically!
Gradle Tasks
Gradle works by running tasks - some are built-in, and you can define your own. Gradle tasks can be executed from the command-line. e.g.
- gradle help: shows available commands
- gradle init: create a new project and dir structure.
- gradle tasks: shows available tasks from build.gradle.
- gradle build: build project into build/
- gradle run: run from build/
$ gradle help
> Task :help
Welcome to Gradle 6.4.1.
To run a build, run gradle
$ gradle build
Starting a Gradle Daemon ...
BUILD PASSED in 2s
Creating a Project
A Gradle project is simply a set of source files, resources and configuration files structured so that Gradle can build it.
Gradle projects require a very specific directory structure. A typical Gradle project directory looks like this:
We could create this by hand, but for now let’s use Gradle to create a starting directory structure and build configuration file that we can modify.
gradle init
will run the project wizard to create a new project in the current directory. Select application
, Kotlin
for a language, and one application project
for this sample.
Gradle supports using either Groovy or Kotlin as a DSL. We’ll use Kotlin DSL in all Gradle build files.
$ gradle init
Select type of project to generate:
1: basic
2: application
3: library
4: Gradle plugin
Enter selection (default: basic) [1..4] 2
Select implementation language:
1: C++
2: Groovy
3: Java
4: Kotlin
5: Scala
6: Swift
Enter selection (default: Java) [1..6] 4
Split functionality across multiple subprojects?:
1: no - only one application project
2: yes - application and library projects
Enter selection (default: no - only one application project) [1..2] 1
Select build script DSL:
1: Groovy
2: Kotlin
Enter selection (default: Kotlin) [1..2] 2
Generate build using new APIs and behavior (some features may change in the next minor release)? (default: no) [yes, no]
Project name (default: single-project):
Source package (default: single.project):
> Task :init
Get more help with your project: https://docs.gradle.org/7.6/samples/sample_building_kotlin_applications.html
BUILD SUCCESSFUL in 16s
2 actionable tasks: 2 executed
The directory structure will resemble this:
$ tree -L 4
.
├── app
│ ├── build.gradle
│ └── src
│ ├── main
│ │ ├── kotlin
│ │ └── resources
│ └── test
│ ├── kotlin
│ └── resources
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle
app
is the application source code folder for our project.app/src
is source code, andapp/test
is for unit tests.gradle
is the gradle wrapper files, which allows gradle to bootstrap itself if required.gradlew
,gradlew.bat
are Gradle scripts that you should use to run commands.settings.gradle
andbuild.gradle
are configuration files.
You can use gradle tasks to see all supported actions. The available tasks will vary based on the type of project you create.
$ gradle tasks
> Task :tasks
------------------------------------------------------------
Tasks runnable from root project
------------------------------------------------------------
Application tasks
-----------------
run - Runs this project as a JVM application
Build tasks
-----------
assemble - Assembles the outputs of this project.
build - Assembles and tests this project.
buildDependents - Assembles and tests this project and all projects that depend on it.
buildNeeded - Assembles and tests this project and all projects it depends on.
classes - Assembles main classes.
clean - Deletes the build directory.
jar - Assembles a jar archive containing the main classes.
A “standard” Gradle project has about 30 tasks. Many of them are called infrequently, or called by other tasks (e.g. build
calling buildNeeded
). The most commonly used commands are build
, run
and clean
.
$ gradle build
BUILD SUCCESSFUL in 8s
8 actionable tasks: 8 executed
$ gradlew run
> Task :run
Hello world.
BUILD SUCCESSFUL in 1s
2 actionable tasks: 1 executed, 1 up-to-date
Single Project Setup
The settings.gradle
file contains basic project settings. It specifies the project name, and the directory containing our project source code.
rootProject.name = 'single-project'
include('app')
The build.gradle
file contains our project configuration.
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm") version "1.8.20"
application
}
group = "net.codebot"
version = "1.0.0"
val compileKotlin: KotlinCompile by tasks
val compileJava: JavaCompile by tasks
compileJava.destinationDirectory.set(compileKotlin.destinationDirectory)
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
tasks.withType<KotlinCompile> {
kotlinOptions.jvmTarget = "1.8"
}
application {
mainClass.set("single.project.AppKt")
}
The build.gradle
file contains information about your project, including the versions of all external libraries that you require. In this project file, you define how your project should be built:
- You define the versions of each tool that Gradle will use e.g. compiler version. This ensures that your toolchain is consistent.
- You define versions of each dependency e.g. library that your build requires. During the build, Gradle downloads and caches those libraries. This ensures that your dependencies remain consistent.
Gradle has a wrapper around itself: gradlew and gradlew.bat You define the version of the build tools that you want to use, and when you run Gradle commands using the wrapper script, it will download and use the correct version of Gradle. This ensures that your build tools are consistent.
Here’s how we run using the wrapper.
$ ./gradlew run
Downloading https://services.gradle.org/distributions/gradle-7.6-bin.zip
...........10%............20%...........30%............40%............50%...........60%............70%............80%...........90%............100%
> Task :app:run
Hello World!
BUILD SUCCESSFUL in 14s
2 actionable tasks: 2 executed
Example: Console
Let’s setup the build for a calculator application.
package calc
fun main(args: Array<String>) {
try {
println(Calc().calculate(args))
} catch (e: Exception ) {
print("Usage: number [+|-|*|/] number")
}
}
class Calc() {
fun calculate(args:Array<String>):Any {
if (args.size != 3) throw Exception("Invalid number of arguments")
val op1:String = args.get(0)
val operation:String = args.get(1)
val op2:String = args.get(2)
return(
when(operation) {
"+" -> op1.toInt() + op2.toInt()
"-" -> op1.toInt() - op2.toInt()
"*" -> op1.toInt() * op2.toInt()
"/" -> op1.toInt() / op2.toInt()
else -> "Unknown operator"
}
)
}
}
Let’s migrate this code into a Gradle project.
- Use Gradle to create the directory structure. Select “application” as the project type, and “Kotlin” as the language.
$ gradle init
Select type of project to generate:
1: basic
2: application
- Copy the
calc.kt
file into src/main, and modify the build.gradle file to point to that source file.
application {
// Main class for the application.
// Kotlin generates a wrapper class for our main method
mainClassName = 'calc.CalcKt'
}
- Use gradle to make sure that it builds.
$ gradle build
BUILD SUCCESSFUL in 975ms
- If you use gradle run, you will see some unhelpful output:
$ gradle run
> Task :run
Usage: number [+|-|*|/] number
We need to pass arguments to the executable, which we can do with –args.
$ gradle run --args="2 + 3"
> Task :run
5
Multi-Project Setup
This configuration works well with a single program, but often you want to built related projects together.
e.g.
- console
- graphical client
- shared components
- service
Gradle supports multi-project configurations, so that you can track and manage sub-projects together.
You can add an extra project to the single-project above by adding a second project directory, and then modifying the settings.gradle
to include the new project.
For example, here we have added a server
project directory and then added it to the settings.gradle
file.
This gives us the ability to build both client AND server from the same project.
.
├── app
│ ├── build.gradle
│ └── src
│ ├── main
│ │ ├── kotlin
│ │ └── resources
│ └── test
│ ├── kotlin
│ └── resources
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── server
│ ├── build.gradle
│ └── src
│ ├── main
│ │ ├── kotlin
│ │ └── resources
│ └── test
│ ├── kotlin
│ └── resources
└── settings.gradle
settings.gradle
rootProject.name = 'single-project'
include('app', 'server')
If you’re creating a new project, you can instead choose to run gradle init
and select multiple-projects from the wizard. This will generate a multi-project setup with a client, server and shared libraries.
Split functionality across multiple subprojects?:
1: no - only one application project
2: yes - application and library projects
Enter selection (default: no - only one application project) [1..2] 2
Managing Dependencies
This works well if your projects are completely independent, but often you will have shared code that you want to share between projects. We call this relationship a project dependency. In this case, it’s an internal dependency, since we’re responsible for producing all relevant classes ourselves (within our organization).
Project dependencies
To add a shared library that can be used by both our client and server projects, you need to:
- Create a new shared project.
- Add it to the top-level settings.gradle file.
- Add this shared project to the build.gradle.kts file for any project that requires it.
If we modify our project from above, we now have app/, server/ and shared/ projects:
tree -L 4
.
├── app
│ ├── build.gradle
│ └── src
│ ├── main
│ │ ├── kotlin
│ │ └── resources
│ └── test
│ ├── kotlin
│ └── resources
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── server
│ ├── build.gradle
│ └── src
│ ├── main
│ │ ├── kotlin
│ │ └── resources
│ └── test
│ ├── kotlin
│ └── resources
├── shared
│ ├── build.gradle
│ └── src
│ ├── main
│ │ ├── kotlin
│ │ └── resources
│ └── test
│ ├── kotlin
│ └── resources
└── settings.gradle
To include the shared project in the client and server projects, we modify the app/build.gradle.kts
and server/build.gradle.kts
to include this dependency:
dependencies {
implementation(project(":shared"))
}
External Dependencies
It would be unusual for us to write all of the code in our application. Typically, you’re leveraging software that was written by someone else e.g. OpenGL for graphics, Kotlin standard library for everything in this course. We refer to this relationship between our source code and these external libraries as an external dependency.
The challenge with using libraries like this is ensuring that you are building, testing and deploying against a consistent version of the same libraries.
Traditionally, software was distributed in release archives, or tarballs, by the people that maintained these libraries. Their users would then download this code and compile it into tools or add it as a library to their own applications. This is extremely error prone (“what version did I test with again?”). The modern way to manage dependencies is using a package manager: a system used to manage dependencies and required libraries e.g. npm
,pip
, go mod
, maven
, gradle
,apt-get
,brew
, etc.
All package managers work roughly the same at a high level:
- A user asks to install a package or set of packages (with specific versions of each one)
- The package manager performs some basic dependency resolution
- The package manager calculates the full set of transitive dependencies, including version conflict resolution
- The package manager installs them, often from a remote repository.
In this course, we’ll use Gradle for both building software and managing dependencies. Gradle can download specific versions of libraries for us, from an online *repository: a location where libraries are stored and made available. Typically a repository will offer a large collection of libraries, and include many years of releases, so that a package manager is able to request through some public interface, a specific version of a library and all of its dependencies.
Repositories can be local (e.g. a large company can maintain its own repository of internal or external libraries), or external (e.g. a collection of public libraries). The most popular Java/Kotlin repository is mavenCentral, and we’ll use it with Gradle to import any external dependencies that we might require.
You can control the repository that Gradle uses by specifying its location in the build.gradle
file.
repositories {
jcenter()
mavenCentral()
}
You add a specific library or dependency by adding it into the dependencies section of the build.gradle
file.
dependencies {
implementation("org.jfxtras:jfxtras-controls:17-r1")
}
To locate available packages, use an online package directory. e.g. https://package-search.jetbrains.com
The details include how to import it into your project.
Managing Dependencies
One challenge with setting up dependencies in multi-project builds is that you will have multiple build.gradle.kts
files, each with their own list of dependencies and versions.
It’s important to keep your versions consistent across projects. How do we do this?
Version Catalogs
A Version Catalog is a list of versions, plugins and dependencies that we are using in our application. We can extract them from our build.gradle.kts
files, and store them in a single location to avoid duplication.
In our settings.gradle.kts
, add a Version Catalog like this:
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
// constants
version("jdk", "17")
version("javafx", "18.0.2")
// https://plugins.gradle.org/
plugin("kotlin-lang", "org.jetbrains.kotlin.jvm").version("1.8.10")
plugin("jlink", "org.beryx.jlink").version("2.26.0")
plugin("javafx", "org.openjfx.javafxplugin").version("0.0.13")
plugin("javamodularity", "org.javamodularity.moduleplugin").version("1.8.12")
// https://mvnrepository.com/
library("kotlin-coroutines", "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
library("junit-jupiter", "org.junit.jupiter:junit-jupiter:5.9.2")
}
}
}
version
specifies a version string that we can use in our build scripts.plugin
specifies a plugin and version number.library
is an external dependency, along with group:artifact and version details.
To use these, just replace the hard-coded version numbers, plugins and dependencies in your build.gradle.kts
file.
plugins {
application
alias(libs.plugins.kotlin.lang)
alias(libs.plugins.javamodularity)
}
dependencies {
implementation(project(":shared"))
testImplementation(libs.junit.jupiter)
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(libs.versions.jdk.get()))
}
}
The multi-project sample in the public-repository is an example that does this across all of the subprojects in the build.
Adding a Custom Task
You can add tasks by defining them in your build.gradle
file.
tasks.register ('helloWorld') {
doLast {
println("Hello World")
}
}
$ ./gradlew helloWorld
> Task :app:helloWorld
Hello World
It’s staggering how much software is available through package repositories….