Giới thiệu Spring MVC và DispatcherServlet

Xem thử

Một đội mới chuyển từ Servlet thuần sang Spring MVC. Họ có một Filter ghi log mỗi yêu cầu và quyết định thêm một HandlerInterceptor để đo thời gian xử lý. Sau một tuần, bảng điều khiển cho thấy số yêu cầu mà interceptor đếm được thấp hơn log của Filter khoảng 6 phần trăm, và không ai giải thích được khoảng chênh. Cuối cùng một người đặt hai con số cạnh nhau và nhận ra: những yêu cầu "mất" là các yêu cầu tới đường dẫn không có controller nào (bot dò đường, favicon, URL gõ sai). Chúng bị Filter nhìn thấy, bị DispatcherServlet trả 404, và chưa bao giờ tới interceptor vì không có handler nào để interceptor bao quanh. Không có lỗi nào, chỉ là hiểu sai ai đứng ở đâu trên đường đi của một yêu cầu.

Bài này vẽ bản đồ đó. Bạn sẽ chạy một ứng dụng Spring MVC trên Tomcat nhúng, gửi yêu cầu HTTP thật và in ra thứ tự từng mốc mà yêu cầu đi qua, kể cả khi controller ném lỗi hay không tồn tại controller nào.

Sau bài này, bạn có thể

  • Mô tả vai trò của DispatcherServlet, HandlerMapping, HandlerAdapter, ViewResolver và đọc tên các bean tương ứng trong một ứng dụng thật.
  • Nêu thứ tự Filter, HandlerInterceptor, controller và view cho một yêu cầu thành công, bằng số liệu đo.
  • Dự đoán yêu cầu nào tới được controller và mốc nào vẫn chạy khi có lỗi (404, 405, 500).
  • Giải thích vì sao view đặt dưới WEB-INF và cách controller trả tên view logic.

Chuẩn bị môi trường và dự án

Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Spring Framework 7.0.9, Tomcat 11.0.27). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 07-spring-mvc/01-dispatcherservlet, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.

Yêu cầu môi trường

Thành phầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Spring Framework7.0.9khai báo trong pom.xml
Tomcat nhúng11.0.27 (Servlet 6.1)khai báo trong pom.xml
Dockerkhông cầnbài này không dùng cơ sở dữ liệu

Cấu trúc

text
01-dispatcherservlet/
  pom.xml
  src/main/java/dev/canhdominich/spring/mvc/
    Trace.java                  ghi lại các sự kiện theo thứ tự xảy ra
    TraceFilter.java            Filter của Servlet ghi trước và sau servlet
    TraceInterceptor.java       HandlerInterceptor ghi preHandle, postHandle, afterCompletion
    HelloController.java        hai endpoint: /hello trả view, /boom ném lỗi
    WebConfig.java              @EnableWebMvc, quét component, interceptor, view resolver JSP
    EmbeddedServer.java         dựng Tomcat, DispatcherServlet, JSP servlet và filter
    ServerMain.java             chạy server thật ở cổng 8080
    DispatcherDemo.java         gửi yêu cầu HTTP thật và in kết quả cùng thứ tự sự kiện
  src/main/webapp/WEB-INF/views/
    hello.jsp                   view của /hello

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven Java 17 với spring-webmvc 7.0.9, Tomcat nhúng 11.0.27 (tomcat-embed-core và tomcat-embed-jasper để render JSP) cùng thư viện JSTL, cờ maven.compiler.parameters để Spring đọc được tên tham số, và JUnit cùng AssertJ cho test. Không cần cơ sở dữ liệu hay Docker. Plugin exec-maven-plugin chạy lớp chính (mặc định ServerMain).

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>spring-mvc-dispatcherservlet</artifactId>
    <version>1.0.0</version>
    <name>Spring MVC - DispatcherServlet</name>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <maven.compiler.parameters>true</maven.compiler.parameters>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <main.class>dev.canhdominich.spring.mvc.ServerMain</main.class>
        <spring.version>7.0.9</spring.version>
        <tomcat.version>11.0.27</tomcat.version>
        <jstl.api.version>3.0.2</jstl.api.version>
        <jstl.impl.version>3.0.1</jstl.impl.version>
        <junit.version>6.0.3</junit.version>
        <assertj.version>3.27.7</assertj.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-core</artifactId>
            <version>${tomcat.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-jasper</artifactId>
            <version>${tomcat.version}</version>
        </dependency>
        <dependency>
            <groupId>jakarta.servlet.jsp.jstl</groupId>
            <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
            <version>${jstl.api.version}</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.web</groupId>
            <artifactId>jakarta.servlet.jsp.jstl</artifactId>
            <version>${jstl.impl.version}</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.6.3</version>
                <configuration>
                    <executable>java</executable>
                    <arguments>
                        <argument>-classpath</argument>
                        <classpath/>
                        <argument>${main.class}</argument>
                    </arguments>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Dựng một ứng dụng Spring MVC tối thiểu

Trước hết là các thành phần của chính ứng dụng. Trace ghi lại các mốc theo thứ tự xảy ra để mọi thứ bài nói đều đo được:

src/main/java/dev/canhdominich/spring/mvc/Trace.java

java
package dev.canhdominich.spring.mvc;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class Trace {

    private static final List<String> EVENTS = Collections.synchronizedList(new ArrayList<>());

    private Trace() {
    }

    public static void record(String event) {
        EVENTS.add(event);
    }

    public static void clear() {
        EVENTS.clear();
    }

    public static List<String> all() {
        synchronized (EVENTS) {
            return new ArrayList<>(EVENTS);
        }
    }
}

TraceFilter là một Filter của Servlet API: nó ghi một mốc trước khi gọi chain.doFilter (tức là trước servlet) và một mốc sau, trong khối finally để mốc sau chạy cả khi phía trong ném lỗi:

src/main/java/dev/canhdominich/spring/mvc/TraceFilter.java

java
package dev.canhdominich.spring.mvc;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import java.io.IOException;

public class TraceFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        Trace.record("filter: before the servlet");
        try {
            chain.doFilter(request, response);
        } finally {
            Trace.record("filter: after the servlet");
        }
    }
}

