Spring Framework

Spring MVC: DispatcherServlet, controllers, formulários e views

Intermediário7 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Spring MVC é um framework do tipo front controller construído sobre a API Servlet. Este guia acompanha uma requisição desde o contêiner de servlets, passando pelo handler mapping, pela resolução de argumentos, pelo seu controller, pelo model e pelo view resolver, e depois parte para a prática: inicialização com configuração Java, WebMvcConfigurer, binding de formulários com Jakarta Bean Validation, redirecionamentos com flash attributes, interceptors e tratamento de exceções para páginas renderizadas no servidor.

O que você vai aprender

  • Descrever o ciclo de vida de uma requisição dentro do DispatcherServlet e nomear as interfaces de estratégia envolvidas
  • Inicializar o Spring MVC com um WebApplicationInitializer e configurá-lo por meio de WebMvcConfigurer
  • Escrever controllers que mapeiam requisições, fazem binding e validação de dados de formulário e preenchem um model
  • Implementar o padrão Post/Redirect/Get com RedirectAttributes e o flash scope
  • Adicionar interceptors, a entrega de recursos estáticos e um tratamento de exceções orientado a views

O Spring MVC é a forma padrão de construir aplicações web renderizadas no servidor na JVM desde muito antes do Spring Boot, e também é o motor por trás do @RestController. Ele segue o padrão front controller: um único servlet recebe todas as requisições e delega a estratégias plugáveis a decisão de qual handler executa, como os argumentos são produzidos e como o resultado vira uma resposta. Conhecer essas estratégias faz o framework deixar de ser um conjunto de anotações mágicas e passar a ser algo que você consegue estender e depurar. Este guia tem como alvo o Spring Framework 6.x, que exige a API Jakarta Servlet 6.0 (Tomcat 10.1, Jetty 12 ou equivalente) e Java 17 ou superior.

O ciclo de vida da requisição

Toda requisição chega ao DispatcherServlet, que executa sempre a mesma sequência:

  1. Handler mapping. O servlet pede um handler a cada HandlerMapping. O RequestMappingHandlerMapping casa as anotações @RequestMapping por caminho, método, cabeçalhos, parâmetros e tipos de conteúdo; o SimpleUrlHandlerMapping serve os recursos estáticos. O resultado é um handler mais a lista ordenada de HandlerInterceptors que se aplicam a ele.
  2. Interceptors pre-handle. O preHandle de cada interceptor é executado e pode interromper a requisição retornando false.
  3. Handler adapter. O RequestMappingHandlerAdapter invoca o método do seu controller. Para isso, ele resolve cada parâmetro por meio de um HandlerMethodArgumentResolver — variáveis de caminho, parâmetros da requisição, o model, objetos de formulário, o principal — e, quando o método retorna, repassa o valor de retorno a um HandlerMethodReturnValueHandler, que decide se ele é um nome de view, um model, uma ResponseEntity ou um corpo a ser serializado.
  4. Os interceptors post-handle são executados com o ModelAndView resultante.
  5. Resolução da view. Um ViewResolver transforma o nome lógico da view em uma View — um template Thymeleaf, uma JSP, um gerador de PDF — e a view renderiza o model na resposta.
  6. Os interceptors after-completion são executados em um finally, quer a renderização tenha dado certo, quer uma exceção tenha sido lançada.
  7. Em caso de exceção, os HandlerExceptionResolvers são consultados em ordem: o ExceptionHandlerExceptionResolver encontra os métodos @ExceptionHandler, o ResponseStatusExceptionResolver respeita o @ResponseStatus e o DefaultHandlerExceptionResolver converte as exceções de binding e de conversão do próprio Spring em códigos 4xx.

Outras estratégias completam o quadro: LocaleResolver para i18n, MultipartResolver para upload de arquivos (desde a 6.0, só resta o StandardServletMultipartResolver, baseado no padrão Servlet; o suporte ao Commons FileUpload foi removido) e FlashMapManager para atributos que sobrevivem a um redirecionamento. Cada uma tem uma implementação padrão, que pode ser substituída declarando um bean com o mesmo nome.

Inicialização sem web.xml

Um contêiner Servlet 3.0+ descobre as implementações de WebApplicationInitializer no classpath e as chama na inicialização. O Spring fornece uma classe base abstrata que monta os dois contextos e registra o 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) };
    }
}

