Tutoriel sur l'API de l'interface COM: Java Spring Boot + bibliothèque JACOB

Dans cet article, je vais vous montrer comment intégrer la bibliothèque JACOB dans votre application Spring Boot. Cela vous aidera à appeler une API d'interface COM via la bibliothèque DLL de votre application Web.

De plus, à des fins d'illustration, je vais fournir une description d'une API COM afin que vous puissiez créer votre application dessus. Vous pouvez trouver tous les extraits de code dans ce dépôt GitHub.

Mais d'abord, un petit mot: chez C the Signs nous avons déployé cette solution qui nous a permis de nous intégrer à EMIS Health. Il s'agit d'un système de dossier électronique des patients utilisé dans les soins primaires au Royaume-Uni. Pour l'intégration, nous avons utilisé leur bibliothèque DLL fournie.

L'approche que je vais vous montrer ici (désinfectée pour éviter toute fuite d'informations sensibles) a été mise en production il y a plus de deux ans et a depuis prouvé sa durabilité.

Comme nous avons récemment utilisé une toute nouvelle approche d'intégration avec EMIS, l'ancien système va être arrêté dans un mois ou deux. Donc, ce tutoriel est son chant du cygne. Dors, mon petit prince.

Qu'est-ce que l'API DLL?

Commençons par une description claire de la bibliothèque DLL. Pour ce faire, j'ai préparé une courte maquette de la documentation technique originale.

Jetons un coup d'œil dessus pour voir quelles sont les trois méthodes d'une interface COM.

InitialiseWithID, méthode

Cette méthode est une fonctionnalité de sécurité requise sur site qui nous permet d'obtenir une connexion à un serveur API que nous souhaitons intégrer à la bibliothèque.

Il nécessite le AccountID(GUID) de l'utilisateur actuel de l'API (pour accéder au serveur) et certains autres arguments d'initialisation répertoriés ci-dessous.

Cette fonction prend également en charge une fonction de connexion automatique. Si un client a une version connectée du système en cours d'exécution (la bibliothèque fait partie de ce système) et appelle la méthode sur le même hôte, l'API terminera automatiquement la connexion sous le compte de cet utilisateur. Ensuite, il renverra le SessionIDpour les appels d'API ultérieurs.

Sinon, le client doit continuer avec la Logonfonction (voir la partie suivante) en utilisant le fichier LoginID.

Pour appeler la fonction, utilisez le nom InitialiseWithIDavec les arguments suivants:

NomEntrée / sortieTypeLa description
adresseDansChaîneIP du serveur d'intégration fourni
Identifiant de compteDansChaînea fourni une chaîne GUID unique
Identifiant de connexionEn dehorsChaîneChaîne GUID utilisée pour l'appel d'API d'ouverture de session
ErreurEn dehorsChaîneErreur de description
RésultatEn dehorsEntier-1 = Se référer à l'erreur

1 = Initialisation réussie en attente de connexion

2 = Impossible de se connecter au serveur en raison d'un serveur absent ou de détails incorrects

3 = ID de compte sans correspondance

4 = Ouverture de session automatique réussie

ID de sessionEn dehorsChaîneGUID utilisé pour les interactions suivantes (si la connexion automatique réussit)

Méthode de connexion

Cette méthode détermine l'autorité de l'utilisateur. Le nom d'utilisateur ici est l'ID utilisé pour se connecter au système. Le mot de passe est le mot de passe API défini pour ce nom d'utilisateur.

Dans le scénario de réussite, l'appel renvoie une SessionIDchaîne (GUID) qui doit être transmise à d'autres appels ultérieurs pour les authentifier.

Pour appeler la fonction, utilisez le nom Logonavec les arguments suivants:

NomEntrée / sortieTypeLa description
Identifiant de connexionDansChaîneL'identifiant de connexion renvoyé par la méthode d'initialisation Initialiser avec l'ID
Nom d'utilisateurDansChaînenom d'utilisateur API fourni
mot de passeDansChaînemot de passe API fourni
ID de sessionEn dehorsChaîneGUID utilisé pour les interactions suivantes (si la connexion réussit)
ErreurEn dehorsChaîneErreur de description
RésultatEn dehorsEntier-1 = Erreur technique

1 = réussi

2 = expiré

3 = échec

4 = ID de connexion ou ID de connexion invalide n'a pas accès à ce produit