TraceInterceptor là một HandlerInterceptor của Spring MVC. Nó có ba mốc: preHandle (trước controller, và biết phương thức nào sẽ được gọi), postHandle (sau controller, trước khi view được render, và biết tên view) và afterCompletion (sau tất cả, kèm exception nếu có):

src/main/java/dev/canhdominich/spring/mvc/TraceInterceptor.java

java
package dev.canhdominich.spring.mvc;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

public class TraceInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String target = handler instanceof HandlerMethod method ? method.getMethod().getName() : handler.getClass().getSimpleName();
        Trace.record("interceptor: preHandle for " + target);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
        Trace.record("interceptor: postHandle, view = " + (modelAndView == null ? "none" : modelAndView.getViewName()));
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        Trace.record("interceptor: afterCompletion, exception = " + (ex == null ? "none" : ex.getClass().getSimpleName()));
    }
}

HelloController có hai endpoint. /hello điền một thông điệp vào Model và trả tên view logic hello; /boom luôn ném lỗi để xem điều gì xảy ra:

src/main/java/dev/canhdominich/spring/mvc/HelloController.java

java
package dev.canhdominich.spring.mvc;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HelloController {

    @GetMapping("/hello")
    public String hello(Model model) {
        Trace.record("controller: hello()");
        model.addAttribute("message", "Xin chao Spring MVC");
        return "hello";
    }

    @GetMapping("/boom")
    public String boom() {
        Trace.record("controller: boom()");
        throw new IllegalStateException("something broke inside the controller");
    }
}

WebConfig cấu hình Spring MVC. @EnableWebMvc đăng ký các bean chiến lược mà DispatcherServlet cần, @ComponentScan tìm controller, addInterceptors gắn interceptor, và configureViewResolvers khai báo tên view logic được đổi thành file JSP dưới /WEB-INF/views/ với đuôi .jsp:

src/main/java/dev/canhdominich/spring/mvc/WebConfig.java

java
package dev.canhdominich.spring.mvc;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableWebMvc
@ComponentScan(basePackageClasses = WebConfig.class)
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TraceInterceptor());
    }

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.jsp("/WEB-INF/views/", ".jsp");
    }
}

View là một file JSP rất nhỏ. Dòng scriptlet ghi một mốc chỉ để chứng minh thứ tự ở bài này; trong ứng dụng thật bạn không viết scriptlet:

src/main/webapp/WEB-INF/views/hello.jsp

html
<%@ page contentType="text/html;charset=UTF-8" %>
<% dev.canhdominich.spring.mvc.Trace.record("view: hello.jsp rendered"); %>
<html><body><h1>${message}</h1></body></html>

