Friday, August 11, 2023

Spring boot - your own banner, actuator, version details etc . . .

Art is good for eyes. Spring boot out of the box comes with a nice text banner of it's name : Spring Boot and displays the version right below the banner. Out of the box, the banner mode is set on and it shows up when the application gets started. There are several articles available on customizing this banner. Also, spring boot documentation has a brief section on thus as well (link in the resources).

Environment: Java 20, Spring Boot 2.7.14, maven 3.9.3 on macOS Catalina 10.15.7

It's always good to customize the banner and see your application name whenever it comes up. Of course, you can display more good-to-have version details like: Spring Boot version, Java version, Your application version, and other available properties along with the banner.

Following is the quick list of steps to have a custom banner in your application.
  • Generate a text banner for your application name. There are several sites for doing this. The one such is: https://devops.datenkollektiv.de/banner.txt/index.html. Generate a text banner and download the text file. I usually go with the standard banner font.
  • Edit the file and add the following properties for versions at the bottom:
____ _ ____ ___ __ | __ ) ___ ___ | |_ / ___|_ __ __ _ __ _| \ \ / / __ ___ | _ \ / _ \ / _ \| __| | | _| '__/ _` |/ _` | |\ \ / / '_ ` _ \ | |_) | (_) | (_) | |_ | |_| | | | (_| | (_| | | \ V /| | | | | | |____/ \___/ \___/ \__| \____|_| \__,_|\__,_|_| \_/ |_| |_| |_| :: Spring Boot :: ${spring-boot.version} :: Running on Java :: ${java.version} :: Application :: ${project.version}
  • Place the text file with file-name: banner.txt under src/java/resources folder.
  • In your maven build file (pom.xml), make sure that you turn on maven filtering and add the resource directory for the extra version properties added to be resolved during the build.
  • Also, make sure you have maven-resources-plugin configured to filter resources.
<build> <resources> <resource> <filtering>true</filtering> <directory>${project.basedir}/src/main/resources/</directory> </resource> </resources> <testResources> <testResource> <filtering>true</filtering> <directory>${project.basedir}/src/test/resources/</directory> </testResource> </testResources> ... <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-resources-plugin</artifactId> <version>3.3.1</version> <executions> <execution> <id>filter-resources</id> <phase>process-resources</phase> <goals> <goal>copy-resources</goal> </goals> <configuration> <resources> <!-- # Filtered Resources --> <resource> <directory>${project.basedir}/src/main/resources/</directory> <filtering>true</filtering> </resource> </resources> <outputDirectory>${project.build.directory}</outputDirectory> </configuration> </execution> </executions> </plugin> ... </build>

Gotcha-1

When you run maven build goal(s) that also runs test-cases like for e.g. ./mvnw clean install, the banner shows up with all properties resolved for every integration test-case. However, for the custom properties used in the banner to be filtered and shown you need to add the same custom property in application.yml if you happen to separate out test configurations under test/resources. Also, make sure that you have enabled <testResources> filtering as well as shown above.

Gotcha-2 (Spring Boot 3.x)

The above ${project.version} doesn't work in Spring boot 3.x. In this case a custom application version property (e.g. app.version) can be defined in application.yml or application.properties and that can be used in the banner.txt file.
E.g. application.yml
spring: application: name: @project.name@ # custom property for banner app: version: @project.version@

____ _ ____ ___ __ | __ ) ___ ___ | |_ / ___|_ __ __ _ __ _| \ \ / / __ ___ | _ \ / _ \ / _ \| __| | | _| '__/ _` |/ _` | |\ \ / / '_ ` _ \ | |_) | (_) | (_) | |_ | |_| | | | (_| | (_| | | \ V /| | | | | | |____/ \___/ \___/ \__| \____|_| \__,_|\__,_|_| \_/ |_| |_| |_| :: Spring Boot :: ${spring-boot.version} :: Running on Java :: ${java.version} :: Application :: ${app.version}

Actuator

Actuator provides production-ready endpoints for monitoring the application. Just adding the following dependency in pom.xml will do. Once the application is up, check http://localhost:8080/actuator

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>

However, by default not all end-points are enabled. Only /actuator, and /actuator/health end-points are enabled. By setting the property management.endpoints.web.exposure.include=* in application.properties or corresponding application.yml will activate all end-points. 

Also, multiple end-points can be listed separated by comma.
e.g. management.endpoints.web.exposure.include=health,info,beans,env

Selectively certain end-points can be disabled, for instance to disable refresh end-point, set management.endpoints.web.exposure.exclude=refresh 

NOTE: The refresh end-point requires an empty POST request. e.g. curl -X POST http://localhost:8081/actuator/refresh 

Application information

The /actuator/info endpoint displays application information. By default there is no information. So, http://localhost:8080/actuator/info displays empty JSON:
{}

Maven plugin - spring-boot-maven-plugin

This plugin comes with build execution goal: build-info which is run by default and creates build information file: build-info.properties under target/classes/META_INF directory. Properties listed in this file are available through the endpoint: http://localhost:8080/actuator/info.
The following is an example of build-info goal execution configuration:

<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <!-- Useful info on /actuator/info --> <id>build-info</id> <goals> <goal>build-info</goal> </goals> </execution> </executions> </plugin>

The above configuration generates build-info.properties file with pre-defined build info properties like:
build.artifact=boot-graalvm build.group=com.example build.name=boot-graalvm build.time=2023-09-22T16\:04\:27.970Z build.version=0.0.1-SNAPSHOT

With the above file generated, the /actuator/info endpoint response would look like:
{ "build": { "artifact": "boot-graalvm", "name": "boot-graalvm", "time": "2023-09-22T16:04:27.970Z", "version": "0.0.1-SNAPSHOT", "group": "com.example" } }
 
Additional custom properties can be added by configuring the build-info goal. For instance to add Java version and the Spring Boot version that the application is running with, the following additional configuration can be added:
... <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.1.2</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>boot-graalvm</artifactId> <version>0.0.1-SNAPSHOT</version> <name>boot-graalvm</name> <description>GraalVm project for Spring Boot</description> <properties> <java.version>20</java.version> <spring.boot.version>${parent.version}</spring.boot.version> </properties> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <!-- Useful info on /actuator/info --> <id>build-info</id> <goals> <goal>build-info</goal> </goals> <configuration> <additionalProperties> <java.version>${java.version}</java.version> <spring.boot.version>${spring.boot.version}</spring.boot.version> </additionalProperties> </configuration> </execution> </executions> ...

Which would result with additional properties in the build-info.properties file like:
build.java.version=20 build.spring.boot.version=3.1.2

The /actuator/info endpoint response looks like:
{ "build": { "java": { "version": "20" }, "spring": { "boot": { "version": "3.1.2" } }, "version": "0.0.1-SNAPSHOT", "artifact": "boot-graalvm", "name": "boot-graalvm", "time": "2023-09-22T18:52:39.193Z", "group": "com.example" } }

Java information

Info endpoint has several info contributors like build, env, java, git etc. By default they are disabled. Enabling these would show related information collected by Spring.

To enable java info, add management.info.java.enabled=true in application.properties or management.info.java.enabled:true in application.yml.

This will show the following java info details collected like:
{ "build": { "java": { "version": "20" }, "spring": { "boot": { "version": "3.1.2" } }, "version": "0.0.1-SNAPSHOT", "artifact": "boot-graalvm", "name": "boot-graalvm", "time": "2023-09-22T18:52:39.193Z", "group": "com.example" }, "java": { "version": "20", "vendor": { "name": "Amazon.com Inc.", "version": "Corretto-20.0.0.36.1" }, "runtime": { "name": "OpenJDK Runtime Environment", "version": "20+36-FR" }, "jvm": { "name": "OpenJDK 64-Bit Server VM", "vendor": "Amazon.com Inc.", "version": "20+36-FR" } } }

TIP

These build properties generated and available through /actuator/info are also are available through BuildProperties object which can be auto-wired into any of the Spring managed beans and be accessed. For instance these properties can be outputted on swagger-ui page.


References

Saturday, August 05, 2023

Java bytecode - compiler version options and compatibilities . . .

One of many strengths of Java platform is its backward compatibility with the language. As language keeps evolving and moving forward, the good old syntax is still supported for backward compatibility. However, the compiler adds certain indicative options for specifying version details. The --source, --target are two such compiler (javac) options. From Java 9 onwards a third option --release got added to this mix. Getting a good understanding of these options is not trivial without actually experiencing all three. When compiling source code of a single class you may not need to specify these options. But in Java project when building with maven like build system, one needs to understand these options and their implications.

Environment: Java 20, Spring Boot 2.7.15, maven 3.9.3 on macOS Catalina 10.15.7

The maven-compiler-plugin

Maven build system uses maven-compiler-plugin for compiling source code. This plugin documentation upfront talks about source and target options and highly recommends to change these in plugin configuration. In order to change these per application/module needs, one needs to look under the hood for understanding.

Various extra Java compiler options can be specified in the maven-compiler-plugin configuration. The actual Java compiler options related to version are: --source, --target and --release that can be specified and passed to the compiler during code compilation through maven-compiler-plugin configuration. This can be done in two different ways in pom.xml:

1. Through maven properties: maven.compiler.source,  maven.compiler.target and maven.compiler.release as highlighted below:

<properties> <maven.compiler.source>20</maven.compiler.source> <maven.compiler.target>20</maven.compiler.target> <maven.compiler.target>20</maven.compiler.target> </properties>

If these properties are not explicitly defined, maven compiler plugin uses 1.8 for source and target.

2. Through the plugin configuration settings as highlighted below. Note - For convenience defined extra properties and used for source, target and release but straight version numbers can be used.

... <properties> <java.version>20</java.version> <javac.source.version>${java.version}</javac.source.version> <javac.target.version>${java.version}</javac.target.version> <javac.release.version>${java.version}</javac.release.version> <!-- Maven plugins --> <maven-compiler-plugin.version>3.11.0</maven-compiler-plugin.version> </properties> <build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>${maven-compiler-plugin.version}</version> <configuration> <source>${javac.source.version}</source> <target>${javac.target.version}</target> <release>${javac.release.version}</release> <compilerArgs> <arg>-Xlint:all</arg> </compilerArgs> </configuration> </plugin> </plugins> </pluginManagement> ...

If no special configuration is required, it doesn't require even to specify maven-compiler-plugin. If specified, the above are two ways to control/change default 1.8 set by the plugin for these options which eventually get passed to the Java compiler (javac) during code compilation of sources (both under src and test

Note from Java 9 onwards, the values to these options are not like 1.7, 1.8 but must be 7 and 8.

Java 20

Java 20 compiler doesn't support version 7 for source, target anymore. The supported releases are 8 through 20. So, for any reason if maven compiler plugin is set explicitly with 1.7, build fails with ERRORS saying: Source option 7 is no longer supported. Use 8 or later. , and Target option 7 is no longer supported. Use 8 or later. 

Now it's time to understand what these options actually tell the compiler, javac. The compiler's help option (javac -help) lists all available options and a brief description about each option. The -source, -target, -release options descriptions are helpful to some extent.

--source <release>, -source <release> Provide source compatibility with the specified Java SE release. Supported releases: 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 --target <release>, -target <release> Generate class files suitable for the specified Java SE release. Supported releases: 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 --release <release> Compile for the specified Java SE release. Supported releases: 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20

To understand these compiler options better, we can compile a simple Java application class with just main method.

HelloJava.java
import java.util.Properties; public class HelloJava { public static void main(String[] args) { Properties systemProperties = System.getProperties(); System.out.println(String.format("Hello Java %s!", systemProperties.getProperty("java.vm.specification.version"))); systemProperties.entrySet().stream() .filter(entry -> entry.getKey().toString().startsWith("java")) .toList().stream() .forEach(entry -> System.out.println(entry.getKey() + "=" + systemProperties.getProperty(entry.getKey().toString())) ); } }
Note - The above class prints system properties that start with "java" with their values. It uses toList() method that Java 16 added to Stream class. With this the expectation is- the code should not be compiled for Java/JVM version less than 16.

Java compiler version options

Let's compile the class with different Java versions and compiler options.
 
// compile with Java 20: no options specified $ sdk use java 20.0.2-amzn // check: major version $ javap -verbose HelloJava.class | grep major major version: 64 // run on Java 20: works $ javac HelloJava.java "Hello Java 20!" // switch to Java 17 and run: fails with LinkageError $ sdk use java 17.0.1.12.1-amzn $ java HelloJava Error: LinkageError occurred while loading main class HelloJava java.lang.UnsupportedClassVersionError: HelloJava has been compiled by a more recent version of the Java Runtime (class file version 64.0), this version of the Java Runtime only recognizes class file versions up to 61.0 // switch to Java 15 and run: fails with LinkageError $ sdk use java 15.0.2.7.1-amzn $ java HelloJava Error: LinkageError occurred while loading main class HelloJava java.lang.UnsupportedClassVersionError: HelloJava has been compiled by a more recent version of the Java Runtime (class file version 64.0), this version of the Java Runtime only recognizes class file versions up to 59.0 // switch to Java 8 and run: fails with UnsupportedClassVersionError Exception $ sdk use java 8.0.352-amzn $ java HelloJava Error: A JNI error has occurred, please check your installation and try again Exception in thread "main" java.lang.UnsupportedClassVersionError: HelloJava has been compiled by a more recent version of the Java Runtime (class file version 64.0), this version of the Java Runtime only recognizes class file versions up to 52.0 at java.lang.ClassLoader.defineClass1(Native Method) at java.lang.ClassLoader.defineClass(ClassLoader.java:756) at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142) at java.net.URLClassLoader.defineClass(URLClassLoader.java:473) at java.net.URLClassLoader.access$100(URLClassLoader.java:74) at java.net.URLClassLoader$1.run(URLClassLoader.java:369) at java.net.URLClassLoader$1.run(URLClassLoader.java:363) at java.security.AccessController.doPrivileged(Native Method) at java.net.URLClassLoader.findClass(URLClassLoader.java:362) at java.lang.ClassLoader.loadClass(ClassLoader.java:418) at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:352) at java.lang.ClassLoader.loadClass(ClassLoader.java:351) at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:601)

So. when compiled with a specific version of java compiler (in this case Java 20 and no version options are specified), it gets compiled with default target of the Java compiler which is 20. The class generated cannot be run on prior JVM versions (prior to 20). To find the target of JVM code the byte-code is generated for, use javap -verbose HelloJava.class | grep major.

Now, let's try compiling for target 17 and try to run on different JVM versions.

// compile with Java 20 for target 17: --source and --target options specified $ sdk use java 20.0.2-amzn $ javac --source=17 --target=17 HelloJava.java warning: [options] system modules path not set in conjunction with -source 17 1 warning // check: major version $ javap -verbose HelloJava.class | grep major major version: 61 // run on Java 20: works $ java HelloJava Hello Java 20! // switch to Java 17 and run: works $ sdk use java 17.0.1.12.1-amzn // run on Java 17: works $ java HelloJava Hello Java 17! // swicth to Java 15 and run: fails with LinkageError $ sdk use java 15.0.2.7.1-amzn $ java HelloJava Error: LinkageError occurred while loading main class HelloJava java.lang.UnsupportedClassVersionError: HelloJava has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 59.0 // compile with Java 20 for target 17: --source and --target options specified $ sdk use java 20.0.2-amzn $ javac --source=15 --target=15 HelloJava.java warning: [options] system modules path not set in conjunction with -source 15 1 warning // check: major version $ javap -verbose HelloJava.class | grep major major version: 59 // switch to Java 17 and run: works $ sdk use java 17.0.1.12.1-amzn $ java HelloJava Hello Java 17! // swicth to Java 15 and run: fails with NoSuchMethodError $ sdk use java 15.0.2.7.1-amzn $ java HelloJava Hello Java 15! Exception in thread "main" java.lang.NoSuchMethodError: 'java.util.List java.util.stream.Stream.toList()' at HelloJava.main(HelloJava.java:15) // switch to Java 8 and run: fais with UnsupportedClassVersionError $ sdk use java 8.0.352-amzn $ java HelloJava Error: A JNI error has occurred, please check your installation and try again Exception in thread "main" java.lang.UnsupportedClassVersionError: HelloJava has been compiled by a more recent version of the Java Runtime (class file version 59.0), this version of the Java Runtime only recognizes class file versions up to 52.0 at java.lang.ClassLoader.defineClass1(Native Method) at java.lang.ClassLoader.defineClass(ClassLoader.java:756) at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142) at java.net.URLClassLoader.defineClass(URLClassLoader.java:473) at java.net.URLClassLoader.access$100(URLClassLoader.java:74) at java.net.URLClassLoader$1.run(URLClassLoader.java:369) at java.net.URLClassLoader$1.run(URLClassLoader.java:363) at java.security.AccessController.doPrivileged(Native Method) at java.net.URLClassLoader.findClass(URLClassLoader.java:362) at java.lang.ClassLoader.loadClass(ClassLoader.java:418) at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:352) at java.lang.ClassLoader.loadClass(ClassLoader.java:351) at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:601) // compile with Java 20 for target 8: --source and --target options specified $ sdk use java 20.0.2-amzn $ javac --source=8 --target=8 HelloJava.java javac --source=8 --target=8 HelloJava.java warning: [options] bootstrap class path not set in conjunction with -source 8 warning: [options] source value 8 is obsolete and will be removed in a future release warning: [options] target value 8 is obsolete and will be removed in a future release warning: [options] To suppress warnings about obsolete options, use -Xlint:-options. 4 warnings // check: major version $ javap -verbose HelloJava.class | grep major major version: 52 // switch to Java 20 and run: works $ sdk use java 20.0.2-amzn $ java HelloJava Hello Java 20! // switch to Java 17 and run: works $ sdk use java 17.0.1.12.1-amzn $ java HelloJava Hello Java 17! // swicth to Java 15 and run: fails with NoSuchMethodError $ sdk use java 15.0.2.7.1-amzn $ java HelloJava Hello Java 15! Exception in thread "main" java.lang.NoSuchMethodError: 'java.util.List java.util.stream.Stream.toList()' at HelloJava.main(HelloJava.java:15)

So, when compiled for a target version, it cannot be run on prior JVM versions.

Let's try target 7.
$ sdk use java 20.0.2-amzn $ javac --source=7 --target=7 HelloJava.java warning: [options] bootstrap class path not set in conjunction with -source 7 error: Source option 7 is no longer supported. Use 8 or later. error: Target option 7 is no longer supported. Use 8 or later.
Java version 7 is not supported anymore.

Let's experience --release option.
// compile with Java 20 using options --source and --target options specified $ sdk use java 20.0.2-amzn $ javac --source=17 --target=17 --release=17 HelloJava.java error: option --source cannot be used together with --release error: option --target cannot be used together with --release Usage: javac <options> <source files> use --help for a list of possible options // compile with Java 20 for target 17: --release options specified $ sdk use java 20.0.2-amzn $ javac --release=17 HelloJava.java // check: major version $ javap -verbose HelloJava.class | grep major major version: 61 // switch to Java 20 and run: works $ sdk use java 20.0.2-amzn $ java HelloJava Hello Java 20! // switch to Java 17 and run: works $ sdk use java 17.0.1.12.1-amzn $ java HelloJava Hello Java 17! // switch to Java 15 and run: fails with NoSuchMethodError $ sdk use java 15.0.2.7.1-amzn $ java HelloJava Hello Java Exception in thread "main" java.lang.NoSuchMethodError: 'java.util.List java.util.stream.Stream.toList()' at HelloJava.main(HelloJava.java:14) // compile with Java 20 for target 15: --release options specified $ sdk use java 20.0.2-amzn $ javac --release=15 HelloJava.java HelloJava.java:14: error: cannot find symbol .toList().stream() ^ symbol: method toList() location: interface Stream<Entry<Object,Object>> 1 error // compile with Java 20 for target 15: --source --target options specified $ sdk use java 20.0.2-amzn $ javac --source=15 --target=15 HelloJava.java warning: [options] system modules path not set in conjunction with -source 15 1 warning // switch to Java 15 and run: fails with NoSuchMethodError $ sdk use java 15.0.2.7.1-amzn $ java HelloJava Exception in thread "main" java.lang.NoSuchMethodError: 'java.util.List java.util.stream.Stream.toList()' at HelloJava.main(HelloJava.java:14)

So, --release option does a strict compilation time checks to see if the code is compliant with the release target and fails to compile if a method not supported in target version is used in the code. This makes sure the compiled class works on target release (the target JVM version that the code is released to run on). Whereas, the --target option doesn't do code compliance checks during compilation time, it simply compiles code but fails during runtime. So, --release seems like better option to leverage when specifying.

Implications of version options

  • No compiler options specified: The code gets compiled with default target as the version of the Java compiler.
  • All 3 options  --source, --target and --release specified:  Not allowed.
  • Options --source--target specified: 1) Both can be same (e.g. 17, 17). 2) The option: --source can be lower version (e.g. 15) and --target can be higher version (e.g. 17), but not the other way. If --source is higher version (e.g.17) and --target is lower version (e.g.15), compiler fails with a warning: warning: source release 17 requires target release 17, it doesn't get compiled.
  • Only option --source: The option --source can be any version but default target would be the version of Java compiler being used.
  • Only option --target: The option: --target cannot be lower than the version of Java compiler being used because default source would be the version of the compiler being used. A lower target version gets into compiler option source higher, target lower and fails compilation. E.g javac --target=19 HelloJava.java with Java 20 compiler fails with warning: target release 19 conflicts with default source release 20 and code doesn't get compiled.
  •  Only option --release: Strict code check during compilation to make sure that compiled code gets compiled and works on the target version. Also, the byte-code generated runs only on the release specified and higher, but doesn't run on any lower versions.
    • Compiling using Java 20, and no --release option or --release=20 results with major version 64 (Java 20).
    • Compiling using Java 20 with --release=19 results with major version 63 (Java 19).
    • Compiling using Java 20 with --release=17 results with major version 61 (Java 17) and would result with LinkageError when run on lower version other than 17. Works on 17 and higher.

