13. Services web
13.1. Introduction
Nous avons présenté au chapitre précédent plusieurs applications client-serveur TCP/IP. Dans la mesure où les clients et le serveur échangent des lignes de texte, ils peuvent être écrits dans n'importe quel langage. Le client doit simplement connaître le protocole de dialogue attendu par le serveur.
Les services web sont, eux aussi, des applications serveur TCP/IP. Ils présentent les caractéristiques suivantes :
- ils sont hébergés par des serveurs web et le protocole d'échange client-serveur est HTTP (HyperText Transfer Protocol), un protocole au-dessus de TCP/IP, que nous avons découvert au chapitre précédent ;
- le service web a un protocole de dialogue standard, quel que soit le service assuré. Un service web offre divers services S1, S2…, Sn. Chacun d'eux attend des paramètres fournis par le client et rend à celui-ci un résultat. Pour chaque service, le client a besoin de savoir :
- comment désigner le service Si ;
- la liste des paramètres qu'il faut lui fournir et leur type ;
- le type du résultat retourné par le service. Une fois ces éléments connus, le dialogue client-serveur suit le même format, quel que soit le service web interrogé. L'écriture des clients est ainsi normalisée ;
- pour se protéger des attaques venant de l'Internet, beaucoup d'organisations n'ouvrent sur l'extérieur que certains ports de leurs serveurs : essentiellement les ports 80 (HTTP) et 443 (HTTPS) du service web. Tous les autres ports sont verrouillés. Aussi les applications client-serveur telles que celles du chapitre précédent sont-elles en général réservées au réseau privé de l'organisation (intranet). Loger un service au sein d'un serveur web le rend accessible à toute la communauté Internet ;
- le service web peut être vu comme un objet distant : les services offerts deviennent alors des méthodes de cet objet. Un client peut utiliser cet objet distant comme s'il était local, au travers d'un objet intermédiaire appelé proxy (mandataire), qui cache toute la partie communication réseau. On construit ainsi un client indépendant de cette couche : si celle-ci vient à changer, le reste du client n'a pas à être modifié ;
- comme pour les applications client-serveur TCP/IP du chapitre précédent, le client et le serveur peuvent être écrits dans des langages quelconques. Ils échangent des lignes de texte qui comportent deux parties : les entêtes nécessaires au protocole HTTP et le corps du message, dont le format est convenu entre le client et le serveur.
L'architecture d'une application client-serveur à base de service web est la suivante :

