CS 346 (W23)
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

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.

  1. Build dependencies must be explicitly defined. Libraries must be present on the build machine, manually maintained, and explicitly defined in your makefile.

  2. Make is fragile and tied to the underlying environment of the build machine.

  3. Performance is poor. Make doesn’t scale well to large projects.

  4. Its language isn’t very expressive, and has a number of inconsistencies.

  5. 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!

Java build tasks

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:

Gradle directory structure

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 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] 1

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, and app/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 and build.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. Gradle supports either groovy or kotlin as build scripts.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm") version "1.6.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")
}
plugins {
    // Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin.
    id 'org.jetbrains.kotlin.jvm' version '1.7.10'

    // Apply the application plugin to add support for building a CLI application in Java.
    id 'application'
}

repositories {
    // Use Maven Central for resolving dependencies.
    mavenCentral()
}

dependencies {
    // Use the Kotlin JUnit 5 integration.
    testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'

    // Use the JUnit 5 integration.
    testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.1'

    // This dependency is used by the application.
    implementation 'com.google.guava:guava:31.1-jre'
}

application {
    // Define the main class for the application.
    mainClass = 'single.project.AppKt'
}

tasks.named('test') {
    // Use JUnit Platform for unit tests.
    useJUnitPlatform()
}

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.

  1. 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
  1. 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' 
} 
  1. Use gradle to make sure that it builds.
$ gradle build 
BUILD SUCCESSFUL in 975ms
  1. 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:

  1. Create a new shared project.
  2. Add it to the top-level settings.gradle file.
  3. 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

Package search

The details include how to import it into your project.

Dependency details

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….

xkcd