The maven-compiler-plugin variations with these options

Maven, out of the box with no maven-compiler-plugin specified in pom.xml, has the following variations with it's compiler version option properties (maven.compiler.source, maven.compiler.target and maven.compiler.release).

<properties> <maven.compiler.source>20</maven.compiler.source> <maven.compiler.target>20</maven.compiler.target> <maven.compiler.release>20</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>
  • No version properties are specified: (No maven.compiler.source, maven.compiler.target and maven.compiler.release properties): Build fails with compilation ERRORS: Source option 5 is no longer supported. Use 8 or later. and Target option 5 is no longer supported. Use 8 or later.
  • All 3 version properties are specified: The option: maven.compiler.release is ignored, it can be any junk. Only source and target properties matter. The value for target option cannot be less than the source. For instance, source 20, target 19 fails build with warning: source release 20 requires target release 20.
  • Only source version property is specified: When only source is specified, target must also be specified. Otherwise, maven build fails with: Fatal error compiling: warning: source release 20 requires target release 20
  • Only target version property is specified: When only target is specified, source must also be specified. Otherwise, maven build fails with: Source option 5 is no longer supported. Use 8 or later.
  • The release property specified: When release is specified and is 8 or later, this takes the precedence. Make a special NOTE of it. When release is specified, it takes the precedence and sourcetarget options are ignored. In this case, any non-sense value will make the build work and code gets compiled for the release version specified. But this must be 8 or later.