C'est une extension de l'architecture en couches du chapitre 6, à laquelle on ajoute des couches de communication réseau spécialisées : le client du service web côté client, le service web lui-même côté serveur. Nous avons déjà rencontré une architecture semblable avec le serveur TCP de calcul de l'impôt (version 8) du chapitre précédent.
13.1.1. Hier : SOAP et WSDL
Dans les années 2000, les services web suivaient le plus souvent la norme SOAP (Simple Object Access Protocol). Le client envoyait une requête HTTP POST dont le corps était un document XML : une « enveloppe » contenant le nom de la méthode appelée et ses paramètres. Le serveur répondait par une autre enveloppe XML contenant le résultat. Chaque service était décrit par un document XML au format WSDL (Web Services Description Language), disponible à l'URL du service suivie de [?wsdl] : il listait les méthodes, leurs paramètres et leurs types. À partir de ce document, des outils généraient automatiquement le proxy du service.
Sur la JVM, ces services s'écrivaient avec l'API JAX-WS : une classe annotée [@WebService], dont les méthodes publiques (éventuellement annotées [@WebMethod]) devenaient les méthodes du service ; l'outil [wsimport] générait le proxy client à partir du WSDL. JAX-WS faisait partie du JDK de Java 6 à Java 10 ; il en a été retiré par Java 11 (JEP 320) et vit désormais en dehors du JDK, sous le nom de Jakarta XML Web Services. Kotlin, apparu (en version 1.0) en 2016, n'a guère connu cette époque : on peut utiliser JAX-WS depuis Kotlin comme toute bibliothèque Java, mais les nouveaux services ne l'utilisent plus guère. Les raisons de cet abandon sont la lourdeur du XML et des enveloppes SOAP, la complexité des normes associées (WS-Security, WS-Addressing…) et la difficulté d'appeler ces services depuis un navigateur.
13.1.2. Aujourd'hui : REST et JSON
Les services web actuels suivent pour la plupart le style REST (REpresentational State Transfer) et échangent des documents JSON (JavaScript Object Notation, présenté au chapitre 5). Le style REST repose sur les principes suivants :
- le service expose des ressources, chacune désignée par une URL : [/impot], [/clients/12], [/clients/12/commandes] ;
- on agit sur ces ressources avec les méthodes (on dit aussi verbes) du protocole HTTP, chacune ayant une signification précise ;
- le résultat de l'action est indiqué par le code de statut HTTP de la réponse ;
- le corps des requêtes et des réponses est le plus souvent un document JSON, plus compact et plus simple que XML ;
- le service est sans état : chaque requête contient toutes les informations nécessaires à son traitement ; le serveur ne mémorise rien d'une requête à l'autre pour un client donné.
Les méthodes HTTP les plus utilisées sont les suivantes :
Méthode | Usage | Exemple |
GET | lire une ressource ; les paramètres éventuels sont dans l'URL | GET /clients/12 |
POST | créer une ressource, ou demander un traitement ; les données sont dans le corps de la requête | POST /clients |
PUT | remplacer une ressource | PUT /clients/12 |
PATCH | modifier une partie d'une ressource | PATCH /clients/12 |
DELETE | supprimer une ressource | DELETE /clients/12 |
Les codes de statut, déjà rencontrés au chapitre précédent, prennent ici tout leur sens :
Code | Signification |
200 OK | la requête a réussi ; le corps de la réponse contient le résultat |
201 Created | la ressource a été créée ; l'entête [Location] donne son URL |
204 No Content | la requête a réussi ; la réponse n'a pas de corps |
400 Bad Request | la requête est incorrecte (paramètre absent ou invalide…) |
404 Not Found | la ressource demandée n'existe pas |
415 Unsupported Media Type | le type du document envoyé (entête [Content-Type]) n'est pas accepté |
500 Internal Server Error | une erreur imprévue s'est produite sur le serveur |
Un service REST n'a pas d'équivalent obligatoire du WSDL. Il est cependant d'usage de le décrire par un document OpenAPI (anciennement Swagger), au format JSON ou YAML, à partir duquel des outils peuvent générer de la documentation, des pages de test et des clients. Nous y reviendrons.
Signalons enfin gRPC, conçu par Google : les méthodes du service et leurs messages sont décrits dans un fichier [.proto], à partir duquel des outils génèrent le serveur et le client, comme au temps du WSDL ; les messages sont codés dans un format binaire compact (Protocol Buffers) et transportés par HTTP/2. gRPC est très efficace et apprécié pour les échanges entre services d'une même organisation, mais il est mal adapté aux navigateurs. Il est disponible sur la JVM avec la bibliothèque [grpc-java], complétée pour Kotlin par [grpc-kotlin] (des fonctions [suspend] et des [Flow], chapitre 9), que nous n'utiliserons pas.
13.1.3. Spring Boot et Spring MVC
Pour écrire nos services web, nous utiliserons Spring Boot. Nous avons utilisé au chapitre 6 le conteneur d'injection de dépendances du Spring Framework. Ce cadriciel comprend aussi un module web, Spring MVC, qui associe une URL et une méthode HTTP à une fonction grâce à des annotations : [@RestController] sur la classe, [@GetMapping], [@PostMapping]… sur ses fonctions. Spring Boot (version 1.0 en 2014, version 4 en novembre 2025, avec Spring Framework 7) simplifie la mise en œuvre de Spring :
- il configure automatiquement l'application d'après les bibliothèques présentes : si Spring MVC et Jackson sont là, il met en place tout ce qu'il faut pour un service web JSON, sans aucune ligne de configuration ;
- il embarque le serveur web dans l'application : le serveur Tomcat (version 11) est une simple bibliothèque, démarrée par le programme principal. Il n'est donc pas nécessaire d'installer un serveur web ou un serveur d'applications séparé (Tomcat, GlassFish, WildFly…), dans lequel on déployait autrefois l'application sous la forme d'une archive [.war] ;
- il fournit des starters, des dépendances qui regroupent tout ce qu'il faut pour un usage donné. Le starter [spring-boot-starter-webmvc] apporte Spring MVC, Tomcat, Jackson 3 (chapitre 5) et la journalisation (SLF4J et Logback) ;
- il fournit une nomenclature (BOM, Bill Of Materials), qui fixe les versions de toutes ces bibliothèques, compatibles entre elles, et un greffon Gradle, [org.springframework.boot], qui lance l'application ([gradlew bootRun]) et la livre sous la forme d'un unique fichier [.jar] exécutable ([gradlew bootJar]).
Spring est écrit en Java, mais il traite Kotlin comme un langage de premier rang depuis Spring Framework 5 (2017) : sa documentation donne ses exemples dans les deux langages, il comprend les types nullables de Kotlin, sait appeler les constructeurs primaires et leurs valeurs par défaut, et fournit des fonctions d'extension propres à Kotlin ([runApplication], [getBean<T>()], [registerBean<T>()] du chapitre 6…). Spring Boot n'est pas la seule solution : Jakarta EE (l'ancien Java EE) définit l'API standard JAX-RS (Jakarta RESTful Web Services), et des cadriciels comme Quarkus, Micronaut ou Helidon sont également très utilisés depuis Kotlin. Signalons surtout Ktor, le cadriciel web de JetBrains, écrit en Kotlin pour Kotlin : ses routes sont décrites par un langage dédié (DSL) plutôt que par des annotations, et ses traitements sont des fonctions [suspend] (chapitre 9). Spring Boot est cependant le plus répandu, et il prolonge naturellement ce que nous avons vu au chapitre 6.
13.2. Un premier service web
Nous allons construire une première application client-serveur ayant l'architecture simplifiée suivante :

Les projets de ce chapitre forment une construction Gradle multi-projets (chapitre 6), le dossier [13-services-web] :
Le fichier [settings.gradle.kts] énumère les sous-projets :
- lignes 3-6 : le plugin qui permet à Gradle de télécharger lui-même le JDK 25 s'il ne le trouve pas sur le poste (chapitre 2) ;
- ligne 11 : les six sous-projets, désignés par le nom de leur dossier ;
- ligne 14 : la construction du chapitre 6 est incluse dans celle-ci (construction composite, déjà utilisée aux chapitres 10 et 11) : les projets [WsImpot] et [ClientWsImpot] pourront dépendre des modules de la version 5 de l'application [Impots] par leurs coordonnées ([exemples.impots:impots-metier:5.0]), et Gradle construira ces modules à partir de leurs sources si nécessaire.
Le projet racine déclare les greffons utilisés par les sous-projets, avec leur version, sans les appliquer :
- ligne 4 : le greffon Kotlin, que nous connaissons ;
- ligne 5 : le greffon [all-open] pour Spring, utilisé au chapitre 6. En Kotlin, les classes et leurs fonctions sont finales par défaut ; or Spring a besoin d'en dériver des sous-classes (des proxys) pour certaines de ses classes. Ce greffon rend ouvertes ([open]) les classes annotées par Spring ([@Configuration], [@Component], [@RestController], [@SpringBootApplication]…) et leurs fonctions ;
- ligne 6 : le greffon de Spring Boot.
Les projets sont déjà prêts dans les codes du cours : le lecteur n'a qu'à ouvrir le dossier [13-services-web] dans IntelliJ IDEA, qui le reconnaît comme une construction Gradle, ou à taper dans ce dossier la commande Gradle indiquée. Les services se lancent par [gradlew :WsHello:bootRun] (le chemin [:WsHello] désigne le sous-projet), les clients console par [gradlew -q :ClientWsHello:run]. La commande [gradlew build] construit tous les projets d'un coup.
Pour créer soi-même un projet Spring Boot, le plus simple est d'utiliser le site Spring Initializr : on y choisit « Gradle - Kotlin » (un projet Gradle dont les fichiers de construction sont écrits en Kotlin), le langage Kotlin, la version de Spring Boot, le nom du projet et ses dépendances (ici « Spring Web »), et on télécharge un zip contenant un projet prêt à l'emploi : les fichiers [build.gradle.kts] et [settings.gradle.kts], le wrapper de Gradle, la classe principale et le fichier [application.properties]. IntelliJ IDEA (édition Ultimate) propose le même assistant. Pour un projet Kotlin, Spring Initializr ajoute de lui-même les dépendances [kotlin-reflect] et [jackson-module-kotlin], dont nous parlerons.
13.2.1. La partie serveur

Le fichier [build.gradle.kts] du projet [WsHello] est le suivant :
Commentaires :
- lignes 3-7 : les trois greffons, sans version : celle-ci est fixée par le projet racine. Le greffon de Spring Boot ajoute au projet la tâche [bootRun], qui compile et lance l'application, et la tâche [bootJar], qui en fait un fichier [build/libs/WsHello-1.0.jar] contenant l'application et toutes ses dépendances, qu'on lance par [java -jar build/libs/WsHello-1.0.jar]. Il est inutile d'appliquer le greffon [application] : la classe principale est trouvée par le greffon de Spring Boot, qui recherche la fonction [main] ;
- ligne 19 : la nomenclature de Spring Boot 4.1.1 (la version du greffon). La fonction [platform] importe dans le projet les versions de plusieurs centaines de bibliothèques, choisies et testées ensemble par l'équipe de Spring Boot. C'est pourquoi les dépendances des lignes 21-23 n'ont pas de version. C'est l'équivalent du projet parent [spring-boot-starter-parent] d'un projet Maven ;
- ligne 21 : le starter web. Il apporte Spring MVC (Spring Framework 7.0), le serveur Tomcat 11 embarqué, Jackson 3 et la journalisation ;
- ligne 23 : la bibliothèque de réflexion de Kotlin. La réflexion de Java ([java.lang.reflect]) ne voit qu'une partie de ce qu'exprime le code Kotlin : elle ignore si un paramètre est de type nullable ([String?]), s'il a une valeur par défaut, quel est le constructeur primaire d'une classe. Spring lit ces informations grâce à [kotlin-reflect] (qui décode les métadonnées que le compilateur Kotlin range dans chaque fichier [.class]) : nous verrons qu'il s'en sert pour les paramètres facultatifs. Sa version est celle du greffon Kotlin ;
- ligne 31 : l'option [-java-parameters] du compilateur, qui conserve dans les fichiers [.class] les noms des paramètres des fonctions. Spring en a besoin pour associer un paramètre de fonction ([nom]) au paramètre de même nom d'une URL, et Jackson pour associer les paramètres du constructeur d'une classe de données aux propriétés d'un document JSON (chapitre 5). Le greffon de Spring Boot active déjà cette option lorsqu'il voit le greffon Kotlin ; nous l'écrivons pour qu'elle soit visible ;
- lignes 35-38 : les programmes lancés par Gradle écrivent leurs sorties en UTF-8, pour que les caractères accentués s'affichent correctement sous Windows (chapitre 2).
La classe principale est la suivante :
- ligne 7 : l'annotation [@SpringBootApplication] réunit trois annotations : [@Configuration] (la classe peut définir des beans, comme au chapitre 6), [@EnableAutoConfiguration] (la configuration automatique d'après les bibliothèques présentes) et [@ComponentScan] (Spring recherche les classes annotées, contrôleurs, configurations…, dans le paquetage de cette classe et ses sous-paquetages) ;
- ligne 8 : la classe n'a pas de corps : elle ne sert qu'à porter l'annotation. Comme elle est annotée (indirectement) [@Configuration], le greffon [plugin.spring] la rend ouverte. Ici, ce n'est pas indispensable : nous avons vérifié que l'application démarre aussi lorsque la classe reste finale, car Spring n'a pas à dériver une classe de configuration qui ne définit aucun bean. Mais une classe de configuration qui définit des beans par des fonctions [@Bean], comme celle du service de calcul de l'impôt, provoquerait l'erreur [@Configuration class '...' may not be final] rencontrée au chapitre 6 : le greffon évite d'avoir à y penser ;
- lignes 10-12 : le programme principal est une fonction de haut niveau, compilée dans la classe [WsHelloApplicationKt]. La fonction [runApplication<WsHelloApplication>(*args)], une fonction d'extension de Spring Boot écrite pour Kotlin, remplace l'appel Java [SpringApplication.run(WsHelloApplication.class, args)] : le type de la classe de configuration est passé comme argument de type ([reified], chapitre 4), et l'opérateur [*] (spread) transmet les éléments du tableau [args] au paramètre [vararg] de la fonction (chapitre 3). [runApplication] lit la configuration (fichier [application.properties], variables d'environnement, arguments de la ligne de commande), crée le conteneur Spring et ses beans, démarre le serveur Tomcat, puis rend la main. Le programme ne s'arrête pas pour autant : les threads de Tomcat attendent les requêtes jusqu'à l'arrêt de l'application (Ctrl-C).
La configuration de l'application est placée dans le fichier [src/main/resources/application.properties] :
- ligne 2 : le nom de l'application ;
- ligne 4 : le serveur écoute sur le port 5100 (8080 par défaut). On pourrait aussi passer cette valeur en argument : [gradlew :WsHello:bootRun --args=--server.port=5100], ou par la variable d'environnement [SERVER_PORT] ;
- ligne 6 : au démarrage, Spring Boot affiche par défaut une grande bannière en caractères ASCII ; nous la supprimons ;
- ligne 8 : le format des messages du journal. Le format par défaut est très complet (date, niveau, numéro du processus, nom de l'application, thread, nom complet de la classe, message) mais donne des lignes très longues. Nous le réduisons à l'heure, au niveau du message, au nom du thread, au nom court de la classe émettrice ([%logger{0}]) et au message.
Le service web lui-même est la classe [HelloController]. Il offre une fonction qui rend la chaîne [Bonjour Mme xx], où xx est le nom reçu :
Commentaires :
- ligne 8 : l'annotation [@RestController] fait de la classe un contrôleur : Spring en crée une instance unique (un singleton) au démarrage, et lui confie les requêtes HTTP dont l'URL correspond à l'une de ses fonctions. Le résultat de chaque fonction est envoyé tel quel au client, dans le corps de la réponse ;
- lignes 12-13 : l'annotation [@GetMapping("/")] associe la méthode HTTP GET et l'URL [/] (la racine du site) à la fonction [accueil], qui rend un texte indiquant comment utiliser le service. La fonction a un corps d'expression ([= ...]) : son type, [String], est inféré. Les accolades de [{nom}] n'ont rien de particulier dans une chaîne Kotlin : seul le caractère [$] y introduit un gabarit ;
- lignes 18-19 : le modèle d'URL (on dit la route) [/hello/{nom}] contient une variable [{nom}] : elle correspond à n'importe quel segment d'URL placé après [/hello/]. Ainsi l'URL [/hello/Carla%20Bruni] donne à [{nom}] la valeur [Carla Bruni] (le code [%20] est le codage d'un espace dans une URL). L'annotation [@PathVariable] demande à Spring de passer cette valeur au paramètre de même nom de la fonction : c'est la liaison de paramètres (data binding). La fonction rend une chaîne de caractères, construite par un gabarit : celle-ci est envoyée telle quelle au client, comme document texte (type [text/plain]) ;
- lignes 22-23 : la même chose, mais la fonction rend un objet de type [Salutation]. Spring le sérialise en JSON avec Jackson (chapitre 5) et l'envoie au client comme document de type [application/json]. C'est la forme normale de la réponse d'un service web REST ;
- ligne 26 : le type du résultat, une classe de données (chapitre 4) imbriquée dans le contrôleur (l'équivalent d'un record Java). Jackson la sérialise grâce à ses accesseurs [getNom()] et [getMessage()], générés par le compilateur : chaque propriété devient une propriété JSON de même nom.
Le service est lancé par la commande [gradlew :WsHello:bootRun], tapée dans le dossier [13-services-web] (ou [..\gradlew bootRun] tapée dans le dossier [WsHello] : le wrapper est à la racine de la construction). Le dossier d'exécution de l'application est celui du sous-projet. Elle affiche son journal (le cours a abrégé avec [...] les chemins de la ligne 2 et omis les messages de Gradle) :
- ligne 2 : le démarrage de l'application, la version de Java et le dossier d'exécution, qui est le dossier du projet. On remarquera le nom [WsHelloApplicationKt] : Spring Boot nomme l'application d'après la classe qui contient la fonction [main], ici la classe générée par le compilateur Kotlin pour le fichier [WsHelloApplication.kt], et non la classe [WsHelloApplication] ;
- ligne 3 : Spring permet de définir des profils (développement, test, production…) qui activent des configurations différentes (fichiers [application-dev.properties]…). Aucun profil n'est actif ici ;
- lignes 4-6 : le serveur Tomcat embarqué est créé ; il écoutera sur le port 5100 ;
- ligne 7 : le conteneur Spring est prêt : il a créé ses beans, parmi lesquels notre contrôleur ;
- lignes 8-9 : Tomcat accepte maintenant les connexions ; l'application est prête. Le démarrage a pris une quinzaine de secondes sur notre machine de test, plutôt lente (et occupée à d'autres tâches) ; il dure en général deux ou trois secondes ;
- lignes 10-12 : ces messages sont apparus lors de la première requête : la servlet [DispatcherServlet] de Spring MVC, qui reçoit toutes les requêtes et les répartit entre les fonctions des contrôleurs, n'est initialisée qu'à ce moment. Une servlet est un composant Java qui traite des requêtes HTTP dans un serveur web comme Tomcat (API Jakarta Servlet) ; Spring MVC est construit au-dessus de cette API. Le nom du thread, [http-nio-5100-exec-1], montre que la requête est traitée par l'un des threads du serveur Tomcat ;
- lignes 13-14 : ces messages sont apparus à l'arrêt du serveur par Ctrl-C, après les essais des paragraphes suivants : l'arrêt est « gracieux », Tomcat termine les requêtes en cours avant de s'arrêter.
Chaque message du journal indique son heure, son niveau ([TRACE], [DEBUG], [INFO], [WARN], [ERROR]), le thread qui l'a émis et la classe émettrice. Les messages de niveau [INFO] et au-delà sont affichés par défaut ; la propriété [logging.level.<paquetage>=DEBUG] affiche aussi les messages de niveau inférieur pour un paquetage donné.
13.2.2. Interroger le service avec curl
Pendant que le service s'exécute, interrogeons-le dans une seconde fenêtre de commandes avec l'outil [curl] présenté au chapitre précédent. L'option [-i] affiche les entêtes de la réponse avant son corps, et l'option [-s] (silent) supprime la barre de progression :
- ligne 2 : la requête a réussi. Tomcat n'envoie pas le texte qui suit habituellement le code de statut ([OK]) : il est facultatif depuis HTTP/1.1 (et n'existe plus en HTTP/2), seul le code compte ;
- ligne 3 : le document est un texte (UTF-8) ;
- ligne 4 : la taille du document, en octets ;
- ligne 5 : l'heure de la réponse, toujours exprimée en temps universel (GMT) dans les entêtes HTTP, alors que le journal du serveur utilise l'heure locale ;
- ligne 7 : le résultat de la fonction [hello].
Demandons maintenant la version JSON :
- ligne 3 : le document est maintenant de type [application/json]. Il n'est pas nécessaire de préciser le jeu de caractères : un document JSON échangé sur le réseau est toujours codé en UTF-8 (RFC 8259) ;
- ligne 7 : l'objet [Salutation] sérialisé. Les propriétés JSON portent les noms des propriétés de la classe de données, [nom] et [message], et dans l'ordre de son constructeur primaire. Ce n'allait pas de soi : par défaut, Jackson 3 classe par ordre alphabétique les propriétés d'une classe ordinaire (nous le verrons avec la classe [ProblemDetail]), mais il place d'abord, dans leur ordre, celles qui sont des paramètres du constructeur qu'il a choisi pour créer les objets. Grâce à l'option [javaParameters], il connaît les noms de ces paramètres et reconnaît le constructeur primaire de la classe de données. Les noms Kotlin étant déjà en « camelCase », l'usage en JavaScript et en JSON, il n'y a rien à convertir.
Une URL qui ne correspond à aucune route donne la réponse 404 :
- ligne 4 : le document est envoyé par morceaux (chunks), chacun précédé de sa taille : le serveur n'a pas eu à calculer la taille totale du document avant de commencer à l'envoyer ;
- ligne 7 : le document d'erreur produit par Spring Boot : la date, le code de statut, son texte et le chemin demandé. Par sécurité, il ne contient ni le message de l'exception éventuelle, ni la pile des appels (propriétés [server.error.include-message] et [server.error.include-stacktrace], désactivées par défaut). Nous verrons avec le service suivant comment obtenir un document d'erreur normalisé.
On peut aussi taper l'URL [http://localhost:5100/hello/Carla Bruni] dans la barre d'adresse d'un navigateur, qui affichera le texte reçu : un service web interrogé par GET se teste très simplement.
13.2.3. Un client console

