1. Présentation et objectifs
Le but est de créer une architecture "à la microservice".
Dans cette architecture, chaque composant a son rôle précis :
-
la servlet reçoit les requêtes HTTP, et les envoie au bon controller (rôle de point d’entrée de l’application)
-
le controlleur implémente une méthode Java par route HTTP, récupère les paramètres, et appelle le service (rôle de routage)
-
le service implémente le métier de notre micro-service
-
le repository représente les accès aux données (avec potentiellement une base de données)
Et pour s’amuser un peu, nous allons réaliser un micro-service qui nous renvoie des données sur les Pokemons !
On retrouve en général le même découpage dans les micro-services NodeJS avec express :
|
Nous allons donc développer un micro-service, qui exposera un canal de communication REST/JSON.
Pour ce faire, nous allons :
-
Créer des annotations Java pour représenter nos objects
-
Créer une servlet, qui se configurera dynamiquement pour router les requêtes au bon controlleur
-
Implémenter un petit service
2. La première Servlet et la structure projet
Pour commencer, créons une première servlet.
2.1. Initialisation du projet
2.1.1. Création de l’arborescence projet
Initialisez un repository GitLab avec ce lien : https://gitlab-classrooms.cleverapps.io/assignments/a480d724-79a4-4f8b-8773-cc1678542477/accept
Ajoutez-y les répertoires de sources java et de test :
$ mkdir -p src/main/java
$ mkdir -p src/test/java
Initialiser un fichier pom.xml à la racine du projet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.miage.alom.tp</groupId>
<artifactId>handcrafting</artifactId>
<version>0.1.0</version>
<packaging>war</packaging> (1)
<properties>
<maven.compiler.source>21</maven.compiler.source> (2)
<maven.compiler.target>21</maven.compiler.target> (3)
</properties>
<dependencies>
</dependencies>
</project>
1 | On va fabriquer un war |
2 | On indique à maven quelle version de Java utiliser pour les sources ! |
3 | On indique à maven quelle version de JVM on cible ! |
2.2. Ecriture de la première servlet
Pour écrire notre première servlet, nous avons besoin de la dépendance jakarta.servlet-api
.
Cette dépendance aura le scope provided
puisque:
-
nous en avons besoin à la compilation
-
à l’exécution, c’est
Tomcat
qui portera la librairie
Ajouter la dépendance suivante dans votre pom.xml
1
2
3
4
5
6
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope> (1)
</dependency>
1 | On précise bien un scope provided à Maven |
Écrire une première servlet :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public class FirstServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
var writer = resp.getWriter();
writer.println("Hello !"); (1)
}
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
System.out.println("Initialisation de la servlet"); (2)
}
}
1 | On dit bonjour ! |
2 | On affiche un log au démarrage |
Écrire un fichier web.xml pour déclarer la servlet :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8"?>
<web-app>
<display-name>handcraft</display-name> (1)
<servlet>
<servlet-name>dispatcherServlet</servlet-name> (2)
<servlet-class>FirstServlet</servlet-class>
<load-on-startup>1</load-on-startup> (4)
</servlet>
<servlet-mapping>
<servlet-name>dispatcherServlet</servlet-name>
<url-pattern>/*</url-pattern> (3)
</servlet-mapping>
</web-app>
1 | Notre application |
2 | Notre servlet |
3 | On écoute l’ensemble des URLs ! |
4 | load-on-startup permet de préciser qu’on souhaite démarrer la servlet immédiatement (sans attendre la première requête) |
2.3. Installation de Tomcat
Nous avons besoin de Tomcat pour exécuter notre Servlet !
Télécharger tomcat depuis la page officielle : https://tomcat.apache.org/download-10.cgi
Prenez bien la 'Binary Distribution', sous la section 'Core'. Si vous prenez la source vous devrez compiler Tomcat vous-même ! Sous Linux, privilégiez le format .tar.gz , qui conserve les bons droits sur les fichiers.
|
2.3.1. Configuration pour IntelliJ IDEA
Ajouter le serveur Tomcat à IntelliJ
Créer une configuration d’exécution utilisant le Tomcat
2.4. Démarrer notre première Servlet
Démarrez votre serveur Tomcat, avec votre servlet, et allez constater le résultat !
Votre application est disponible à l’URL http://localhost:8080 |
3. Passer votre servlet en mode "annotations" servlet-api
3.0
3.1. Le code
Depuis la version 3.0 de servlet-api
, les servlets supportent les annotations Java.
Plus besoin de web.xml
!
Supprimer le fichier web.xml
, et le répertoire src/main/webapp
.
Modifier la servlet pour ajouter une annotation java :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@WebServlet(urlPatterns = "/*", (1) (2)
loadOnStartup = 1) (3)
public class FirstServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
PrintWriter writer = resp.getWriter();
writer.println("Hello !");
}
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
System.out.println("Initialisation de la servlet"); (2)
}
}
1 | On déclare la servlet avec une annotation java ! |
2 | On déclare les URL d’écoute |
3 | et on déclare souhaiter démarrer la servlet sans attendre de première requête |
3.2. Le packaging
Par défaut, Maven ne connaît pas les servlets 3.0. Il s’attend donc à trouver un fichier web.xml
dans le répertoire
src/main/webapp/WEB-INF
.
Si on lance un mvn package
après avoir supprimé le web.xml
et le répertoire webapp
, on obtient l’erreur suivante :
$> mvn clean package
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------< com.miage.alom.tp:handcrafting >-------------------
[INFO] Building handcrafting 0.1.0
[INFO] --------------------------------[ war ]---------------------------------
[INFO]
[INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ handcrafting ---
[INFO]
[INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ handcrafting ---
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] skip non existing resourceDirectory /home/jwittouck/workspaces/alom/alom-2020-2021/tp/02-handcrafting/src/main/resources
[INFO]
[INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ handcrafting ---
[INFO] Changes detected - recompiling the module!
[WARNING] File encoding has not been set, using platform encoding UTF-8, i.e. build is platform dependent!
[INFO] Compiling 1 source file to /home/jwittouck/workspaces/alom/alom-2020-2021/tp/02-handcrafting/target/classes
[INFO]
[INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ handcrafting ---
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] skip non existing resourceDirectory /home/jwittouck/workspaces/alom/alom-2020-2021/tp/02-handcrafting/src/test/resources
[INFO]
[INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ handcrafting ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ handcrafting ---
[INFO] No tests to run.
[INFO]
[INFO] --- maven-war-plugin:2.2:war (default-war) @ handcrafting ---
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by com.thoughtworks.xstream.core.util.Fields (file:/home/jwittouck/.m2/repository/com/thoughtworks/xstream/xstream/1.3.1/xstream-1.3.1.jar) to field java.util.Properties.defaults
WARNING: Please consider reporting this to the maintainers of com.thoughtworks.xstream.core.util.Fields
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release
[INFO] Packaging webapp
[INFO] Assembling webapp [handcrafting] in [/home/jwittouck/workspaces/alom/alom-2020-2021/tp/02-handcrafting/target/handcrafting-0.1.0]
[INFO] Processing war project
[INFO] Webapp assembled in [23 msecs]
[INFO] Building war: /home/jwittouck/workspaces/alom/alom-2020-2021/tp/02-handcrafting/target/handcrafting-0.1.0.war
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.635 s
[INFO] Finished at: 2019-01-11T14:55:59+01:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-war-plugin:2.2:war (default-war) on project handcrafting: Error assembling WAR: webxml attribute is required (or pre-existing WEB-INF/web.xml if executing in update mode) -> [Help 1] (1)
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException
1 | Maven n’est pas content, et veut un fichier web.xml ! |
Pour corriger ce comportement, il faut utiliser une version récente du plugin maven war
.
Pour ce faire, ajouter dans votre pom.xml
le bloc suivant (en dessous de votre bloc dependencies
)
1
2
3
4
5
6
7
8
9
10
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version> (1)
</plugin>
</plugins>
</pluginManagement>
</build>
1 | La version 3.4.0 du maven-war-plugin ne nécessite pas de fichier web.xml par défaut, comme précisé dans la documentation |
On relance un mvn package
pour valider la configuration
$> mvn clean package
[INFO] Scanning for projects...
[INFO]
[INFO] -------------------< com.miage.alom.tp:w01-servlet >--------------------
[INFO] Building w01-servlet 0.1.0
[INFO] --------------------------------[ war ]---------------------------------
[INFO]
[INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ w01-servlet ---
[INFO] Deleting /home/jwittouck/workspaces/univ-lille/alom-2024/exercices/corrections/w01-servlet/target
[INFO]
[INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ w01-servlet ---
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] skip non existing resourceDirectory /home/jwittouck/workspaces/univ-lille/alom-2024/exercices/corrections/w01-servlet/src/main/resources
[INFO]
[INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ w01-servlet ---
[INFO] Changes detected - recompiling the module!
[WARNING] File encoding has not been set, using platform encoding UTF-8, i.e. build is platform dependent!
[INFO] Compiling 1 source file to /home/jwittouck/workspaces/univ-lille/alom-2024/exercices/corrections/w01-servlet/target/classes
[INFO]
[INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ w01-servlet ---
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] skip non existing resourceDirectory /home/jwittouck/workspaces/univ-lille/alom-2024/exercices/corrections/w01-servlet/src/test/resources
[INFO]
[INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ w01-servlet ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ w01-servlet ---
[INFO] No tests to run.
[INFO]
[INFO] --- maven-war-plugin:3.4.0:war (default-war) @ w01-servlet ---
[INFO] Packaging webapp
[INFO] Assembling webapp [w01-servlet] in [/home/jwittouck/workspaces/univ-lille/alom-2024/exercices/corrections/w01-servlet/target/w01-servlet-0.1.0]
[INFO] Processing war project
[INFO] Building war: /home/jwittouck/workspaces/univ-lille/alom-2024/exercices/corrections/w01-servlet/target/w01-servlet-0.1.0.war
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS (1)
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 0.915 s
[INFO] Finished at: 2024-09-15T14:55:58+02:00
[INFO] ------------------------------------------------------------------------
1 | Maven est content ! |
Validez que votre servlet fonctionne toujours en la démarrant et en allant voir http://localhost:8080 |
4. La servlet dynamique
4.1. Les annotations
Nous allons utiliser des annotations Java customisées pour créer notre couche de routage.
Ces annotations seront analysées par la servlet, avec l’aide des api java.lang.reflect
, afin de configurer
le routage des requêtes HTTP vers le bon controller.
Pour la couche Controller, nous allons créer 2 annotations :
-
@ServletController
: afin de marquer une classe comme étant un controller dans notre architecture -
@ServletRequestMapping
: afin de marquer une méthode de controller comme devant recevoir des requêtes HTTP
Créer les annotations suivantes dans votre projet :
Positionnez votre code dans un package Java ! Par exemple dans com.miage.alom.servlet .
|
1
2
3
@Retention(RetentionPolicy.RUNTIME) (1)
public @interface ServletController {
}
1 | On met une rétention au runtime, puisque nous allons utiliser l’annotation à l’exécution |
1
2
3
4
5
@Retention(RetentionPolicy.RUNTIME) (1)
public @interface ServletRequestMapping {
// uri à écouter
String uri(); (2)
}
1 | On a encore une rétention au runtime |
2 | Notre annotation utilise un paramètre uri , permettant de déclarer quelle URI sera écoutée
(comme ce qu’on peut faire avec une servlet) |
4.2. Notre premier controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ServletController (1)
public class HelloController {
@ServletRequestMapping(uri="/hello") (2)
public String sayHello(){
return "Hello World !";
}
@ServletRequestMapping(uri="/bye")
public String sayGoodBye(){
return "Goodbye !";
}
@ServletRequestMapping(uri="/boum")
public String explode(){
throw new RuntimeException("Explosion !"); (3)
}
}
1 | Nous utilisons ici notre annotation |
2 | La méthode sayHello écoute à l’URI /hello et renvoie une chaîne de caractères |
3 | La méthode explode lève une exception ! |
4.3. L’analyse dynamique du code
Notre servlet, que l’on nommera DispatcherServlet
va analyser le code de notre controller,
pour être capable de router les requêtes HTTP, et récupérer les résultats
Supprimez votre servlet précédente, elle ne nous sera plus utile pour la suite.
Pour réaliser notre servlet, nous allons travailler en TDD (test-driven-development).
J’ai implémenté pour vous les tests, il ne reste plus qu’à les faire passer !
4.3.1. JUnit et Maven
Pour utiliser les tests unitaires, il faut rajouter JUnit en dépendance maven.
Ajoutez les dépendances suivant dans votre pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId> (1)
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId> (2)
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>
1 | L’API de JUnit 5 |
2 | Le moteur d’exécution |
Il vous faut également surcharger la version du maven-surefire-plugin
(qui est le plugin maven qui implémente la phase d’exécution des tests).
1
2
3
4
5
6
7
8
9
10
11
12
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version> (1)
</plugin>
</plugins>
</pluginManagement>
1 | On a besoin de la version 2.22.0 minimum pour JUnit 5 comme indiqué dans la documentation junit |
4.3.2. Le test unitaire
Implémentez le test unitaire 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
package com.miage.alom.servlet;
import com.miage.alom.controller.HelloController;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class DispatcherServletTest { (1)
@Test (2)
void registerController_throwsIllegalArgumentException_forNonControllerClasses() {
var servlet = new DispatcherServlet();
assertThrows(IllegalArgumentException.class,
() -> servlet.registerController(String.class));
assertThrows(IllegalArgumentException.class,
() -> servlet.registerController(SomeEmptyClass.class));
}
@Test
void registerController_doesNotRegisters_nonAnnotatedMethods() {
var servlet = new DispatcherServlet();
servlet.registerController(SomeControllerClassWithAMethod.class);
assertTrue(servlet.getMappings().isEmpty());
}
@Test
void registerController_doesNotRegisters_voidReturningMethods() {
var servlet = new DispatcherServlet();
servlet.registerController(SomeControllerClassWithAVoidMethod.class);
assertTrue(servlet.getMappings().isEmpty());
}
@Test (4)
void registerController_shouldRegisterCorrectyMethods(){
var servlet = new DispatcherServlet();
servlet.registerController(SomeControllerClass.class);
servlet.registerController(SomeOtherControllerClass.class);
assertEquals("someGoodMethod",
servlet.getMappingForUri("/test").getName());
assertEquals("someOtherNiceMethod",
servlet.getMappingForUri("/otherTest").getName());
}
@Test
void registerHelloController_shouldWorkCorrectly(){
var servlet = new DispatcherServlet();
servlet.registerController(HelloController.class);
assertEquals("sayHello", servlet.getMappingForUri("/hello").getName());
assertEquals("sayGoodBye", servlet.getMappingForUri("/bye").getName());
assertEquals("explode", servlet.getMappingForUri("/boum").getName());
}
}
class SomeEmptyClass{}
(3)
@com.miage.alom.servlet.ServletController
class SomeControllerClassWithAMethod{
public String myMethod(){
return "test";
}
}
@com.miage.alom.servlet.ServletController
class SomeControllerClassWithAVoidMethod{
@com.miage.alom.servlet.ServletRequestMapping(uri="/test")
public void myMethod(){}
}
@com.miage.alom.servlet.ServletController
class SomeControllerClass {
@com.miage.alom.servlet.ServletRequestMapping(uri="/test")
public String someGoodMethod(){
return "Hello";
}
@com.miage.alom.servlet.ServletRequestMapping(uri="/test-throwing")
public String someThrowingMethod(){
throw new RuntimeException("some exception message");
}
@com.miage.alom.servlet.ServletRequestMapping(uri="/test-with-params")
public String someThrowingMethod(Map<String, String[]> params){
return params.get("id")[0];
}
}
@com.miage.alom.servlet.ServletController
class SomeOtherControllerClass {
@com.miage.alom.servlet.ServletRequestMapping(uri="/otherTest")
public String someOtherNiceMethod(){
return "Hello again";
}
}
1 | Notre classe de test |
2 | Nos tests sont annotés @Test |
3 | Quelques controlleurs d’exemple pour valider le fonctionnement de votre implémentation |
4 | On teste l’enregistrement du HelloController |
4.3.3. La DispatcherServlet (code à trous)
Implémentez la servlet 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
package com.miage.alom.servlet;
import com.miage.alom.controller.HelloController;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
@WebServlet(urlPatterns = "/*", loadOnStartup = 1)
public class DispatcherServlet extends HttpServlet {
private Map<String, Method> uriMappings = new HashMap<>(); (1)
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
System.out.println("Getting request for " + req.getRequestURI());
// TODO (3)
}
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
// on enregistre notre controller au démarrage de la servlet
this.registerController(HelloController.class);
}
/**
* This methods checks the following rules :
* - The controllerClass is annotated with @ServletController
* Then all methods are scanned and processed by the registerMethod method
* @param controllerClass the controller to scan
*/
protected void registerController(Class controllerClass){
System.out.println("Analysing class " + controllerClass.getName());
// TODO (2)
}
/**
* This methods checks the following rules :
* - The method is annotated with @ServletRequestMapping
* - The @ServletRequestMapping annotation has a URI
* - The method does not return void
* If these rules are followed, the method and its URI are added to the uriMapping map.
* @param method the method to scan
*/
protected void registerMethod(Method method) {
System.out.println("Registering method " + method.getName());
// TODO (2)
}
protected Map<String, Method> getMappings(){
return this.uriMappings;
}
protected Method getMappingForUri(String uri){
return this.uriMappings.get(uri);
}
}
1 | Cette Map va contenir l’association entre une URI et la méthode Java qui l’écoute (annotée @ServletRequestMapping ) |
2 | C’est là qu’il faut coder ! |
3 | Cette méthode sera implémentée dans la partie 4.4 |
Il faut maintenant implémenter les méthodes registerController
et registerMethod
pour faire passer les tests unitaires.
Cette partie fait un usage intensif de l’api Vous aurez surement besoin des méthodes
|
4.4. Le routage des requêtes (code à trous)
Une fois les annotations analysées, le routage des requêtes se fait de la manière suivante :
-
Récupération de l’URI entrante (depuis l’objet HttpServletRequest)
-
Récupération de la méthode implémentant l’URI (issue de l’analyse du code)
-
Si aucune méthode n’est trouvée, renvoyer une erreur 404
-
-
Instanciation du controller
-
Récupération des paramètres (depuis l’objet HttpServletRequest)
-
Appel de la méthode (avec les paramètres ou non)
-
En cas d’exception, renvoyer une erreur 500 avec le message de l’exception
-
En cas de succès, récupérer le résultat de l’appel, et renvoyer le résultat convertit en chaîne de caractères
-
Nous devons donc ici, implémenter la méthode doGet
de notre DispatcherServlet
.
4.4.1. Les tests unitaires du routage
Ajoutez les tests suivants dans le test unitaire de la DispatcherServlet
:
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
@Test
void doGet_shouldReturn404_whenNotMethodIsFound() throws IOException {
var servlet = new DispatcherServlet();
var req = mock(HttpServletRequest.class);
var resp = mock(HttpServletResponse.class);
when(req.getRequestURI()).thenReturn("/test");
servlet.doGet(req, resp);
verify(resp).sendError(404, "no mapping found for request uri /test");
}
@Test
void doGet_shouldReturn500WithMessage_whenMethodThrowsException() throws IOException {
var servlet = new DispatcherServlet();
servlet.registerController(SomeControllerClass.class);
var req = mock(HttpServletRequest.class);
var resp = mock(HttpServletResponse.class);
when(req.getRequestURI()).thenReturn("/test-throwing");
servlet.doGet(req, resp);
verify(resp).sendError(500,
"exception when calling method someThrowingMethod : some exception message");
}
@Test
void doGet_shouldReturnAResult_whenMethodSucceeds() throws IOException {
var servlet = new DispatcherServlet();
servlet.registerController(SomeControllerClass.class);
var req = mock(HttpServletRequest.class);
var resp = mock(HttpServletResponse.class);
var printWriter = mock(PrintWriter.class);
when(resp.getWriter()).thenReturn(printWriter);
when(req.getRequestURI()).thenReturn("/test");
servlet.doGet(req, resp);
verify(printWriter).print((Object)"Hello");
}
@Test
void doGet_shouldReturnAResult_whenMethodWithParametersSucceeds() throws IOException {
var servlet = new DispatcherServlet();
servlet.registerController(SomeControllerClass.class);
var req = mock(HttpServletRequest.class);
var resp = mock(HttpServletResponse.class);
var printWriter = mock(PrintWriter.class);
when(req.getRequestURI()).thenReturn("/test-with-params");
when(req.getParameterMap()).thenReturn(Map.of("id", new String[]{"12"}));
when(resp.getWriter()).thenReturn(printWriter);
servlet.doGet(req, resp);
verify(printWriter).print((Object)"12");
}
@Test
void doGet_shouldReturnAResult_forHelloController() throws IOException {
var servlet = new DispatcherServlet();
servlet.registerController(HelloController.class);
var req = mock(HttpServletRequest.class);
var resp = mock(HttpServletResponse.class);
var printWriter = mock(PrintWriter.class);
when(req.getRequestURI()).thenReturn("/hello");
when(resp.getWriter()).thenReturn(printWriter);
servlet.doGet(req, resp);
verify(printWriter).print((Object)"Hello World !");
}
Ces tests unitaires valident que les méthodes sont correctement appelées et que les erreurs sont renvoyées.
Vous devrez probablement ajouter l’import java suivant
import static org.mockito.Mockito.*;
Une fois tous les tests au vert , vous pouvez démarrer votre projet et requêter via votre navigateur web : |
5. Le micro-service PokemonType
Pour la suite de ce TP, nous allons développer un micro-service pokemon-type, qui s’appuiera sur notre DispatcherServlet. Ce micro-service a pour but de gérer les données de référence des pokémons, à savoir les 151 types de pokemon existants.
Le micro-service sera composé de 3 niveaux:
-
La DispatcherServlet
-
Le PokemonController, qui va exposer une route dédiée
-
Le PokemonRepository, qui va consommer un fichier JSON
Pour avoir quelques données à disposition, nous utiliserons les données de l’API https://pokeapi.co
5.1. La structure
Nous allons donner une structure à notre micro-service. Cette structure prendra la forme de packages Java.
On retrouvera cette organisation de packages dans l’ensemble de nos TPs. |
Créez les packages suivants :
-
com.miage.alom.bo
-
com.miage.alom.controller
-
com.miage.alom.repository
Créez également le répertoire src/main/resources
.
5.2. La classe PokemonType
Pour commencer, nous allons créer notre objet métier.
Pour implémenter notre objet, nous devons nous inspirer des champs que propose l’API https://pokeapi.co.
Par exemple, voici ce qu’on obtient en appelant l’API (un peu simplifiée) :
{
"base_experience": 261,
"height": 16,
"id": 145,
"moves": [],
"name": "zapdos",
"sprites": {
"back_default": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/145.png",
"back_shiny": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/145.png",
"front_default": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/145.png",
"front_shiny": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/145.png"
},
"stats": [
{
"base_stat": 100,
"effort": 0,
"stat": {
"name": "speed",
"url": "https://pokeapi.co/api/v2/stat/6/"
}
},
{
"base_stat": 90,
"effort": 0,
"stat": {
"name": "special-defense",
"url": "https://pokeapi.co/api/v2/stat/5/"
}
},
{
"base_stat": 125,
"effort": 3,
"stat": {
"name": "special-attack",
"url": "https://pokeapi.co/api/v2/stat/4/"
}
},
{
"base_stat": 85,
"effort": 0,
"stat": {
"name": "defense",
"url": "https://pokeapi.co/api/v2/stat/3/"
}
},
{
"base_stat": 90,
"effort": 0,
"stat": {
"name": "attack",
"url": "https://pokeapi.co/api/v2/stat/2/"
}
},
{
"base_stat": 90,
"effort": 0,
"stat": {
"name": "hp",
"url": "https://pokeapi.co/api/v2/stat/1/"
}
}
],
"types": [
{
"slot": 2,
"type": {
"name": "flying",
"url": "https://pokeapi.co/api/v2/type/3/"
}
},
{
"slot": 1,
"type": {
"name": "electric",
"url": "https://pokeapi.co/api/v2/type/13/"
}
}
],
"weight": 526
}
Nous allons donc créer une classe Java qui reprend cette structure, mais en ne conservant que les champs qui nous intéressent.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.miage.alom.bo;
public class PokemonType { (1)
private int id;
private int baseExperience;
private int height;
private String name;
private Sprites sprites; (3)
private Stats stats; (3)
private int weight;
(2)
}
1 | On sélectionne les champs "id", "name", et "sprites" |
2 | On a besoin des getters et setters par la suite (pour les générer, utilisez Alt+Inser sous IntelliJ) |
3 | Pour les objets imbriqués, on utilise d’autres classes |
1
2
3
4
5
6
7
8
package com.miage.alom.bo;
public class Sprites {
private String back_default;
private String front_default;
}
1
2
3
4
5
6
7
8
9
10
package com.miage.alom.bo;
public class Stats {
private Integer speed;
private Integer defense;
private Integer attack;
private Integer hp;
}
5.3. Le PokemonTypeRepository
Le repository est donc la classe qui va consommer notre fichier JSON et retourner notre Pokemon.
Le repository va utiliser l’API jackson-databind
pour convertir le JSON en objet Java
5.3.1. jackson-databind
Ajouter la dépendance suivante à votre projet :
1
2
3
4
5
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
Ecrire un test unitaire pour apprendre à manipuler jackson-databind :
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
class JacksonDatabindTest {
public static class Car { (1)
public String color; (2)
public String brand;
}
@Test
void testWriteJson() throws JsonProcessingException { (3)
var objectMapper = new ObjectMapper();
var car = new Car();
car.color = "yellow";
car.brand = "renault";
var json = objectMapper.writeValueAsString(car);
assertEquals("{\"color\":\"yellow\",\"brand\":\"renault\"}", json);
}
@Test
void testReadJson() throws IOException { (4)
var objectMapper = new ObjectMapper();
var json = "{ \"color\" : \"black\", \"brand\" : \"opel\" }";
var car = objectMapper.readValue(json, Car.class);
assertEquals("black", car.color);
assertEquals("opel", car.brand);
}
}
1 | La classe qui représente nos données |
2 | On positonne les champs en visibilité public pour ne pas avoir à écrire de getters/setters sur ce cas de test |
3 | L’écriture de JSON depuis notre objet |
4 | La lecture d’un JSON pour reconstruire un objet |
Plus d’infos sur le Github de jackson-databind
Dans la DispatcherServlet, on peut utiliser jackson-databind pour transformer le résultat de nos appels de controllers en JSON !
|
5.3.2. Le jeu de données du repository
Récupérez le fichier pokemons.json et enregistrez-le dans le répertoire src/main/resources
de votre projet.
5.3.3. Les tests unitaires du repository
Comme pour la DispatcherServlet
, nous allons travailler en TDD.
Voici la classe de tests unitaires à implémenter
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
package com.miage.alom.repository;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PokemonTypeRepositoryTest {
private PokemonTypeRepository repository = new PokemonTypeRepository();
@Test
void findPokemonById_with25_shouldReturnPikachu(){ (1)
var pikachu = repository.findPokemonById(25);
assertNotNull(pikachu);
assertEquals("pikachu", pikachu.getName());
assertEquals(25, pikachu.getId());
}
@Test
void findPokemonById_with145_shouldReturnZapdos(){ (1)
var zapdos = repository.findPokemonById(145);
assertNotNull(zapdos);
assertEquals("zapdos", zapdos.getName());
assertEquals(145, zapdos.getId());
}
@Test
void findPokemonByName_withEevee_shouldReturnEevee(){ (2)
var eevee = repository.findPokemonByName("eevee");
assertNotNull(eevee);
assertEquals("eevee", eevee.getName());
assertEquals(133, eevee.getId());
}
@Test
void findPokemonByName_withMewTwo_shouldReturnMewTwo(){ (2)
var mewtwo = repository.findPokemonByName("mewtwo");
assertNotNull(mewtwo);
assertEquals("mewtwo", mewtwo.getName());
assertEquals(150, mewtwo.getId());
}
@Test
void findAllPokemon_shouldReturn151Pokemons(){
var pokemons = repository.findAllPokemon();
assertNotNull(pokemons);
assertEquals(151, pokemons.size());
}
}
1 | On valide la récupération d’un pokemon par son id |
2 | et par son nom |
5.3.4. Le PokemonTypeRepository
Et voici la classe du repository, à compléter !
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
package com.miage.alom.repository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.miage.alom.bo.PokemonType;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
public class PokemonTypeRepository {
private List<PokemonType> pokemons;
public PokemonTypeRepository() {
try {
var pokemonsStream = this.getClass().getResourceAsStream("/pokemons.json"); (1)
var objectMapper = new ObjectMapper(); (2)
var pokemonsArray = objectMapper.readValue(pokemonsStream, PokemonType[].class);
this.pokemons = Arrays.asList(pokemonsArray);
} catch (IOException e) {
e.printStackTrace();
}
}
public PokemonType findPokemonById(int id) {
System.out.println("Loading Pokemon information for Pokemon id " + id);
// TODO (3)
}
public PokemonType findPokemonByName(String name) {
System.out.println("Loading Pokemon information for Pokemon name " + name);
// TODO (3)
}
public List<PokemonType> findAllPokemon() {
// TODO (3)
}
}
1 | On charge le fichier json depuis le classpath (maven ajoute le répertoire src/main/resources au classpath java !) |
2 | On utilise l’ObjectMapper de jackson-databind pour transformer les objets JSON en objets JAVA |
3 | On a un peu de code à compléter ! |
5.4. Le PokemonTypeController
Écrire un controller qui expose une route "/pokemon".
Cette route pourra être appelée avec des paramètres éventuels, id
ou name
.
Les requêtes devant être implémentées sont donc, par exemple :
5.4.1. Les tests unitaires du PokemonTypeController
Implémenter les tests unitaires suivants :
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
package com.miage.alom.controller;
import com.miage.alom.bo.PokemonType;
import com.miage.alom.repository.PokemonTypeRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class PokemonTypeControllerTest {
@InjectMocks
PokemonTypeController controller;
@Mock
PokemonTypeRepository pokemonRepository;
@BeforeEach
void init(){
MockitoAnnotations.initMocks(this);
}
@Test
void getPokemon_shouldRequireAParameter(){
var exception = assertThrows(IllegalArgumentException.class,
() -> controller.getPokemon(null));
assertEquals("parameters should not be empty", exception.getMessage());
}
@Test
void getPokemon_shouldRequireAKnownParameter(){
var parameters = Map.of("test", new String[]{"25"});
var exception = assertThrows(IllegalArgumentException.class,
() -> controller.getPokemon(parameters));
assertEquals("unknown parameter", exception.getMessage());
}
@Test
void getPokemon_withAnIdParameter_shouldReturnAPokemon(){
var pikachu = new PokemonType();
pikachu.setId(25);
pikachu.setName("pikachu");
when(pokemonRepository.findPokemonById(25)).thenReturn(pikachu);
var parameters = Map.of("id", new String[]{"25"});
var pokemon = controller.getPokemon(parameters);
assertNotNull(pokemon);
assertEquals(25, pokemon.getId());
assertEquals("pikachu", pokemon.getName());
verify(pokemonRepository).findPokemonById(25);
verifyNoMoreInteractions(pokemonRepository);
}
@Test
void getPokemon_withANameParameter_shouldReturnAPokemon(){
var zapdos = new PokemonType();
zapdos.setId(145);
zapdos.setName("zapdos");
when(pokemonRepository.findPokemonByName("zapdos")).thenReturn(zapdos);
var parameters = Map.of("name", new String[]{"zapdos"});
var pokemon = controller.getPokemon(parameters);
assertNotNull(pokemon);
assertEquals(145, pokemon.getId());
assertEquals("zapdos", pokemon.getName());
verify(pokemonRepository).findPokemonByName("zapdos");
verifyNoMoreInteractions(pokemonRepository);
}
@Test
void pokemonTypeController_shouldBeAnnotated(){
var controllerAnnotation =
PokemonTypeController.class.getAnnotation(ServletController.class);
assertNotNull(controllerAnnotation);
}
@Test
void getPokemon_shouldBeAnnotated() throws NoSuchMethodException {
var getPokemonMethod =
PokemonTypeController.class.getDeclaredMethod("getPokemon", Map.class);
var requestMappingAnnotation =
getPokemonMethod.getAnnotation(ServletRequestMapping.class);
assertNotNull(requestMappingAnnotation);
assertEquals("/pokemons", requestMappingAnnotation.uri());
}
}
5.4.2. Le PokemonTypeController (code à trous)
Implémenter le PokemonTypeController et compléter la méthode !
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.miage.alom.controller;
import com.miage.alom.bo.PokemonType;
import com.miage.alom.repository.PokemonTypeRepository;
import java.util.Map;
public class PokemonTypeController {
private PokemonTypeRepository repository = new PokemonTypeRepository();
public PokemonType getPokemon(Map<String,String[]> parameters){
// TODO
}
}
Peut-être faut-il ajouter des annotations java sur le controller pour l’enregistrer auprès de la DispatcherServlet .
|
5.5. Modifications de la DispatcherServlet
Enfin, pour finaliser notre développement, nous devons :
-
Enregistrer notre
PokemonTypeController
dans laDispatcherServlet
(en modifiant la méthodeinit
de laDispatcherServlet
) -
Utiliser
jackson-databind
pour transformer les résultats de nos controlleurs en JSON -
Ne pas oublier de transmettre les paramètres reçus en requête au controlleur !
Testez votre micro-service en consultant les urls suivantes :