With maven-compiler-plugin specified in pom.xml, has the following variations with these options specified in the plugin configuration (<configuration>): 

<properties> <maven.compiler.source>20</maven.compiler.source> <maven.compiler.target>20</maven.compiler.target> <maven.compiler.release>20</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> ... <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>20</source> <target>20</target> <release>20</release> </configuration> </plugin> </plugins> </build>

With the above way of having both defined set of properties, and maven-compiler-plugin configuration, the configuration values override the property values defined. If no configuration is specified for the maven-compiler-plugin, it uses the set of properties defined. With configuration <source>, <target>, and <release> taking the precedence, the variations are as follows:
  • No <configuration> specified for the plugin, but set of properties are defined: It uses defined set of properties if exist, release takes the precedence over target and source.
  • No properties are defined, and no <configuration> is specified for the plugin : It defaults to target 1.8.
  • All 3 configuration options are specified: The release configuration takes the precedence and is built for the release version.
  • No properties are set, and only source configuration option is specified: When only source is specified, target must also be specified. Otherwise, maven build fails with: Fatal error compiling: warning: source release 20 requires target release 20
  • No properties are set, and only target configuration option is specified: Code gets compiled for the target specified.
  • No properties are set, and both target and release configuration options are specified: The release option takes the precedence and code gets compiled for the release specified.

