Spring Framework
Spring MVC : DispatcherServlet, contrôleurs, formulaires et vues
Intermédiaire8 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Spring MVC est un framework de type front controller bâti sur l'API Servlet. Ce guide suit une requête depuis le conteneur de servlets à travers le handler mapping, la résolution des arguments, votre contrôleur, le modèle et le view resolver, puis passe à la pratique : démarrage en configuration Java, WebMvcConfigurer, binding de formulaires avec Jakarta Bean Validation, redirections avec flash attributes, intercepteurs et gestion des exceptions pour les pages rendues côté serveur.
Ce que vous allez apprendre
- Décrire le cycle de vie d'une requête dans DispatcherServlet et nommer les interfaces de stratégie en jeu
- Démarrer Spring MVC avec un WebApplicationInitializer et le configurer via WebMvcConfigurer
- Écrire des contrôleurs qui mappent les requêtes, lient et valident les données de formulaire et alimentent un modèle
- Mettre en œuvre le pattern Post/Redirect/Get avec RedirectAttributes et le flash scope
- Ajouter des intercepteurs, la prise en charge des ressources statiques et une gestion des exceptions orientée vues
Spring MVC est la manière standard de construire des applications web rendues côté serveur sur la JVM depuis bien avant Spring Boot, et c'est aussi le moteur qui fait tourner @RestController. Il suit le pattern front controller : une seule servlet reçoit toutes les requêtes, puis délègue à des stratégies interchangeables le soin de décider quel handler s'exécute, comment les arguments sont produits et comment le résultat devient une réponse. Connaître ces stratégies transforme le framework : ce n'est plus un jeu d'annotations magiques, mais un mécanisme que vous pouvez étendre et déboguer. Ce guide cible Spring Framework 6.x, qui exige l'API Jakarta Servlet 6.0 (Tomcat 10.1, Jetty 12 ou équivalent) et Java 17 ou plus récent.
Le cycle de vie d'une requête
Chaque requête arrive dans DispatcherServlet, qui déroule à chaque fois la même séquence :
- Handler mapping. La servlet demande un handler à chaque
HandlerMapping.RequestMappingHandlerMappingfait correspondre les annotations@RequestMappingselon le chemin, la méthode, les en-têtes, les paramètres et les types de contenu ;SimpleUrlHandlerMappingsert les ressources statiques. Le résultat est un handler accompagné de la liste ordonnée desHandlerInterceptorqui s'y appliquent. - Intercepteurs pre-handle. La méthode
preHandlede chaque intercepteur s'exécute et peut interrompre la requête en renvoyantfalse. - Handler adapter.
RequestMappingHandlerAdapterinvoque votre méthode de contrôleur. Pour cela, il résout chaque paramètre via unHandlerMethodArgumentResolver— variables de chemin, paramètres de requête, modèle, objets de formulaire, principal — puis, une fois la méthode terminée, confie la valeur de retour à unHandlerMethodReturnValueHandler, qui détermine s'il s'agit d'un nom de vue, d'un modèle, d'uneResponseEntityou d'un corps à sérialiser. - Les intercepteurs post-handle s'exécutent avec le
ModelAndViewobtenu. - Résolution de la vue. Un
ViewResolvertransforme le nom logique de la vue enView— un template Thymeleaf, une JSP, un moteur de rendu PDF — et la vue écrit le modèle dans la réponse. - Les intercepteurs after-completion s'exécutent dans un
finally, que le rendu ait réussi ou qu'une exception ait été levée. - En cas d'exception, les
HandlerExceptionResolversont consultés dans l'ordre :ExceptionHandlerExceptionResolvertrouve les méthodes@ExceptionHandler,ResponseStatusExceptionResolverapplique@ResponseStatus, etDefaultHandlerExceptionResolverconvertit les exceptions de binding et de conversion propres à Spring en codes 4xx.
D'autres stratégies complètent le tableau : LocaleResolver pour l'i18n, MultipartResolver pour l'upload de fichiers (depuis la 6.0, seul le StandardServletMultipartResolver, fondé sur le standard Servlet, subsiste ; le support de Commons FileUpload a été retiré) et FlashMapManager pour les attributs qui survivent à une redirection. Chacune a une implémentation par défaut, que l'on remplace en déclarant un bean du même nom.
Démarrer sans web.xml
Un conteneur Servlet 3.0+ détecte les implémentations de WebApplicationInitializer présentes dans le classpath et les appelle au démarrage. Spring fournit une classe de base abstraite qui construit les deux contextes et enregistre la servlet :
public class ShopWebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class<?>[] { AppConfig.class, PersistenceConfig.class }; // shared beans
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[] { WebConfig.class }; // web beans
}
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
@Override
protected Filter[] getServletFilters() {
return new Filter[] { new CharacterEncodingFilter("UTF-8", true) };
}
}
C'est ce que fait pour vous, d'une autre manière, le démarrage à serveur embarqué de Spring Boot : Boot crée un contexte unique, enregistre DispatcherServlet comme un bean et se passe complètement de l'initialiseur. L'ancien web.xml, avec son élément <servlet> et ses paramètres contextConfigLocation, fonctionne toujours et reste courant dans les applications déployées sous forme de WAR sur un serveur existant.
La configuration web elle-même active MVC et le personnalise via WebMvcConfigurer — jamais en étendant des classes internes :
@Configuration
@EnableWebMvc
@ComponentScan("com.example.shop.web")
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic());
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LocaleChangeInterceptor());
registry.addInterceptor(new RequestTimingInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/about").setViewName("about"); // no controller needed
}
@Bean
public SpringResourceTemplateResolver templateResolver(ApplicationContext ctx) {
var resolver = new SpringResourceTemplateResolver();
resolver.setApplicationContext(ctx);
resolver.setPrefix("classpath:/templates/");
resolver.setSuffix(".html");
return resolver;
}
@Bean
public SpringTemplateEngine templateEngine(SpringResourceTemplateResolver resolver) {
var engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
return engine;
}
@Bean
public ThymeleafViewResolver viewResolver(SpringTemplateEngine engine) {
var resolver = new ThymeleafViewResolver();
resolver.setTemplateEngine(engine);
return resolver;
}
}
@EnableWebMvc importe le RequestMappingHandlerMapping par défaut, l'adapter, les exception resolvers et les message converters, puis applique chaque bean WebMvcConfigurer qu'il trouve. Avec Spring Boot, omettez @EnableWebMvc — l'ajouter désactive l'auto-configuration MVC de Boot — et contentez-vous d'implémenter WebMvcConfigurer.
Deux changements du matching de chemins sont arrivés avec Spring 6.0 et piègent ceux qui migrent : PathPatternParser a remplacé le matching de style Ant comme comportement par défaut, et un mapping sur /orders ne correspond plus à /orders/, sauf si vous configurez le matching du slash final ou déclarez les deux chemins.
Les contrôleurs
Un contrôleur est un bean @Controller dont les méthodes sont mappées par @GetMapping, @PostMapping et consorts. Les paramètres de méthode sont résolus pour vous ; la valeur de retour choisit la vue.
@Controller
@RequestMapping("/customers")
public class CustomerController {
private final CustomerService customers;
public CustomerController(CustomerService customers) {
this.customers = customers;
}
@GetMapping
public String list(@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "0") int page,
Model model) {
model.addAttribute("customers", customers.search(q, page));
model.addAttribute("query", q);
return "customers/list"; // logical view name
}
@GetMapping("/{id}")
public String detail(@PathVariable long id, Model model) {
model.addAttribute("customer", customers.get(id).orElseThrow(CustomerNotFoundException::new));
return "customers/detail";
}
}
Les types de paramètres les plus utiles : @PathVariable et @RequestParam avec conversion automatique (y compris Optional<T>, les enums, les dates via @DateTimeFormat) ; @RequestHeader et @CookieValue ; Model ou Map<String, Object> pour exposer des données à la vue ; HttpSession, Principal et Locale ; HttpServletRequest et HttpServletResponse quand vous avez besoin de l'API brute ; et @ModelAttribute pour les objets de formulaire. Renvoyez une String pour un nom de vue, un ModelAndView pour réunir les deux dans un seul objet, ou void lorsque la méthode écrit elle-même la réponse.
N'utilisez @RequestMapping au niveau de la classe que pour un préfixe commun. Sur les méthodes, préférez les annotations propres à chaque méthode HTTP — un @RequestMapping("/x") nu accepte toutes les méthodes, ce qui est rarement voulu.
Formulaires : binding et validation
C'est sur l'aller-retour d'un formulaire que MVC montre ce qu'il vaut. L'objet de formulaire est une classe ordinaire — un record convient depuis la 6.1, qui a ajouté le binding par constructeur pour @ModelAttribute — annotée avec des contraintes Jakarta Bean Validation :
public record RegistrationForm(
@NotBlank @Size(max = 80) String name,
@NotBlank @Email String email,
@NotBlank @Size(min = 12, message = "{password.length}") String password,
@AssertTrue(message = "{terms.required}") boolean acceptTerms) {
}
@Controller
@RequestMapping("/register")
public class RegistrationController {
private final RegistrationService registrations;
public RegistrationController(RegistrationService registrations) {
this.registrations = registrations;
}
@GetMapping
public String form(Model model) {
model.addAttribute("form", new RegistrationForm("", "", "", false));
return "register";
}
@PostMapping
public String submit(@Valid @ModelAttribute("form") RegistrationForm form,
BindingResult binding, // must directly follow the form
RedirectAttributes redirect) {
if (!binding.hasErrors() && registrations.emailTaken(form.email())) {
binding.rejectValue("email", "email.taken"); // programmatic error, resolved via MessageSource
}
if (binding.hasErrors()) {
return "register"; // re-render with the form and errors in the model
}
registrations.register(form);
redirect.addFlashAttribute("message", "Welcome, " + form.name());
return "redirect:/"; // Post/Redirect/Get
}
}
Le binding convertit d'abord les paramètres de la requête vers les champs du formulaire, en consignant les erreurs d'incompatibilité de type ; ensuite, grâce à @Valid, le provider Bean Validation (Hibernate Validator dans la quasi-totalité des projets) vérifie les contraintes et enregistre les violations. Les deux aboutissent dans BindingResult. Renvoyer la vue du formulaire la réaffiche avec les valeurs saisies et les messages — th:field et th:errors de Thymeleaf, comme les tags de formulaire JSP, les lisent automatiquement dans le modèle.
Deux détails passent facilement inaperçus. Un échec de @Valid ne se traduit par un réaffichage propre que parce que BindingResult suit immédiatement le paramètre du formulaire ; déplacez-le et vous obtenez une 400. Par ailleurs, le pattern Post/Redirect/Get — répondre à un POST réussi par une redirection — empêche le navigateur de soumettre à nouveau le formulaire lors d'un rafraîchissement. Les données destinées à la page qui suit la redirection transitent par des flash attributes, stockés en session pour une seule requête exactement, puis supprimés ; les attributs de modèle ordinaires deviennent, eux, des paramètres de query string lors d'une redirection, ce qui est rarement ce que l'on veut pour un message.
Les méthodes @InitBinder personnalisent le binding pour un contrôleur donné : enregistrer un éditeur ou un formatter personnalisé ou, point important, restreindre les champs autorisés au binding avec binder.setAllowedFields(...), pour qu'une requête ne puisse pas renseigner des propriétés absentes du formulaire. Utiliser un objet de formulaire dédié plutôt qu'une entité JPA comme @ModelAttribute offre la même protection avec moins d'effort, et c'est le réflexe le plus robuste.
Intercepteurs et préoccupations liées à la requête
Un HandlerInterceptor voit passer chaque requête associée à un handler ; c'est le bon outil pour des préoccupations qui demandent une connaissance du contrôleur sans relever de la logique métier :
public class RequestTimingInterceptor implements HandlerInterceptor {
private static final String START = RequestTimingInterceptor.class.getName() + ".start";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request.setAttribute(START, System.nanoTime());
return true; // false would end the request here
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
long millis = (System.nanoTime() - (long) request.getAttribute(START)) / 1_000_000;
if (handler instanceof HandlerMethod hm) {
Metrics.record(hm.getBeanType().getSimpleName() + "." + hm.getMethod().getName(), millis, ex != null);
}
}
}
Choisissez un intercepteur plutôt qu'un Filter de servlet lorsque vous avez besoin du handler résolu ou du modèle ; choisissez un filtre lorsque la préoccupation doit s'appliquer avant même que Spring n'intervienne — encodage, sécurité, compression. Les intercepteurs ne peuvent pas voir le corps de réponse d'une méthode @ResponseBody, déjà écrit au moment où postHandle s'exécute.
Gestion des exceptions pour les vues
Une classe @ControllerAdvice héberge des méthodes @ExceptionHandler partagées par tous les contrôleurs. Dans une application rendue côté serveur, le handler choisit en général une vue d'erreur et un statut :
@ControllerAdvice
public class WebExceptionHandler {
@ExceptionHandler(CustomerNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public String notFound(CustomerNotFoundException ex, Model model) {
model.addAttribute("id", ex.id());
return "errors/not-found";
}
@ExceptionHandler(Exception.class)
public ModelAndView unexpected(Exception ex, HttpServletRequest request) {
var mav = new ModelAndView("errors/generic", HttpStatus.INTERNAL_SERVER_ERROR);
mav.addObject("path", request.getRequestURI());
return mav;
}
}
Les méthodes @ExceptionHandler locales à un contrôleur priment sur l'advice, et les types d'exception les plus spécifiques l'emportent sur les plus généraux, quel que soit l'ordre de déclaration. Tout ce qui échappe à l'ensemble des resolvers aboutit à la configuration des pages d'erreur du conteneur de servlets — configurez-en une, car la page par défaut du conteneur divulgue les stack traces.
Erreurs courantes
Des contrôleurs obèses. Un contrôleur qui interroge des repositories, applique des règles et met en forme des résultats ne peut pas être testé sans HTTP. Le contrôleur fait la traduction entre HTTP et un appel de service ; c'est le service qui porte la logique.
Lier directement des entités. Une requête peut renseigner n'importe quelle propriété modifiable, y compris celles que vous n'avez jamais affichées — un flag admin, un id. Utilisez un objet de formulaire qui ne contient que les champs prévus.
Oublier Post/Redirect/Get. Si une page est rendue directement en réponse à un POST, un rafraîchissement du navigateur soumet à nouveau le formulaire : l'utilisateur crée deux commandes.
De l'état dans les champs du contrôleur. Les contrôleurs sont des singletons partagés par tous les threads.
Ajouter @EnableWebMvc dans une application Spring Boot. Cela désactive l'auto-configuration MVC de Boot, et des fonctionnalités comme le mapping des ressources statiques, les message converters et la page d'erreur cessent de se comporter comme le décrit la documentation.
Ignorer le changement sur le slash final. Après une migration vers Spring 6, les liens vers /orders/ renvoient une 404. Corrigez les liens ou configurez des mappings explicites pour les deux formes.
Points clés à retenir
DispatcherServletest un front controller qui délègue à des stratégies : handler mappings, adapters et argument resolvers, view resolvers, exception resolvers — toutes remplaçables.- Démarrez avec un
WebApplicationInitializer(ou Boot) et configurez viaWebMvcConfigurer; le contexte racine contient les beans partagés, le contexte de la servlet, les beans web. - Les contrôleurs mappent les requêtes avec des annotations propres à chaque méthode HTTP, reçoivent des paramètres résolus et renvoient un nom de vue accompagné d'un modèle.
- Le traitement d'un formulaire, c'est
@Valid @ModelAttributesuivi immédiatement deBindingResult, un réaffichage en cas d'erreur et une redirection avec flash attributes en cas de succès. - Les intercepteurs prennent en charge les préoccupations transverses qui ont besoin du handler ;
@ControllerAdvicecentralise la correspondance entre exceptions et vues. - Spring 6 a relevé le socle à Jakarta Servlet 6.0, fait de
PathPatternParserle comportement par défaut et cessé de faire correspondre les slashs finaux.
Sources et lectures complémentaires
Questions fréquentes
- Quelle différence entre le contexte racine et le contexte de la servlet ?
- Le contexte racine contient les beans partagés par toute l'application web — services, repositories, data source — et il est créé par ContextLoaderListener. Chaque DispatcherServlet dispose de son propre contexte enfant, qui contient les beans web : contrôleurs, view resolvers, intercepteurs. L'enfant voit les beans de la racine, mais pas l'inverse. Avec une seule DispatcherServlet, vous pouvez tout mettre dans un seul contexte ; la séparation compte lorsque plusieurs servlets ou filtres ont besoin des beans partagés.
- Pourquoi BindingResult doit-il suivre immédiatement le paramètre @ModelAttribute ?
- Spring lie et valide chaque attribut de modèle dans l'ordre des paramètres, et si le paramètre suivant est un BindingResult (ou un Errors), il y consigne le résultat au lieu de lever une exception. Si un autre paramètre s'intercale, les erreurs de binding n'ont nulle part où aller : Spring lève une MethodArgumentNotValidException, qui se traduit par une réponse 400 au lieu de votre formulaire accompagné de ses messages d'erreur.
- Un contrôleur est-il un singleton, et est-ce sans danger ?
- Oui, comme tout bean Spring par défaut, et oui, tant qu'il ne porte aucun champ mutable propre à une requête. Les données de la requête arrivent par les paramètres de méthode, et tout ce qui doit survivre d'une requête à l'autre relève de la session, d'un bean de portée request ou d'une base de données — jamais d'un champ du contrôleur.
- Spring MVC prend-il encore en charge JSP ?
- Oui, via InternalResourceViewResolver et la bibliothèque de tags de formulaire de Spring, et de nombreuses applications existantes s'en servent. Les nouveaux projets rendus côté serveur choisissent en général Thymeleaf, dont les templates sont du HTML valide qu'on prévisualise sans serveur, et qui s'intègre au modèle de Spring, aux messages de validation et au dialecte de sécurité grâce au module thymeleaf-spring6.