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 T
Mono<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.