Summary

For Java 9 and after, use --release option.
For older versions prior to Java 9 use --source and --target options.

TIPS

  • Use SDKMAN to install multiple Java versions and easily switch between different versions.
  • If You see noisy warning: Using deprecated '-debug' fallback for parameter name resolution. Compile the affected code with '-parameters' instead or avoid its introspection:, then add compiler argument <arg>-parameters</arg> to the maven-compiler-plugin configuration as shown below:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>${maven-compiler-plugin.version}</version> <configuration> <source>${javac.source.version}</source> <target>${javac.target.version}</target> <release>${javac.release.version}</release> <compilerArgs> <arg>-Xlint:all</arg> <arg>-parameters</arg> </compilerArgs> </configuration> </plugin>

References

Thursday, January 26, 2023

Spring Boot - WebTestClient Gotcha . . .

The Scenario

Recently I had a scenario to write an integration test in a Spring Boot micro-service (say MyService) which hosts it's own data in it's own PostgreSQL database in order to verify it's data against another Spring Boot micro-service which hosted master data in it's own database. The database contains a specific entity data set (say Item), a fixed set of items, used for a specific purpose. Another micro-service (say OtherService) uses one of the end-points of MyService by passing entity ids (Item ids) for some processing that MyService is capable of. The OtherService hosts master data of those Entities (Items) in its own database. But, the data hosted for each entity by both services is entirely different, and each has its own business with it's own data set. Only ids are common. OtherService data is considered master data. So, a MyService integration test requires to make sure that there are no entities missing in it's database. OtherService, offers an end-point to get it's hosted Item entities.