Cuối cùng là phần dựng máy chủ. EmbeddedServer dựng một Tomcat nhúng, đăng ký JSP servlet (để render hello.jsp), một DispatcherServlet dùng cấu hình Spring ở trên và ánh xạ nó vào / (tức mọi đường dẫn không khớp servlet nào khác), và đăng ký TraceFilter cho mọi yêu cầu:

src/main/java/dev/canhdominich/spring/mvc/EmbeddedServer.java

java
package dev.canhdominich.spring.mvc;

import jakarta.servlet.DispatcherType;
import java.io.File;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.Wrapper;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.startup.Tomcat;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;

public final class EmbeddedServer implements AutoCloseable {

    private final Tomcat tomcat;
    private final AnnotationConfigWebApplicationContext springContext;

    private EmbeddedServer(Tomcat tomcat, AnnotationConfigWebApplicationContext springContext) {
        this.tomcat = tomcat;
        this.springContext = springContext;
    }

    public static EmbeddedServer start(int port, Class<?>... configurations) throws LifecycleException {
        Logger root = Logger.getLogger("");
        root.setLevel(Level.WARNING);
        for (Handler handler : root.getHandlers()) {
            handler.setLevel(Level.WARNING);
        }
        Tomcat tomcat = new Tomcat();
        tomcat.setBaseDir(new File("target/tomcat").getAbsolutePath());
        tomcat.setPort(port);
        Connector connector = tomcat.getConnector();
        connector.setProperty("address", "127.0.0.1");
        Context context = tomcat.addContext("", new File("src/main/webapp").getAbsolutePath());
        if (context instanceof org.apache.catalina.core.StandardContext standard) {
            standard.setClearReferencesRmiTargets(false);
            standard.setClearReferencesThreadLocals(false);
        }
        context.addLifecycleListener(new Tomcat.FixContextListener());
        context.addServletContainerInitializer(new org.apache.jasper.servlet.JasperInitializer(), null);
        Wrapper jsp = Tomcat.addServlet(context, "jsp", "org.apache.jasper.servlet.JspServlet");
        jsp.setLoadOnStartup(3);
        context.addServletMappingDecoded("*.jsp", "jsp");

        AnnotationConfigWebApplicationContext springContext = new AnnotationConfigWebApplicationContext();
        springContext.register(configurations);
        Wrapper dispatcher = Tomcat.addServlet(context, "dispatcher", new DispatcherServlet(springContext));
        dispatcher.setLoadOnStartup(1);
        context.addServletMappingDecoded("/", "dispatcher");

        FilterDef def = new FilterDef();
        def.setFilterName("trace");
        def.setFilter(new TraceFilter());
        context.addFilterDef(def);
        FilterMap map = new FilterMap();
        map.setFilterName("trace");
        map.addURLPattern("/*");
        map.setDispatcher(DispatcherType.REQUEST.name());
        context.addFilterMap(map);

        tomcat.start();
        return new EmbeddedServer(tomcat, springContext);
    }

    public int port() {
        return tomcat.getConnector().getLocalPort();
    }

    public AnnotationConfigWebApplicationContext springContext() {
        return springContext;
    }

    @Override
    public void close() throws LifecycleException {
        tomcat.stop();
        tomcat.destroy();
    }
}

ServerMain chạy máy chủ thật ở cổng 8080 nếu bạn muốn mở bằng trình duyệt:

src/main/java/dev/canhdominich/spring/mvc/ServerMain.java

java
package dev.canhdominich.spring.mvc;

public class ServerMain {

    public static void main(String[] args) throws Exception {
        int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
        EmbeddedServer server = EmbeddedServer.start(port, WebConfig.class);
        System.out.println("listening on http://127.0.0.1:" + server.port() + "/hello");
        Thread.currentThread().join();
    }
}

Gửi yêu cầu thật và đọc thứ tự các mốc

DispatcherDemo khởi động máy chủ ở một cổng ngẫu nhiên, in tên các bean chiến lược và các ánh xạ mà Spring đã dựng, rồi gửi năm yêu cầu HTTP bằng HttpClient và in mã trạng thái cùng chuỗi mốc đã ghi:

src/main/java/dev/canhdominich/spring/mvc/DispatcherDemo.java

java
package dev.canhdominich.spring.mvc;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

public class DispatcherDemo {

    public static void main(String[] args) throws Exception {
        run();
    }

