Création d’une Interface Fluide en Java

Pour cette partie en tutoriel du cours de Construction et Évolution de Logiciels, nous allons nous intéresser à la création d’APIs lisibles et faciles à utiliser.

Plus précisément, nous verrons comment construire une Fluent Interface (en français, désignation chaînée ou chaînage de méthodes) et comment se servir de l’auto-complétion des environnements de développement pour utiliser ces interfaces.

  • La première partie de ce tutoriel est un introduction aux Interfaces Fluent.

  • La seconde partie de ce tutoriel est un travail pratique, où vous créerez une Interface Fluent en Java.

1. Le Pattern Fluent Builder

En construction de logiciels, le pattern Fluent Builder (en français, monteur fluide) est particulièrement utile lorsque l’on souhaite construire des objets complexes.

Par exemple, supposons que l’on souhaite construire un objet complexe en créant une instance de son builder (monteur), en appelant les setters (modificateurs) respectifs, et enfin, en appelant la méthode de construction. Une fois la méthode de construction exécutée, on peut récupérer l’objet de la classe souhaitée.

EmailMessageBuilder builder = new EmailMessageBuilder();
EmailMessage message = builder.buildEmailMessage();
message.setFrom("gerson.sunye@univ-nantes.fr");
message.seTo("licence.informatique@univ-nantes.fr");
message.setSubject("tutoriel");
message.setBody("Au travail !!!");
message.build();

Ce code semble très simple, mais il y a une difficulté potentielle: si la construction de l’objet est complexe et qu’il y a trop d’attributs à modifier, le développeur peut en oublier certains et créer un objet incomplet.

