GLO-4002 - Site du cours 2023

Temps estimé: 1 heure

Exercice sur les couches applicatives

L'objectif de cet exercice est d'améliorer une application existante. Plusieurs concepts vus en classe seront nécessaires pour y arriver

  • Modèle hexagonal
  • DIP
  • ISP
  • Polymorphisme
  • Injection des dépendances
  • Repository
  • DTO
  • Factory

L'objectif final est de faire fonctionner l'application pour laquelle un GUI existe déjà : https://epic-austin-420b87.netlify.com (désolé pour l'URL boboche!). Puisque l'application appelle votre serveur sur localhost:7272 (par défaut), certains browsers tel que firefox pourraient ne pas fonctionner.

Vous n'avez pas à modifier le frontend, seulement le backend et son API qui se trouvent dans ce repository.

Le code de base est divisé en trois couches :

  1. Domaine : Cette couche contient la logique d'affaires de l'application. Si vous voulez un truc pour savoir ce qui s'y trouve et ce qui ne s'y trouve pas, demandez-vous si votre client pourrait le comprendre. Le client comprend le concept d'un panier d'achat auquel on ajoute des articles. Il ne comprend cependant pas c'est quoi la différence entre une persistence MongoDB et postgresql.
  2. Interface : tout ce qui est en lien avec un UI qui permet d'accéder au domaine. Un UI n'est pas nécessairement graphique, un API REST ou une ligne de commande compte comme UI.
  3. Application : Fait techniquement parti du domaine, mais est séparé pour des raisons qui seront vues plus tard. Permet de faire le lien entre 1..N UI et le domaine. Son rôle est d'orchestrer les accès au domaine. En gros: il sait quoi faire quand, mais ne sait pas comment le faire (c'est le domaine qui le sait).

Ces couches ne sont pas les seules possibles. Il en existe d'autres et celles présentées ici ne sont pas nécessairement idéales (surtout dans le projet de départ). C'est le but de ce laboratoire de découvrir d'où viennent ces couches et pourquoi elles sont utilisées. Les couches présentées ici ne sont qu'une façon d'abstraire l'architecture d'un logiciel (DDD), mais il en existe plusieurs autres qui respectent également le modèle hexagonal.

Notez aussi que les couches applicatives sont en fait une sorte d'abstraction. Elles nous permettent d'abstraire certains concepts afin (entre autre) que chacun puisse évoluer différement. C'est pour cela que le UI ne parle jamais à la base de données dans une application avec une forte logique d'affaire : on peut en changer une sans impacter l'autre.

Quelques références supplémentaires : domaine (tiré du DDD), services applicatifs.

Première exécution

