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,ViewResolvervà đọ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-INFvà 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ần | Phiên bản đã kiểm chứng | Ghi chú |
|---|---|---|
| JDK | 17 trở lên | java -version |
| Maven | 3.9 trở lên | mvn -v |
| Spring Framework | 7.0.9 | khai báo trong pom.xml |
| Tomcat nhúng | 11.0.27 (Servlet 6.1) | khai báo trong pom.xml |
| Docker | không cần | bài này không dùng cơ sở dữ liệu |
Cấu trúc
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 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
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
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
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
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
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
<%@ 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
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
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
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):
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:
HandlerMappingtrả lời "yêu cầu này do ai xử lý?".requestMappingHandlerMappinglà cái ánh xạ theo@GetMappingvà các annotation tương tự; hai dòngmapped:cho thấy nó đã đọc controller của bạn:GET /hellotớihello()vàGET /boomtớiboom(). Hai ánh xạ còn lại (theo tên bean, và theoRouterFunction) là cách khác để khai báo handler.HandlerAdaptertrả lời "gọi handler đó thế nào?".requestMappingHandlerAdapterbiế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. Ở đâymvcViewResolverlà cáiregistry.jsp(...)đã tạo:hellothà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 /nopetrả 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 /hellotrả 405, headerAllow: GET. Đường dẫn khớp nhưng phương thức HTTP thì không. Spring biết/hellochỉ nhậnGETvà nói rõ qua headerAllow; cũng chỉ filter chạy.GET /boomtrả 500. Controller chạy rồi némIllegalStateException.postHandlevà view không chạy, nhưngafterCompletionvẫ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 định | Lựa chọn | Đánh đổi |
|---|---|---|
| Đo hoặc ghi log mọi yêu cầu | Filter | Không biết controller nào xử lý |
| Ghi nhận theo controller, kiểm tra quyền theo handler | HandlerInterceptor | Không thấy yêu cầu 404 không có handler |
| Dọn dẹp sau yêu cầu | afterCompletion | Không sửa được phản hồi ở đó |
| Vị trí view | Dưới WEB-INF | Chỉ truy cập qua controller |
| Cách dựng Spring MVC | Cấu hình Java với @EnableWebMvc | Phải tự đăng ký DispatcherServlet (Boot làm hộ) |
Playbook: gặp sự cố định tuyến thì làm gì
| Triệu chứng | Kiểm tra đầu tiên | Nguyên nhân hay gặp | Cách xử lý |
|---|---|---|---|
| Interceptor đếm ít hơn filter | Có yêu cầu 404 không | Khô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 RequestMappingHandlerMapping | Controller không được quét, hoặc sai đường dẫn | Kiểm tra @ComponentScan và đường dẫn |
| 405 | Header Allow của phản hồi | Sai phương thức HTTP | Dùng phương thức trong Allow hoặc thêm mapping |
500 và không thấy postHandle | exception ở afterCompletion | Controller ném lỗi | Thêm xử lý lỗi (bài sau) |
| Trang view không tìm thấy | Tiền tố và hậu tố của ViewResolver | Sai đường dẫn hoặc tên view | Kiểm tra registry.jsp(...) |
| Gõ URL view trực tiếp bị 404 | Vị 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ứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Address already in use ở cổng 8080 | Có ứng dụng khác dùng cổng 8080 | PORT=9090 mvn -q compile exec:exec |
| Mọi đường dẫn đều 404 dù controller đúng | Thiế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.
- 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ủaGET /hellovà trạng thái 404, 405, 500. - Mở bằng trình duyệt.
mvn -q compile exec:execrồi mởhttp://127.0.0.1:8080/hello. Tiêu chí: thấy trang "Xin chao Spring MVC"; thử/nopevà/boomvà so mã trạng thái. - Thêm một endpoint. Thêm vào
HelloControllermột phương thức@GetMapping("/bye")trả viewhello(dùng lại JSP). ChạyDispatcherDemovà dòngmapped:. Tiêu chí: có dòngmapped: {GET [/bye]} -> bye(thứ tự các dòngmapped:có thể thay đổi). - Đặt một interceptor có thể chặn. Đổi
preHandletrảfalsecho đường dẫn/hello(ví dụreturn !request.getRequestURI().equals("/hello");). Tiêu chí:GET /hellotrả 200 với thân rỗng và chuỗi mốc chỉ cònfilter before,preHandle,filter after; controller,postHandle, view vàafterCompletioncủa chính interceptor đó đều không chạy. Giải thích vì sao bằng sơ đồ của bài. - 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 testchoTests 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.
- "
postHandleluôn chạy." Không chạy khi controller ném lỗi;afterCompletionthì 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@EnableWebMvctrong Spring Boot, nơi một phần cấu hình được tự đăng ký.
Tự kiểm tra
FiltervàHandlerInterceptorkhác nhau ở đâu về nơi thuộc về và phạm vi yêu cầu nhìn thấy?- Nêu thứ tự các mốc của một yêu cầu
GET /hellothành công. - Vì sao
GET /nopekhông đi qua interceptor, vàPOST /hellotrả mã nào? - Controller ném lỗi: mốc nào còn chạy, mốc nào mất?
- Vì sao
GET /WEB-INF/views/hello.jsptrả 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ớ
DispatcherServletlà 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ỏ
postHandlevà view nhưngafterCompletionvẫn chạy. - View dưới
WEB-INFchỉ tới được qua controller.
Đọc thêm
- Spring Framework Reference: DispatcherServlet
- Spring Framework Reference: Interception
- Spring Framework Reference: View Resolution
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.