Il est possible d'écrire le client du service avec un simple client TCP, comme au chapitre précédent : il suffit d'envoyer la requête HTTP et de lire la réponse. On utilise cependant la classe [java.net.http.HttpClient] du JDK (Java 11), elle aussi présentée au chapitre précédent, qui gère le protocole HTTP pour nous, et Jackson pour les documents JSON. Le projet [ClientWsHello] est un projet Kotlin ordinaire, dont le fichier [build.gradle.kts] déclare la dépendance Jackson et la classe principale :
- ligne 3 : la dépendance Jackson 3 ; elle a une version, car ce projet n'utilise pas la nomenclature de Spring Boot ;
- ligne 11 : Jackson a besoin des noms des paramètres du constructeur pour créer un objet [Salutation] à partir d'un document JSON (chapitre 5) ;
- lignes 15-17 : la classe principale, exécutée par [gradlew -q :ClientWsHello:run] ;
- lignes 20-22 : le programme lit le clavier.
Son programme est le suivant :
Commentaires :
- ligne 13 : la réponse JSON du service sera désérialisée en un objet [Salutation]. Le client n'a pas accès au type [Salutation] du serveur : il déclare le sien, une classe de données avec les mêmes noms de propriétés. Un client ne partage pas le code du serveur, seulement le format des documents échangés ;
- ligne 18 : l'URL du service est passée en argument ou, à défaut, vaut [http://localhost:5100/] : si l'adresse du service change, le client n'a pas à être recompilé. [firstOrNull()] rend le premier élément du tableau, ou [null] s'il est vide, et l'opérateur Elvis [?:] remplace ce [null] par l'URL par défaut ;
- ligne 20 : le convertisseur JSON de Jackson 3 (chapitre 5) ;
- ligne 23 : un unique [HttpClient] pour tout le programme. Il gère un ensemble de connexions réutilisables et doit être partagé, et non recréé à chaque requête. Depuis Java 21, [HttpClient] implémente [AutoCloseable] : la fonction [use] de Kotlin (chapitre 3) le ferme à la fin du bloc, même en cas d'exception ;
- lignes 26-27 : la question, puis la lecture de la réponse ; en fin de flux, [readlnOrNull] rend [null], que l'opérateur Elvis transforme en chaîne vide ;
- ligne 33 : le nom saisi devient un segment de l'URL : il faut coder les caractères qui n'y sont pas autorisés. [URLEncoder.encode] code le é en [%C3%A9], mais il est fait pour les paramètres de formulaires, où l'espace est codé [+]. Dans le chemin d'une URL, l'espace doit être codé [%20] : on remplace donc les [+] (un vrai [+] saisi est codé [%2B] et n'est pas concerné). [Charsets.UTF_8] est la constante de Kotlin pour le jeu de caractères UTF-8 (celle de Java, [StandardCharsets.UTF_8], convient aussi) ;
- ligne 35 : la méthode [resolve] de la classe [URI] construit une URL à partir de l'URL de base et d'une URL relative. Attention : l'URL de base doit se terminer par [/] pour que [hello/...] lui soit ajouté. La fonction [get] est appelée comme une méthode de [HttpClient] : c'est une fonction d'extension, définie plus bas ;
- lignes 38-39 : la réponse JSON est désérialisée en un objet [Salutation] par la méthode [readValue] de Jackson ;
- lignes 41-43 : l'exception [ConnectException] est lancée lorsque la connexion au service est impossible, par exemple lorsque aucun service n'écoute à l'adresse indiquée. Elle n'a pas de message : nous en construisons un. Le nom [_] indique que l'exception n'est pas utilisée. Ce [catch] doit précéder celui de la ligne 44, car [ConnectException] dérive de [IOException] ;
- lignes 44-46 : les autres erreurs de communication, et les réponses d'erreur du service (voir ligne 59) ;
- lignes 54-61 : une requête GET, dont le corps de la réponse est rendu sous forme de chaîne. Nous l'avons écrite comme une fonction d'extension de la classe [HttpClient] (chapitre 4) : dans son corps, [send] désigne la méthode [send] du client sur lequel elle est appelée ([this]). En Java, c'était une méthode statique qui recevait le client en paramètre ;
- ligne 55 : la requête est construite par un constructeur (builder) : l'URL, la méthode HTTP, puis [build] ;
- ligne 56 : la méthode [send] envoie la requête et attend la réponse. Son second paramètre, un [BodyHandler], indique ce qu'il faut faire du corps de la réponse : [ofString] le lit entièrement dans une chaîne, décodée selon le jeu de caractères indiqué par l'entête [Content-Type] (UTF-8 par défaut). [send] lance une [IOException] en cas d'erreur de communication et une [InterruptedException] si le thread est interrompu pendant l'attente. Kotlin n'ayant pas d'exceptions contrôlées, ni la fonction [get] ni la fonction [main] n'ont à les déclarer ([throws IOException, InterruptedException] en Java) ;
- lignes 58-59 : contrairement à d'autres bibliothèques, [HttpClient] ne considère pas un code de statut d'erreur (4xx, 5xx) comme une exception : la réponse est rendue normalement, et c'est à nous d'examiner son code. Nous lançons ici une [IOException] pour tout code autre que 2xx.
Résultats, le service étant lancé (les saisies, fournies au programme par une redirection, ont été replacées après les questions) :
La ligne 4 affiche l'objet [Salutation] reçu, au format de la fonction [toString] générée pour les classes de données : [Salutation(nom=…, message=…)], là où un record Java affichait [Salutation[nom=…, message=…]]. L'option [-q] (quiet) de Gradle supprime ses propres messages. Si l'on désigne une adresse où aucun service n'écoute, la connexion est refusée :
Et si l'URL du service est incorrecte, le service répond 404 :
La classe [Salutation] du client a deux propriétés non nullables, de type [String]. Que se passerait-il si le service omettait l'une d'elles, [message] par exemple ? Jackson appellerait le constructeur avec la valeur [null] pour le paramètre [message], et c'est le code généré par le compilateur Kotlin, qui vérifie à l'entrée de toute fonction publique que ses paramètres non nullables ne sont pas [null], qui refuserait cette valeur. Nous l'avons vérifié avec un petit programme d'essai :
La sûreté vis-à-vis de [null] est donc préservée, même pour des objets créés par réflexion. Une propriété de type [Int] absente est, elle, refusée par Jackson 3 lui-même ([Cannot map null into type int]). Pour qu'une propriété puisse manquer, on la déclare nullable ([String?], [Int?]) ; c'est ce que nous ferons dans les services suivants. Le module Kotlin de Jackson, [jackson-module-kotlin] (dépendance [tools.jackson.module:jackson-module-kotlin]), va plus loin : il comprend les valeurs par défaut des paramètres (une propriété absente prend la valeur par défaut) et donne des messages d'erreur plus précis. Il fournit aussi la fonction d'extension [mapper.readValue<Salutation>(json)], qui remplace [mapper.readValue(json, Salutation::class.java)]. Lorsqu'il est présent parmi les dépendances d'une application Spring Boot, celle-ci l'enregistre automatiquement auprès du convertisseur JSON de Spring MVC ; Spring Initializr l'ajoute d'ailleurs aux projets Kotlin. Nous nous en passons dans ce cours, les classes de données et l'option [javaParameters] suffisant à nos besoins.
Avec un service SOAP, le client utilisait un proxy généré à partir du WSDL du service, et appelait la méthode distante comme une méthode locale. Nous appelons ici directement les URL du service. Nous écrirons un véritable proxy pour le service de calcul de l'impôt.
13.2.4. La description du service : OpenAPI
Le WSDL décrivait un service SOAP de façon normalisée. Son équivalent actuel est le document OpenAPI. Pour Spring Boot, la bibliothèque [springdoc-openapi] (dépendance [org.springdoc:springdoc-openapi-starter-webmvc-ui]) le produit sans aucune ligne de code : elle examine les contrôleurs et leurs annotations au démarrage et publie le document à l'URL [/v3/api-docs], ainsi qu'une page de test interactive, Swagger UI, à l'URL [/swagger-ui.html]. Le document décrit chaque route : son URL, sa méthode HTTP, ses paramètres et leur type, le schéma JSON de ses réponses. Avec Kotlin, elle tient compte de la nullabilité des types : une propriété de type [String] est déclarée obligatoire, une propriété de type [String?] facultative. Des générateurs comme OpenAPI Generator en tirent un client typé dans divers langages (Kotlin compris), exactement comme [wsimport] le faisait avec le WSDL. Cette bibliothèque ne fait pas partie de Spring Boot, et nous ne l'utiliserons pas dans ce chapitre.
13.3. Un service web d'opérations arithmétiques
Le service précédent offrait une unique fonction. Nous considérons maintenant un service web qui offre les quatre opérations arithmétiques : ajouter(a, b), soustraire(a, b), multiplier(a, b) et diviser(a, b). Nous allons en profiter pour étudier les deux façons de transmettre des paramètres à un service web et la façon de signaler les erreurs.
13.3.1. La partie serveur
Le service [WsOperations] accepte deux formes de requête :
Requête | Paramètres |
GET /ajouter?a=1.5&b=2 | l'opération dans le chemin de l'URL, les nombres dans sa chaîne de requête |
POST /calcul | un document JSON dans le corps de la requête : [{"operation":"ajouter","a":1.5,"b":2}] |
Dans les deux cas, la réponse est un document JSON : [{"operation":"ajouter","a":1.5,"b":2.0,"resultat":3.5}]. GET convient aux requêtes simples, qui ne modifient rien sur le serveur : l'URL peut être tapée dans un navigateur, mise dans un favori, et sa réponse peut être conservée par un cache. POST convient aux données volumineuses ou structurées. Le fichier [build.gradle.kts] du projet est celui de [WsHello], au commentaire près, et son fichier [WsOperationsApplication.kt] est identique à celui de [WsHello], aux noms près ([WsOperationsApplication], paquetage [wsoperations]). Son fichier [application.properties] est le suivant :
- ligne 4 : le service écoute sur le port 5110 ;
- ligne 6 : les erreurs détectées par Spring MVC lui-même (URL inconnue, document JSON incorrect, type de document non accepté…) seront signalées par une réponse au format Problem Details. Ce format, normalisé par la RFC 9457, est un document JSON de type [application/problem+json] qui décrit une erreur : son type, un titre, le code de statut, un détail et d'éventuelles informations complémentaires. C'est la façon standard, pour un service REST, de dire ce qui ne va pas.
Le contrôleur est le suivant :
Commentaires :
- lignes 20-25 : la requête GET. Le paramètre [operation] de la fonction est lié à la variable de route [{operation}] ([@PathVariable]). Les paramètres [a] et [b], annotés [@RequestParam], sont liés aux paramètres de même nom de la chaîne de requête ([?a=1.5&b=2]). Ils sont facultatifs : un paramètre absent vaut [null]. En Java, il fallait l'écrire ([@RequestParam(required = false) String a]) ; en Kotlin, il suffit de déclarer le paramètre nullable, [String?] : Spring lit, grâce à [kotlin-reflect], la nullabilité du type et en déduit que le paramètre est facultatif. Inversement, un paramètre de type non nullable ([String]) est obligatoire : Spring rejetterait par une réponse 400 une requête à laquelle il manquerait. Le type déclaré dit donc à la fois au compilateur et à Spring ce qui peut manquer ;
- lignes 23-24 : [a] et [b] sont déclarés de type [String?]. Nous aurions pu les déclarer de type [Double?] : Spring aurait alors fait lui-même la conversion, et rejeté une valeur incorrecte par une réponse 400 au message générique. En les recevant sous forme de chaînes, nous gardons la maîtrise des messages d'erreur ;
- ligne 25 : la fonction a un corps d'expression. Son type de retour, [ResponseEntity<*>], est écrit explicitement : le type étoilé [*] (chapitre 4) désigne une réponse dont le type du corps n'est pas précisé, l'équivalent du [ResponseEntity<?>] de Java ;
- lignes 29-31 : la requête POST. Le paramètre [demande], annoté [@RequestBody], est construit par Jackson à partir du corps JSON de la requête. Les propriétés de la classe [DemandeCalcul] sont nullables (ligne 79) : une propriété absente du document JSON vaut alors [null], ce qui permet de la détecter ;
- ligne 35 : la conversion d'une chaîne en nombre, [null] si elle échoue, écrite en une expression. L'opérateur [?.] propage le [null] d'une chaîne absente ; la fonction [toDoubleOrNull] de Kotlin rend [null] au lieu de lancer une exception ; la fonction [takeIf] rend son objet si la condition est vraie, [null] sinon : elle refuse ici les valeurs [NaN] et les infinis, que [toDoubleOrNull] accepte (textes [NaN] et [Infinity]). Dans une URL comme en JSON, les nombres s'écrivent avec un point décimal : [toDoubleOrNull], qui ne dépend pas de la langue du système, est donc le bon outil (et non [NumberFormat], qui attendrait une virgule sur un serveur français, chapitre 5). En Java, il fallait un bloc [try]/[catch] autour de [Double.parseDouble] ;
- ligne 40 : le nom de l'opération, en minuscules. La fonction [lowercase] de Kotlin fait la conversion indépendamment de la langue du système (elle utilise [Locale.ROOT], qu'il fallait préciser en Java) ;
- lignes 42-47 : l'opération demandée est représentée par une fonction de type [(Double, Double) -> Double] (chapitre 7), nullable : [((Double, Double) -> Double)?]. Une expression [when] choisit la lambda correspondant à l'opération, [null] pour une opération inconnue. Le type déclaré de [calcul] permet au compilateur d'inférer le type des paramètres [x] et [y] des lambdas ;
- lignes 50-64 : on vérifie les trois paramètres. Si l'un d'eux est incorrect, on construit la liste des erreurs : un dictionnaire qui associe à chaque paramètre la liste de ses messages d'erreur. La fonction [buildMap], de la même famille que [buildString] et [buildList] (chapitre 5), crée un dictionnaire modifiable, l'offre comme receveur ([this]) au bloc, où [put] le remplit, puis le rend en lecture seule ; il conserve l'ordre d'insertion ;
- lignes 60-64 : la réponse 400. La classe [ProblemDetail] de Spring représente un document « Problem Details » : [forStatus] le crée avec un code de statut. La fonction [apply] (chapitre 4) exécute le bloc avec l'objet créé comme receveur, puis rend cet objet : on y fixe son titre en écrivant la propriété [title] (Kotlin voit les méthodes Java [getTitle]/[setTitle] comme une propriété) et on lui ajoute par [setProperty] une propriété quelconque, ici [errors], la liste des erreurs. La classe [ResponseEntity] représente une réponse HTTP complète : code de statut, entêtes et corps. [ResponseEntity.badRequest()] prépare une réponse 400 et [body] lui donne son corps ;
- ligne 66 : après le [return] de la ligne 64, le compilateur sait que [calcul], [a] et [b] ne sont pas [null] : ce sont des variables locales (ou des paramètres) qui ne peuvent plus changer. Il les traite désormais comme des valeurs de types non nullables [(Double, Double) -> Double] et [Double] : c'est le transtypage intelligent (smart cast) du chapitre 3. Nous avons organisé le test pour en profiter : le Java testait séparément chaque paramètre et accumulait les erreurs dans un dictionnaire, puis testait si le dictionnaire était vide ; or ce dernier test ne dirait rien au compilateur sur [a] et [b] ;
- lignes 68-72 : la division par zéro est refusée par une réponse 400, dont le document comporte cette fois un détail ([forStatusAndDetail]). Ce refus est nécessaire : la division d'un [Double] par zéro ne lance pas d'exception mais donne l'infini ([Double.POSITIVE_INFINITY]), qu'un document JSON ne sait pas représenter (Jackson l'écrirait sous forme de chaîne, ["Infinity"]). Le test [b == 0.0] compare deux [Double] : le littéral doit être [0.0], car Kotlin refuse de comparer un [Double] et un [Int] ([b == 0] provoque l'erreur de compilation [operator '==' cannot be applied to 'Double' and 'Int']) ;
- ligne 75 : la réponse 200, dont le corps est l'objet [ResultatCalcul] sérialisé en JSON. [calcul(a, b)] appelle la fonction choisie plus haut ;
- lignes 79-82 : les deux types échangés avec les clients. Ces objets, qui ne servent qu'à transporter des données entre deux applications, sont appelés DTO (Data Transfer Object). Les classes de données sont parfaites pour cet usage. Les propriétés de [DemandeCalcul], reçue du client, sont nullables ; celles de [ResultatCalcul], produite par le service, ne le sont pas.
Les deux fonctions du contrôleur rendent le type [ResponseEntity<*>] : une réponse dont le corps peut être de n'importe quel type, ici un [ResultatCalcul] ou un [ProblemDetail]. Spring examine le type réel du corps pour le sérialiser ; il donne au document le type [application/json], ou [application/problem+json] pour un [ProblemDetail].
13.3.2. Essais avec curl
Le service est lancé par [gradlew :WsOperations:bootRun]. Une addition :
Le nombre 2 est devenu 2.0 : Jackson écrit toujours un [Double] avec sa partie décimale. Pour un client, JavaScript en particulier, c'est le même nombre. Une division par zéro :
- ligne 2 : la requête est refusée ;
- ligne 3 : le corps est un document « Problem Details » ;
- ligne 6 : après une réponse 400, Tomcat ferme la connexion par précaution : une requête incorrecte pourrait avoir laissé des données imprévues dans la connexion. Le client en ouvrira une autre pour sa requête suivante ;
- ligne 8 : le document contient les propriétés [title], [status] et [detail], fixées par notre code, et [instance], ajoutée par Spring : le chemin de la requête qui a produit l'erreur. La propriété [type], une URL qui documente le type d'erreur, est absente : la RFC 9457 lui donne alors la valeur [about:blank], qui signifie que le code de statut suffit à décrire le problème. On remarquera que les propriétés sont classées par ordre alphabétique : c'est le comportement par défaut de Jackson 3 pour une classe Java ordinaire comme [ProblemDetail], dont les objets ne sont pas créés par un constructeur à paramètres ; les classes de données, nous l'avons vu, gardent l'ordre de leur constructeur primaire.
Une opération inconnue, un nombre écrit avec une virgule décimale et un nombre absent :
La propriété [errors] du document contient les erreurs de validation, paramètre par paramètre ; ajoutée par [setProperty], elle est placée après les propriétés normalisées. Le paramètre [b], absent de l'URL, a bien été reçu comme [null] : il est facultatif parce que nullable. Une requête POST maintenant. L'option [-d] de [curl] envoie le texte qui la suit dans le corps d'une requête POST, et l'option [-H] ajoute un entête à la requête, ici le type du document envoyé :
Une propriété absente du document JSON, ici [operation], est reçue comme [null] grâce au type nullable de la propriété [operation] de [DemandeCalcul] ; l'opération est alors inconnue :
Si le document JSON envoyé est incorrect, Jackson ne peut construire le paramètre [demande] : Spring répond lui-même 400, sans exécuter notre fonction :
Le document « Problem Details » de la ligne 8 a été produit par Spring MVC, grâce à la propriété [spring.mvc.problemdetails.enabled] ; sans elle, on obtiendrait le document d'erreur de Spring Boot vu avec [WsHello]. Il en va de même si l'on oublie l'entête [Content-Type] : [curl] envoie alors le corps comme un formulaire HTML, et Spring refuse ce type de document :
Le code 415 (Unsupported Media Type) signifie que le type du document envoyé n'est pas accepté ; l'entête [Accept] de la ligne 3 indique au client les types acceptés.
13.3.3. La partie client

