Lors de l'écriture de projets complexes, il est essentiel de développer une bonne culture du code. L'utilisation d'objets immuables et cohérents est l'un des plus importants.
Vous pouvez négliger cela et écrire des objets complexes comme des objets standard, mais ce sera une source importante de bogues lorsque le projet se développera suffisamment.
Dans monarticle précédent, j'ai montré comment on peut améliorer la cohérence et la fiabilité des objets standards. En quelques mots:
Ajouter des validations lors de la définition des valeurs
Utilisez java.util.Optional
pour chaque champ nullable
Placez les mutations complexes au bon endroit - à la classe responsable elle-même.
Mais ces actions ne suffisent pas à fabriquer des objets entièrement fiables. Dans cet article, je vais montrer comment rendre les objets immuables et les rendre éloquents et efficaces.
Si nous créons un objet sérialisable simple avec un constructeur/getters/setters par défaut, il est normal de le faire de manière standard. Mais supposons que nous écrivions quelque chose de plus complexe. Et très probablement, il est utilisé dans d'innombrables endroits.
Par exemple, il est utilisé dans HashMaps et peut-être dans un environnement multi-threading. Donc, cela ne semble plus être une bonne idée - de l'écrire par défaut. Briser HashMap n'est pas un gros problème, et un état incohérent entre les threads ne fera pas attendre longtemps.
Les premières choses qui viennent à l'esprit lorsque l'on pense à créer des objets immuables sont :
Mais comment vivre avec ce genre d'objet ? Lorsque nous devons le modifier, nous devons en faire une copie ; comment pouvons-nous bien le faire sans copier-coller le code et la logique à chaque fois ?
Disons que nous avons des comptes. Chaque compte a un id
, un status
et une adresse e- email
. Les comptes peuvent être vérifiés par e-mail. Lorsque le statut est CREATED
, nous ne nous attendons pas à ce que l'e-mail soit rempli. Mais lorsqu'il est VERIFIED
ou INACTIVE
, l'email doit être renseigné.
public enum AccountStatus { CREATED, VERIFIED, INACTIVE }
L'implémentation canon Account.java
:
public class Account { private final String id; private final AccountStatus status; private final String email; public Account(String id, AccountStatus status, String email) { this.id = id; this.status = status; this.email = email; } // equals / hashCode / getters }
Imaginons que nous avons créé un compte. Ensuite, quelque part dans la logique métier, nous devons changer un e-mail.
var account = new Account("some-id", CREATED, null);
Comment pouvons-nous faire cela? La méthode par défaut ne fonctionnera pas, nous ne pouvons pas avoir de setters avec une classe immuable.
account.setEmail("[email protected]");// we can not do that, we have no setters
La seule façon de le faire est de créer une nouvelle instance et de placer les valeurs précédentes du constructeur :
var withEmail = new Account(account.getId(), CREATED, "[email protected]");
Mais ce n'est pas la meilleure façon de changer la valeur d'un champ, cela produit tellement de copier/coller, et la classe Account n'est pas responsable de sa cohérence.
La solution suggérée consiste à fournir des méthodes de mutation à partir de la classe Account et à implémenter une logique de copie à l'intérieur de la classe responsable. De plus, il est essentiel d'ajouter les validations requises et l'utilisation de Facultatif pour le champ e-mail, afin que nous n'ayons pas de problèmes de NPE ou de cohérence.
Pour construire un objet, j'utilise le pattern 'Builder'. C'est assez célèbre, et il existe de nombreux plugins pour que votre IDE le génère automatiquement.
public class Account { private final String id; private final AccountStatus status; private final Optional<String> email; public Account(Builder builder) { this.id = notEmpty(builder.id); this.status = notNull(builder.status); this.email = checkEmail(builder.email); } public Account verify(String email) { return copy() .status(VERIFIED) .email(of(email)) .build(); } public Account changeEmail(String email) { return copy() .email(of(email)) .build(); } public Account deactivate() { return copy() .status(INACTIVE) .build(); } private Optional<String> checkEmail(Optional<String> email) { isTrue( notNull(email).map(StringUtils::isNotBlank).orElse(false) || this.status.equals(CREATED), "Email must be filled when status %s", this.status ); return email; } public static final class Builder { private String id; private AccountStatus status; private Optional<String> email = empty(); private Builder() { } public static Builder account() { return new Builder(); } public Builder id(String id) { this.id = id; return this; } public Builder status(AccountStatus status) { this.status = status; return this; } public Builder email(Optional<String> email) { this.email = email; return this; } public Account build() { return new Account(this); } } // equals / hashCode / getters }
Comme vous pouvez le voir, il existe une copy
de méthode privée dans notre classe qui renvoie Builder
avec une copie exacte. Cela supprime le copier-coller de tous les champs, cependant, il est crucial que cette méthode ne soit pas accessible depuis l'extérieur de Account.java
car, avec ce Builder à l'extérieur, nous perdons le contrôle de l'état et de la cohérence.
Maintenant, créons un compte :
var account = account() .id("example-id") .status(CREATED) .email((empty()) .build();
Lorsque nous devons modifier un e-mail, nous n'avons pas besoin d'être responsables de la création d'une copie, nous appelons simplement une méthode à partir du Account
lui-même :
var withNewEmail = account.changeEmail("[email protected]");
Démonstration de cela dans un test unitaire:
@Test void should_successfully_change_email() { // given var account = account() .id("example-id") .status(VERIFIED) .email(of("[email protected]")) .build(); var newEmail = "[email protected]"; // when var withNewEmail = account.changeEmail(newEmail); // then assertThat(withNewEmail.getId()).isEqualTo(account.getId()); assertThat(withNewEmail.getStatus()).isEqualTo(account.getStatus()); assertThat(withNewEmail.getEmail()).isEqualTo(of(newEmail)); }
Pour vérifier un compte, nous ne créons pas de copie avec le statut VERIFIED
et un nouvel e-mail. Nous appelons simplement la méthode verify
, qui non seulement créera une copie pour nous, mais vérifiera également la validité d'un e-mail.
@Test void should_successfully_verify_account() { // given var created = account() .id("example-id") .status(CREATED) .build(); var email = "[email protected]"; // when var verified = created.verify(email); // then assertThat(verified.getId()).isEqualTo(created.getId()); assertThat(verified.getStatus()).isEqualTo(VERIFIED); assertThat(verified.getEmail().get()).isEqualTo(email); }
Vivre avec des objets immuables, cohérents et fiables est difficile, mais de la bonne manière, cela peut devenir beaucoup plus facile.
Lors de la mise en œuvre d'un,
copy
la méthode à l'intérieur de la classe responsable qui renvoie un Builder
et utilisez-la pour créer de nouvelles instances dans votre classe.Optional
avec des champs nullables
Vous pouvez trouver l'exemple entièrement fonctionnel avec plus de tests unitaires sur GitHub .
Photo principale par Adam Nieścioruk sur Unsplash