Environment: Java 17, Spring Boot 3.0.2, maven 3.8.5 on macOS Catalina 10.15.7

So, WebTestClient seemed like better choice to leverage to make an API request and get the master list of Items and check it's database to see if the count of Items and list of Item ids match.

In MyService, there was an integration test already in place written to check most of it's lookup like database entities; one test method for testing each entity's data set. This was a natural integration test to extend by adding another integration test method for testing Item entity set. But this one goes beyond it's database, out to OtherService, making an API request to to get the master list of Items to verify against.

The Issue

Simply Auto-wiring WebTestClient like:
@Autowired privateWebTestClient webTestClient;

broke the auto-configuration by the following exception:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'com.hmhco.sgm.scoring.api.persistence.LookupRepositoriesDataIT': Unsatisfied dependency expressed through field 'webTestClient'; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.test.web.reactive.server.WebTestClient' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)} Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.test.web.reactive.server.WebTestClient' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}

The Solution

A quick Googling suggested to try the following, which actually worked (NOTE: WebTestClient requires org.springframework.boot:spring-boot-starter-webflux dependency which we already had in MyService app pom.xml:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

But this solution brings up the embedded web server (Tomcat) starting it at a random port which is actually unnecessary for this test. All I need is an instance of WebTestClient to make a HTTP Get request to OtherService end-point.

Here is the code snippet for this solution:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @RunWith(SpringRunner.class) public class MyServiceDataIT { @Value("${spring.other-service-api.get-Items-end-point}") private String apiEndPoint; @Value("${spring.other-service-api.trusted_token}") private String trustedToken; @Autowired private WebTestClient webTestClient; ... @Test public void itemLookup_has_no_missing_items() throws Exception { // given: request spec var requestSpec = webTestClient.get() .uri(apiEndPoint) .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .header(HttpHeaders.AUTHORIZATION, trustedToken); // expect: end-point used to get items, succeeds and get JSON response body var response = requestSpec.exchange() .expectStatus().isOk() .expectBody(String.class) .returnResult() .getResponseBody(); // and: convery JSON response to List List<Map<String, String>> items = new ObjectMapper().readValue(response, List.class); // verify: items size matches with what we have in database ... // verify: item ids match with what we have in database ... }


Little more Research

But a follow-up reading of Spring Boot Documentation made me dig bit deeper. Actually, I did not need a full web environment to be up with embedded servers started and listening on random port that the above change brings in, which also satisfied auto-configuration need for WebTestClient.

Spring Boot auto configuration has a set of annotations with which you can pick and chose the ones that you really need. Apparently there is one for WebTestClient,  @AutoConfigureWebTestClient. That sounded like the way to go instead of making having the complete test web environment up and running.

So, thought the following would work, but it also failed with the above, same exception:
@SpringBootTest @AutoConfigureWebTestClient @AutoConfigureWebFlux @ActiveProfiles("test") @RunWith(SpringRunner.class) public class MyServiceDataIT { @Autowired private WebTestClient webTestClient; ... }

After little more investigation, I found that we do have dependency: spring-boot-starter-hateoas that Spring Boot Documentation clearly warns on this saying it is meant to be specifically for Spring MVC and should not be used with WebFlux. The alternative in this case is to use: org.springframework.hateoas:spring-hateoas instead. Well, tried that too with the above annotations, but ended up with the same exception.

Better and cleaner solution

The better and cleaner solution is not to have the embedded web server started, but WebTestClient instance created to make a HTTP Get request. So the obvious solution is to ditch dependency injection for WebTestClient and create an instance.

The following is the code snippet:
@SpringBootTest @ActiveProfiles("test") @RunWith(SpringRunner.class) public class MyServiceDataIT { @Value("${spring.other-service-api.get-Items-end-point}") private String apiEndPoint; @Value("${spring.other-service-api.trusted_token}") private String trustedToken; ... @Test public void itemLookup_has_no_missing_items() throws Exception { // given: web test client WebTestClient webTestClient = WebTestClient .bindToServer() .baseUrl(apiEndPoint) .build(); // and: request spec var requestSpec = webTestClient .get() .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .header(HttpHeaders.AUTHORIZATION, trustedToken); // expect: end-point used to get items, succeeds and get JSON response body var response = requestSpec.exchange() .expectStatus().isOk() .expectBody(String.class) .returnResult() .getResponseBody(); // and: convery JSON response to List List<Map<String, String>> items = new ObjectMapper().readValue(response, List.class); // verify: items size matches with what we have in database ... // verify: item ids match with what we have in database ... }

TIP

Sometimes, WebTestClient times out if the response takes more time than the default value 5000 milliseconds (5 seconds) by throwing the following exception:
java.lang.IllegalStateException: Timeout on blocking read for 5000 MILLISECONDS at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:123) at reactor.core.publisher.Mono.block(Mono.java:1734)

This timeout can be configured in two ways.

1. If WebTestClient is @Autowired with web server starting on a random port, by using annotation @AutoConfigureWebTestClient as shown below:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureWebTestClient(timeout = "10000") // millis, 10 seconds @ActiveProfiles("test") @RunWith(SpringRunner.class) public class MyServiceDataIT { @Value("${spring.other-service-api.get-Items-end-point}") private String apiEndPoint; @Value("${spring.other-service-api.trusted_token}") private String trustedToken; @Autowired private WebTestClient webTestClient; ... }

2. If WebTestClient is NOT @AutoWired and the embedded web server is not started, then by setting response timeout while building WebTestClient instance as shown below:
@Test public void itemLookup_has_no_missing_items() throws Exception { // given: web test client WebTestClient webTestClient = WebTestClient .bindToServer() .baseUrl(apiEndPoint) .build() .mutate() .responseTimeout(Duration.ofSeconds(10)) .build(); ... }

Conclusion

These days, most of the times, solutions can be found by googling or on stackoverflow. But, sometimes a clea(ne)r solution takes time to explore and try out.

A software developer's life is never easy, it's always challenging by simple things that look simple on the surface, yet complex under the hoods. Spring Boot framework is no exception in this regard ;)

Here is the link to GitHub repo that contains example code for two different integration test-cases of the two solutions mentioned in this post to try out: spring-boot-gotchas GitHub repo