Notre client console demande sur une ligne l'opération et les deux nombres, puis interroge le service deux fois : par GET, puis par POST, afin de montrer les deux façons de faire. Son fichier [build.gradle.kts] est celui de [ClientWsHello], aux noms du projet et de la classe principale près ([clientwsoperations.MainKt]). Son programme est le suivant :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | |
Commentaires :
- lignes 14-23 : les DTO du client. Ils sont déclarés ici à nouveau : un client ne partage pas le code du serveur, seulement le format des documents JSON échangés. Les propriétés de [DemandeCalcul] ne sont pas nullables : le client n'envoie que des demandes complètes. La classe [ProblemDetail] de Spring fait partie du cadriciel web ; une simple classe de données suffit au client. Ses propriétés sont nullables : une propriété absente du document, comme [detail] ou [errors], vaut [null] ;
- ligne 27 : les nombres sont saisis et affichés au format de la langue du système, ici le français : virgule décimale. [FORMAT] est une propriété de haut niveau, privée au fichier ;
- ligne 33 : depuis Jackson 3, les propriétés JSON inconnues de la classe (ici [instance]) sont ignorées par défaut lors de la désérialisation ;
- lignes 43-49 : la saisie est découpée en champs par une expression régulière (un ou plusieurs espaces), puis la liste de trois éléments est déstructurée en trois variables (chapitre 3) : la déclaration [val (operation, texteA, texteB) = champs] appelle les fonctions [component1()], [component2()] et [component3()] de la liste ;
- lignes 50-58 : les nombres sont vérifiés localement, avant tout appel au service. Un service doit toujours vérifier ses paramètres (il ne sait pas qui l'appelle), mais un client peut éviter un échange réseau inutile. Après le [continue] de la ligne 59, le compilateur sait que [a] et [b] ne sont pas [null] : ils sont désormais de type [Double] (transtypage intelligent) ;
- ligne 61 : le texte qui décrit le calcul, avec les nombres au format français ;
- ligne 64 : l'URL de la requête GET. Les nombres doivent y être écrits avec un point décimal : un [Double] inséré dans un gabarit de chaîne est converti par sa fonction [toString], qui ne dépend pas de la langue du système. L'opération, saisie par l'utilisateur, est codée par [URLEncoder] ;
- lignes 66-67 : la requête GET ;
- lignes 68-70 : en cas de succès (code 200), la réponse JSON est désérialisée en un objet [ResultatCalcul] ;
- lignes 72-73 : en cas d'échec, on affiche le code de statut et le document reçu, tel quel ;
- lignes 75-77 : l'exception [IOException] signale une erreur de communication, par exemple un service injoignable ;
- lignes 82-86 : la requête POST. L'objet [DemandeCalcul] est sérialisé en JSON par [writeValueAsString] ; la requête reçoit l'entête [Content-Type], puis son corps : [BodyPublishers.ofString] envoie une chaîne, codée en UTF-8 ;
- lignes 94-98 : en cas d'échec, le corps est un document « Problem Details », désérialisé en un objet [Probleme] dont on affiche le titre, le détail et les erreurs de validation. Les propriétés nullables [detail] et [errors] sont traitées par l'opérateur [?.] : [probleme.detail?.let { ... }] n'exécute le bloc que si le détail existe ([it] est alors de type [String]), [probleme.errors?.forEach { ... }] ne parcourt le dictionnaire que s'il existe. La lambda passée à [forEach] reçoit chaque élément du dictionnaire ([Map.Entry]), déstructuré en [(parametre, messages)] (chapitre 7) ; la fonction [joinToString] réunit les messages d'un paramètre ;
- lignes 110-114 : la lecture d'un nombre au format français. La méthode [parse(String, ParsePosition)] de [NumberFormat] lit le début de la chaîne et indique dans l'objet [ParsePosition] où elle s'est arrêtée (propriété [index], la méthode Java [getIndex()]) : si elle ne s'est pas arrêtée à la fin, la saisie contient des caractères en trop ([2,5x]). [parse] rend un [Number] (ou [null]), converti par [toDouble()].
Résultats, le service étant lancé (saisies replacées après les questions) :
- ligne 3 : l'URL de la requête GET : le nombre 1,5 saisi y est écrit 1.5 ;
- lignes 6-8 : la même erreur vue par les deux requêtes : le document brut pour la requête GET, son exploitation pour la requête POST ;
- lignes 10-12 : l'opération inconnue a été détectée par le service ;
- ligne 14 : le nombre incorrect a été détecté par le client, qui n'a pas interrogé le service.
13.4. Application exemple - version 9 : un service web de calcul d'impôt
Nous reprenons l'application de calcul de l'impôt. Au chapitre précédent (version 8), nous en avions fait un serveur TCP que l'on pouvait interroger à distance, avec un protocole de dialogue qui lui était propre. Nous en faisons maintenant un service web REST.
13.4.1. L'architecture
L'architecture de la version 9 est la suivante :

- côté serveur, le service web [WsImpot] reçoit les requêtes HTTP et les fait traiter par les couches [metier] et [dao] de la version 5 (chapitre 6), réutilisées telles quelles ;
- côté client, la couche [ui] de la version 5 est elle aussi réutilisée telle quelle. Elle dialogue avec une couche [metier] d'un nouveau genre, [ImpotMetierHttp], qui implémente l'interface [IImpotMetier] en interrogeant le service web : c'est un proxy du service. La couche [ui] ne sait pas que le calcul se fait sur une autre machine ;
- de part et d'autre, l'intégration des couches est confiée au conteneur d'injection de dépendances de Spring, comme dans la version 5.
13.4.2. Le service web
Le contrat du service est le suivant :
Requête | Réponse en cas de succès | Réponse en cas d'erreur |
GET /impot?marie=o&enfants=2&salaire=60000 | 200 : [{"impot":4282}] | 400 : document « Problem Details » |
POST /impot avec le corps [{"marie":true,"enfants":2,"salaire":60000}] | 200 : [{"impot":4282}] | 400 : document « Problem Details » |
Le projet [WsImpot] dépend des modules [impots-dao] et [impots-metier] de la version 5. Grâce à la construction composite déclarée dans [settings.gradle.kts] ([includeBuild("../06-architectures")]), Gradle les trouve directement dans le dossier du chapitre 6 et les construit au besoin : il n'y a pas, comme avec Maven, à les installer au préalable dans un dépôt local. Les dépendances du fichier [build.gradle.kts] de [WsImpot] sont les suivantes (le reste du fichier est celui de [WsHello], aux coordonnées du projet près : [exemples.impots:WsImpot:9.0]) :
- lignes 2-5 : les dépendances de tout service Spring Boot écrit en Kotlin ;
- ligne 8 : le module [impots-metier] de la version 5, désigné par ses coordonnées. Il déclare le module [impots-dao] comme une dépendance de son API ([api(project(":ImpotsV5:impots-dao"))], chapitre 6) : celui-ci est donc lui aussi visible du service, avec les classes [IImpotDao], [JsonImpot]… de la couche [dao] et [TrancheImpot], [ImpotException] de la couche [entites]. Le module [impots-dao] dépend de Jackson 3, que le starter web apporte déjà ; c'est alors la version choisie par la nomenclature de Spring Boot qui est retenue.
Le fichier de configuration [application.properties] est le suivant :
- ligne 4 : le service écoute sur le port 5120 ;
- lignes 13-14 : la configuration de la couche [dao], sous la même forme que dans le fichier [impots.properties] de la version 5 : l'implémentation à utiliser et son fichier de données. Ce ne sont pas des propriétés de Spring Boot, mais des propriétés propres à notre application : un fichier [application.properties] peut en contenir autant qu'on le souhaite. Nous utilisons ici le fichier JSON, [DataImpot.json], placé dans le dossier du projet, qui est le dossier d'exécution du service avec [gradlew :WsImpot:bootRun].
Les couches [dao] et [metier] sont intégrées au conteneur Spring par une classe de configuration :
Commentaires :
- lignes 17-18 : les propriétés [dao.*] de la configuration sont rassemblées dans un objet de type [DaoProperties], une classe de données. L'annotation [@ConfigurationProperties("dao")] demande à Spring Boot de lier les propriétés de préfixe [dao] aux paramètres du constructeur de même nom : [dao.implementation] au paramètre [implementation], [dao.fichier] au paramètre [fichier]. Les valeurs par défaut des paramètres sont utilisées pour les propriétés absentes de la configuration : Spring Boot, qui lit grâce à [kotlin-reflect] le constructeur primaire et ses valeurs par défaut, sait appeler le constructeur sans les arguments manquants. Nous l'avons vérifié en lançant le service sans fichier [application.properties] : il a utilisé l'implémentation [HardwiredImpot]. Cette liaison est typée (une propriété de type [Int] ou [Boolean] serait convertie, et une valeur incorrecte empêcherait le démarrage) et l'objet obtenu est immuable ;
- ligne 22 : l'annotation enregistre la classe [DaoProperties] auprès de Spring Boot, qui crée l'objet au démarrage et en fait un bean. On peut aussi placer [@ConfigurationPropertiesScan] sur la classe principale : Spring Boot recherche alors lui-même les classes annotées [@ConfigurationProperties] ;
- lignes 21-23 : l'annotation [@Configuration] (chapitre 6) : la classe définit des beans. Elle est trouvée par Spring car elle se trouve dans le paquetage de la classe principale ([@ComponentScan]). Le greffon [plugin.spring] la rend ouverte, ainsi que ses fonctions ;
- lignes 27-32 : la fonction annotée [@Bean] crée la couche [dao]. Son paramètre est fourni par Spring : c'est le bean [DaoProperties]. Une expression [when] choisit l'implémentation ; une implémentation inconnue provoque une [ImpotException] ([throw] est une expression en Kotlin : elle peut figurer dans une branche du [when]) ;
- ligne 38 : la couche [metier]. Le paramètre [dao] de la fonction reçoit le bean de type [IImpotDao] : Spring crée donc la couche [dao] avant la couche [metier].
Le chapitre 6 avait injecté chaque propriété séparément, par l'annotation [@Value] : [@Value("\${dao.implementation}") implementation: String]. Cette forme reste possible avec Spring Boot ; on se souviendra qu'en Kotlin, le caractère [$] de [${...}] doit être protégé par une barre oblique inverse, sans quoi le compilateur y verrait un gabarit de chaîne. [@ConfigurationProperties] est préférable dès qu'un groupe de propriétés va ensemble : la configuration est décrite par une classe, typée et vérifiée au démarrage, et elle s'injecte d'un bloc là où on en a besoin. Les fonctions [dao] et [metier] reprennent celles de la classe [ImpotsConfig] de la version 5, sans l'annotation [@PropertySource] : Spring Boot lit lui-même le fichier [application.properties].
Ces lignes remplacent l'intégration des couches que faisait, dans la version 5, le programme principal : le conteneur Spring est le même, mais c'est désormais Spring Boot qui le crée et qui trouve lui-même la classe de configuration. Les beans définis ainsi sont des singletons, créés au démarrage de l'application, avant le traitement de la moindre requête : si le fichier de données est absent ou incorrect, le service ne démarre pas, au lieu de répondre par une erreur 500 à toutes les requêtes. Nous le vérifierons plus loin.
Le service web est le contrôleur [ImpotController] :
Commentaires :
- ligne 17 : le contrôleur reçoit la couche [metier] par son constructeur primaire. Spring, qui crée le contrôleur, lui passe le bean de type [IImpotMetier] défini par [ImpotConfig] : c'est l'injection par constructeur du chapitre 6. La propriété [metier], déclarée [private val] dans le constructeur, garantit que la couche ne changera pas. Le Java avait besoin d'un champ [final] et d'un constructeur de quatre lignes ;
- lignes 20-31 : la requête GET. Les trois paramètres de la chaîne de requête sont reçus sous forme de chaînes nullables (donc facultatives) et convertis : [o]/[n] pour [marie] (une expression [when] dont le type est [Boolean?], la valeur [null] de [marie?.lowercase()] tombant dans la branche [else]), des entiers pour [enfants] et [salaire] par [toIntOrNull], [null] en cas d'erreur ;
- lignes 35-37 : la requête POST : le corps JSON est désérialisé en un objet [DemandeImpot] (ligne 72), dont les propriétés nullables ([Boolean?], [Int?]) permettent de détecter les valeurs absentes. La même URL [/impot] est associée à deux fonctions, selon la méthode HTTP de la requête ;
- lignes 40-60 : le traitement commun aux deux requêtes. Il rend la réponse en cas de succès, et lance une exception en cas d'erreur. Le type de son résultat, comme celui des fonctions [calculerGet] et [calculerPost], est donc simplement [ReponseImpot] : on voit à la lecture de leur signature ce que rend le service quand tout va bien ;
- ligne 42 : les trois paramètres sont-ils corrects ? Les comparaisons [enfants >= 0] et [salaire >= 0] ne sont évaluées que si ceux-ci ne sont pas [null], grâce à l'évaluation paresseuse de l'opérateur [&&] ; le compilateur le sait, et considère [enfants] comme un [Int] dès la comparaison qui suit le test. Dans le bloc, [marie], [enfants] et [salaire] sont de types non nullables : ils peuvent être passés à la fonction [calculerImpot] de la couche [metier], dont les paramètres sont de types [Boolean] et [Int] ;
- ligne 43 : le calcul est confié à la couche [metier] de la version 5 ;
- ligne 44 : chaque calcul est journalisé, au niveau [INFO]. Le texte du message est un modèle : chaque [{}] y est remplacé par l'argument correspondant, dans l'ordre. Contrairement à un gabarit de chaîne Kotlin (["Impôt(marié=$marie...)"]), le message n'est construit que s'il doit être affiché (selon le niveau du journal), et les outils de journalisation peuvent enregistrer séparément chaque valeur ;
- lignes 48-60 : en cas d'erreur, on construit la liste des erreurs, on journalise un avertissement ([warn]) et on lance une exception [DemandeIncorrecteException] qui transporte cette liste ;
- lignes 64-68 : une fonction annotée [@ExceptionHandler] est un gestionnaire d'exception : Spring l'appelle lorsqu'une fonction du contrôleur lance une exception du type de son paramètre, ici [DemandeIncorrecteException]. Ce qu'elle rend devient la réponse HTTP : un [ProblemDetail], dont le code de statut (400) devient celui de la réponse. Le document est construit et rendu en une seule expression, grâce à [apply] ;
- lignes 72-75 : les DTO du service ;
- lignes 77-79 : le journal de la classe. Spring Boot utilise l'API de journalisation SLF4J (Simple Logging Facade for Java), dont l'implémentation est ici Logback. [LoggerFactory.getLogger] rend un journal dont la catégorie est le nom complet de la classe. Le journal est partagé par toutes les instances de la classe : il est placé dans son objet compagnon, l'équivalent du champ [static] du Java (chapitre 4). On peut aussi le déclarer comme une propriété privée de haut niveau, dans le fichier.
L'exception [DemandeIncorrecteException] est la suivante :
- ligne 5 : la liste des erreurs est une propriété déclarée dans le constructeur primaire, qui sert aussi à construire le message passé au constructeur de la classe parente, [RuntimeException]. En Kotlin, toutes les exceptions sont non contrôlées : aucune fonction n'a à la déclarer. Les trois lignes de cette classe remplacent la vingtaine de lignes de la version Java : il n'y a ni accesseur à écrire ni numéro de version de sérialisation ([serialVersionUID]) à déclarer, le compilateur Kotlin n'émettant pas l'avertissement correspondant du compilateur Java ; et si l'on voulait exclure la propriété [erreurs] de la sérialisation binaire, comme le faisait le mot clé Java [transient], on l'annoterait [@Transient]. Nos exceptions ne sont jamais sérialisées.
La classe principale [WsImpotApplication.kt] est identique à celle des deux autres services. Le dossier [src/main/resources/static] contient le client web, étudié au paragraphe suivant.
Le service web est sans état : les fonctions du contrôleur ne conservent rien d'une requête à l'autre. Le contrôleur et les couches [metier] et [dao] sont des singletons, créés une fois pour toutes, qui vivent aussi longtemps que l'application ; ils sont partagés par toutes les requêtes, éventuellement traitées en même temps par des threads différents du serveur Tomcat (chapitre 8). Cela ne pose pas de problème ici car ces objets ne sont jamais modifiés après leur construction : les tranches d'impôt sont lues une fois pour toutes et le contrôleur n'a qu'une propriété [val]. Un contrôleur ne doit jamais ranger dans ses propriétés des données propres à une requête. Le singleton est la portée par défaut des beans Spring ; il en existe d'autres : [prototype] (un objet à chaque demande au conteneur), [request] (un objet par requête HTTP), [session] (un objet par session HTTP). Signalons enfin que la propriété [spring.threads.virtual.enabled=true] fait traiter chaque requête par un thread virtuel (Java 21, chapitre 8) au lieu d'un thread du pool de Tomcat : le service peut alors traiter un très grand nombre de requêtes simultanées qui attendent des entrées-sorties. Spring MVC accepte aussi des fonctions de contrôleur [suspend] (chapitre 9) ; elles prennent tout leur sens avec Spring WebFlux, la variante non bloquante de Spring MVC, où une requête n'occupe aucun thread pendant ses attentes.
13.4.3. Exécution du service
Commençons par une erreur de configuration. Les arguments de la ligne de commande de la forme [--propriété=valeur] font partie de la configuration de l'application et remplacent les valeurs de [application.properties]. Avec Gradle, on les passe à la tâche [bootRun] par l'option [--args]. Désignons un fichier de données inexistant (le cours a abrégé avec [...] les lignes de ce journal, qui sont très longues, et omis les messages de Gradle) :
- ligne 4 : le serveur Tomcat a été créé, mais il n'a pas encore ouvert son port ;
- ligne 6 : la création des beans a échoué. Pour créer le contrôleur, Spring a dû créer la couche [metier], donc la couche [dao], dont le constructeur a lancé une [ImpotException] (chapitre 6). Les messages de nos couches sont en français ; ceux de Spring sont en anglais ;
- ligne 7 : le serveur Tomcat est arrêté : le service n'a accepté aucune requête ;
- lignes 9-21 : le rapport d'échec du démarrage. Sa description (ligne 17) et l'action conseillée (ligne 21) sont produites par une classe que nous avons écrite pour cela.
En effet, lorsque le démarrage échoue, Spring Boot affiche normalement l'exception et toute la pile des appels : plusieurs dizaines de lignes, où le message utile est difficile à trouver. Spring Boot peut aussi confier l'exception à des analyseurs d'échec (failure analyzers), qui la traduisent en un rapport lisible. Il en possède pour les erreurs les plus courantes (port déjà utilisé, bean introuvable…), et nous pouvons ajouter les nôtres :
- ligne 9 : la classe abstraite [AbstractFailureAnalyzer<T>] de Spring Boot recherche une exception de type [T], ici [ImpotException], dans la chaîne des causes de l'exception qui a fait échouer le démarrage. Si elle en trouve une, elle appelle la fonction [analyze]. Notre classe en dérive : les parenthèses de [AbstractFailureAnalyzer<ImpotException>()] sont l'appel de son constructeur ;
- ligne 11 : le paramètre [rootFailure] est l'exception de départ, [cause] l'[ImpotException] trouvée dans la chaîne. La redéfinition est signalée par le mot clé [override], obligatoire en Kotlin. Dans la classe Java de Spring Boot, la méthode peut rendre [null] (« je ne sais pas analyser cette exception ») ; notre redéfinition déclare un type de retour non nullable, [FailureAnalysis], ce que Kotlin permet : une redéfinition peut restreindre le type de son résultat ;
- lignes 13-14 : la description de l'erreur : le message de l'exception, suivi entre parenthèses de celui de sa cause éventuelle. [cause.cause?.let { ... } ?: ""] rend le texte entre parenthèses si la cause existe, une chaîne vide sinon ;
- lignes 16-17 : le rapport est un objet [FailureAnalysis] composé d'une description, d'une action conseillée et de l'exception analysée.
Cet analyseur n'est pas un bean (il doit fonctionner même si le conteneur n'a pas pu être créé) : Spring Boot le cherche dans les fichiers [META-INF/spring.factories] des bibliothèques et de l'application. Le nôtre est placé dans [src/main/resources/META-INF] :
- ligne 2 : la clé est le nom de l'interface [FailureAnalyzer], la valeur le nom de notre classe (plusieurs classes seraient séparées par des virgules).
Lançons maintenant le service normalement, par [gradlew :WsImpot:bootRun], puis interrogeons-le avec [curl] :
C'est la valeur de contrôle attendue : un contribuable marié avec deux enfants et un salaire annuel de 60 000 euros paie 4 282 euros d'impôt (rappelons que le barème utilisé, celui de 2004, n'est conservé que pour l'exercice). Avec des paramètres incorrects :
Puis en POST, avec un document JSON :
Le journal du service sera montré à la fin du chapitre : il contient la trace de tous les calculs demandés.
13.4.4. Le client console : une nouvelle couche [metier]
Le client de la version 9 reprend la couche [ui] de la version 5, sans la modifier : l'objet [impots.ui.Dialogue], dont la fonction [run(metier: IImpotMetier)] dialogue avec l'utilisateur et demande les calculs à la couche [metier] qu'on lui passe. Il suffit de lui passer une couche [metier] qui interroge le service web. Les dépendances du projet [ClientWsImpot] sont les suivantes (le reste du fichier [build.gradle.kts] est celui de [ClientWsHello], aux noms du projet et de la classe principale près : [exemples.impots:ClientWsImpot:9.0], [impots.client.MainKt]) :
- lignes 3-4 : les modules de la version 5 : [impots-metier] pour l'interface [IImpotMetier], [impots-ui] pour l'objet [Dialogue]. Un projet peut dépendre d'un module qui contient un programme principal : il utilise alors ses classes publiques, sans exécuter ce programme. Le module [impots-dao] est une dépendance de l'API de [impots-metier] : le client n'utilise de lui que la classe [ImpotException] ;
- ligne 6 : le conteneur Spring. Le module [impots-ui] en dépend, mais il le déclare par [implementation] (chapitre 6) : la bibliothèque est présente à l'exécution du client, mais elle n'est pas visible à la compilation de son code. Gradle distingue ainsi ce qu'un module utilise en interne de ce qu'il expose ([api]) ; comme notre programme principal utilise directement Spring, il doit le déclarer lui-même (là où Maven rendait visibles toutes les dépendances indirectes) ;
- ligne 8 : Jackson, pour les documents JSON. Il est aussi apporté par [impots-dao], mais, là encore, comme une dépendance interne : le client, qui l'utilise directement, doit le déclarer.
La nouvelle couche [metier] est la classe [ImpotMetierHttp] :
Commentaires :
- ligne 14 : la classe implémente l'interface [IImpotMetier] de la version 5. Elle reçoit par son constructeur primaire le client HTTP, dont elle fait une propriété privée ([private val client]), et l'URL du service, simple paramètre du constructeur ;
- lignes 17-19 : les DTO, déclarés à l'intérieur de la classe ([private]) car personne d'autre ne les utilise ;
- lignes 22-23 : l'URL de la fonction [POST /impot], calculée à partir de celle du service lors de l'initialisation de la propriété (le paramètre [urlService] du constructeur primaire est utilisable dans les initialiseurs des propriétés), et le convertisseur JSON ;
- lignes 27-31 : la requête [POST /impot], avec la demande sérialisée en JSON dans son corps ;
- lignes 32-34 : la requête est envoyée de façon synchrone : la méthode [send] attend la réponse, ce qui convient à la fonction [calculerImpot] de l'interface, elle-même synchrone (elle rend un [Int]). [try] est une expression en Kotlin (chapitre 3) : sa valeur, ici la réponse, est celle du bloc [try] si tout va bien ; les blocs [catch] se terminant par un [throw] (de type [Nothing]), la variable [reponse] est de type [HttpResponse<String>] et peut être un [val], là où le Java devait la déclarer avant le [try] ;
- lignes 35-37 : [send] lance une [IOException] si le service est injoignable. Elle est encapsulée dans une [ImpotException], l'exception de l'application [Impots]. Kotlin ne l'imposait pas (il n'a pas d'exceptions contrôlées), mais c'est la bonne pratique de l'architecture en couches : la couche [ui] ne connaît que les exceptions de l'application, pas celles de la technique de communication utilisée par la couche [metier] ;
- lignes 38-41 : de même pour l'[InterruptedException], lancée si le thread est interrompu pendant l'attente. Le fait de l'attraper efface l'indicateur d'interruption du thread : la bonne pratique (chapitre 8) est de le rétablir, par [Thread.currentThread().interrupt()], pour que le code appelant puisse savoir que le thread a été interrompu ;
- lignes 44-47 : une réponse d'erreur est transformée en [ImpotException], dont le code est le code de statut HTTP. Le constructeur de [ImpotException] (chapitre 6) a un paramètre [cause] avec une valeur par défaut ; pour fixer le code sans donner de cause, on nomme l'argument : [code = reponse.statusCode()] ;
- ligne 51 : la réponse JSON [{"impot":4282}] est désérialisée et le montant de l'impôt est rendu.
Avec un service SOAP, l'outil [wsimport] générait un proxy, et une classe semblable à celle-ci implémentait [IImpotMetier] en lui déléguant le calcul : aujourd'hui, nous écrivons nous-mêmes les quelques dizaines de lignes du proxy.
La fonction [calculerImpot] de l'interface [IImpotMetier] étant synchrone, nous avons utilisé la méthode synchrone [send] de [HttpClient]. Celui-ci offre aussi une méthode asynchrone, [sendAsync], qui rend immédiatement un [CompletableFuture<HttpResponse<T>>]. En Kotlin, on l'utiliserait depuis une fonction [suspend], en attendant le résultat par la fonction d'extension [await()] de kotlinx-coroutines (chapitre 9) : [suspend fun calculerImpot(marie: Boolean, nbEnfants: Int, salaire: Int): Int]. Cela obligerait à modifier l'interface [IImpotMetier], ses implémentations et la couche [ui]. Nous ne l'avons pas fait, pour garder intactes les couches de la version 5. Avec les threads virtuels (Java 21), le code synchrone redevient d'ailleurs une façon raisonnable d'écrire des programmes qui attendent beaucoup d'entrées-sorties : un thread virtuel bloqué par [send] ne bloque pas de thread du système.
Le programme principal du client intègre les couches :
Commentaires :
- ligne 14 : l'URL du service, passée en argument ou par défaut [http://localhost:5120/] ;
- ligne 18 : le conteneur Spring, comme dans la version 5. Il est fermé à la fin du bloc [use]. La valeur du bloc, sa dernière expression, est rendue par [use] : c'est ici le code de sortie du programme ;
- lignes 20-22 : on enregistre trois beans, grâce aux fonctions d'extension [registerBean<T>] de Spring pour Kotlin (chapitre 6) : l'URL du service et le client HTTP, fournis par des lambdas, puis la classe [ImpotMetierHttp]. Pour créer celle-ci, Spring examine son constructeur primaire et lui passe les beans de types [HttpClient] et [URI] ;
- ligne 23 : le conteneur crée les beans ;
- ligne 24 : on demande au conteneur un bean de type [IImpotMetier] : il rend l'objet [ImpotMetierHttp], qui implémente cette interface. Le reste du programme ignore tout de l'implémentation ;
- ligne 28 : le dialogue de la version 5, sans aucune modification ; s'il se termine normalement, la valeur du bloc [try] est sa dernière expression, 0 ;
- lignes 30-32 : si le service ne peut être joint, l'exception remonte de la couche [metier] à travers la couche [ui], qui ne la gère pas : on l'affiche avec la fonction [Dialogue.afficherErreur] de la version 5, et la valeur du [try] est celle du bloc [catch], 1 ;
- lignes 36-37 : en cas d'erreur, le programme se termine avec le code 1, après la fermeture du conteneur. [exitProcess] est la fonction de Kotlin qui appelle [System.exit] ; son type de retour est [Nothing], car elle ne rend jamais la main.
Résultats, le service étant lancé (saisies replacées après les questions) :
On retrouve les valeurs de contrôle du calcul de l'impôt : 4 282, 2 959 et 11 519 euros. La saisie incorrecte de la ligne 8 a été détectée par la couche [ui], qui n'a pas appelé la couche [metier] : le service n'a reçu que des demandes correctes. Si le service est arrêté :
L'[ImpotException] de notre proxy encapsule l'exception [ConnectException] lancée par [send], qui encapsule elle-même les exceptions de la couche réseau (chapitre 12). Aucune n'a de message : leur propriété [message] vaut [null], que le gabarit de chaîne de [afficherErreur] affiche sous la forme [null].
13.5. Un client web pour le service de calcul d'impôt
Une application web « classique » produit ses pages HTML sur le serveur : à chaque clic sur un bouton, le formulaire est envoyé au serveur, qui renvoie au navigateur une nouvelle page complète. Aujourd'hui, le navigateur interroge le plus souvent un service web lui-même, en JavaScript, et ne met à jour que la partie de la page qui change. C'est ce que nous allons faire. L'architecture est la suivante :

