Accéder à l'en-tête Accéder au contenu principal Accéder au pied de page
Retour aux actualités
Non classé
10/04/2022 Benjamin Dupin

Gestion des erreurs en Java

Par le biais de cet article, je vous propose d’explorer différentes solutions à la gestion d’erreur en Java.

Vavr en deux mots

Vavr est une librairie rajoutant à Java des structures de contrôle fonctionnel qui manque pour le moment au langage (comme le filtrage par motif mais aussi les Either, qui existent notamment dans Scala).

Je vais par moment utiliser certaines classes apportées par cette librairie.

Présentation du problème

Scénarios

Pour ce faire, je vais présenter différents exemples basés sur la problématique du retrait d’argent d’un compte bancaire, respectant les scénarios Gherkin suivant :

[code lang=gherkin]
Given a client bank account with a balance of « 150 »
When the client withdraw « 50 »
Then the client see the message « New balance: 100 »
[/code]

[code lang=gherkin]
Given a client bank account with a balance of « 20 »
When the client withdraw « 100 »
Then the client see the message « Withdrawal refused »
[/code]

Classes

Amount

Il s’agit simplement d’un objet valeur représentant un montant, possédant une méthode subtract pour soustraire le montant d’un autre :

[code lang=java]
public record Amount(int value) {

public Amount subtract(Amount amount) {
return new Amount(value – amount.value());
}

}
[/code]

BankAccount

Cette classe contient un Amount servant de balance.

[code lang=java]
private Amount balance;

public BankAccount(Amount balance) {
this.balance = balance;
}


[/code]

BankAccount contient également une méthode public :

[code lang=java]
withdraw(Amount amount)
[/code]

C’est cette méthode qui nous intéresse : elle contiendra ma logique métier et c’est sa signature qui va changer suivant la gestion des erreurs dans le cas où un retrait est impossible.

ATM

C’est pour le moment une simple application console avec une méthode main, mais on pourrait très bien imaginer dans le futur une API REST qui me retournerait un code HTTP différent suivant le scénario :

[code lang=java]
public class ATM {

public static void main(String[] args) {

var scanner = new Scanner(System.in);

System.out.print(« Initial amount: « );
var bankAccount = createBankAccount(scanner.nextInt());


}

private static BankAccount createBankAccount(int amount) {
return new BankAccount(new Amount(amount));
}
}
[/code]

Les solutions

Avec une Exception

La première solution envisageable est celle des exceptions.

Je commence donc par créer une nouvelle Exception :

[code lang=java]
public class WithdrawalRefusedException extends Exception {
}
[/code]

L’implémentation de BankAccount#withdraw est la suivante :

[code lang=java]
public Amount withdraw(Amount amount) throws WithdrawalRefusedException {

if (hasInsufficientBalance(amount)) {
throw new WithdrawalRefusedException();
}

balance = balance.subtract(amount);

return balance;
}
[/code]

Ainsi, côté ATM j’ai au choix, un bon vieux try/catch :

[code lang=java]
try {
var balance = bankAccount.withdraw(amount);
System.out.println(« New balance:  » + balance);
} catch (WithdrawalRefusedException e) {
System.err.println(« Withdrawal refused »);
}
[/code]

Ou alors solution un peu plus fonctionnelle et plus propre grâce au Try :

[code lang=java]
Try.of(() -> bankAccount.withdraw(amount))
.onSuccess(balance -> System.out.println(« New balance:  » + balance))
.onFailure(WithdrawalRefusedException.class, withdrawalRefusedException -> System.err.println(« Withdrawal refused »));
[/code]

Avec un Either

Les Either apportés par Vavr représente une valeur avec deux types possibles : soit Left soit Right.

Right représente généralement un cas succès tandis que Left représente une erreur.

BankAccount#withdraw ressemble donc à :

[code lang=java]
public Either<WithdrawalRefused, Amount> withdraw(Amount amount) {

if (hasInsufficientBalance(amount)) {
return Either.left(new WithdrawalRefused());
}

balance = balance.subtract(amount);

return Either.right(balance);
}
[/code]