getMatchedUsers, méthode

Cet appel vous permet de trouver des enregistrements de données utilisateur qui correspondent à des critères spécifiques. Le terme de recherche ne peut faire référence qu'à un champ à la fois tel que le nom, le prénom ou la date de naissance.

Un appel réussi renvoie une chaîne XML contenant les données.

Pour appeler la fonction, utilisez le nom getMatchedUsersavec les arguments suivants:

NomEntrée / sortieTypeLa description
ID de sessionDansChaîneL'ID de session renvoyé par la méthode d'ouverture de session
MatchTermDansChaîneTerme de recherche
MatchedListEn dehorsChaîneXML conforme au schéma XSD correspondant fourni
ID de sessionEn dehorsChaîneGUID utilisé pour les interactions suivantes (si la connexion réussit)
ErreurEn dehorsChaîneErreur de description
RésultatEn dehorsEntier-1 = Erreur technique

1 = Utilisateurs trouvés

2 = Accès refusé

3 = Aucun utilisateur

Flux d'application de la bibliothèque DLL

Pour faciliter la compréhension de ce que nous voulons implémenter, j'ai décidé de créer un diagramme de flux simple.

Il décrit un scénario étape par étape de la façon dont un client Web peut interagir avec notre application serveur à l'aide de son API. Il encapsule l'interaction avec la bibliothèque DLL et nous permet d'obtenir des utilisateurs hypothétiques avec le terme de correspondance fourni (critères de recherche):

Enregistrement de COM

Voyons maintenant comment nous pouvons accéder à la bibliothèque DLL. Pour pouvoir interagir avec une interface COM tierce, elle doit être ajoutée au registre.

Voici ce que disent les documents:

Le registre est une base de données système qui contient des informations sur la configuration du matériel et des logiciels du système ainsi que sur les utilisateurs du système. Tout programme Windows peut ajouter des informations au registre et lire les informations à partir du registre. Les clients recherchent dans le registre des composants intéressants à utiliser.

Le registre conserve des informations sur tous les objets COM installés dans le système. Chaque fois qu'une application crée une instance d'un composant COM, le Registre est consulté pour résoudre le CLSID ou ProgID du composant dans le chemin d'accès de la DLL du serveur ou de l'EXE qui le contient.

Après avoir déterminé le serveur du composant, Windows charge le serveur dans l'espace de processus de l'application cliente (composants en cours de processus) ou démarre le serveur dans son propre espace de processus (serveurs locaux et distants).

Le serveur crée une instance du composant et renvoie au client une référence à l'une des interfaces du composant.

Pour savoir comment faire cela, la documentation officielle de Microsoft dit:

Vous pouvez exécuter un outil de ligne de commande appelé l'outil d'enregistrement d'assembly (Regasm.exe) pour enregistrer ou désinscrire un assembly à utiliser avec COM.

Regasm.exe ajoute des informations sur la classe au registre système afin que les clients COM puissent utiliser la classe .NET Framework de manière transparente.

La classe RegistrationServices fournit la fonctionnalité équivalente. Un composant géré doit être enregistré dans le registre Windows avant de pouvoir être activé à partir d'un client COM

Assurez-vous que votre machine hôte a installé les .NET Frameworkcomposants requis . Après cela, vous pouvez exécuter la commande CLI suivante:

C:\Windows\Microsoft.NET\Framework\v2.0.50727\RegAsm.exe {PATH_TO_YOUR_DLL_FILE} /codebase

Un message s'affiche pour indiquer si le fichier a été enregistré avec succès. Nous sommes maintenant prêts pour la prochaine étape.

Définition de l'épine dorsale de l'application

DllApiService

Tout d'abord, définissons l'interface qui décrit notre bibliothèque DLL telle quelle:

public interface DllApiService { /** * @param accountId identifier for which we trigger initialisation * @return Tuple3 from values of Outcome, SessionID/LoginID, error * where by the first argument you can understand what is the result of the API call */ Mono initialiseWithID(String accountId); /** * @param loginId is retrieved before using {@link DllApiService#initialiseWithID(String)} call * @param username * @param password * @return Tuple3 from values of Outcome, SessionID, Error * where by the first argument you can understand what is the result of the API call */ Mono logon(String loginId, String username, String password); /** * @param sessionId is retrieved before using either * {@link DllApiService#initialiseWithID(String)} or * {@link DllApiService#logon(String, String, String)} calls * @param matchTerm * @return Tuple3 from values of Outcome, MatchedList, Error * where by the first argument you can understand what is the result of the API call */ Mono getMatchedUsers(String sessionId, String matchTerm); enum COM_API_Method { InitialiseWithID, Logon, getMatchedUsers } }