- 1 : le navigateur demande la page [/] ; le serveur [WsImpot] lui envoie le fichier statique [index.html] ;
- 2 : à chaque clic sur le bouton [Calculer], le code JavaScript de la page envoie la requête [POST /impot] au service web et affiche l'impôt reçu dans la page, sans la recharger.
Le service web n'a pas été modifié : il sert simplement un fichier de plus. Spring Boot sert en effet automatiquement, comme fichiers statiques, les fichiers placés dans le dossier [static] des ressources de l'application ([src/main/resources/static]) : pages HTML, images, scripts, feuilles de style… Le fichier [static/index.html] est de plus servi pour l'URL [/] : c'est la page d'accueil (welcome page). Un même serveur peut ainsi héberger un site web et un service web. La page [index.html], qui ne dépend en rien du langage dans lequel est écrit le service (c'est la même que dans le cours Java), est la suivante :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | |
Commentaires :
- lignes 15-44 : le formulaire : deux boutons radio pour l'état marital, deux zones de saisie pour le nombre d'enfants et le salaire, chacune suivie d'une cellule où s'affichera un éventuel message d'erreur, une zone pour l'impôt, un paragraphe pour un message général et deux boutons ;
- lignes 46-108 : le code JavaScript de la page, exécuté par le navigateur. Le cours ne fait pas l'étude de JavaScript ; ce code est cependant assez proche de Kotlin pour être lu sans difficulté ;
- ligne 58 : une saisie qui n'est pas un entier donne [null] ; ce sera au service de signaler l'erreur ;
- ligne 70 : le gestionnaire du clic sur le bouton [Calculer] (envoi du formulaire). C'est une fonction asynchrone ([async]) : comme une fonction [suspend] de Kotlin (chapitre 9), elle ne bloque pas le navigateur pendant l'attente de la réponse ;
- ligne 71 : on empêche le navigateur d'envoyer lui-même le formulaire, ce qui provoquerait le chargement d'une nouvelle page ;
- lignes 74-77 : la demande, un objet JavaScript qui a la forme attendue par [POST /impot] ;
- lignes 80-83 : la fonction [fetch] du navigateur envoie la requête HTTP : la méthode [POST], l'entête [Content-Type] et le corps, la demande sérialisée en JSON par [JSON.stringify]. L'URL [impot] est relative à celle de la page : la requête part vers le serveur qui a fourni la page. [await] attend la réponse sans bloquer le navigateur, comme l'appel d'une fonction [suspend] dans une coroutine ;
- ligne 85 : le corps JSON de la réponse est désérialisé en un objet JavaScript ;
- lignes 86-88 : en cas de succès (code 2xx), on affiche l'impôt ;
- lignes 91-94 : en cas d'erreur, on affiche le titre du document « Problem Details » et les messages d'erreur du nombre d'enfants et du salaire, chacun à côté de sa zone de saisie. L'opérateur [?.] rend [undefined] si l'objet à sa gauche n'existe pas, au lieu de provoquer une erreur ; l'opérateur [??] remplace une valeur absente par celle qui le suit. Le lecteur aura reconnu l'appel sûr [?.] et l'opérateur Elvis [?:] de Kotlin, que JavaScript a adoptés en 2020 ;
- lignes 103-106 : le bouton [Effacer] remet le formulaire à zéro, localement, sans interroger le service.
Le service étant lancé, demandons l'URL [http://localhost:5120/] avec un navigateur :

