Dîtes adieux aux NullPointerException dans Java

par Benjamin CAILLARD - Expert Technique Java / Spring
| minutes de lecture

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.NotNullorg.springframework.lang.NonNulljakarta.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>

 

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

  2. Cependant, j'indique à ErrorProne que je souhaite utiliser l'extension NullAway sur les packages donnés.

  3. 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).

  4. Ces paramètres sont indispensables depuis le renforcement des fonctionnalités internes du JDK en version 16.

  5. Je n'oublie pas de déclarer ErrorProne et NullAway comme annotation processor.
    N
    otez bien l'utilisation de combine.children="append", cet argument vous permet, pour des modules ou projets enfants,
    d
    e 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)

) {


}

 

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

  2. Tous les champs sans annotations sont considérés comme non-null par défaut grâce à l'annotation @NullMarked.

  3. 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 NullAwayErrorProne 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)

     

     


    Search

    language-lib

    tools

    Contenus associés

    GraphQL une Nième Base de Données ? Ou pas …

    GraphQL est un langage de requête et de manipulation de données développé par Facebook, offrant une approche flexible pour interagir avec les API. Dans cet article, nous explorerons ses cas d'utilisation, ses avantages et ses inconvénients.

    Apache Kafka, un nouvel usage à venir ? KIP-932 Queues for Kafka

    Kafka est une plateforme de data streaming offrant réplication, partitionnement et ordre des messages. Mal adapté au message queuing, la KIP-932 introduira des share groups pour plus de flexibilité et d’usages.

    Podman : une alternative à Docker

    Podman, alternative open source à Docker, offre une CLI similaire, compatible avec Docker Compose, rootless et sans démon. Facile à installer sous WSL2, il utilise les normes Open Container Initiative