    public static List<String> run() throws Exception {
        List<String> lines = new ArrayList<>();
        try (EmbeddedServer server = EmbeddedServer.start(0, WebConfig.class)) {
            AnnotationConfigWebApplicationContext context = server.springContext();
            lines.add("handler mappings: " + new TreeSet<>(context.getBeansOfType(HandlerMapping.class).keySet()));
            lines.add("handler adapters: " + new TreeSet<>(context.getBeansOfType(HandlerAdapter.class).keySet()));
            lines.add("view resolvers: " + new TreeSet<>(context.getBeansOfType(ViewResolver.class).keySet()));
            RequestMappingHandlerMapping mapping = context.getBean(RequestMappingHandlerMapping.class);
            mapping.getHandlerMethods().forEach((info, method) -> lines.add("mapped: " + info + " -> " + method.getMethod().getName()));
            lines.forEach(System.out::println);
            lines.add(call(server, "GET", "/hello"));
            lines.add(call(server, "GET", "/nope"));
            lines.add(call(server, "POST", "/hello"));
            lines.add(call(server, "GET", "/boom"));
            lines.add(call(server, "GET", "/WEB-INF/views/hello.jsp"));
        }
        return lines;
    }

    private static String call(EmbeddedServer server, String method, String path) throws Exception {
        Trace.clear();
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.port() + path))
                .method(method, HttpRequest.BodyPublishers.noBody())
                .build();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        StringBuilder out = new StringBuilder(method + " " + path + " -> " + response.statusCode());
        response.headers().firstValue("Allow").ifPresent(allow -> out.append(", Allow: ").append(allow));
        if (method.equals("GET") && path.equals("/hello")) {
            out.append(", body: ").append(response.body().replaceAll("\\s+", " ").trim());
        }
        for (String event : Trace.all()) {
            out.append("\n    ").append(event);
        }
        System.out.println(out);
        return out.toString();
    }
}

Chạy bằng mvn -q compile exec:exec -Dmain.class=dev.canhdominich.spring.mvc.DispatcherDemo. Kết quả (đã bỏ log ở stderr, và rút gọn thân của trang lỗi 405):

text
handler mappings: [beanNameHandlerMapping, requestMappingHandlerMapping, routerFunctionMapping]
handler adapters: [handlerFunctionAdapter, httpRequestHandlerAdapter, requestMappingHandlerAdapter, simpleControllerHandlerAdapter]
view resolvers: [mvcViewResolver]
mapped: {GET [/boom]} -> boom
mapped: {GET [/hello]} -> hello
GET /hello -> 200, body: <html><body><h1>Xin chao Spring MVC</h1></body></html>
    filter: before the servlet
    interceptor: preHandle for hello
    controller: hello()
    interceptor: postHandle, view = hello
    view: hello.jsp rendered
    interceptor: afterCompletion, exception = none
    filter: after the servlet
GET /nope -> 404
    filter: before the servlet
    filter: after the servlet
POST /hello -> 405, Allow: GET
    filter: before the servlet
    filter: after the servlet
GET /boom -> 500
    filter: before the servlet
    interceptor: preHandle for boom
    controller: boom()
    interceptor: afterCompletion, exception = IllegalStateException
    filter: after the servlet
GET /WEB-INF/views/hello.jsp -> 404

Các bean chiến lược

Ba dòng đầu cho thấy DispatcherServlet không tự biết cách làm gì: nó dựa vào một tập "chiến lược" là các bean trong context của nó, và @EnableWebMvc đã đăng ký chúng:

  • HandlerMapping trả lời "yêu cầu này do ai xử lý?". requestMappingHandlerMapping là cái ánh xạ theo @GetMapping và các annotation tương tự; hai dòng mapped: cho thấy nó đã đọc controller của bạn: GET /hello tới hello() và GET /boom tới boom(). Hai ánh xạ còn lại (theo tên bean, và theo RouterFunction) là cách khác để khai báo handler.
  • HandlerAdapter trả lời "gọi handler đó thế nào?". requestMappingHandlerAdapter biết gọi một phương thức có annotation: đọc tham số từ yêu cầu, gọi phương thức, xử lý giá trị trả về (bài sau đi sâu).
  • ViewResolver đổi tên view logic thành view thật. Ở đây mvcViewResolver là cái registry.jsp(...) đã tạo: hello thành /WEB-INF/views/hello.jsp.

Cách đọc hình: HandlerMapping (hàng trên) chọn ai xử lý URL và có ba bản cài đặt, trong đó requestMappingHandlerMapping ánh xạ GET /hello và GET /boom. HandlerAdapter (hàng dưới) biết cách gọi handler đã chọn. mvcViewResolver biến hello thành hello.jsp.

Thứ tự các mốc của một yêu cầu thành công