Avec :

[code lang=java]
public record WithdrawalRefused() {
}
[/code]

Au niveau ATM, le Either peut se gérer ainsi :

[code lang=java]
bankAccount.withdraw(amount)
.peek(balance -> System.out.println(« New balance:  » + balance))
.orElseRun(withdrawalRefused -> System.err.println(« Withdrawal refused »));
[/code]

Avec une classe

Je débute avec sealed interface avec deux implémentations :

[code lang=java]
public sealed interface WithdrawalResult {

record WithdrawalRefused() implements WithdrawalResult {
}

record WithdrawalSuccess(Amount newBalance) implements WithdrawalResult {
}
}
[/code]

Désormais, ma méthode withdraw est :

[code lang=java]
public WithdrawalResult withdraw(Amount amount) {

if (hasInsufficientBalance(amount)) {
return new WithdrawalRefused();
}

balance = balance.subtract(amount);

return new WithdrawalSuccess(balance);
}
[/code]

Maintenant, comment gérer ce cas-là au niveau de l’appelant de BankAccount#withdraw ?

Eh bien j’ai le choix :

  • Avec un instanceof

[code lang=java]
var result = bankAccount.withdraw(amount);

if (result instanceof WithdrawalSuccess success) {
System.out.println(« New balance:  » + success.newBalance());
} else {
System.err.println(« Withdrawal refused »);
}
[/code]

  • Avec du filtrage par motif
    • Avec Vavr

    [code lang=java]
    var result = bankAccount.withdraw(amount);

    Match(result).of(
    Case($(instanceOf(WithdrawalSuccess.class)), success -> run(() -> System.out.println(« New balance:  » + success.newBalance()))),
    Case($(instanceOf(WithdrawalRefused.class)), ignored -> run(() -> System.err.println(« Withdrawal refused »)))
    );
    [/code]

    • En Java (nécessite --enable-preview)

    [code lang=java]
    var result = bankAccount.withdraw(amount);

    switch (result) {
    case WithdrawalSuccess success -> System.out.println(« New balance:  » + success.newBalance());
    case WithdrawalRefused ignored -> System.err.println(« Withdrawal refused »);
    }
    [/code]

Conclusion

Les exceptions sont le mécanisme de base des erreurs à Java. Il en existe deux types, les vérifiées (héritant de Exception) et les non vérifiées (héritant de RuntimeException). Les exceptions vérifiées ont l’avantage d’être explicite et doivent donc être gérée par l’appelant tandis que les non vérifiées peuvent survenir par surprise. Or une erreur comme celle présentée ci-dessus ne doit pas être une surprise.

A mon avis, les exceptions doivent, comme leur nom l’indique, rester exceptionnelles. En effet, lever une exception est énormément coûteux en ressource (plus coûteux qu’un new). Autre inconvénient : les méthodes levant des exceptions vérifiées ne sont pas utilisables dans des lambdas sans un try/catch, ou sans l’aide de Vavr.

Tout comme une Exception, le Either force l’appelant à gérer le cas d’un Either#Left, mais de manière bien plus propre et efficace d’une exception, tout en étant relativement explicite.

Vavr est-il la solution idéale à la gestion d’erreur en Java ? Le seul inconvénient est l’utilisation d’une librairie tierce, qui peut s’avérér intrusive, et qui n’est à mon avis pas forcément simple d’utilisation pour un développeur non initié à la programmation fonctionnelle.

Question de gout ? La réponse est oui, mais aussi de contexte !

Je pense que tout dépend de la version de Java utilisée. En attendant l’arrivée de la version finale du filtrage par motif dans Java, Vavr peut être une excellente solution. Mais à terme, je suis persuadé que l’utilisation de sealed class avec filtrage par motif sera la meilleure solution.

Et vous, quelle solution à la gestion d’erreur préférez-vous ?

Retrouvez le code d’exemple ici.