Dans cette séance, nous allons couvrir plusieurs thèmes avancés de Spring MVC.
Avant de commencer, nous allons revenir sur les beans que gère Spring.
Source: Documentation de Spring MVC
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } @Bean public CommandLineRunner printAllBeans(ApplicationContext applicationContext) { return args -> { Arrays.stream(applicationContext.getBeanDefinitionNames()) .forEach(System.out::println); }; } }
ApplicationContext
est le conteneur IoC,printAllBeans
(le @Autowire
est implicite),CommandLineRunner
sont exécutés au démarrage de l'application (après la création de tous les beans).Il y a 164 beans dans le conteneur IoC.
... rectangleController ... dispatcherServlet ... mvcViewResolver ...
On retrouve notre controlleur, le servlet dispatcher, le viewResolver ...
Les beans injectés par Spring sont par défaut des singletons et correspondent à des composants logiciels.
public record Rectangle( @Min(value=0,message="Width must be positive") int width, @Min(value=0,message="Height must be positive") int height) { public int area() { return width*height; } }
@PostMapping("/rectanglevalidation") public String rectangleFormProcess(@Valid @ModelAttribute Rectangle parameter, BindingResult bindingResult, Model model){ if (bindingResult.hasErrors()){ return "rectangle-validation"; } return "rectangle-validation-result"; }
Les messages d'erreurs définis dans le schéma de validation sont accessibles depuis la vue en cas d'erreur de validation.
<form action="#" th:action="@{/rectanglevalidation}" method="post" th:object="${rectangleAttribute}"> <h2>Rectangle area calculator</h2> <div><label for="h">Height:</label> <input id="h" type="text" th:field="*{height}"> <span th:if="${#fields.hasErrors('height')}" th:errors="*{height}">Invalid height</span></div> <div><label for="w">Width:</label> <input type="text" id="w" th:field="*{width}"> <span th:if="${#fields.hasErrors('width')}" th:errors="*{width}">Invalid width</span></div> <div><button type="submit">Compute</button></div> </form>
rectangleAttribute
du Model sont injectés dans le formulaire.Tout se passe comme prévu!
Le problème ici est qu'il y a une erreur au binding et pas à la validation.
Rectangle
en String
et valider avec @Pattern
que ce sont bien des chaînes représentant des entiers.On peut définir des messages dans un fichier messages.properties.
height.positive=Height must be a positive number. width.positive=Width must be a positive number. typeMismatch.rectangleAttribute.height=Heigh must be an integer number typeMismatch.rectangleAttribute.width=Heigh must be an integer number
Pour les messages d'erreur au binding, on se base sur le nom de l'attribut dans le Model. Dans notre cas, c'est rectangleAttribut.
Les autres messages peuvent être utilisé dans les schémas de validation.
On peut alors facilement définir des messages dans plusieurs langues dans des fichiers messages_FR.properties, messages_ES.properties.
Il faut dire à Spring d'utiliser notre fichier de messages pour la validation.
@SpringBootApplication public class Application { @Bean public MessageSource messageSource() { ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); messageSource.setBasename("classpath:messages"); messageSource.setDefaultEncoding("UTF-8"); return messageSource; } @Bean public LocalValidatorFactoryBean getValidator() { LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean(); bean.setValidationMessageSource(messageSource()); return bean; } }
Bonus 1 : on peut utiliser nos messages pour la validation !
Bonus 2 : on peut traduire notre application sans toucher au code !
public class Rectangle { @Min(value=0,message="{height.positive}") private int width; @Min(value=0,message="{width.positive}") private int height; ... }
Nous allons voir 3 méthodes:
HttpSession
depuis la HTTPRequest
,@AttributeSession
.Pour illustrer, on reprend l'exercice sur l'application Rectangle
du TD.
public class History { public record Computation(int width, int height, int area) {...} private final ArrayDeque<String> computations = new ArrayDeque<>(); public void addComputation(Computation computation){...} public boolean isEmpty(){...} public String latest(){...} public List<Computation> pastComputations(){...} }
Un concept = Un objet
Attention, l'implémentation du History
<form action="#" th:action="@{/rectanglesession}" method="post" th:object="${rectangleAttribute}"> ... </form> <div th:if="${!history.isEmpty() and !#fields.hasErrors('${rectangleAttribute.*}')}"> <div> <h2>Result</h2> <p th:text="${history.latest}"/> </div> <div th:if="${!history.pastComputations.isEmpty()}"> <h2>Previous computations</h2> <ul> <li th:each="line : ${history.pastComputations}" th:text="${line}"/> </ul> </div> </div>
HttpSession
@PostMapping("/rectanglesession1") public String form(@Valid @ModelAttribute("rectangleAttribute") Rectangle rectangle, BindingResult bindingResult, HttpSession session Model model){ var history = getOrCreateHistoryFromSession(session); model.addAttribute("history",history); if (bindingResult.hasErrors()){ return "rectangle-session"; } history.addComputation(new Computation(rectange.width(),rectangle.height(),rectangle.area()); return "rectangle-session"; }
Facile à mettre en oeuvre mais on gère explicitement la Map de HttpSession.
Par défaut, il y a un unique controlleur pour tous les clients (Singleton). On peut demander Spring de créer un controlleur par session HTTP.
@Controller @Scope(value = WebApplicationContext.SCOPE_SESSION) public class RectangleSessionWithSessionScope { private final History history = new History(); @GetMapping("/rectanglesession") public String rectangleForm(Model model){ model.addAttribute("history",history); model.addAttribute("rectangleAttribute",new Rectangle(0,0)); return "rectangle-session"; } }
Facile à mettre en oeuvre mais potentiellement couteux si le controlleur est complexe. De plus, pas moyen de partager des données de session avec un autre controlleur. A éviter globalement.
Mieux, on peut définir un bean History dont le scope est la session HTTP tout en gardant un seul controlleur.
@SpringBootApplication public class Application { ... @Bean @Scope(value = WebApplicationContext.SCOPE_SESSION,proxyMode = ScopedProxyMode.TARGET_CLASS) public History beanHistory() { return new History(); } }
@Controller public class RectangleSessionWithSessionScope { private final History history; public RectangleSessionWithSessionScope(History history){ this.history = history; } ... }
Pourquoi faut-il passer par un proxy ?
Un peu plus difficile à mettre en oeuvre que HttpSession
mais en gagne en abstraction au prix des indirections du proxy.
Spring MVC permet de récupérer des attributs du Model dans la Session HTTP.
@Controller @SessionAttributes("history") public class RectangleSessionWithSessionAttribute { @ModelAttribute("history") public History history(){ return new History(); } ... @PostMapping("/rectanglesession") public String form(@Valid @ModelAttribute("rectangleAttribute") Rectangle rectangle, BindingResult bindingResult, Model model, @ModelAttribute("history") History history){ ... } }
Dans un API Rest, les requêtes et les réponses sont toujours envoyées en HTTP mais les informations pertinentes sont systématiquement encodées en JSON.
Un controlleur Rest est annoté par @RestController et ne renvoie pas le nom d'une vue mais un objet Java qui sera transformé en JSON.
Spring MVC peut transformer le JSON en un objet Java (l'inverse) en se basant sur le nom des champs. On peut modifier la conversion avec des annotations.
public record Student { @JsonIgnore long uid, @JsonProperty("first_name") String firstName, @JsonProperty("last_name") String lastName, ... }
@RestController public class HelloRestController { private final Map<Long,Student> students = Map.of(...); @GetMapping("/students/{id}") public Student getStudent(@PathVariable("id") long id) { var student = students.get(id); if (student==null){ throw new ResponseStatusException( HttpStatus.NOT_FOUND, "No student with id ("+id+")"); } else { return student; } } }
Spring utilise convertie automatiquement le type renvoyé en JSON.
Il arrive souvent que le back-end doivent faire des requêtes à des API.
RestTemplate
est deprecated. Il faut maintenant utiliser WebClient qui permet de faire des requêtes HTTP en bloquant ou en non-bloquant.
Dans sa forme bloquante, l'utilisation de WebClient est simple:
WebClient webClient = WebClient.create(); Student student = webClient.get() .uri("http://localhost:8080/students/1") .retrieve() .bodyToMono(Student.class) .block();
Pour accéder à la puissance des appels non-bloquants, il faut quelques concepts supplémentaires.
WebClient
utilise le framework Reactor pour écrire des applications non-bloquantes.
Mono<T>
: Publisher d'un TFlux<T>
: Publisher d'un flot de TMono<Student> student(int id){ WebClient webClient = WebClient.create(); return webClient.get() .uri("http://localhost:8080/students/"+id) .retrieve() .bodyToMono(Student.class); }
La méthode student(int id)
renvoie la promesse d'un student
.
Pour attendre que la promesse soit réalisée, on peut utiliser la méthode mono.block()
.
Grâce à Flux.merge
, on peut fusionner plusieurs promesses de Student
en un Flux<Student>
.
List<Mono<Student>> monos = List.of(1,2,3).stream().map(id -> getStudent(id)).toList(); Flux<Student> flux = Flux.merge(monos); Stream<Student> students = flux.toStream();
La méthode flux.toStream()
va exécuter le code des différentes Mono
dans un pool de threads et combiner les résultat en Stream<Student>
au fur et à mesure que les résultats arrivent.