Với GET /hello có bảy mốc theo thứ tự: filter before, preHandle, controller, postHandle, view, afterCompletion, filter after. Controller nằm ở giữa interceptor, và view chỉ được render sau postHandle. Đây là điều "Filter hay Interceptor" cần: Filter bao ngoài cùng, thuộc Servlet API, chạy cả khi không có controller nào; interceptor thuộc Spring MVC, bao quanh đúng controller, nên biết phương thức handler (dòng preHandle for hello) và tên view.

Đang tải Animation…

Cách đọc hình: hàng trên đi từ Filter vào controller; hàng dưới đi ngược ra qua view, afterCompletion và Filter. Mỗi ô là một dòng của trace.

Khi yêu cầu không đi hết đường

Ba yêu cầu tiếp theo cho thấy mỗi trạng thái xảy ra ở đâu:

  • GET /nope trả 404, chỉ có hai mốc của filter. Không có handler nào khớp, nên không có gì để interceptor bao quanh: interceptor và controller không chạy. Đây là câu chuyện đầu bài: yêu cầu "mất" khỏi bộ đếm của interceptor.
  • POST /hello trả 405, header Allow: GET. Đường dẫn khớp nhưng phương thức HTTP thì không. Spring biết /hello chỉ nhận GET và nói rõ qua header Allow; cũng chỉ filter chạy.
  • GET /boom trả 500. Controller chạy rồi ném IllegalStateException. postHandle và view không chạy, nhưng afterCompletion vẫn chạy và nhận được exception (exception = IllegalStateException), rồi filter chạy phần sau (nhờ finally). Lỗi không có xử lý nào bắt nên ném tiếp ra container và Tomcat trả trang 500 (bài cuối chương sẽ xử lý lỗi đúng cách).

Vì vậy afterCompletion là chỗ đáng tin cậy để dọn dẹp hay ghi nhận kết thúc một yêu cầu đã vào controller, còn postHandle thì không chắc chạy.

Cách đọc hình: ô vàng là đường đi đầy đủ (200). Ba ô xám là các đường cụt: 404 và 405 dừng ở filter, 500 vào tới controller rồi dừng ở afterCompletion.

Vì sao view nằm dưới WEB-INF

Yêu cầu cuối cùng, GET /WEB-INF/views/hello.jsp, trả 404 dù file có thật trên đĩa. Đó là chủ ý của đặc tả Servlet: container không phục vụ bất kỳ thứ gì dưới WEB-INF cho trình duyệt. Chỉ mã bên trong ứng dụng (như DispatcherServlet chuyển tiếp tới view) mới truy cập được. Nhờ vậy người dùng chỉ xem được trang qua controller, nơi bạn có thể kiểm tra quyền và nạp dữ liệu, không thể gõ thẳng đường dẫn tới file view.

Cách đọc hình: hàng trên: yêu cầu đi qua controller và được chuyển tiếp nội bộ tới view. Hàng dưới: gõ thẳng đường dẫn tới view bị từ chối với 404.

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Đo hoặc ghi log mọi yêu cầuFilterKhông biết controller nào xử lý
Ghi nhận theo controller, kiểm tra quyền theo handlerHandlerInterceptorKhông thấy yêu cầu 404 không có handler
Dọn dẹp sau yêu cầuafterCompletionKhông sửa được phản hồi ở đó
Vị trí viewDưới WEB-INFChỉ truy cập qua controller
Cách dựng Spring MVCCấu hình Java với @EnableWebMvcPhải tự đăng ký DispatcherServlet (Boot làm hộ)

Playbook: gặp sự cố định tuyến thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Interceptor đếm ít hơn filterCó yêu cầu 404 khôngKhông có handler nên interceptor không chạyĐo ở filter nếu cần mọi yêu cầu
404 dù controller cóDanh sách mapped: của RequestMappingHandlerMappingController không được quét, hoặc sai đường dẫnKiểm tra @ComponentScan và đường dẫn
405Header Allow của phản hồiSai phương thức HTTPDùng phương thức trong Allow hoặc thêm mapping
500 và không thấy postHandleexception ở afterCompletionController ném lỗiThêm xử lý lỗi (bài sau)
Trang view không tìm thấyTiền tố và hậu tố của ViewResolverSai đường dẫn hoặc tên viewKiểm tra registry.jsp(...)
Gõ URL view trực tiếp bị 404Vị trí fileĐúng thiết kế: file ở WEB-INFĐi qua controller

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Address already in use ở cổng 8080Có ứng dụng khác dùng cổng 8080PORT=9090 mvn -q compile exec:exec
Mọi đường dẫn đều 404 dù controller đúngThiếu Tomcat.addContext hoặc DispatcherServlet chưa được ánh xạ vào /Dùng đúng EmbeddedServer của bài (Tomcat.addContext); khi tác giả thử addWebapp với cùng cách đăng ký, mọi đường dẫn đều trả 404

