Dîtes adieux aux NullPointerException dans Java
Contexte
Dans le cadre d'un précédent projet pour un client du monde de l'Assurance, j'ai réalisé le socle technique de l'ensemble des microservices en Kotlin & Spring Boot 3. Pour les personnes ne connaissant pas Kotlin, c'est
un langage moderne et interopérable avec Java. Parmi ses atouts, il a l'avantage d'être naturellement null-safe ! Comme vous pouvez l'imaginer, la transition de Kotlin vers Java est donc assez douloureuse. Pour imager, cela reviendrait
presque à switcher de Typescript vers Javascript pour des développeurs frontend.
Souhaitant absolument retrouver le confort (ou tout du moins une partie) de développement que j'avais sous Kotlin, j'ai donc recherché
des outils pouvant garantir cette sécurité de développement directement dans Java. Et comme vous pouvez le supposer, si j'ai écrit cet article, c'est que j'ai trouvé une solution satisfaisante, moderne et facile
à mettre en place.
Outils
Cette solution se compose de trois outils que je vais vous présenter en détail ci-après. Ces derniers sont tous compatibles avec Maven & Gradle,
NullAway
Github : uber/NullAway / Licence : MIT
NullAway est un outil d'analyse statique dont son seul et unique
but est de traquer l'apparition potentielle de NullPointerException
.
Pour cela, il s'appuie sur la déclaration d'annotations qui permettent d'indiquer si un champ, un paramètre ou un retour d'une fonction est nullable ou non.
Error Prone
Github : google/error_prone / Licence : Apache 2.0
ErrorProne est un outil d'analyse statique
qui détecte les mauvaises pratiques de développement. Dans notre contexte, NullAway s'y intègre comme une extension et c'est ErrorProne qui
joue le rôle d'appliquer les règles de contrôle de nullité.
JSpecify
Github : jspecify / Licence : GNU 2.0
JSpecify a pour objectif de standardiser les annotations
de nullité autour de Java.
Dans quel but ? Actuellement, il existe des dizaines d'annotations similaires autour de la nullité provenant de différentes organisations (org.jetbrains.annotations.NotNull
,
org.springframework.lang.NonNull
, jakarta.validation.constraints.NotNull
1, etc). C'est pourquoi les principaux acteurs du développement (Google, JetBrains, Microsoft, Oracle, etc) 2 ont
formé cette initiative afin d'uniformiser son usage et simplifier nos développements.
JSpecify met donc à disposition les quatre annotations ci-dessous :
@org.jspecify.annotations.Nullable : Indique que le champ, la paramètre ou le retour peut être null.
@org.jspecify.annotations.NonNull : Indique que le champ, la paramètre ou le retour ne peut pas être null.
@org.jspecify.annotations.NullMarked : Déclarée sur un package ou une classe, elle indique que tout est
non null par défaut et donc, que seules les annotations nullable doivent être positionnées.
@org.jspecify.annotations.NullUnmarked : Rôle inverse de @NullMarked
Remarque #1 :
L'utilisation de @NullMarked
et
de @NullUnmarked
sur
un package via package-info.java
ne
s'applique pas récursivement dans tous les sous packages. Il faut donc le déclarer autant de fois qu'il y a de packages.
Remarque #2 :
L'utilisation de JSpecify reste en soi facultative avec NullAway
mais
comme décrit précédemment, ces annotations seront la standardisation de demain.
Démonstration
Dans le cadre de mon projet actuel, je réalise un socle technique en Java 21 & Spring Boot 3 d'un ensemble de microservices. Démarrant from scratch, un des fils conducteur de la réalisation a été de garantir
ce côté null-safe par défaut sur l'ensemble des applications.
Application de la stratégie d'annotations
Pour aboutir à une solution simple qui s'assure également que les développeurs ne devront déclarer que le strict minium en terme d'annotations, la meilleure stratégie a été d'utiliser @NullMarked
dans
les package-info.java
sur
tous les packages et sous-packages et de ne déclarer que ce qui est facultatif via @Nullable
.
À ce stade et en tant que développeur, vous allez me détester si vous vous retrouvez obligé de renseigner manuellement tous les package-info
!
Pour éviter toute action manuelle, j'ai créé un plugin Maven qui génère automatiquement tous les package-info
de
tous les modules du projet. Je ne vais pas le détailler ici, mais c'est très facile à réaliser et cela demande à peine une centaine de lignes de code.
Configuration Maven
La quasi-totalité de la configuration est réalisée dans le pom.xml
.
Vous trouverez ci-dessous un exemple simplifié de ce que j'ai mis en place mais sachez qu'il est possible d'appliquer un grand nombre de propriétés avec NullAway et de les variabiliser
comme vous savez faire dans Maven. 3
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>${jspecify.version}</version>
</dependency>
...
<!--
Mon plugin custom générant les package-info avant la compilation,
facultatif en fonction de votre contexte.
-->
<plugin>
<groupId>fr.soprasteria.blog.techmeup.maven.plugins</groupId>
<artifactId>package-info-generator-maven-plugin</artifactId>
<version>${package-info-generator-maven-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>generate-package-info</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<fork>true</fork>
<compilerArgs combine.children="append">
<arg>-XDcompilePolicy=simple</arg>
<arg>
-Xplugin:ErrorProne -XepDisableAllChecks \ (1)
-XepOpt:NullAway:AnnotatedPackages=fr.soprasteria \ (2)
-Xep:NullAway:ERROR (3)
</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg> (4)
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
</compilerArgs>
<annotationProcessorPaths combine.children="append">(5)
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>${error-prone-core.version}</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>${nullaway.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Dans mon contexte, je ne souhaite pas utiliser ErrorProne pour qu'il contrôle la qualité du code, je désactive donc le plugin de
tout son référentiel de règles.
Cependant, j'indique à ErrorProne que je souhaite utiliser l'extension NullAway sur les packages donnés.
Je précise que je souhaite absolument stopper la compilation si un potentiel NullPointerException est détecté
(il est possible de remonter uniquement des messages d'alerte).
Ces paramètres sont indispensables depuis le renforcement des fonctionnalités internes du JDK en version 16.
Je n'oublie pas de déclarer ErrorProne et NullAway comme annotation processor.
Notez bien l'utilisation de combine.children="append", cet argument vous permet, pour des modules ou projets enfants,
de venir enrichir la liste des annotations processor. Par défaut, Maven fonctionne par annule et remplace.xxx
Exemple
À présent, pour illustrer le fonctionnement, voici un exemple de package-info
automatiquement
généré.
@org.jspecify.annotations.NullMarked
package fr.soprasteria.blog.techmeup.exemple;
À l'intérieur de ce package, imaginons que nous avons l'objet Article
ci-dessous
:
package fr.soprasteria.blog.techmeup.exemple;
(1)
public record Article(
ArticleId id, (2)
String title, (2)
String contenu, (2)
Auteur auteur, (2)
Etat etat, (2)
@Nullable Validateur validateur (3)
) {
}
Pas besoin de l'annotation @NullMarked car nous l'avons déclaré précédemment dans le package-info.java pour
ce même package.
Tous les champs sans annotations sont considérés comme non-null par défaut grâce à l'annotation @NullMarked.
Déclaration du champ validateur comme facultatif et donc, nullable. On peut imaginer une règle métier nous disant
que le validateur de l'article n'est renseigné que lorsque l'article est à l'état publié.
Si je souhaite connaitre la personne qui a validé l'article, je vais devoir m'assurer au préalable qu'il est bien valorisé.
Là où sur une application classique, l'erreur ne serait détectée
qu'à l'exécution et avec le bon jeu de test, ici IntelliJ la détecte dès l'écriture du code. 
Et bien évidemment, puisque seul le compilateur fait foi, ce dernier ne compilera pas :
[ERROR] /C:/.../src/main/java/fr/soprasteria/blog/techmeup/exemple/Article.java:[29,29] [NullAway] dereferenced expression article.validateur() is @Nullable (see http://t.uber.com/nullaway )
[NullAway] dereferenced expression article.validateur() is @Nullable
Intégration
L'exemple précédent est très simpliste mais permet d'illustrer le fonctionnement et la puissance de cette approche.
Maintenant, je vous laisse mettre en perspective son utilisation sur une application web avec une analyse
sur l'ensemble des couches applicatives, sachant que NullAway est déjà préconfiguré pour être compatible avec des annotations existantes dont celles utilisées
par Spring. Ce qui signifie qu'en plus d'avoir une sécurité sur votre propre code, vous avez une sécurité sur l'utilisation des fonctionnalités de Spring.
Sur l'extrait de code ci-dessous, Spring déclare
à juste titre la récupération du payload en réponse d'une requête HTTP comme facultative.
package org.springframework.http;
import org.springframework.lang.Nullable;
public class HttpEntity<T> {
...
@Nullable
public T getBody() {
return this.body;
}
...
}
Une anomalie sera donc détectée en retournant directement le code ci-dessous :
public ArticleResponseDto consulter(ArticleId id) {
ResponseEntity<ArticleResponseDto> article = articleClient.consulter(id);
return article.getBody(); // Expression 'article.getBody()' might evaluate to null but is returned by the method declared as @NullMarked
}
Cependant, pour réussir cette intégration de bout en bout et sur une approche design first, cela nécessite un travail supplémentaire. En effet, pour la génération du code des API REST (via OpenApi Generator
4) ou des messages AVRO 5 pour
Kafka, il est nécessaire de customiser les templates de génération pour y intégrer nos annotations JSpecify.
Avantages
La mise en pratique de tout ce que j'ai montré précédemment :
Permet la détection des NullPointerException au plus tôt, dès la compilation. Cela nous garantit donc de l'absence de
mauvaises surprises lors de son exécution ;
Apporte de la robustesse de bout en bout, du traitement d'une request REST jusqu'à son enregistrement base de données
par exemple ;
Simplifie et clarifie l'écriture du code. En effet, il n'est plus nécessaire de contrôler les champs à outrance ou de se poser
la question si telle ou telle propriété sera null ou non ;
Est intégrée directement dans l'IntelliJ (Community et Ultimate) ;
Rend les Optional enfin sûr si annoté par @NonNull. En effet, actuellement rien n'interdit (hormis le bon sens) d'écrire
un return null plutôt que return Optional.empty() pour une fonction ayant comme type de retour un Optional.
Points de vigilance
Ce qu'il faut avoir en tête avant de l'intégrer sur vos projets :
Ralentissement d'exécution du build de moins de 10% (d'après NullAway). Rien d'illogique, le contrôle s'appliquant à la compilation, il y a forcément un coût supplémentaire incompressible ;
Ce n'est pas infaillible. En effet, il n'existera jamais d'outil magique permettant de deviner de lui-même qu'un champ sera
valorisé par introspection ou post-construct. C'est pourquoi, NullAway met à disposition des annotations et propriétés de
contournement ;
L'implémentation de JSpecify est toujours en cours de réalisation 6.
Pour aller plus loin
Spring
La future version du framework (7.0.0
)
va désormais utiliser JSpecify comme annotations de références.
https://github.com/spring-projects/spring-framework/releases/tag/v7.0.0-M1
https://github.com/spring-projects/spring-framework/issues/28797#issuecomment-2387137015
OpenRewrite
Si vous souhaitez intégrer JSpecify dans vos projets existants, appuyez-vous sur OpenRewrite pour effectuer le refactoring automatiquement via Maven ou Gradle.
https://docs.openrewrite.org/recipes/java/recipes/recipenullabilitybestpractices
JDK
Sachez qu'une JEP
(
JDK Enhancement Proposal
) a été proposée pour mettre en place une gestion native des variables et paramètres nullables.
https://openjdk.org/jeps/8316779
Conclusion
Il est indéniable que la gestion de la nullité en Java reste un enjeu crucial pour la qualité et la fiabilité des applications. En permettant de détecter et de prévenir au plus tôt les erreurs
de nullité dès la phase de compilation, par le biais d'annotations standardisées et d'outils éprouvés, nous facilitons le travail des développeurs et fiabilisons le delivery.
J'espère
que la présentation de la combinaison des outils NullAway, ErrorProne et JSpecify vous
ont convaincu tout autant que moi.
C'est pourquoi je vous invite à utiliser ces outils sur vos projets, vous ferez à n'en pas douter un pas vers un code plus sûr et plus lisible.
Notes
[1] JSR-305 : https://jcp.org/en/jsr/detail?id=305
[2] Liste des organisations à l'origine de JSpecify : https://jspecify.dev/about/#who-are-we
[3] Liste des propriétés NullAway : https://github.com/uber/NullAway/wiki/Configuration
[4] OpenAPI Generator : https://github.com/OpenAPITools/openapi-generator
[5] Avro : https://avro.apache.org/
[6] Travaux d'intégration de Jspecify par NullAway : https://github.com/uber/NullAway/wiki/JSpecify-Support & https://github.com/uber/NullAway/wiki/How-NullAway-Works#jspecify)