Comme vous l'avez peut-être remarqué, toutes les méthodes correspondent à la définition de l'interface COM décrite ci-dessus, à l'exception de la initialiseWithIDfonction.

J'ai décidé d'omettre la addressvariable dans la signature (l'adresse IP du serveur d'intégration) et de l'injecter en tant que variable d'environnement que nous allons implémenter.

SessionIDService expliqué

Pour pouvoir récupérer des données à l'aide de la bibliothèque, nous devons d'abord obtenir le fichier SessionID.

Selon le diagramme ci-dessus, cela implique d'appeler d'abord la initialiseWithIDméthode. Après cela, en fonction du résultat, nous obtiendrons soit le SessionID, soit LoginIDà utiliser dans les Logonappels suivants .

Donc, fondamentalement, il s'agit d'un processus en deux étapes dans les coulisses. Maintenant, créons l'interface, et après cela, l'implémentation:

public interface SessionIDService { /** * @param accountId identifier for which we retrieve SessionID * @param username * @param password * @return Tuple3 containing the following values: * result ( Boolean), sessionId (String) and status (HTTP Status depending on the result) */ Mono getSessionId(String accountId, String username, String password); }
@Service @RequiredArgsConstructor public class SessionIDServiceImpl implements SessionIDService { private final DllApiService dll; @Override public Mono getSessionId(String accountId, String username, String password) { return dll.initialiseWithID(accountId) .flatMap(t4 -> { switch (t4.getT1()) { case -1: return just(of(false, t4.getT3(), SERVICE_UNAVAILABLE)); case 1: { return dll.logon(t4.getT2(), username, password) .map(t3 -> { switch (t3.getT1()) { case -1: return of(false, t3.getT3(), SERVICE_UNAVAILABLE); case 1: return of(true, t3.getT2(), OK); case 2: case 4: return of(false, t3.getT3(), FORBIDDEN); default: return of(false, t3.getT3(), BAD_REQUEST); } }); } case 4: return just(of(true, t4.getT2(), OK)); default: return just(of(false, t4.getT3(), BAD_REQUEST)); } }); } }

Façade API

La prochaine étape consiste à concevoir notre API d'application Web. Il doit représenter et encapsuler notre interaction avec l'API de l'interface COM:

@Configuration public class DllApiRouter { @Bean public RouterFunction dllApiRoute(DllApiRouterHandler handler) { return RouterFunctions.route(GET("/api/sessions/{accountId}"), handler::sessionId) .andRoute(GET("/api/users/{matchTerm}"), handler::matchedUsers); } }

Outre la Routerclasse, définissons une implémentation de son gestionnaire avec une logique de récupération du SessionID et l'utilisateur enregistre les données.

Pour le deuxième scénario, pour pouvoir faire un getMatchedUsersappel d'API DLL selon la conception, utilisons l'en-tête obligatoire X-SESSION-ID:

@Slf4j @Component @RequiredArgsConstructor public class DllApiRouterHandler { private static final String SESSION_ID_HDR = "X-SESSION-ID"; private final DllApiService service; private final AccountRepo accountRepo; private final SessionIDService sessionService; public Mono sessionId(ServerRequest request) { final String accountId = request.pathVariable("accountId"); return accountRepo.findById(accountId) .flatMap(acc -> sessionService.getSessionId(accountId, acc.getApiUsername(), acc.getApiPassword())) .doOnEach(logNext(t3 -> { if (t3.getT1()) { log.info(format("SessionId to return %s", t3.getT2())); } else { log.warn(format("Session Id could not be retrieved. Cause: %s", t3.getT2())); } })) .flatMap(t3 -> status(t3.getT3()).contentType(APPLICATION_JSON) .bodyValue(t3.getT1() ? t3.getT2() : Response.error(t3.getT2()))) .switchIfEmpty(Mono.just("Account could not be found with provided ID " + accountId) .doOnEach(logNext(log::info)) .flatMap(msg -> badRequest().bodyValue(Response.error(msg)))); } public Mono matchedUsers(ServerRequest request) { return sessionIdHeader(request).map(sId -> Tuples.of(sId, request.queryParam("matchTerm") .orElseThrow(() -> new IllegalArgumentException( "matchTerm query param should be specified")))) .flatMap(t2 -> service.getMatchedUsers(t2.getT1(), t2.getT2())) .flatMap(this::handleT3) .onErrorResume(IllegalArgumentException.class, this::handleIllegalArgumentException); } private Mono sessionIdHeader(ServerRequest request) { return Mono.justOrEmpty(request.headers() .header(SESSION_ID_HDR) .stream() .findFirst() .orElseThrow(() -> new IllegalArgumentException(SESSION_ID_HDR + " header is mandatory"))); } private Mono handleT3(Tuple3 t3) { switch (t3.getT1()) { case 1: return ok().contentType(APPLICATION_JSON) .bodyValue(t3.getT2()); case 2: return status(FORBIDDEN).contentType(APPLICATION_JSON) .bodyValue(Response.error(t3.getT3())); default: return badRequest().contentType(APPLICATION_JSON) .bodyValue(Response.error(t3.getT3())); } } private Mono handleIllegalArgumentException(IllegalArgumentException e) { return Mono.just(Response.error(e.getMessage())) .doOnEach(logNext(res -> log.info(String.join(",", res.getErrors())))) .flatMap(res -> badRequest().contentType(MediaType.APPLICATION_JSON) .bodyValue(res)); } @Getter @Setter @NoArgsConstructor public static class Response implements Serializable { private String message; private Set errors; private Response(Set errors) { this.errors = errors; } public static Response error(String error) { return new Response(singleton(error)); } } }

Entité de compte

Comme vous l'avez peut-être remarqué, nous avons importé AccountRepodans le gestionnaire du routeur pour trouver l'entité dans une base de données par le fichier accountId. Cela nous permet d'obtenir les informations d'identification de l'utilisateur API correspondantes et d'utiliser les trois dans l' Logonappel d'API DLL .

Pour avoir une image plus claire, définissons également l' Accountentité gérée :

@TypeAlias("Account") @Document(collection = "accounts") public class Account { @Version private Long version; /** * unique account ID for API, provided by supplier * defines restriction for data domain visibility * i.e. data from one account is not visible for another */ @Id private String accountId; /** * COM API username, provided by supplier */ private String apiUsername; /** * COM API password, provided by supplier */ private String apiPassword; @CreatedDate private Date createdAt; @LastModifiedDate private Date updatedOn; }

La configuration de la bibliothèque JACOB

Toutes les parties de notre application sont maintenant prêtes à l'exception du noyau - la configuration et l'utilisation de la bibliothèque JACOB. Commençons par configurer la bibliothèque.

La bibliothèque est distribuée via sourceforge.net. Je ne l'ai trouvé nulle part disponible sur le Central Maven Repo ou sur tout autre référentiel en ligne. J'ai donc décidé de l'importer manuellement dans notre projet en tant que package local.

Pour ce faire, je l'ai téléchargé et je l'ai mis dans le dossier racine sous /libs/jacob-1.19.

Après cela, placez la configuration maven-install-plugin suivante dans pom.xml. Cela ajoutera la bibliothèque au référentiel local pendant la installphase de construction de Maven :

 org.apache.maven.plugins maven-install-plugin   install-jacob validate  ${basedir}/libs/jacob-1.19/jacob.jar default net.sf.jacob-project jacob 1.19 jar true   install-file    

Cela vous permettra d'ajouter facilement la dépendance comme d'habitude:

 net.sf.jacob-project jacob 1.19 

L'importation de la bibliothèque est terminée. Maintenant, préparons-le à l'utiliser.