Bài thực hành

Làm trong dự án của bài (thư mục 07-spring-mvc/01-dispatcherservlet). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. Chạy thí nghiệm. mvn -q compile exec:exec -Dmain.class=dev.canhdominich.spring.mvc.DispatcherDemo. Tiêu chí: khớp các dòng trong bài, đặc biệt bảy mốc của GET /hello và trạng thái 404, 405, 500.
  2. Mở bằng trình duyệt. mvn -q compile exec:exec rồi mở http://127.0.0.1:8080/hello. Tiêu chí: thấy trang "Xin chao Spring MVC"; thử /nope và /boom và so mã trạng thái.
  3. Thêm một endpoint. Thêm vào HelloController một phương thức @GetMapping("/bye") trả view hello (dùng lại JSP). Chạy DispatcherDemo và dòng mapped:. Tiêu chí: có dòng mapped: {GET [/bye]} -> bye (thứ tự các dòng mapped: có thể thay đổi).
  4. Đặt một interceptor có thể chặn. Đổi preHandle trả false cho đường dẫn /hello (ví dụ return !request.getRequestURI().equals("/hello");). Tiêu chí: GET /hello trả 200 với thân rỗng và chuỗi mốc chỉ còn filter before, preHandle, filter after; controller, postHandle, view và afterCompletion của chính interceptor đó đều không chạy. Giải thích vì sao bằng sơ đồ của bài.
  5. Chạy bộ test (tùy chọn). Bộ test nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone, mvn test cho Tests run: 6, Failures: 0, Errors: 0.

Hiểu lầm và giới hạn

  • "Interceptor thấy mọi yêu cầu." Chỉ yêu cầu có handler; 404 do không có handler thì không.
  • "postHandle luôn chạy." Không chạy khi controller ném lỗi; afterCompletion thì có.
  • "Gõ đường dẫn thẳng tới JSP trong WEB-INF được." Container từ chối với 404.
  • Số đo thuộc về Spring MVC 7.0.9 trên Tomcat 11.0.27 với cấu hình Java (không dùng Spring Boot). Bài chưa nói về việc truyền tham số vào controller (bài sau), @RestController, xử lý lỗi (bài cuối chương) và phiên bản @EnableWebMvc trong Spring Boot, nơi một phần cấu hình được tự đăng ký.

Tự kiểm tra

  1. Filter và HandlerInterceptor khác nhau ở đâu về nơi thuộc về và phạm vi yêu cầu nhìn thấy?
  2. Nêu thứ tự các mốc của một yêu cầu GET /hello thành công.
  3. Vì sao GET /nope không đi qua interceptor, và POST /hello trả mã nào?
  4. Controller ném lỗi: mốc nào còn chạy, mốc nào mất?
  5. Vì sao GET /WEB-INF/views/hello.jsp trả 404 dù file có thật?

Gợi ý đáp án. (1) Filter thuộc Servlet API và thấy mọi yêu cầu; interceptor thuộc Spring MVC và chỉ bao quanh handler đã được chọn. (2) filter before, preHandle, controller, postHandle, view, afterCompletion, filter after. (3) Không có handler khớp nên không có gì để bao quanh; POST /hello trả 405 kèm Allow: GET. (4) postHandle và view mất; afterCompletion (kèm exception) và filter after vẫn chạy. (5) Đặc tả Servlet không phục vụ nội dung dưới WEB-INF cho trình duyệt; chỉ chuyển tiếp nội bộ mới tới được.

Điều cần nhớ

  • DispatcherServlet là front controller và dựa vào các bean chiến lược (HandlerMapping, HandlerAdapter, ViewResolver) mà @EnableWebMvc đăng ký.
  • Thứ tự: filter, preHandle, controller, postHandle, view, afterCompletion, filter.
  • 404 và 405 dừng trước controller; lỗi trong controller bỏ postHandle và view nhưng afterCompletion vẫn chạy.
  • View dưới WEB-INF chỉ tới được qua controller.

Đọc thêm

Bài tiếp theo đi sâu vào bước giữa HandlerMapping và controller: cách @RequestMapping khớp URL và cách các tham số của phương thức được điền từ yêu cầu.