É isso que a inicialização com servidor embarcado do Spring Boot faz por você, só que de outro jeito: o Boot cria um contexto único, registra o DispatcherServlet como bean e dispensa completamente o inicializador. O velho web.xml, com um elemento <servlet> e parâmetros contextConfigLocation, continua funcionando e é comum em aplicações implantadas como WAR em um servidor já existente.

A configuração web em si habilita o MVC e o personaliza por meio de WebMvcConfigurer — nunca estendendo classes internas:

@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;
    }
}

O @EnableWebMvc importa o RequestMappingHandlerMapping padrão, o adapter, os exception resolvers e os message converters, e aplica todo bean WebMvcConfigurer que encontrar. No Spring Boot, você omite o @EnableWebMvc — adicioná-lo desliga a autoconfiguração de MVC do Boot — e apenas implementa WebMvcConfigurer.

Duas mudanças no matching de caminhos chegaram no Spring 6.0 e pegam desprevenido quem faz upgrade: o PathPatternParser substituiu o matching no estilo Ant como padrão, e um mapeamento para /orders não casa mais com /orders/, a menos que você configure o matching da barra final ou declare os dois caminhos.

Controllers

Um controller é um bean @Controller cujos métodos são mapeados por @GetMapping, @PostMapping e afins. O Spring resolve os parâmetros do método para você; o valor de retorno escolhe a view.

@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";
    }
}

Os tipos de parâmetro mais úteis: @PathVariable e @RequestParam com conversão automática (incluindo Optional<T>, enums e datas via @DateTimeFormat); @RequestHeader e @CookieValue; Model ou Map<String, Object> para expor dados à view; HttpSession, Principal e Locale; HttpServletRequest e HttpServletResponse quando você precisa da API crua; e @ModelAttribute para objetos de formulário. Retorne String para um nome de view, ModelAndView quando quiser os dois em um único objeto, ou void quando o próprio método escreve a resposta.

Use @RequestMapping no nível da classe apenas para um prefixo comum. Nos métodos, prefira as anotações específicas de cada método HTTP — um @RequestMapping("/x") sozinho aceita qualquer método, o que raramente é a intenção.

Formulários: binding e validação

É no ciclo completo de um formulário que o MVC mostra seu valor. O objeto de formulário é uma classe comum — um record funciona desde a 6.1, que adicionou binding por construtor para @ModelAttribute — anotada com restrições do 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
    }
}

O binding primeiro converte os parâmetros da requisição para os campos do formulário, registrando erros de incompatibilidade de tipo; depois, por causa do @Valid, o provedor de Bean Validation (o Hibernate Validator em praticamente todo projeto) verifica as restrições e registra as violações. Ambos vão parar no BindingResult. Retornar a view do formulário faz com que ele seja renderizado de novo com os valores enviados e as mensagens — o th:field e o th:errors do Thymeleaf, assim como as tags de formulário JSP, leem tudo isso do model automaticamente.

Dois detalhes passam despercebidos com facilidade. Uma falha de @Valid só vira uma nova renderização limpa porque o BindingResult vem imediatamente depois do parâmetro do formulário; mude-o de lugar e você recebe um 400. E o padrão Post/Redirect/Get — responder a um POST bem-sucedido com um redirecionamento — impede que o navegador reenvie o formulário ao recarregar a página. Os dados para a página após o redirecionamento viajam em flash attributes, guardados na sessão por exatamente uma requisição e depois removidos; atributos comuns do model, em um redirecionamento, viram parâmetros de query string, o que raramente é o que você quer para uma mensagem.

Métodos @InitBinder personalizam o binding de um controller específico: registrar um editor ou formatter customizado ou, o que é importante, restringir os campos passíveis de binding com binder.setAllowedFields(...), para que uma requisição não consiga definir propriedades que você não colocou no formulário. Usar um objeto de formulário dedicado em vez de uma entidade JPA como @ModelAttribute garante a mesma proteção com menos esforço e é o hábito mais robusto.

Interceptors e preocupações ligadas à requisição

Um HandlerInterceptor vê toda requisição mapeada para um handler e é a ferramenta certa para preocupações que precisam conhecer o controller, mas não são lógica de negócio:

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);
        }
    }
}

Escolha um interceptor em vez de um Filter de servlet quando precisar do handler resolvido ou do model; escolha um filtro quando a preocupação precisar ser aplicada antes mesmo de o Spring entrar em cena — encoding, segurança, compressão. Interceptors não conseguem ver o corpo da resposta de um método @ResponseBody, que já foi escrito quando o postHandle é executado.

Tratamento de exceções para views

