Au travers de cet article, je vous propose quelque chose de nouveau : vous faire un retour d’expérience sur l’upgrade d’une application Spring Boot (Java/Kotlin).
Contexte
Pour un de mes clients, avec une équipe, nous avons monté leur plateforme SaaS. A cette occasion, nous avons mis en place un politique d’upgrade des stacks techniques successives. Cela nous a permis de rester à jour et de profiter des dernières fonctionnalités.
En général, on fait ces upgrades dans des périodes plutôt creuses en terme d’attente utilisateurs, souvent pendant les vacances de décembre et d’été. Ces périodes nous ont permis :
- d’avoir un recul sur les dernières releases des frameworks et librairies que l’on utilise,
- de prendre le temps de faire les choses correctement,
Petit retour dans le passé avec nos premières upgrades
> 2020
Etat des lieux
Quand je suis arrivé sur le projet en 2020, on était sur les versions suivantes :
- Spring Boot: 2.1.x
- JAVA 8
- Spring Cloud: Greenwich
Petit changement de stack technique
Etant en architecture micro services, nous avons un certain nombre de services sur une stack JVM. 2 membres de l’équipe ont d’abord poussé pour passer sur une stack SpringBoot Kotlin.
À chaque démarrage d’un nouveau service, on a fait le choix de le faire en Kotlin en Java 11 et sur la dernière version de spring boot 2.2.x en mars 2020. Concernant Kotlin, la version 1.3.70 était la dernière version stable.
Pourquoi vous parler de cela ? Il est important de voir que l’on a fait les choses progressivement. De plus, l’ajout du Kotlin n’a pas été sans conséquence dans les upgrades spring boot qui ont suivi et je vous en parlerai plus tard 😉
Première upgrade
Avec l’arrivée de nouveau service en Kotlin sur des stacks récente, on a décidé de faire une première upgrade de nos services en Java 8 vers Java 11 et de Spring Boot 2.1.x vers 2.2.x en juin 2020.
- Spring Boot: 2.1.x -> 2.2.x
- JAVA 8 -> JAVA 11
- Spring Cloud: Greenwich -> Hoxton
Globalement cette upgrade s’est bien passée. On a quelques soucis sur des changements de signatures dans spring data mongo qui nous ont obligé à faire des corrections de code. Ex:
// Avant dans un repository mongo
Query expectedQuery = new Query();
expectedQuery.with(new Sort(Sort.Direction.DESC, "foo"));
// Après
Query expectedQuery = new Query();
expectedQuery.with(Sort.by(Sort.Direction.DESC, "foo"));
Côté Kafka, une dépendance aussi avec légèrement changé de nom, mais rien de bien méchant:
<dependencies>
<!-- Avant -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-schema</artifactId>
</dependency>
<!-- Après -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-schema-registry-client</artifactId>
</dependency>
</dependencies>
L’intérêt de cette upgrade était de pouvoir profiter des dernières fonctionnalités de Spring Boot 2.2.x et de Java 11 notamment en termes de configuration par défaut de la JVM pour les conteneurs Docker au niveau du CPU et de la mémoire.
NB: JAVA 8 supportait déjà les conteneurs Docker, mais il fallait faire des configurations spécifiques ou alors être sur une version de Java 8 assez récente (>= 8u191).
> 2022
C’est en août 2022 que j’ai poussé à une seconde mise à jour de nos applications. En effet, cela allait faire presqu’un an que JAVA 17 était sorti. On se devait donc de mettre à jour notre stack technique afin d’exploiter les nouveautés de cette version et de profiter des dernières versions de Spring Boot et Spring Cloud.
Etat des lieux
L’ensemble des applications à cette date étaient sur les versions suivantes :
- Spring Boot: 2.2.x ou 2.3.x (pour les services en Kotlin)
- JAVA 11
- Spring Cloud: Hoxton
Méthodologie choisie
A cette date, on a donc décidé de faire une upgrade de nos services en Java 11 vers Java 17 et de Spring Boot 2.2.x vers 2.7.x en août 2022. Cela étant dit, plutôt que de migrer directement sur Spring Boot 2.7.x, on a décidé de faire plusieurs étapes intermédiaires afin de limiter les risques d’upgrade. De plus, le switch sur Java 17 ne s’est qu’une fois que l’ensemble des services étaient sur Spring Boot 2.7.x.
Upgrade Spring Boot 2.2.x vers 2.5.x
- Spring Boot: 2.2.x -> 2.5.x
- JAVA 11
- Spring Cloud: Hoxton -> 2020.0.3
Cette montée de version s’est bien passée même si nous avons rencontré quelques petits problèmes d’upgrade.
Le premier est survenu sur une dépendance transitive d’un serializer avro de confluent qui rentrait en conflit et nous a obligé à faire une exclusion de dépendance.
<project>
<properties>
<kafka-avro-serializer.version>5.5.1</kafka-avro-serializer.version>
</properties>
<dependencies>
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-avro-serializer</artifactId>
<version>${kafka-avro-serializer.version}</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
<!--
Upgrade spring boot 2.2.8 ===> spring boot 2.5.12
===> Spring Kafka Test with Confluent Kafka Avro Serializer cannot find ZKClientConfig
https://stackoverflow.com/a/62511508
-->
<exclusion>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</project>
Un second a concerné une dépendance que nous utilisions pour nos tests sur les services utilisant la base de données
MongoDB. En effet, nous utilisions la dépendance de.flapdoodle.embed:de.flapdoodle.embed.mongo
pour démarrer une base
de données MongoDB en mémoire. Nous avions figé une version de cette dépendance qui n’était plus compatible avec Spring
Boot 2.5.x. Nous avons donc fait le choix de ne plus figer cette dépendance et de laisser Spring Boot gérer la version:
<project>
<properties>
<!-- Upgrade spring boot 2.2.8 ===> spring boot 2.5.12 - Let spring boot manage embed mongo version -->
<!-- <flapdoodle.embed.mongo.version>2.2.0</flapdoodle.embed.mongo.version> -->
</properties>
<dependencies>
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
<!-- <version>${flapdoodle.embed.mongo.version}</version> -->
<scope>test</scope>
</dependency>
</dependencies>
</project>
À part cela, tout s’est très bien passé sur cet upgrade 😎.
Utilisant spring-cloud-stream
, nous avons profité également de cette upgrade pour migrer en mode fonctionnel nos bindings
spring cloud stream afin d’anticiper à une future suppression du mode impératif au travers d’annotation @StreamListener
devenu
déprécié au moment de l’upgrade vers Spring Cloud 2020.x.
Exemple de code avant migration:
@StreamListener(Processor.INPUT)
fun process(Message<String> message) {
// ...
}
Exemple de code après migration:
class MyConsumer : Consumer<List<String>> {
override fun consume(message: List<String>) {
// ...
}
}
Puis dans la configuration java spring :
@Configuration
class MyConsumerConfiguration {
@Bean
fun myConsumer() = MyConsumer()
}
Voir également Introduction to event-driven microservices with Spring Cloud Stream.
Upgrade Spring Boot 2.5.x vers 2.6.x
- Spring Boot: 2.5.x -> 2.6.x
- JAVA 11
- Spring Cloud: 2020.0.3 -> 2021.0.3
Lors de cette montée de version,nous avons eu un problème sur la partie assertions de nos tests.
Nous utilisons AssertJ pour faire nos assertions. Or, la montée de spring boot en 2.6.x a fait passer la version d’AssertJ de 3.19.0 à 3.21.0. Cette nouvelle version a modifié la manière de gérer l’enchainement des assertions (notamment sur la partie Collection). Plutôt que de perdre plus de temps à comprendre le problème, on a fait le choix de revenir sur la version 3.19.0 d’AssertJ en attendant de trouver une solution plus pérenne.
Un autre problème est survenu sur la dépendance vers liquibase. En effet, nous utilisons liquibase pour gérer nos scripts de migration de base de données. Or, la montée de spring boot en 2.6.x a fait passer la version de liquibase de 3.10.3 à 4.5.0. Cette nouvelle version semblait comporter un bug sur l’exécution de script sur une base postgres ayant un schema autre que public. Après quelques recherches et ayant vu que spring boot 2.7 incorporait une version de liquibase plus récente, on a fait le choix de montée de version liquidbase sur une version plus récente : 4.13.0.
En conséquence, les modifications suivantes ont été faites sur notre fichier pom.xml
:
<project>
<properties>
<!--
On fixe à la version 3.19.0 car sur spring boot 2.6.9, on passe sur assertj 3.21.0 qui a modifié
la manière de gérer l'enchainement de certaines assertions.
Donc une migration à part entière est à prévoir sur ce sujet.
-->
<assertj.version>3.19.0</assertj.version>
<!--
Avec l'upgrade de spring boot en 2.6.x, liquidbase est sur la version 4.5.0
qui semble comporter un bug sur le lancement des scripts sur un autre schema que public.
-->
<liquibase.version>4.13.0</liquibase.version>
</properties>
<dependencies>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>${liquibase.version}</version>
</dependency>
</dependencies>
</project>
Au passage de cette migration, nous avons également fait le choix de profiter pour migrer notre module de sécurité
spring qui s’appuyait sur le module spring-security-oauth2-autoconfigure
vers le module désormais intégrer à
spring security spring-boot-starter-oauth2-client
.
Cette modification a principalement changer la manière de configurer spring security au travers des propriétés.
Exemple de configuration avant migration:
security:
oauth2:
client:
clientId: app-id
clientSecret: app-secret
accessTokenUri: https://sso.example.com/oauth/token
userAuthorizationUri: https://sso.example.com/oauth/authorize
Exemple de code après migration:
spring:
security:
oauth2:
client:
registration:
sso:
client-id: app-id
client-secret: app-secret
authorization-grant-type: authorization_code
redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
scope: openid,profile,email
client-name: Keycloak
provider:
sso:
authorization-uri: https://sso.example.com/oauth/authorize
token-uri: https://sso.example.com/oauth/token
Migration spécifique pour AssertJ
Nous avons entrepris de faire une migration vers la version 3.21.0 d’AssertJ. Pour cela, nous retiré la version figé d’AssertJ et laissé Spring Boot gérer la version. Ensuite, nous avons fait une recherche sur les tests qui ne passaient plus et nous avons fait les modifications nécessaires.
Les 2 problèmes principaux étaient les suivants:
- l’enchainement des assertions sur les collections,
- le changement dans l’api pour ignorer la comparaison sur certains champs (ex: identifiant auto générée).
Globalement, nous avons du corriger ce genre d’assertions :
// Avant
val users: Set<User> = userService.findAll()
assertThat(users)
.usingRecursiveFieldByFieldElementComparator()
.usingElementComparatorIgnoringFields("id")
.containsExactlyInAnyOrder(userOne, userTwo)
// Après
val users: Set<User> = userService.findAll()
assertThat(users.toList())
.usingRecursiveFieldByFieldElementComparatorIgnoringFields("id")
.containsExactlyInAnyOrder(userOne, userTwo)
Pour plus d’informations, je vous invite à consulter la documentation.
Upgrade Spring Boot 2.6.x vers 2.7.x
- Spring Boot: 2.6.x -> 2.7.x
- JAVA 11
- Spring Cloud: 2021.0.3 -> 2021.0.4
Cette upgrade s’est très bien passée sans aucun souci, merci Spring Boot 😁.
Upgrade Java 11 vers Java 17
- Spring Boot: 2.7.x
- JAVA 11 -> JAVA 17
- Spring Cloud: 2021.0.3
Une seule modification à appliquée :
<project>
<properties>
<!-- Upgrade java 11 ===> java 17 -->
<java.version>17</java.version>
</properties>
</project>
Cette upgrade s’est très bien passée sans aucun souci, merci Java et Spring Boot 😁.
Upgrade Spring Cloud 2021.0.3 vers 2021.0.5 avortée
Désormais je vais vous parler d’une upgrade qui a été clairement plus compliqué car on a du revenir en arrière, le temps de trouver la raison du problème. En effet, en décembre 2023, nous avons mis à jour nos versions spring boot (en version mineure) et spring cloud. Nous n’avons pas souhaité passer de suite sur spring boot 3.0.x car nous étions pas mal occupé sur d’autres sujets fonctionnelles et préférions avoir plus de recul sur cette version majeure avant de l’adopter.
En revanche, la mise à jour vers spring cloud 2021.0.5 a été plus compliqué. En effet, nous avons été confronté à un bug de spring-cloud-function
venant avec spring-cloud-stream
qui nous a obligé à revenir sur la version 2021.0.3 de spring cloud.
Ce bug a été identifié et était uniquement présent si on utilisait Kotlin 😢 :
Using Kotlin Functions in Spring Cloud Stream broken since 2021.0.4
Upgrade Spring Cloud 2021.0.3 vers 2021.0.8
Cet été, nous avons fait le choix de procéder à une nouvelle salve d’upgrade. En effet, nous avons profité de la période estivale pour faire une nouvelle montée de version de nos services dans l’optique de passer sur Spring Boot 3.1.x.
Avant d’attaquer cette montée de version, nous avons fait le choix de faire une montée de version de Spring Cloud 2021.0.3 qui nous avait posé problème en décembre dernier. Nous avons donc fait la montée de version suivante :
- Spring Boot: 2.7.x
- JAVA 17
- Spring Cloud: 2021.0.3 -> 2021.0.8
Durant cette montée de version, de nouveau, nous avons été confronté à un problème similaire à celui de décembre dernier.
Il s’agissait désormais à un problème de ClassCastException
. Ce problème est répertorié sur le repository de
spring-cloud-stream
: Converting Kotlin Consumer to the java.util.Function.
Nous avons malgré tout réussi à contourner le problème en encapsulant nos consumers Kotlin par un Consumer Java:
@Configuration
class MyConsumerConfiguration {
@Bean
fun myConsumer() = MyConsumer().let {
Consumer { message: List<String> -> it.consume(message) }
}
}
Cette solution n’est pas très élégante, mais elle nous a permis de passer outre le problème et de continuer notre montée de version.
Elle a été également soumise sur le repository de spring-cloud-stream
:
Converting Kotlin Consumer to the java.util.Function.
Upgrade Spring Boot 2.7.x vers 3.1.x
Cet upgrade a été la dernière étape afin d’atterir sur spring boot 3.1.x. Cette montée de version a été plus douloureuse que les précédentes car elle a nécessité de faire des modifications sur notre code.
En effet, spring boot 3.1.x s’appuie désormais sur Jakarta EE 9 et non plus sur Jakarta EE 8 nécessitant de renommer l’ensemble des import javax.persistence en jakarta.persistence.
Une nouvelle version majeure d’Hibernate est également utilisée. Or de notre côté, nous chiffrions certains champs de nos
entités avec JPA à l’aide de Jasypt. Or, la version de Jasypt que nous utilisions n’était pas compatible avec la nouvelle
version d’Hibernate. Jasypt n’étant plus maintenu, on a supprimé le module jasypt-hibernate5 et on utilise maintenant
le standard JPA pour chiffrer nos champs à l’aide de l’annotation @Convert
et d’un AttributeConverter
. Cet attribute
converter est maintenant compatible avec la nouvelle version d’Hibernate.
@Converter
class EncryptedStringAttributeConverter(private val stringEncryptor: Encryptor<String>) : AttributeConverter<String, String> {
override fun convertToDatabaseColumn(attribute: String): String = stringEncryptor.encrypt(attribute)
override fun convertToEntityAttribute(databaseValue: String): String = stringEncryptor.decrypt(databaseValue)
}
Du côté de spring cloud, nous avons eu uniquement à mettre à jour la version de spring cloud stream.
<project>
<properties>
<!-- Upgrade spring cloud stream 2021.x.x ===> 2022.x.x -->
<spring-cloud-stream.version>2022.0.3</spring-cloud-stream.version>
</properties>
</project>
Après avoir fait ces modifications, nous avons pu passer sur spring boot 3.1.x sans plus aucune difficultés 🚀🎉
Conclusion
Au travers de cet article, je vous ai fait un retour d’expérience sur l’upgrade d’une application Spring Boot et Kotlin. La leçon que je retiens de ces upgrades est qu’il faut faire les choses progressivement et ne pas hésiter à faire des montées de version mineures régulièrement. Cela permet de limiter les risques d’upgrade et de pouvoir profiter des dernières fonctionnalités des frameworks et librairies que l’on utilise.
Et pour cela, n’hésitez pas à l’automatiser au maximum avec des outils tels que Dependabot ou Renovate.
Mais je vous réserve déjà un article sur la mise en place du dernier 😉
Notes - Annexes
En finissant d’écrire cette article de blog, j’ai découvert également un outils qui permet de faire des upgrades de dépendances de manière automatique : OpenRewrite. Je vous invite à aller voir ce que cela donne. Un article sur le site Baeldung apporte un mini guide sur l’utilisation d’OpenRewrite : A Guide to OpenRewrite. Enfin il est à noter que Netflix s’appuie notamment sur ça pour leur upgrade de leur stack technique bâti sur Spring Boot :
Resources utiles
- Spring Cloud @StreamListener condition deprecated what is the alternative
- Spring 6: Spring Cloud Stream Kafka - Replacement for @EnableBinding
- End of life - Spring Boot
- Spring Cloud Stream Reference
- 10 best practices to build a Java container with Docker
- Spring Blog
- Spring Security and OpenID Connect