Pour interagir avec le composant COM, JACOB fournit un wrapper appelé une ActiveXComponentclasse (comme je l'ai déjà mentionné).

Il a une méthode appelée invoke(String function, Variant... args)qui nous permet de faire exactement ce que nous voulons.

De manière générale, notre bibliothèque est configurée pour créer le ActiveXComponentbean afin que nous puissions l'utiliser partout où nous voulons dans l'application (et nous le voulons dans l'implémentation de DllApiService).

Définissons donc un printemps séparé @Configurationavec toutes les préparations essentielles:

@Slf4j @Configuration public class JacobCOMConfiguration { private static final String COM_INTERFACE_NAME = "NAME_OF_COM_INTERFACE_AS_IN_REGISTRY"; private static final String JACOB_LIB_PATH = System.getProperty("user.dir") + "\\libs\\jacob-1.19"; private static final String LIB_FILE = System.getProperty("os.arch") .equals("amd64") ? "\\jacob-1.19-x64.dll" : "\\jacob-1.19-x86.dll"; private File temporaryDll; static { log.info("JACOB lib path: {}", JACOB_LIB_PATH); log.info("JACOB file lib path: {}", JACOB_LIB_PATH + LIB_FILE); System.setProperty("java.library.path", JACOB_LIB_PATH); System.setProperty("com.jacob.debug", "true"); } @PostConstruct public void init() throws IOException { InputStream inputStream = new FileInputStream(JACOB_LIB_PATH + LIB_FILE); temporaryDll = File.createTempFile("jacob", ".dll"); FileOutputStream outputStream = new FileOutputStream(temporaryDll); byte[] array = new byte[8192]; for (int i = inputStream.read(array); i != -1; i = inputStream.read(array)) { outputStream.write(array, 0, i); } outputStream.close(); System.setProperty(LibraryLoader.JACOB_DLL_PATH, temporaryDll.getAbsolutePath()); LibraryLoader.loadJacobLibrary(); log.info("JACOB library is loaded and ready to use"); } @Bean public ActiveXComponent dllAPI() { ActiveXComponent activeXComponent = new ActiveXComponent(COM_INTERFACE_NAME); log.info("API COM interface {} wrapped into ActiveXComponent is created and ready to use", COM_INTERFACE_NAME); return activeXComponent; } @PreDestroy public void clean() { temporaryDll.deleteOnExit(); log.info("Temporary DLL API library is cleaned on exit"); } }

Il convient de mentionner qu'en plus de définir le bean, nous initialisons les composants de la bibliothèque en fonction de l'ISA (architecture du jeu d'instructions) de la machine hôte.

De plus, nous suivons certaines recommandations courantes pour faire une copie du fichier de la bibliothèque correspondante. Cela évite toute corruption potentielle du fichier d'origine pendant l'exécution. Nous devons également nettoyer toutes les ressources allouées lorsque les applications se terminent.

La bibliothèque est maintenant configurée et prête à être utilisée. Enfin, nous pouvons mettre en œuvre notre dernière composante principale qui nous aide à communiquer avec l'API DLL:   DllApiServiceImpl.

Comment implémenter un service d'API de bibliothèque DLL

Comme tous les appels d'API COM vont être préparés en utilisant une approche commune, implémentons d' InitialiseWithIDabord. Après cela, toutes les autres méthodes peuvent être mises en œuvre facilement de la même manière.

Comme je l'ai mentionné précédemment, pour interagir avec l'interface COM, JACOB nous fournit la ActiveXComponentclasse qui a la invoke(String function, Variant... args)méthode.

Si vous voulez en savoir plus sur la Variantclasse, la documentation JACOB dit ce qui suit (vous pouvez la trouver dans l'archive ou sous /libs/jacob-1.19dans le projet):

Le type de données multiformat utilisé pour tous les rappels et la plupart des communications entre Java et COM. Il fournit une classe unique qui peut gérer tous les types de données.

Cela signifie que tous les arguments définis dans la InitialiseWithIDsignature doivent être encapsulés new Variant(java.lang.Object in)et transmis à la invokeméthode. Utilisez le même ordre que celui spécifié dans la description de l'interface au début de cet article.

La seule autre chose importante que nous n'avons pas encore abordée est de savoir comment distinguer inet outtaper les arguments.

À cette fin, Variantfournit un constructeur qui accepte l'objet de données et des informations sur le fait que ce soit par référence ou non. Cela signifie qu'après l' invokeappel, toutes les variantes qui ont été initialisées comme références sont accessibles après l'appel. Nous pouvons donc extraire les résultats des outarguments.

Pour ce faire, il suffit de passer une variable booléenne supplémentaire au constructeur comme second paramètre: new Variant(java.lang.Object pValueObject, boolean fByRef).

L'initialisation de l' Variantobjet en tant que référence impose au client une exigence supplémentaire pour décider quand libérer la valeur (afin qu'elle puisse être supprimée par le garbage collector).

Pour cela, vous disposez de la safeRelease()méthode qui est censée être appelée lorsque la valeur est extraite de l' Variantobjet correspondant .

L'assemblage de toutes les pièces nous donne la mise en œuvre du service suivant:

@RequiredArgsConstructor public class DllApiServiceImpl implements DllApiService { @Value("${DLL_API_ADDRESS}") private String address; private final ActiveXComponent dll; @Override public Mono initialiseWithID(final String accountId) { return Mono.just(format("Calling %s(%s, %s, %s, %s, %s, %s)",// InitialiseWithID, address, accountId, "loginId/out", "error/out", "outcome/out", "sessionId/out")) .doOnEach(logNext(log::info)) //invoke COM interface method and extract the result mapping it onto corresponding *Out inner class .map(msg -> invoke(InitialiseWithID, vars -> InitialiseWithIDOut.builder() .loginId(vars[3].toString()) .error(vars[4].toString()) .outcome(valueOf(vars[5].toString())) .sessionId(vars[6].toString()) .build(), // new Variant(address), new Variant(accountId), initRef(), initRef(), initRef(), initRef())) //Handle the response according to the documentation .map(out -> { final String errorVal; switch (out.outcome) { case 2: errorVal = "InitialiseWithID method call failed. DLL API request outcome (response code from server via DLL) = 2 " +// "(Unable to connect to server due to absent server, or incorrect details)"; break; case 3: errorVal = "InitialiseWithID method call failed. DLL API request outcome (response code from server via DLLe) = 3 (Unmatched AccountID)"; break; default: errorVal = handleOutcome(out.outcome, out.error, InitialiseWithID); } return of(out, errorVal); }) .doOnEach(logNext(t2 -> { InitialiseWithIDOut out = t2.getT1(); log.info("{} API call result:\noutcome: {}\nsessionId: {}\nerror: {}\nloginId: {}",// InitialiseWithID, out.outcome, out.sessionId, t2.getT2(), out.loginId); })) .map(t2 -> { InitialiseWithIDOut out = t2.getT1(); //out.outcome == 4 auto-login successful, SessionID is retrieved return of(out.outcome, out.outcome == 4 ? out.sessionId : out.loginId, t2.getT2()); }); } private static Variant initRef() { return new Variant("", true); } private static String handleOutcome(Integer outcome, String error, COM_API_Method method) { switch (outcome) { case 1: return "no error"; case 2: return format("%s method call failed. DLL API request outcome (response code from server via DLL) = 2 (Access denied)", method); default: return format("%s method call failed. DLL API request outcome (response code from server via DLL) = %s (server technical error). " + // "DLL API is temporary unavailable (server behind is down), %s", method, outcome, error); } } /** * @param method to be called in COM interface * @param returnFunc maps Variants (references) array onto result object that is to be returned by the method * @param vars arguments required for calling COM interface method * @param  type of the result object that is to be returned by the method * @return result of the COM API method invocation in defined format */ private  T invoke(COM_API_Method method, Function returnFunc, Variant... vars) { dll.invoke(method.name(), vars); T res = returnFunc.apply(vars); asList(vars).forEach(Variant::safeRelease); return res; } @SuperBuilder private static abstract class Out { final Integer outcome; final String error; } @SuperBuilder private static class InitialiseWithIDOut extends Out { final String loginId; final String sessionId; }

Deux autres méthodes, Logonet getMatchedUsers, sont mises en œuvre en conséquence. Vous pouvez vous référer à mon dépôt GitHub pour une version complète du service si vous souhaitez le vérifier.

Félicitations - Vous avez appris peu de choses

Nous avons parcouru un scénario étape par étape qui nous a montré comment une API COM hypothétique pouvait être distribuée et appelée en Java.

Nous avons également appris comment la bibliothèque JACOB peut être configurée et utilisée efficacement pour interagir avec une bibliothèque DDL dans votre application Spring Boot 2.

Une petite amélioration serait de mettre en cache le SessionID récupéré, ce qui pourrait améliorer le flux général. Mais cela sort un peu du cadre de cet article.

Si vous souhaitez approfondir vos recherches, vous pouvez le trouver sur GitHub où il est implémenté à l'aide du mécanisme de mise en cache de Spring.

J'espère que vous avez aimé tout parcourir avec moi et que vous avez trouvé ce tutoriel utile!