Uma classe @ControllerAdvice abriga métodos @ExceptionHandler compartilhados por todos os controllers. Em uma aplicação renderizada no servidor, o handler normalmente escolhe uma view de erro e um status:

@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;
    }
}

Métodos @ExceptionHandler locais dentro de um controller têm precedência sobre o advice, e tipos de exceção mais específicos vencem os mais genéricos, independentemente da ordem de declaração. O que escapar de todos os resolvers chega à configuração de páginas de erro do contêiner de servlets — configure uma, porque a página padrão do contêiner expõe stack traces.

Erros comuns

Controllers inchados. Um controller que consulta repositories, aplica regras e formata resultados não pode ser testado sem HTTP. Controllers traduzem entre HTTP e uma chamada de service; a lógica fica no service.

Fazer binding direto em entidades. Uma requisição pode preencher qualquer propriedade gravável, inclusive as que você nunca exibiu — uma flag admin, um id. Use um objeto de formulário só com os campos pretendidos.

Esquecer o Post/Redirect/Get. Renderizar uma página diretamente a partir de um POST faz com que recarregar o navegador reenvie o formulário: o usuário cria dois pedidos.

Estado em campos do controller. Controllers são singletons compartilhados por todas as threads.

Adicionar @EnableWebMvc em uma aplicação Spring Boot. Isso desliga a autoconfiguração de MVC do Boot, e recursos como o mapeamento de recursos estáticos, os message converters e a página de erro deixam de se comportar como documentado.

Ignorar a mudança da barra final. Depois do upgrade para o Spring 6, links para /orders/ retornam 404. Corrija os links ou configure mapeamentos explícitos para as duas formas.

Principais pontos

  • O DispatcherServlet é um front controller que delega a estratégias: handler mappings, adapters e argument resolvers, view resolvers, exception resolvers — todas substituíveis.
  • Inicialize com um WebApplicationInitializer (ou com o Boot) e configure via WebMvcConfigurer; o contexto raiz guarda os beans compartilhados, e o contexto do servlet, os beans web.
  • Controllers mapeiam requisições com anotações específicas de cada método HTTP, recebem parâmetros já resolvidos e retornam um nome de view mais um model.
  • Tratar um formulário é @Valid @ModelAttribute seguido imediatamente de BindingResult, uma nova renderização em caso de erro e um redirecionamento com flash attributes em caso de sucesso.
  • Interceptors cuidam de preocupações transversais que precisam conhecer o handler; o @ControllerAdvice centraliza o mapeamento de exceções para views.
  • O Spring 6 elevou o mínimo para Jakarta Servlet 6.0, tornou o PathPatternParser o padrão e deixou de casar barras finais.

Fontes e leituras complementares

Perguntas frequentes

Qual é a diferença entre o contexto raiz e o contexto do servlet?
O contexto raiz guarda os beans compartilhados por toda a aplicação web — services, repositories, o data source — e é criado pelo ContextLoaderListener. Cada DispatcherServlet ganha seu próprio contexto filho, com os beans web: controllers, view resolvers, interceptors. O filho enxerga os beans do raiz, mas não o contrário. Com um único DispatcherServlet, você pode colocar tudo em um só contexto; a separação importa quando vários servlets ou filtros precisam dos beans compartilhados.
Por que o BindingResult precisa vir logo depois do parâmetro @ModelAttribute?
O Spring faz o binding e a validação de cada atributo do model na ordem dos parâmetros e, se o parâmetro seguinte for um BindingResult (ou Errors), guarda ali o resultado em vez de lançar uma exceção. Se houver qualquer outro parâmetro no meio, os erros de binding não têm para onde ir e o Spring lança MethodArgumentNotValidException, que vira uma resposta 400 em vez do seu formulário com as mensagens de erro.
Um controller é singleton? Isso é seguro?
Sim, como qualquer bean do Spring por padrão, e é seguro desde que ele não tenha campos mutáveis por requisição. Os dados da requisição chegam pelos parâmetros do método, e tudo o que precisa sobreviver entre requisições deve ficar na sessão, em um bean com escopo request ou em um banco de dados — nunca em um campo do controller.
O Spring MVC ainda suporta JSP?
Sim, por meio do InternalResourceViewResolver e da biblioteca de tags de formulário do Spring, e muitas aplicações existentes usam isso. Projetos novos renderizados no servidor costumam escolher o Thymeleaf, cujos templates são HTML válido que pode ser visualizado sem servidor e que se integra ao model do Spring, às mensagens de validação e ao dialeto de segurança pelo módulo thymeleaf-spring6.