Vous pouvez démarrer le projet en executant la classe CartServer depuis Eclipse ou IntelliJ, ou encore, en ligne de commande en exécutant la commande mvn exec:java (n'oubliez pas préalablement de lancer mvn compile pour compiler votre code). Ce serveur offre un API REST pour gérer un panier d'achat (très minimaliste).

Utilisez l'interface web pour voir que tout est en ordre. Cette page assume que votre CartServer est démarré pour fonctionner.

Premier défi

Un problème est que votre backend sert à faire des démos à des clients (d'où le data pré-chargé dans l'application). Or, certains clients veulent maintenant déployer dans des systèmes qui n'ont pas de disque dur (style Google App Engine) alors que la BD est présentement consituée de fichiers XML.

Pour ces clients, on voudrait pouvoir simplement garder en mémoire les informations. Pas besoin de les écrire dans un fichier ou une base de données. Les données sont donc perdues lors d'un redémarrage du serveur.

Le hic c'est qu'on veut vraiment conserver la persistence XML! Il faudrait pouvoir choisir au démarrage de l'application quelle persistence prendre...

Demandez-vous également si la notion de stockage XML versus In-Memory est réellement un problème de domaine d'affaires. Votre client vous demandera de stocker l'information, certes, mais ne vous dira pas le comment. Serait-ce possible qu'il manque une couche à notre application afin de placer la BD dans l'hexagone extérieur?

Astuce technique

Afin de paramétrer le système lors du démarrage, nous utiliserons des propriétés systèmes. Par exemple, si on execute mvn exec:java -Dstore=xml (ou mvn exec:java -Dstore=memory), on peut y accéder avec System.getProperty("store"). Vous pouvez arriver au même résultat dans Eclipse en allant dans "Run configurations..." et modifier celle du serveur Cart :

system properties

Il est possible de faire la même chose dans la configuration d'IntelliJ en ajoutant -Dstore=xml dans le champ "VM arguments".

Maintenant, il faut choisir le bon repository en fonction de ce paramètre. Vous pouvez utiliser la classe ci-dessous afin de vous aider :

package ca.ulaval.glo4002.cart.interfaces.rest;

import ca.ulaval.glo4002.cart.application.CartRepository;

public class PersistenceProvider {
    public static CartRepository getCartRepository() {
        if (System.getProperty("store").equalsIgnoreCase("xml")) {
            // xml
        } else {
            // in memory
        }
    }

    // Idem pour ShopRepository
}

Il ne vous reste maintenant qu'à utiliser cette classe ainsi : PersistenceProvider.getCartRepository()... mais où? Pour l'instant, nous vous indiquons que la classe PersistenceProvider doit résider dans la couche interfaces. Nous verrons pourquoi plus tard. Comment est-ce que les ApplicationServices peuvent utiliser le bon repository s'ils n'ont pas accès à PersistenceProvider?

Attention : Cette classe static n'est pas la meilleure façon de faire. La suite de cet exercice viendra corriger la situation.

Deuxième défi

L'application ira bientôt en production. Il faudrait donc arrêter de générer des données de démo au milieu de l'application...

Il faut cependant garder cette possibilité, donc par exemple avoir la possibilité de démarrer l'application en mode démo ou production (i.e. -Dmode=demo). Vous avez déjà vu comment utiliser les propriétés système. Cependant, cette logique ne devrait pas faire partie du domaine d'affaires (et donc pas de la couche applicative non plus).

Nous introduisons ici une nouvelle couche : celle nommée "contexte". Cette couche représente un contexte dans lequel une application s'exécute. Si nous sommes dans un mode démo, alors charger les données. Sinon, ne rien faire. Afin de faire son travail, cette couche devra potentiellement dépendre de toutes les autres couches du système. En contrepartie, aucune couche ne dépend de contexte. C'est donc la première couche à s'exécuter, mais une fois son travail fait, elle ne sert plus à rien.

Comment utiliser ce nouveau concept afin de charger les données une fois au démarrage?

Troisième défi

Note: À partir d'ici, si vous voulez commencer avec les 2 premiers défis déjà fait, vous pouvez utiliser la branche "sample_solution_part_1" sur github.

Le premier défi consiste à se débarasser de la classe PersistenceProvider fournie ci-haut. Tel qu'indiqué, cette solution temporaire n'est pas l'idéal à long terme.

En fait, lors du laboratoire sur les mocks, vous avez vu qu'il est possible de passer les dépendances via, par exemple, un constructeur. Vous pourriez aller dans CartServer et y construire toute la chaîne de dépendances. Ça marcherait bien et ce serait une solution acceptable.

Cependant, dans ce défi, nous vous demanderons plutot d'essayer le pattern ServiceLocator. Un service locator fonctionne en deux étapes :

  1. Lors du démarrage, on enregistre tous les liens Abstraction --> Implémentations. Par exemple, on pourrait dire que CartRepository (interface) sera implémenté par XmlCartRepository. Cette étape se fait une seule fois, au démarrage du serveur.
  2. Lorsqu'une classe a besoin d'un collaborateur, elle demande au service locator de lui fournir l'implémentation correspondante à l'interface voulue. La classe ne connaît donc que l'interface.

Ces deux étapes se nomment typiquement register et resolve. Voici un exemple d'un service locator très minimaliste (les 2 exceptions utilisées ne sont pas fournies, simplement les créer en tant que RuntimeException):

public enum ServiceLocator {
    INSTANCE;

    private Map<Class<?>, Object> instances = new HashMap<>();

    public <T> void register(Class<T> contract, T service) {
        if (instances.containsKey(contract)) {
            throw new ServiceAlreadyRegisteredException(contract);
        }
        instances.put(contract, service);
    }

    public <T> T resolve(Class<T> contract) {
        T service = (T) instances.get(contract);
        return Optional.ofNullable(service).orElseThrow(() -> new UnknownServiceResolvedException(contract));
    }
}

// Étape 1 : ServiceLocator.INSTANCE.register(CartRepository.class, new XmlCartRepository());
// Étape 2 : ServiceLocator.INSTANCE.resolve(CartRepository.class);

Prenez un peu de temps pour comprendre le code!

Ensuite, posez-vous les questions suivantes :

  • Qui doit configurer le service locator? Idéalement, c'est une couche qui connaît à la fois le domaine et l'infrastructure. Ça en élimine pas mal...
  • Qui a le droit d'utiliser le .resolve() du service locator? Ce n'est clairement pas un concept du domaine d'affaires...

N'oubliez pas que le service locator n'est pas nécessairement le meilleur pattern pour l'injection de dépendances (google: service locator anti-pattern). Cependant, il est très acceptable dans de petits projets, et c'est déjà mieux que d'hardcoder ses dépendances! Il est laissé en exercice supplémentaire de refaire ce défi avec un framework d'inversion of control (IoC) tel que HK2 ou Guice.

Vous pouvez en lire davantage sur les service locators.

Question: Le ServiceLocator connaitra tous les services possibles. Est-ce que cela est un problème de SRP? Si le service locator savait comment se configurer lui-même, est-ce que ça serait un problème?

Question: Est-ce un problème d'OCP que le service locator connaissent toutes les implémentations? Devra-t-il changé à chaque fois qu'un nouveau service est ajouté au code?

Quatrième défi

Des informations sensibles sont "leakées" dans les requêtes web! On peut y voir les marges de profits du magasin!

leak d'information

Régler ce problème devrait également régler le fait que des annotations jackson (JSON) sont dans le domaine d'affaires, alors que c'est clairement un problème du UI.

Hint: Regardez ce que sont les DTO (note: ce qu'il appelle 'adapter' est en réalité un assembler ou mapper en DDD). Le travail d'un assembler est de convertir d'une représentation à une autre. Un assembler n'est PAS une factory.

Cinquième défi

Promo 2 pour 1! Dès qu'un article est ajouté au panier, on vous en donne un deuxième gratuitement! Donc, à l'ajout d'un item, on devrait voir "2x Item" dans le cart. Le prix reste le même par contre.

Dans un cas réel, le mode promo sera activé au runtime, par un administrateur. Afin de simplifier ce cas, supposons qu'il s'agit également d'un flag au démarrage du serveur (i.e. -Dpromo=true).

Il est conseillé d'utiliser la configuration du service locator faite préalablement. Vous pourriez combiner cela avec une 2 implémentations de factory par exemple.

Défi supplémentaire : abstractions

Discutez de la question suivante : Pourquoi existe-t-il une classe ShopItem et une classe CartItem alors qu'au final les 2 représentent la boîte de goglu que tu es en train d'acheter?

Défi supplémentaire : Tell Don't Ask

Ajouter une classe d'item dans le magasin qui est "fragile". En ce moment, le coût de shipping est calculé ainsi :

  • Si item standard, alors 2\$ par kilo.
  • Si item prime, alors 0.

On veut ajouter un item fragile, qui coûte 3$ par kilo + 5$ pour le shipping. On pourrait ajouter une méthode isFragile() à l'interface ShopItem, mais voyez-vous le problème qui s'en vient? Et si on avait un estimateur de coût de livraison, est-ce qu'il faudrait dupliquer ce calcul? Bref, il y a un problème de Tell Don't Ask dans le code existant... Trouver comment le régler afin de simplifier le code et le rendre plus maintenable à long terme.

Défi supplémentaire : Comprendre les serveurs web

Voir l'exercice supplémentaire sur les servlets.

Concepts utilisés

Voici quelques noms typiquement associés aux concepts OO que vous pouvez utiliser pour les problèmes ci-haut :

  • Repository : Un entrepôt de stockage, permet d'abstraire le type de persistence utilisé
  • Composition root : Autre nom donné à la couche "contexte" que nous utilisons ici. La raison pour le nom différent sera vu dans le prochain laboratoire
  • Contexte : Pas un "pattern" typiquement nommé, mais permet de démarrer l'application dans un état ou dans un autre (incluant des données de démo). Permet de configurer l'injection de dépendances différement.
  • Lecture intéressante si vous désirez voir un comparatif entre l'OO et le procédural.
  • Factory : Permet de remplacer les "new" au milieu du code afin d'obtenir plus de flexibilité. Notez qu'il existe aussi le pattern "factory method".
  • DTO : Permet de créer une vue sur nos données afin de ne pas être lié au domaine d'affaires (les deux peuvent répondre à des besoins très différents).

Solution

La solution est (historiquement) en 2 parties. La branche sample_solution_2 contient toutes les solutions. La branche sample_solution_1 contient : exercice TDA, repository, context.