4

I'm quite new to Java and Spring. I'm looking for a containerized solution that watches the src folder, rebuilds the project and takes advantage of Spring devtools hotswap to reload the changed classes.

I searched, but I just keep finding about production-ready containers, with separated steps for build and run. I tried to use 2 different containers, one with Gradle that keeps building (gradle build --continuous) and one that executes the built result:

version: '3.7'
services:

  builder:
    image: gradle:jdk11
    working_dir: /home/gradle/project
    volumes:
      - ./:/home/gradle/project
    command: gradle build --continuous

  api:
    image: openjdk:11-slim
    volumes:
    - ./build/classes:/app
    command: java -classpath /app/java/main com.example.Application

It fails because Java doesn't find the dependencies (Spring, devtools, h2, etc.) inside the api container, and I don't know how to ask Gradle to include the external jars in the build folder. I want to do something like this, except that the example is outdated.

Still, I keep thinking that there might be a more elegant, simpler solution. It doesn't have to be with Gradle, it can be Maven if it works! :)

I know that many IDE have support for automatic builds and devtools, I just want to achieve it on Docker. This way, I would have a development workflow that is on repository, instead of on IDE's configuration, and virtually compatible with any dev environment. Is it a bad idea?

  • VSCode allows development inside a [container](https://code.visualstudio.com/docs/remote/containers). You could, for example, build an image for development with all the dependencies already installed. In regards to the hotswap, I am not sure how it works with spring, but it works fine with node, I suppose it is about the same with Java. – andre Dec 02 '19 at 04:03
  • No, it's not a bad idea to use Docker for this use case. The `gradle --continuous build` will work. I'm not exactly sure what you mean by Gradle being unable to find the Spring library. If it's listed in your `build.gradle` as a dependency, it should be installed when you run `gradle --continuous build`. As for your `api` service, it might be better to set the `ports` so that you can access it outside your container. Here's an example of my [`docker-compose.dev.yml`](https://privatebin.net/?f1a42fd3bffa32db#3HX347eAq6F7GzR7p31Yq6Pv853L7MXF5oqCbvE2jefz). – Neel Kamath Dec 02 '19 at 05:53
  • Yeah, I've seen it broadly on every single flavor of Node, so I'm very surprised that such a basic feature is so hard to achieve with Java. – Alessandro Cappello Dec 02 '19 at 05:57
  • @NeelKamath thanks for sharing your example. As far as I can see, your Gradle container builds on start and then runs. It doesn't rebuild and rerun if the code changes, does it? I've edited the part on Gradle and Spring dependency for clarity. – Alessandro Cappello Dec 02 '19 at 07:46
  • @AlessandroCappello My example was for a Kotlin/ktor server (instead of a Java/Spring one). But I think you should be able to use `gradle run` even for a Spring project (just use `gradle --continuous build` in the background so that automatic reload is enabled). – Neel Kamath Dec 02 '19 at 08:19
  • @AlessandroCappello Java doesn't know the dependencies because you have to include them using a [shadowJar](https://github.com/johnrengelman/shadow) (e.g., run `gradle shadowJar` after adding the plugin to your `build.gradle`). If you have resource files, remember to copy them over as shown in [line 17 of this Dockerfile](https://privatebin.net/?3560b9fa10405f34#DyxtXFZfmFMX83xz6ueSwwm4BDSK1b9HQoo3fkECTgnp). But this is for production packaging, Gradle should handle this during development (i.e., just use `gradle run`). – Neel Kamath Dec 02 '19 at 08:21
  • @AlessandroCappello as for the question in your reply to my first comment, my Docker Compose does indeed rebuild on code changes automatically. As you can see in [line 5](https://privatebin.net/?6c2f1e50a5ce9f1d#BBmtC61HxQLoRDhnpQ7E2SaVHfyiuDWKK4fb9JrA8cRm), I use the `--continuous` flag so that Gradle watches the source set. Also, on [line 13](https://privatebin.net/?6c2f1e50a5ce9f1d#BBmtC61HxQLoRDhnpQ7E2SaVHfyiuDWKK4fb9JrA8cRm), I'm using a bind mount with my current working directory, so that the Docker machine has continuous access to the relevant files on my development machine. – Neel Kamath Dec 02 '19 at 08:24
  • @NeelKamath are you sure that you linked the right file? I don't see any `--continuous` flag on your docker-compose.dev.yml. Also, I need to build the .class files, not a single jar, otherwise spring-devtools will not do its hotswap feature. I tried `gradle run`, but it doesn't seem to reload on changes, even with a `gradle build --continuous` in the background. – Alessandro Cappello Dec 02 '19 at 08:53
  • 1
    @AlessandroCappello I've used `--continuous` in the files I've linked to in my newer comments. My first comment's `docker-compose.dev.yml` used `-t` instead of `--continuous`, but they are the same flag. – Neel Kamath Dec 02 '19 at 09:03
  • @AlessandroCappello According to what I [found online](https://stackoverflow.com/questions/35200889/spring-boot-hot-swapping-does-not-work), Gradle should work (which makes sense, because build tools exist so that you needn't worry about compilation intermediates such as `.class` files). But I don't know Spring, so I won't be able to help further. I hope my Docker Compose helped you somewhat in how to bind a development environment in Docker. If you haven't already, try running the hot swap-enabled server without Docker first, to make sure you're using the right commands. – Neel Kamath Dec 02 '19 at 09:04

1 Answers1

4

At last, I've found a solution that works quite well, with just one caveat. This is of course a development environment, meant to quickly change files, automatically build and refresh the Spring application. It is not for production.

The build process is delegated to a Gradle container that watches for changes. Since Gradle has Incremental Compilation, it should scale well even for big projects.

The application itself is executed on a openjdk:11-slim. Since it runs the .class files, SpringBoot gets that it's dev-env and activates its devtools.

Here's my docker-compose.yml:

version: '3.7'
services:

  builder:
    image: gradle:jdk11
    working_dir: /home/gradle/project
    volumes:
      - ./build:/home/gradle/project/build
      - ./src:/home/gradle/project/src
      - ./build.gradle:/home/gradle/project/build.gradle
    command: gradle build --continuous -x test -x testClasses

  api:
    image: openjdk:13-alpine
    volumes:
    - ./build:/app
    depends_on:
      - builder
    command: java -cp "/app/classes/java/main:/app/dependencies/*:/app/resources/main" com.example.Application

And here's my build.gradle:

plugins {
    id 'org.springframework.boot' version '2.2.1.RELEASE'
    id 'io.spring.dependency-management' version '1.0.8.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.1.0-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    developmentOnly
    runtimeClasspath {
        extendsFrom developmentOnly
    }
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

task copyLibs(type: Copy) {
    from configurations.runtimeClasspath
    into "${buildDir}/dependencies"
}

build.dependsOn(copyLibs)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'com.h2database:h2'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

test {
    useJUnitPlatform()
}

All in all, it takes 5s for the whole thing to rebuild and hot-swap a change in the source code. Not webpack-like quick, but still acceptable. And the biggest advantage of having it this way is that it resides on code, and everyone can get it working, regardless of their workstation.

The caveat? On the first run, the build folder is empty and the api container fails to start. You have to wait for builder to complete its work, and then restart api.

I'm still hoping for a better solution, and I encourage you to post everything that works smoother than this.

  • 1
    You can have the `api` service `depends_on: []` the `builder` service. This will of course only wait for the container to start, and not just the service, so you'll have to use something like [`wait-for-it.sh`](https://docs.docker.com/compose/startup-order/). Using an additional shell script might complicate it a little, and so for simpler setups such as a development environment, I simply `sleep 10` or the like (e.g., `command: bash -c 'sleep 10 && gradle run'` in my `docker-compose.yml`). – Neel Kamath Dec 03 '19 at 02:30