Après saisie d'un calcul et clic sur le bouton [Calculer] :

Avec des saisies incorrectes, les messages d'erreur sont ceux du service web :

Ces copies d'écran ont été prises avec le navigateur Chromium, piloté par un programme grâce à la bibliothèque Playwright : c'est ainsi qu'on automatise les tests d'une interface web. Nous avons refait l'expérience avec le service écrit en Kotlin : la page, les réponses et donc les copies d'écran sont identiques à celles du service écrit en Java. C'est tout l'intérêt d'un service web : son client ne voit que les documents échangés.
Ici, la page et le service ont la même origine (même protocole, même machine, même port : [http://localhost:5120]). Si la page était servie par un autre site, par exemple [http://localhost:5000], le navigateur appliquerait la politique de même origine : il ne laisserait le code JavaScript lire la réponse du service que si celui-ci l'y autorise par l'entête [Access-Control-Allow-Origin]. Pour une requête comme la nôtre (POST avec un corps JSON), le navigateur demanderait même cette autorisation avant d'envoyer la requête, par une requête préalable de méthode OPTIONS (preflight). C'est le mécanisme CORS (Cross-Origin Resource Sharing), qui protège l'utilisateur contre les pages malveillantes qui voudraient utiliser à son insu des services où il est connecté. Pour accepter d'autres origines, un contrôleur Spring peut être annoté [@CrossOrigin(origins = ["http://localhost:5000"])] (en Kotlin, un argument de type tableau s'écrit entre crochets dans une annotation) ; une configuration globale est aussi possible (fonction [addCorsMappings] de l'interface [WebMvcConfigurer]). Les clients qui ne sont pas des navigateurs ([curl], nos clients console) ne sont pas concernés par CORS.
Pour des interfaces web plus riches produites par le serveur, Spring MVC propose aussi des contrôleurs qui rendent des pages HTML construites à partir de modèles (Thymeleaf, par exemple), qui ne relèvent pas de ce cours. Kotlin offre de plus une solution qui lui est propre : Kotlin/JS et Kotlin/Wasm compilent du code Kotlin pour le navigateur, ce qui permet d'écrire le client web dans le même langage que le service, en partageant au besoin les classes de données des DTO (projets Kotlin Multiplatform).
13.6. Un service web est indépendant du langage de son client
Avec un service SOAP, le client devait disposer d'un outil capable de lire le WSDL et de générer le proxy, et les différences d'interprétation des normes entre plateformes posaient souvent des problèmes d'interopérabilité. Avec un service REST/JSON, n'importe quel langage disposant d'un client HTTP et d'une bibliothèque JSON, c'est-à-dire tous les langages actuels, peut interroger le service sans outil particulier. Nous l'avons déjà vu avec [curl], qui n'est pas écrit en Kotlin, et avec le JavaScript du navigateur. Voici un client écrit pour Node.js, l'environnement d'exécution de JavaScript hors du navigateur :
- ligne 3 : l'URL du service, passée en argument ou par défaut ;
- lignes 6-9 : trois demandes de calcul, la dernière étant incorrecte ;
- lignes 14-17 : la même fonction [fetch] que dans le navigateur ;
- lignes 20-23 : l'affichage de l'impôt ou des erreurs.
Le service étant lancé :
En Python, on utiliserait de même le module [requests] ou [httpx], en C# la classe [HttpClient], en Java la classe [HttpClient] que nous avons utilisée depuis Kotlin. Voici enfin le journal du service [WsImpot] pendant l'ensemble des essais de ce chapitre ([curl], client console, navigateur, client Node.js), jusqu'à son arrêt par Ctrl-C (le cours a abrégé avec [...] les chemins de la ligne 2 et omis les messages de Gradle) :
- ligne 8 : Spring Boot a trouvé la page d'accueil [static/index.html] ;
- lignes 14-24 : les messages de notre contrôleur : les lignes [INFO] journalisent les calculs, les lignes [WARN] les demandes incorrectes, avec les noms des paramètres en cause. Les lignes 14-16 viennent de [curl], les lignes 17-19 du client console, les lignes 20-21 du navigateur et les lignes 22-24 du client Node.js. La catégorie du journal, [ImpotController], est bien le nom de la classe, et non celui de son objet compagnon ;
- les noms des threads ([http-nio-5120-exec-1] à [exec-10]) montrent que les requêtes sont réparties entre les threads du pool de Tomcat ; les requêtes successives d'un même client ne sont pas forcément traitées par le même thread. Les deux dernières requêtes du client console (lignes 18-19) ont été traitées par le même thread : le client a réutilisé la même connexion (HTTP/1.1 garde la connexion ouverte entre deux requêtes), et Tomcat confie la requête suivante d'une connexion au premier thread disponible.
13.7. Compléments
13.7.1. Le type des réponses et la gestion des erreurs
Le service [WsOperations] rend des objets [ResponseEntity<*>] : on ne sait pas, à la lecture de la signature de ses fonctions, quelles réponses elles peuvent produire, et le compilateur ne vérifie rien. Le service [WsImpot] rend le type précis de sa réponse en cas de succès, [ReponseImpot], et signale les erreurs par des exceptions, transformées en réponses par un gestionnaire [@ExceptionHandler]. C'est la forme la plus courante avec Spring : le code des fonctions ne traite que le cas normal, et les outils (le générateur de documents OpenAPI en particulier) connaissent le schéma JSON de la réponse sans qu'on le leur dise. Un test unitaire peut aussi appeler directement une fonction du contrôleur et examiner son résultat, sans passer par HTTP. [ResponseEntity<T>], avec un type précis, reste utile lorsqu'il faut choisir le code de statut ou ajouter des entêtes, par exemple pour une réponse 201 (Created) accompagnée de son entête [Location] : [ResponseEntity.created(uri).body(client)].
Un gestionnaire [@ExceptionHandler] placé dans un contrôleur ne traite que les exceptions de ce contrôleur. Pour traiter les exceptions de tous les contrôleurs d'une application, on place les gestionnaires dans une classe annotée [@RestControllerAdvice]. Spring fournit aussi la classe [ErrorResponseException], une exception qui porte directement un [ProblemDetail] et produit la réponse correspondante sans gestionnaire, et la classe [ResponseEntityExceptionHandler], dont on peut hériter pour personnaliser les réponses aux erreurs détectées par Spring MVC lui-même (document JSON illisible, type de document refusé…).
En Kotlin, on peut aussi représenter le résultat d'une opération par une classe scellée (chapitre 4) : [sealed interface Resultat], avec une classe de données pour le succès et une autre pour l'échec. Une expression [when] exhaustive transforme alors chaque cas en réponse HTTP, et le compilateur vérifie qu'aucun cas n'est oublié. C'est une alternative aux exceptions, appréciée des développeurs Kotlin, qui garde visibles dans les types les échecs possibles d'une opération.
13.7.2. Un préfixe commun aux routes
Lorsque plusieurs routes partagent un préfixe, on l'indique une fois pour toutes par l'annotation [@RequestMapping] placée sur la classe du contrôleur. Par exemple, avec [@RequestMapping("/api/operations")] sur la classe [OperationsController], les routes deviennent [GET /api/operations/ajouter?a=1&b=2] et [POST /api/operations/calcul], sans autre modification. C'est aussi une façon simple de gérer les versions d'un service ([/api/v1/...], [/api/v2/...]).
13.7.3. Intercepteurs et validation
Nos services vérifient eux-mêmes leurs paramètres. Spring propose deux mécanismes pour sortir ce code des contrôleurs :
- les intercepteurs : une classe qui implémente l'interface [HandlerInterceptor], enregistrée par la fonction [addInterceptors] de [WebMvcConfigurer], est exécutée avant et après les fonctions des contrôleurs ; elle peut examiner la requête et répondre elle-même, sans appeler le contrôleur. On les utilise pour la journalisation, la mesure des temps de réponse, l'authentification… Les filtres de servlet ([jakarta.servlet.Filter]) jouent le même rôle à un niveau plus bas ;
- la validation : avec le starter [spring-boot-starter-validation] (qui apporte Hibernate Validator, l'implémentation de référence de l'API Jakarta Validation), les propriétés d'un DTO peuvent être annotées par des contraintes : [data class DemandeImpot(@NotNull val marie: Boolean?, @NotNull @Min(0) val enfants: Int?, @NotNull @Min(0) val salaire: Int?)]. Avec Kotlin 2.4, une annotation placée sur une propriété déclarée dans le constructeur primaire s'applique à la fois au paramètre du constructeur et au champ de la propriété, que lit le validateur (nous l'avons vérifié par réflexion sur une annotation de notre cru). Les versions antérieures de Kotlin ne l'appliquaient qu'au paramètre : il fallait préciser la cible de l'annotation, [@field:NotNull], forme que l'on rencontre encore dans beaucoup d'exemples. Un paramètre [@Valid @RequestBody demande: DemandeImpot] est alors vérifié avant l'appel de la fonction, et une demande incorrecte est rejetée par une réponse 400 « Problem Details », comme celles que nous avons construites.
13.7.4. Hébergement
Nos services sont exécutés par [gradlew bootRun], ce qui convient au développement. En production :
- l'application est d'abord empaquetée par [gradlew bootJar] : le greffon de Spring Boot produit un unique fichier [.jar] « exécutable », qui contient l'application, ses dépendances (dont la bibliothèque standard de Kotlin) et le serveur Tomcat. Il suffit de le copier sur le serveur et de le lancer par [java -jar WsImpot-9.0.jar], la configuration pouvant être modifiée par des arguments ([--server.port=80]), des variables d'environnement ou un fichier [application.properties] placé à côté du [.jar]. Pour la JVM, une application Kotlin est une application Java comme une autre ;
- Tomcat peut être exposé directement sur l'Internet, mais il est souvent placé derrière un proxy inverse (nginx ou Apache sous Linux, IIS sous Windows) qui reçoit les requêtes HTTPS, gère les certificats et répartit la charge entre plusieurs instances de l'application ;
- l'application peut aussi être livrée sous forme d'image de conteneur (Docker), que le greffon de Spring Boot sait produire sans fichier [Dockerfile] : [gradlew bootBuildImage]. L'image s'exécute ensuite sur n'importe quelle plateforme de conteneurs, chez soi ou chez un hébergeur ;
- HTTPS est indispensable : les propriétés [server.ssl.*] de Spring Boot configurent le certificat du serveur Tomcat lorsque celui-ci est exposé directement.
13.8. Conclusion
Ce chapitre a présenté les services web REST/JSON, successeurs des services web SOAP, écrits en Kotlin avec Spring Boot et Spring MVC. Nous avons vu :
- qu'un service web associe des URL et des méthodes HTTP à des fonctions Kotlin, grâce aux annotations [@RestController], [@GetMapping] et [@PostMapping], et que les paramètres de ces fonctions sont tirés de la route ([@PathVariable]), de la chaîne de requête ([@RequestParam]), du corps JSON ([@RequestBody]) ou du conteneur Spring (injection par le constructeur primaire) ;
- que Spring comprend Kotlin : [runApplication], les paramètres nullables qui deviennent facultatifs, les constructeurs primaires et leurs valeurs par défaut ([@ConfigurationProperties]), à condition de lui donner la bibliothèque [kotlin-reflect] et d'appliquer le greffon [plugin.spring] ;
- que les résultats sont envoyés en JSON par Jackson, à partir de classes de données, et les erreurs signalées par un code de statut et un document « Problem Details » ([ProblemDetail], [ResponseEntity], [@ExceptionHandler]) ;
- qu'un client Kotlin utilise [java.net.http.HttpClient] et Jackson, et qu'un client écrit dans n'importe quel autre langage peut faire de même ;
- que l'architecture en couches a tenu ses promesses : la version 9 du calcul de l'impôt réutilise sans modification les couches [dao] et [metier] de la version 5 côté serveur, et sa couche [ui] côté client ; il a suffi d'écrire le service web (une centaine de lignes) et une nouvelle implémentation de l'interface [IImpotMetier] (une cinquantaine de lignes).