Dans de nombreuses applications, il y a souvent une entité centrale, comme une "Commande", un "Produit" ou un "Service", dont les instances sont créées dans différentes morceaux de code. Dans ces cas, l’oubli d`initialiser un attribut peut s’avérer très coûteux en termes de débogage et de maintenance. Une question se pose:

Comment aider le développeur à se rappeler de tous les attributs, obligatoires et optionnels?

La réponse est de forcer le développeur à initialiser tous les attributs obligatoires avant d’appeler la méthode de construction. Ainsi, tous les attributs obligatoires seront initialisés et l’objet créé sera complet. Ce qui soulève une nouvelle question:

Comment forcer le développeur à initialiser correctement un objet?

La réponse est simple : en utilisant le patron Fluent Builder.

L’idée c’est qu’au lieu de précéder chaque appel de méthode par le nom de l’objet --ce qui conduit à un style verbeux-- le nom de l’objet reste actif dans toute l’instruction en cours. Le code devient ainsi à la fois plus concis et plus clair. Par exemple:

EmailMessage message = EmailMessageBuilder.builder()
    .from("gerson.sunye@univ-nantes.fr")
    .to("licence.informatique@univ-nantes.fr")
    .subject("tutoriel")
    .body("Au travail !!!")
    .build();

La réduction de verbosité est semblable à celle de l’expression with en Pascal, bien que basée sur des principes différents.

1.1. Chaînage de méthodes

Ce style (ou idiome) de programmation, appelé chaînage de méthodes a été introduit par le langage Smalltalk, où par convention les modificateurs retournent l’objet modifié (this).

Dans le cas de Java, la logique est similaire: toutes les méthodes de modification (from, to, etc.) seront spécifiées dans la Builder et retourneront une instance de cette classe.

Par exemple, la méthode from() sera mise en oeuvre comme suit:

public EmailMessageBuilder from(String sender) {
    this.from = sender;
    return this;
}

Quant à la méthode to(), sa mise en oeuvre sera semblable à la précédente:

public EmailMessageBuilder to(String recipient) {
    this.to = recipient;
    return this;
}

Si le chaînage de méthodes permet de simplifier le code de création d’objets, il ne peut pas empêcher l’oubli de certains attributs, ni l’appel de la méthode build() avant que tous les attributs soient initialisés. Une nouvelle question se pose alors:

Comment empêcher le développeur d’appeler la méthode de construction avant d’initialiser tous les attributs obligatoires ?

Pour répondre à cette question, nous allons utiliser le chaînage d’interfaces.

1.2. Le chaînage d’interfaces

La logique du chaînage d’interfaces est de limiter l’accès lors du chaînage de méthodes, en créant une interface pour chaque attribut obligatoire et en modifiant le type de retour de chaque modificateur pour le faire retourner la prochaine interface.

Compliqué ? Oui, en effet, mais la logique deviendra plus claire en regardant le code des interfaces:

interface EmailFrom {
    EmailTo from(String sender);
}

interface EmailTo {
    EmailSubject to(String recipient);
}

interface EmailSubject {
    EmailBody subject(String subject);
}

interface EmailBody {
    EmailCreator body(String contents);
}

interface EmailCreator {
    EmailMessage build();
}

Notez que dans cette suite d’interfaces, chaque modificateur a comme type de retour l’interface suivante dans la suite. Ainsi, pour atteindre l’interface EmailCreator, on doit passer par une suite d’interfaces, ce qui oblige le développeur à appeler différentes méthodes avant de pouvoir accéder à la méthode build().

La question qu’on peut se poser maintenant est : "- Faut-il implémenter toutes ces interfaces par une nouvelle classe ?". La réponse est simple, non, car en Java, rappelez-vous, une classe peut implémenter plus d’une interface. C’est à la classe builder de le faire:

public class EmailMessageBuilder
    implements EmailFrom, EmailTo, EmailSubject, EmailBody, EmailBody {

    }

Ainsi, le développeur manipule toujours le même objet, mais n’a pas toujours accès aux mêmes méthodes. Une autre question que l’on peut se poser est : "- Comment faire lorsque certains attributs sont optionnels ?".

En effet, dans notre exemple on peut penser à ajouter ces attributs optionnels, comme le champ "CC" d’un message. Dans ce cas, on ajoute les modificateurs de ces attributs à la dernière interface, celle qui contient la méthode build():

interface EmailCreator {
    EmailCreator cc(String recipient);
    EmailMessage build();
}

En prime, l’auto-complétion de l’IDE guidera les développeurs à travers les interfaces, en proposant par exemple, la méthode to(), après la méthode from() ou la méthode build() après la méthode body().

2. Exercice pratique : Publication de services DNS-SD

Dans cette partie, vous allez appliquer ces notions pour coder une interface fluide.

2.1. Préparation

  1. Effectuez une divergence du projet tutoriel où vous vous trouvez actuellement. Pour rappel, il suffit d’aller utiliser le bouton correspondant en faut de la page d’accueil du projet.

  2. Configurez votre nouveau projet GitLab obtenu via la divergence de la manière suivante :

    • Dans "Paramètres → Général", allez dans "Visibility, project features, permissions", et mettez "Project visibility" à "Private". Ainsi, vous devenez le seul utilisateur autorisé à accéder à votre code source.

    • Dans "Paramètres → Membres", ajoutez comme nouveau membre l’utilisateur virtuel appelé Naobot, avec le statut "Reporter". Cet utilisateur virtuel que nous contrôlons nous donne le droit d’accéder à votre travail et nous permettra de récupérer vos projets.

  3. Clonez avec git votre divergence sur votre ordinateur de travail, ce qui vous donnera un répertoire remote

2.2. Le protocole DNS-SD

DNS Service Discovery, ou simplement DNS-SD est un protocole de publication et découverte de services à l’intérieur d’un réseau local, et qui utilise des enregistrements DNS de type SRV, PTR et TXT.

Notre objectif ici n’est pas de comprendre le fonctionnement de ce protocole dans les détails, mais seulement de simplifier la publication de services grâce à une interface fluide.

Plus précisément, nous allons nous intéresser à la création d’instances de la classe ServiceDescription, dont la signature du constructeur est donnée ci-dessous:

  public ServiceDescription(String name, int port, String transport,
      String application, String subtype, int weight, int priority, int ttl,
      boolean persistent) {

    this.name         = name;
    this.port         = port;
    this.transport    = transport;
    this.application  = application;
    this.subtype      = subtype;
    this.weight       = weight;
    this.priority     = priority;
    this.ttl          = ttl;
    this.persistent   = persistent;
  }
  • Comme nous pouvons déduire à partir du constructeur, cette classe possède 8 attributs:

    • Seulement 4 sont obligatoires: name, port, transport et application.

    • La valeur par défaut des attributs weight et priority est de 0.

    • La valeur par défaut de l’attribut ttl est de 3.600.

    • La valeur par défaut de l’attribut persistent est false.

    • Il est possible qu’un service n’ait pas de sous-type (attribut subtype nul).

2.3. Implémentation

  • Première étape: mettre en oeuvre la classe monteur, appelée SimpleServiceDescriptionBuilder:

    • Cette classe doit contenir exactement les mêmes attributs que la classe ServiceDescription.

    • Les attributs qui ont une valeur par défaut doivent être initialisés avec cette valeur.

  • Deuxième étape: mettre en oeuvre le chaînage d’interfaces:

    • Créer une interface par attribut obligatoire, plus une interface de construction: ServiceNameModifier, ServicePortModifier, ServiceTransportModifier, ServiceApplicationModifier et enfin ServiceDescriptionBuilder.

    • Les 4 premières interfaces doivent spécifier une seule méthode, le modificateur de l’attribut concerné. Le type de retour de ces modificateurs doit être l’interface suivante.

    • Ainsi, le code de l’interface ServiceNameModifier doit ressembler à:

      interface ServiceNameModifier {
          ServicePortModifier name(String name);
      }
    • La dernière interface, ServiceDescriptionBuilder, doit spécifier la méthode build(), dont le type de retour est ServiceDescription, ainsi que les modificateurs des attributs optionnels.

  • Troisième étape: mettre en oeuvre des interfaces dans la classe SimpleServiceDescriptionBuilder:

    • Modifier l’en-tête de la classe monteur pour la faire implémenter les 5 interfaces définies ci-dessous.

    • Implémenter chacune des méthodes de modification, sans oublier de retourner l’objet courant.

    • Implémenter enfin la méthode build() qui doit créer une instance de ServiceDescription.

  • Quatrième et dernière étape: mettre en oeuvre un test unitaire qui utilise votre API fluide.

    • Pensez à vérifier que l’auto-complétion marche correctement.

2.4. Rendre le projet

Pour rendre le projet, il vous suffit de vous assurer d’avoir :

  • bien ajouté Naobot comme membre Reporter de votre projet.

  • bien validé et publié (commit & push) tous vos fichiers de projet

Tant que tout cela est bien fait avant la date limite de rendu, alors tout est bon !