Jakarta EE(曾被称为Java EE)是Java平台企业版(Java Platform Enterprise Edition)的下一代版本,它在Oracle将Java EE的开发和维护交给Eclipse Foundation后得以重生,并更名为Jakarta EE。Jakarta EE保留了Java EE的核心特性和API,但在标准化和开发过程上有了更加开放和透明的方式。
在Jakarta官网写的标语就是:BUILDING AN OPEN SOURCE ECOSYSTEM FOR CLOUD NATIVE ARCHITECTURES WITH ENTERPRISE JAVA(为JavaEE云原生架构建立开源生态系统)。
- 起源:Java EE于1999年首次发布,旨在简化企业级应用的开发。随着技术的发展,Java EE逐渐成为了企业级开发的标准。
- 更名:2017年,Oracle宣布将Java EE的开发和维护交给Eclipse Foundation,之后Java EE更名为Jakarta EE。这一更名旨在反映Java EE在Eclipse Foundation下的新身份和新起点。
Javax和Jakarta
对于Javax和Jakarta包如果存在部分更替时候经常会出现包找不到的情况,比如你在使用jakarta.servlet-api最新版本6.1.0写了一个web应用程序,但是你通过Tomcat 8.x版本启动,那么你在访问的时候就会java.lang.NoClassDefFoundError: jakarta/servlet/Servlet。

这是因为Tomcat本身就依赖了servlet包,因此我们导入依赖通常都会设置<scope>provided</scope>而不同版本的Tomcat依赖的servlet版本也不同,从Tomcat 10.x开始所有包从javax.* 到 jakarta.*。
所以如果你导入的坐标还是javax.servlet的依赖,但是你却使用Tomcat 10及以上版本,或者你使用Jakarta.servlet依赖但是却使用Tomcat 10.x以下的版本,那么自然在运行时候会出现类找不到的情况。
可以通过这个网页查看对照版本:Tomcat支持版本
Tomcat对应servlet版本

Jakarta Servlet
1 概述
参考最新版本:Jakarta Servlet 6.1
版本时间:2024-03-28
RFCs协议标准:RFCs
像HTTP、SMTP都是RFC定义的标准,JAVA在源码中文档注释也大量提到此标准,然后根据标准进行设计的。
1.1 什么是Servlet?
A servlet is a Jakarta technology-based web component, managed by a container, that generates dynamic content. Like other Jakarta technology-based components, servlets are platform-independent Java classes that are compiled to platform-neutral byte code that can be loaded dynamically into and run by a Jakarta technology-enabled web server. Containers, sometimes called servlet engines, are web server extensions that provide servlet functionality. Servlets interact with web clients via a request/response paradigm implemented by the servlet container.
这是规范的原文,大致意思就是说servlet就是Java的技术组件,和其他的组件一样,比如GUI作用图形用户界面,servlet则是作用于开发web应用程序,他需要交给web服务器运行,这个服务器就是容器(Container),有时把容器也称为servlet引擎,servlet通过容器对请求request和响应response的响应范式和Web客户端交互。
所谓容器也就是我们熟知的Tomcat、Jetty等Web服务器;如果细心的话你会发现在Tomcat启动的时候你会看到这样一条日志:
07-Aug-2024 18:25:13.718 信息 [main] org.apache.catalina.core.StandardEngine.startInternal 正在启动 Servlet 引擎:[Apache Tomcat/10.1.25]
所以有时候容器也叫servlet引擎。

在这里你的容器就是Tomcat,你写的每个web应用程序都可以同时部署到Tomcat,其中每个程序都对应一个ServletConext应用上下文,而我们经常需要设置的应用上下文就是为了对应一个ServletContext,比如上面存在两个应用程序,如果来了一个请求,如何找到是第一个应用程序呢,就是通过应用上下问路径,代码中叫ServletContextPath。
在路径中表现为

在Tomcat磁盘上的表现为

如果手动部署的话,就是将我们的web应用目录放在这个webapps目录下。但需要注意的是,只有手动部署的时候文件名就是servletContext,如果通过IDEA设置,则单独设置servletContext。
在IDEA中的表现为

2 Servlet接口
Servlet是所有Servlet的顶级接口,他只有两个抽象类实现,abstract HttpServelt及其父类abstract GenericServlet,对于正常开发而言,我们都是继承HttpServlet。
接口Servlet
public interface Servlet {
void init(ServletConfig config) throws ServletException;
ServletConfig getServletConfig();
void service(ServletRequest req, ServletResponse res) throws ServletException, IOException;
String getServletInfo();
void destroy();
}
方法解释:
生命周期相关
init由容器进行调用,如果不设置load-on-startup默认第一次请求时候才调用(只调用一次)service每次请求就是由这个方法负责处理的逻辑destory容器销毁时候执行
配置信息相关
getServletConfig用于存储每个servlet的配置信息(名称,初始化参数)getServletInfo自定义一些信息返回,比如作者信息,版权之类的(没太大作用)
关于servlet的生命周期,容器在部署应用时候会加载 部署描述文件(web.xml) 这里定义了servlet的初始化参数和名称等的ServletConfig的配置信息,当请求路径映射到某个servlet,则开始加载此servlet的字节码文件,即实例化对象,随后立即调用init方法,就是这一步将ServletConfig信息传递给具体的某一个servlet,从而可以读取初始化的信息。
比如spring启动时候读取配置文件路径,用于创建bean对象。
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
version="5.0">
<servlet>
<servlet-class>com.clcao.servlet.HelloServlet</servlet-class>
<servlet-name>helloServlet</servlet-name>
<init-param>
<param-name>hello</param-name>
<param-value>Jakarta.Servlet</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>helloServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>
</web-app>
HelloServlet
public class HelloServlet extends HttpServlet {
public HelloServlet() {
System.out.println("HelloServlet构造器");
}
@Override
public void init(ServletConfig servletConfig) throws ServletException {
super.init(servletConfig);
System.out.println("init 方法");
}
@Override
public ServletConfig getServletConfig() {
return super.getServletConfig();
}
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
System.out.println("service 方法");
HttpServletResponse resp = (HttpServletResponse) servletResponse;
String contextPath = servletRequest.getServletContext().getContextPath();
ServletConfig config = getServletConfig();
System.out.println("servlet名称:" + config.getServletName());
System.out.println("servlet启动参数:" + config.getInitParameter("hello"));
resp.sendRedirect(contextPath + "/index.jsp");
}
@Override
public String getServletInfo() {
return super.getServletInfo();
}
@Override
public void destroy() {
System.out.println("destroy 方法");
}
}
输出

需要注意的是,destroy是容器关闭才执行,可以看到日志输出,销毁协议处理器后,调用了一次。
2.1 请求处理方法
因为
Servlet的应用主要就是HTTP协议,所以这里介绍HTTPServlet。
我们已经知道一个servlet处理请求的方法为service(),HTTPServlet对父接口做了此部分的实现逻辑。当一个请求过来的时候,通过HttpServletRequest的getMethod方法获取请求的方式,然后分别执行不同的doXXX方法。具体包括:
doGet处理GET请求方式doPost处理POST请求方式doPut处理PUT请求方式doDelete处理DELETE请求方式doHead处理HEAD请求方式doOptions处理OPTIONS请求方式doTrace处理TRACE请求方式
所谓doHead就是只有请求头的响应,其本质最终还是调用doGet,只是将响应体禁用了。
如果需要执行这样的请求,需要在初始化参数设置参数jakarta.servlet.http.legacyDoHead为true。
web.xml
<servlet>
<servlet-class>com.clcao.servlet.HelloServlet</servlet-class>
<servlet-name>helloServlet</servlet-name>
<init-param>
<param-name>jakarta.servlet.http.legacyDoHead</param-name>
<param-value>true</param-value>
</init-param>
</servlet>
HEAD请求

不过自Jakarta 6开始已经弃用了!
2.2 Servlet生命周期
servlet的生命周期就是由顶级接口Servlet提供,生命周期定义了一个servlet如何加载、实例化和初始化,到处理来自客户端的请求再到如何退出服务。包括init、service、destroy三个方法,所有servlet都直接实现Servlet接口或者间接继承抽象类GenericServlet和HttpServlet。
2.2.1 加载和实例化
servlet的实例化和初始化是由容器管理的,servlet的实例化和初始化由容器启动的时候执行,或者延迟到需要处理来自客户端的请求request,这取决于你是否设置了参数load-on-startup,如果设置了这个参数>=0则容器在启动时候就会实例化,并且数值越小优先度越高,如果<0或者不设置,则延迟到需要处理第一个请求时进行实例化。
<servlet>
<servlet-class>com.clcao.servlet.HelloServlet</servlet-class>
<servlet-name>helloServlet</servlet-name>
<!--参数>=0,则在容器启动时候实例化和初始化-->
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>helloServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>
容器启动输出

2.2.2 初始化
初始化方法用于servlet能够在处理来自客户端的请求之前读取配置信息(ServletConfig)和一些一次性的操作,init方法调用一次就不再调用。
如果在init初始化过程中出错,那么这个servlet将不会用于处理请求,并且会间隔一段时间后再次尝试实例化,然后调用init方法。
比如我主动构建一个错误👇

2.2.3 请求处理
在加载实例化和初始化都完成后,容器才会使用这个servlet处理request请求,一个请求用ServletRequet表示,而对应的响应为ServletResponse,在处理HTTP请求时,代表请求的对象为HttpServletRequest相应的响应用HttpServletResponse表示。
2.2.3.1 并发问题
一个servlet在容器内只有一个对象,但是对于请求而言都是多线程的,所以在servlet对象中不应该共享一些私有数据,并且一定要避免使用synchronize同步锁,包括间接调用此方法,因为很影响性能!
2.2.3.4 异步处理
有时候一个请求处理过程中可能存在多个耗时操作,这个时候就可以用到异步处理了,比如一个异步请求,存在一个耗时操作5秒,而主线程本身执行任务需要3秒,那么同步情况下,一个请求等待响应就是8秒,但是通过异步处理,只需等待五秒即可。
栗子🌰
1.定义一个servlet并且设置asyncSupported=true(web.xml配置一样)
@WebServlet(value = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("AsyncServlet 开始执行doGet...");
if (req.isAsyncSupported()) {
AsyncContext ac = req.startAsync();
ac.start(() -> {
System.out.println("开始执行异步耗时任务5秒");
try {
Thread.sleep(5000);
ac.getResponse().getWriter().write("异步任务完成");
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
System.out.println("异步任务执行完毕");
ac.complete();
});
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
resp.setContentType("application/html;charset=utf-8");
resp.getWriter().write("<h1>AsyncServlet的响应</h1>");
}
}
注意这里有两个resp.getWriter().write()
调用结果

使用异步处理器的前提是定义servlet的时候开启支持异步,并且对应的过滤器需要设置异步。
异步请求流程

Note:
- 一个请求的本质还是
HTTP协议,通过Socket就可以进行输入流读取HTTP协议的请求内容,而响应的通过输出流就可以按照HTTP协议指定的规则写数据响应给浏览器客户端。(由容器Tomcat负责解析输入流和封装输出流格式) - 一个请求经过过滤器链再执行
servlet处理请求,最后还要返回过滤器再到容器Tomcat。 - 异步处理单独开出一个线程,这个线程并不走过滤器,而是直接将响应流
OutputStream直接写给容器。 - 容器将这两部分的响应合并,最后提交给客户端,这一次请求就结束了。
在容器提交Response之前,所有的异步操作都是有效的,如果已提交则异步处理是无效的,这个可以通过AsyncContext的setTimeout()方法设置异步处理的超时时间,比如我设置3s,那么对于5秒时常的异步请求就会失效,所以实际的请求就只有doGet本身的响应内容,并且时间为3s。
if (req.isAsyncSupported()) {
AsyncContext ac = req.startAsync();
// 设置超时时间3秒
ac.setTimeout(3000);
ac.start(() -> {
System.out.println("开始执行异步耗时任务5秒");
try {
Thread.sleep(5000);
ac.getResponse().getWriter().write("异步任务完成");
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
System.out.println("异步任务执行完毕");
ac.complete();
});
}
输出

超时时间默认是30秒,设置小于等于0的数字意味着永不超时。
关于为什么可以两次response写数据(doGet一次异步任务AsyncContext.start()一次)
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
System.out.println("请求处理之前执行 doFilter");
chain.doFilter(request,response);
System.out.println("请求处理完毕返回执行 doFilter");
}
所谓doFilter()一般叫放行,可以理解为调用栈开始执行servlet的doService方法,既然是调用栈,那servlet执行完毕必然是要返回到过滤器的,这就是为什么常说容器接收请求到过滤器再到servlet最后再返回过滤器,如果是spring还有一层拦截器。
2.2.4 结束服务
一个servlet处理完请求,如果容器主动调用了destroy方法意味着这个servlet就生命结束了,之后的任何请求都不会交给这个servlet执行,同时容器应该释放这个servlet实例的引用,方便GC。但实际开发上基本只有容器关闭才会去执行他。
到这里就是一整个servelt从创建到结束的过程了。
3 Request请求
请求对象HttpServletRequest,它封装了来自客户端请求的所有信息,而这个信息由客户端(通常为浏览器)发起到服务器,其协议为HTTP,所谓协议就是规定的标准,所有实现都遵循此标准,就可以正常传输数据了。
HTTP协议标准参考:HTTP/1.1
基本的网络通信

如果我写一个ServerSocket用于监听8082端口,然后按照协议规定,比如第一行是请求行,然后是请求头,请求头和请求体之间放一个空行,<CRLF>作为结束符,那我就可以循环读取InputStream流的请求信息,然后遇到结束符号停止读取,同样的通过OutputStream写回数据,然后客户端根据协议标准结束读取,那么一次请求的数据交流就完成了,而这个信息传输标准就是HTTP(HyperText Transfer Protocol,即超文本传输协议)。
socket完成HTTP通信栗子🌰
public class ParseDemo {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8082);
while (true) {
Socket socket = serverSocket.accept();
new Thread(() -> {
try (
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()))
) {
String line;
StringBuilder sb = new StringBuilder();
// 读取请求头
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
if (line.isEmpty()) {
// 这里demo读取到头就不读了
break;
}
}
System.out.println(sb);
// 响应
// 获取输出流
OutputStream outputStream = socket.getOutputStream();
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream);
// 构建HTTP响应
String responseBody = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 13\r\n\r\nHello, World!";
// 写入响应到输出流
bufferedOutputStream.write(responseBody.getBytes());
// 刷新并关闭输出流(这将关闭socket连接)
// 注意:在实际应用中,如果你想要保持socket打开以处理更多请求,则不应该在这里关闭它。
bufferedOutputStream.flush();
bufferedOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
}
输出

可以看到这样一次请求到响应就完成了,这就是HTTP的本质,不过请求的解析和响应远比这些复杂和繁琐,这个在Tomcat容器内帮我们实现了,HttpServletRequest就是封装这些请求信息,同理HttpServletResponse也是如此,这也是为什么异步时候两次response写数据都是可以的,因为最后还是交给容器提交response才是一次请求的真正结束。
总结一句话:有了HttpServletRequeset对象,就有了请求的一切信息!
3.1 HTTP协议参数
ServletRequest以及HttpServletRequest是由Java定义的接口规范,并没有实现类,真正的实现还是Tomcat,因为真正的HttpServletRequest请求对象需要容器封装请求信息,通过查看字节码对象也可以看到。

如果从字面含义去理解Facade为门面,也就是请求门面即入口,意味着封装请求的一切信息都在这里。
在ServletRequest接口中定义了获取参数的方法:
getParametergetParameterNamesgetParamterValuesgetParameterMaps
对于GET请求由于没有请求体,参数通过URL方式携带,并且为字符串,因此可以直接通过上面方法获取参数。
对于POST请求,必须设置Content-Type为application/x-www-form-urlencoded或者multipart/form-data。而后者为多部分内容,就是上传文件的类型,如果是表单提交设置POST请求,则默认内容类型为application/x-www-form-urlencoded。
<!--默认Content-Type就是:application/x-www-form-urlencoded-->
<form action="/hello" method="post">
<input type="text" name="post2" value="p1">
<input type="text" name="post2" value="p2">
<input type="submit" value="post">
</form>
如果请求URL和请求体,同时携带同一个参数名,则这些参数会被聚合在一起,并且URL携带的参数在前面,比如:/hello/?a=hello,而body携带参数a=goodbye&a=world,则输出a=[hello,goodbye,world]。

所以我们常用的application/json呢,参数怎么获取?
由于json作为字符串传输,并没有传统的k=v这种键值对,或者说对于程序而言,他只是一个字符串,对于这种数据,需要自身使用InputStream流读取数据:
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("doPost...");
System.out.println("解析application/json参数:");
try (BufferedReader reader = new BufferedReader(new InputStreamReader(req.getInputStream()))) {
String line;
StringBuilder sb = new StringBuilder();
while ((line = reader.readLine()) != null) {
sb.append(line.trim());
}
System.out.println(sb);
}
}
输出

3.2 文件上传
文件上传必须请求指定Content-Type为multipart/form-data,而对于servlet获取数据则需要指定multipart-config,如果是web.xml部署描述符则为👇
web.xml
<servlet>
<servlet-class>com.clcao.servlets.FileUploadServlet</servlet-class>
<servlet-name>fileUploadServlet</servlet-name>
<multipart-config>
<!--配置文件上传的限制-->
<!--临时文件存储位置-->
<location>/tmp</location>
<!--1MB后写入磁盘-->
<file-size-threshold>1048576</file-size-threshold>
<!--文件最大限制 20MB-->
<max-file-size>20848820</max-file-size>
<!--请求最大限制-->
<max-request-size>418018841</max-request-size>
</multipart-config>
</servlet>
通过注解@MultipartConfig则表现为👇
@MultipartConfig(location = "/tmp",fileSizeThreshold = 1024*1024,maxFileSize = 1024*1024*20,maxRequestSize = 1024*1024*400)
@WebServlet("/upload")
public class FileUploadServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
super.doPost(req, resp);
}
}
这将表明这个servlet可以处理文件内容,尽管文件区别于k-v这种字符串参数,但本质对于servlet而言,他还是参数,如同解析application/json字符串内容一样,也需要通过输入流进行解析。
public class FileUploadServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 设置文件保存路径
File dirPath = new File(req.getServletContext().getRealPath("") + File.separator + "upload");
System.out.println(dirPath);
if (!dirPath.exists()) {
dirPath.mkdirs();
}
// 解析文件
for (Part part : req.getParts()) {
String fileName = part.getSubmittedFileName();
System.out.println("文件名:" + fileName);
// 保存文件
part.write(dirPath.getPath() + File.separator + fileName);
}
resp.getWriter().write("文件上传成功!");
}
}
其中每一个Part对应一个文件,而这个文件,都对应一个参数,所以也可以通过getPart()获取指定的Part。
req.getPart("file1")
这个file1就是请求的时候指定的参数名👇

总结:
GET请求没有请求体,直接getParameter等方法获取即可POST请求必须设置Content-Type为application/x-www-form-urlencoded,这也是表单提交默认的- 如果是文件上传,则必须指定
Content-Type为multipart/form-data,而servlet解析文件则必须为这个servlet指定multipart-config配置,可以是web.xml也可以是注解。
不配置的后果

3.3 请求路径元素
一个完整的URI(比如/user/hello)由三部分组成:
ContextPath表示应用上下文路径,对应方法getContextPathServletPath表示Servlet对应的路径,对应方法getServletPathPathInfo表示既不是contextPath也不是ServletPath的部分,对应方法getPathInfo
上面所有方法都是HttpServletRequest中定义的,一个请求路径下面的等式是永远成立的。
requestURI = contextPath + servletPath + pathInfo
栗子🌰:/app/user/hello/index.html
ContextPath为/appServletPath为/user/helloPathInfo为index.html
4 ServletContext上下文
ServletContext描述了一个Web应用程序的运行环境,他代表了一整个应用,比如监听器,过滤器,servlet等都是在ServletContext这个上下文对象中,这个就好比Spring中的ApplicationContext,我们知道一个web应用的部署是放在tomcat中webapps目录的

如果手动启动bin/startup.sh,那么这个目录下每一个目录都将被部署,这些就是一个应用,在文件系统我们可以这么理解,但是在JVM运行时候呢,就是由ServletContext描述这个目录(确切的说是web应用程序)。
ServletContext是Java定义的接口,所以依旧他只是一种规范,由谁去实现的呢,还是Tomcat,准确来讲,是我们经常看到的日志catalina这个servlet引擎实现的。
// 查看 ServletContext 实现类
System.out.println(req.getServletContext().getClass().getName());
// org.apache.catalina.core.ApplicationContextFacade
容器在部署时候为每个web应用创建ServletContext对象,在读取web.xml部署描述符后将配置信息封装在ServletConfig中,同时拥有这个ServletContext的引用,而在servlet实例化饼调用初始化方法时候则将servletConfig对象传递进去,这也是为什么说servlet的实例化是交给容器管理的,在理解servlet的过程中,容器一直是个很关键的概念。
4.1 ServletContext的作用域
一个ServletContext就对应一个Web应用程序,因此他的作用是全局(一个web应用程序)的,对于分布式虚拟机(接触很好)则为每个虚拟机都持有一个ServletContext引用。
4.2 初始化
类似servlet定义初始化参数,在web.xml中可以定义容器启动时的参数,然后通过ServletContext中定义的方法获取
- `getInitParameter
getInitParameterNames
web.xml
<context-param>
<param-name>hello</param-name>
<param-value>ServletContext!</param-value>
</context-param>
这个使用场景有在启动的时候传递一个邮箱,或者应用的名称。(感觉意义一般)
4.3 配置方式
如之前说的,ServletContext代表了一个Web应用程序,也可以理解运行环境,因为所有的servlets都在这里,而主要的内容就包括:servlet、过滤器Filter,监听器Listener。
一个ServletContext对象要添加这些内容都是由ServletContextListener中的contexInitialized或者ServletContainerInitializer中的onStartup方法中添加,如果一个容器启动后,比如我们在一个servlet中尝试添加一个servlet,那么则会抛出异常。
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getServletContext().addServlet("hello",Hello1Servlet.class);
}
输出

换句话说,上下文对象ServletContext添加servlet或者过滤器或者监听器只能在初始化阶段添加,而初始化的方法有两种:
ServletContextListener接口提供的contexInitialized方法ServletContainerInitializer接口提供的onStartup方法
⚡️Spring中的DispatcherServlet
先从语义上理解下ServletContainerInitializer,意为servlet容器的初始化,在容器时候将调用此接口方法,并将代表web应用的唯一实例对象ServletContext作为参数。
而ServletContextListener顾名思义是用来监听ServletContext实例对象的。
关于监听器的栗子🌰
一个完整的事件监听包括:
- 事件源
- 事件
- 事件监听器
- 监听器注册机制
事件
public class MyEvent extends EventObject {
public MyEvent(Object source) {
super(source);
}
}
监听器接口(本质是提供回调方法)
public interface MyListener {
void onCustomEvent(MyEvent myEvent);
}
事件源
public class AppContext {
List<MyListener> myListeners = new ArrayList<MyListener>();
public void addMyListener(MyListener myListener) {
myListeners.add(myListener);
}
public AppContext() {
// 注册监听器,在servlet中表现为web.xml 配置
addMyListener(myEvent -> System.out.println("监听到了事件"));
// 初始化
System.out.println("容器启动...");
MyEvent event = new MyEvent(this);
for (MyListener listener : myListeners) {
listener.onCustomEvent(event);
}
}
}
监听器注册机制
所谓监听器注册就是将监听器接口添加的位置,如果我在容器启动之前就注册,那就意味着,在之后比如上面的for循环,就可以调用接口的方法也叫回调。在servlet中表现为👇
web.xml
<listener>
<listener-class>com.clcao.context.MyServletContextListener</listener-class>
</listener>
测试类及输出

这就是ServletContextListener接口的本意,在servletContext实例化后调用,就意味着监听到了容器创建。
ServletContainerInitializer是利用Jar包的Service API实现的,ServiceLoader.load()来实现动态插拔的服务提供机制,注意这是JDK自带的功能,在Log框架体系中也有体现。
ServiceLoader
ServiceLoader是Java中的一个服务提供者加载机制,它提供了一种灵活的方式来发现和加载服务提供者(即实现了特定服务接口的类)
工作原理
ServiceLoader通过读取META-INF/services目录下的配置文件来发现服务提供者。这些配置文件以服务接口的全限定名命名,文件内容包含了一个或多个服务提供者的全限定名,每行一个。
当ServiceLoader被用来加载服务时,它会根据配置文件中的信息来查找并加载服务提供者。ServiceLoader以延迟初始化的方式工作,即只有在需要时才会加载服务提供者并实例化它们。

所谓服务就是一个接口或者抽象类,而这个接口的实现则作为服务提供者,那如果我作为实现者我就可以在resources/META-INF/services目录下创建一个接口(服务)的全限定名,然后内容就写我的实现类全限定名,这样在需要实现类的时候,就可以动态加载这个提供者实例了!
在servlet中,ServletContainerInitializer就是基于服务提供者机制实现的。

可以看到通过创建一个工程(这个工程打包为.jar然后让web应用依赖),按照服务提供者机制,就可以让容器启动时候回调这个接口。
如前面提到的,所有Servlet都必须在部署描述符中定义,或者通过上面两个接口方法回调去添加,否则就会出现上面的错误:无法将Servlet添加到上下文中,因为上下文已经初始化。
还有一种添加错过上下文初始化添加Servlet的方式就是通过ServletRegistration.Dynamic
所以添加Servlet要么通过服务提供者记者注册,要么通过监听器机制注册,在Spring中使用的是前者,通过查看ServletContainerInitializer接口继承树可以发现,他的实现类在Spring中为SpringServletContailerInitializer(也就是提供者了),那么这个接口实现的时候,方法重写做了什么呢?
public void onStartup(@Nullable Set<Class<?>> webAppInitializerClasses, ServletContext servletContext)
throws ServletException {
List<WebApplicationInitializer> initializers = Collections.emptyList();
// 省略了一些判断
servletContext.log(initializers.size() + " Spring WebApplicationInitializers detected on classpath");
AnnotationAwareOrderComparator.sort(initializers);
for (WebApplicationInitializer initializer : initializers) {
initializer.onStartup(servletContext);
}
}
这里主要做的一件事情就是对所有WebApplicationInitializer接口间接调用一次onStartup()并且参数还是传递的servletContext这个代表web应用程序的对象。这就意味着,其效果等同WebApplicationInitializer和ServletContainerInitializer功能是一致的。
WebApplicationInitializer
public interface WebApplicationInitializer {
void onStartup(ServletContext servletContext) throws ServletException;
}
而这个接口的文档也给的很详细👇

这么做的好处就是,任何我们编程式写的类,只要实现此接口,我们就可以在容器启动的时候注册DispatcherServlet了,而这也就意味着,我们可以完全丢弃web.xml这个部署描述符了。
栗子🌰
public class MyWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
// servlet容器启动时候,注册spring context的 配置类
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.register(AppConfig.class);
// 添加 DispatcherServlet 到 servlet容器
servletContext.addServlet("servlet", DispatcherServlet.class).addMapping("/");
}
}
这就是百分百纯编程方式的web应用程序了。
如果我们没有实现这个接口,Spring也有默认的抽象实现类AbstractDispatcherServletInitializer在这个类中,会默认注册一个名称为dispatcher的Servlet。
只列举部分相关的源码
public abstract class AbstractDispatcherServletInitializer extends AbstractContextLoaderInitializer {
public static final String DEFAULT_SERVLET_NAME = "dispatcher";
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
super.onStartup(servletContext);
registerDispatcherServlet(servletContext);
}
protected void registerDispatcherServlet(ServletContext servletContext) {
ServletRegistration.Dynamic registration = servletContext.addServlet(servletName, dispatcherServlet);
registration.setLoadOnStartup(1);
registration.addMapping(getServletMappings());
registration.setAsyncSupported(isAsyncSupported());
}
}
可以看到LoadOnStartup意味着这种方式注册的dispatcher容器启动时候就会创建。
5 Response响应
与Request相对应,Response代表了响应对象,封装了所有响应需要的信息,对于HTTP协议而言其对象就是HttpServletResponse。
5.1 缓冲Buffering
一个容器应该为响应提供缓冲,但并不是必须要求的,大多数容器默认都有缓冲,并且给出设置缓冲大小的方法。
在ServletResponse中定义了缓冲相关的方法:
getBufferSizesetBufferSizeisCommittedresetresetBufferflushBuffer
Tomcat默认缓冲大小8KB

而这个大小定义在org.apache.catalina.connector.Response的构造中,因此,不能通过配置文件设置。
public Response() {
this(8192);
}
设置的缓冲大小应该至少比这个大,否则设置是无效的。
5.2 重定向
所谓重定向值的是响应通过重定向这个请求到不同的URL,在容器内可以使用相对路径,比如上下文路径为/app,servlet的路径为/user则可以直接重定向到另一个servlet的路径/hello
resp.sendRedirect("hello")
唯一需要注意的是,这个会改变URL和请求转发不同,请求转发更加类似"过滤器",将这此的请求和响应携带转发给另一个servlet处理。
5.3 响应编码
如果是传统的web.xml配置可以通过标签<response-character-encoding>设置响应的编码方式,而如果没有设置则使用默认的编码ISO-8859-1。
web.xml
<response-character-encoding>UTF-8</response-character-encoding>
设置响应编码的方法有三种:
setCharacterEncodingsetContentTypesetLocale
setLocale只有在上面两个方法都没有调用时候才可以,否则将被覆盖。而setCharacterEncoding则会被setContentType覆盖,这是因为setContentType本质还是操作HttpServletResponse对象,这个对象是负责将servlet的内容写到容器Tomcat,而Tomcat在提交响应给客户端的时候,编码会优先按照响应头Content-Type编码。格式为Content-Type:application/json;charset=utf-8,其中application/json才是内容类型(标准格式:type/subtype),charset=utf-8在RFC标准中叫属性,不区分大小写。
⚡️关于中文乱码
我们知道数据传输最终都是01010011这种二进制形式,无论你是图片还是字符,最终都需要转换为二进制信息传输,那对于一个字符比如a如何表示,则是按照ascii码表对应数字97,那么就可以在二进制中表示为1100001,对于任何一个字符都需要在计算机中表示为数字,才能转换为二进制进行传输。
而一个8位的二进制显然最多只能表示256个数字,为了能够表示更多的字符,于是就有了其他的各种码表;比如GBK,GBK2321,ISO-8859-1,Unicode,UTF-8等,而有编码自然就应该存在解码。
中文乱码的本质是编码的码表和解码的码表对应不上。 比如上面说的Tomcat 9.x版本,客户端接收响应时候按照UTF-8解析ISO-8859-1乱码,但实际上,如果我将响应解析设置为ISO-8859-1去解析响应,也同样可以解决乱码问题。
编码与解码

所谓解码解的就是[63,63]这种字节数组,如果没有按照编码的码表对应去解码,自然就出现对不上号的错误信息了!
而通过Content-Type规定,就可以告诉编码的人(Tomcat)和解码的人(浏览器等客户端)都按照统一指定的方式进行编码和解码,那么问题就解决了!
Content-Type基本格式:type/subtype;attribute=value,其中type就是格式,可以是application或者multipart媒体类型或者text纯文本类型等,而subtype对应子类型,比如text/html就是富文本HTML格式,而application/json就是应用json字符串格式,而multipart/form-data则是文件格式,而后面的对应属性key-value的形式,通常就是用这个设置字符编码,比如application/json;charset=utf-8,不区分大小写。
如果只是通过resp.setCharacterEncoding(“UTF-8”)就意味着只是编码按照UTF-8但是解码却依赖客户端的默认设置,虽然现在大多数浏览器默认为UTF-8但是部分可能不是,这种情况下,依旧会乱码,所以解决的本质是,统一编码和解码的标准为同一个码表。
Safari的设置

⚡️关于Tomcat默认编码
如果你正在使用的是Tomcat 10.x版本,那么你即使不设置也会默认使用UTF-8编码,所以你不会出现乱码问题,所以这个编码的设置具体在哪呢?
我们知道,我们在上面方式设置字符编码的时候,操作的都是HttpServletResponse对象。
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("resp实例对象:" + resp.getClass().getName());
System.out.println("resp字符编码:" + resp.getCharacterEncoding());
resp.getWriter().write("你好,世界!");
}
可是HttpServletResponse是一个接口,因此我们需要知道他的具体实例,然后才能追本溯源找到他的设置本源。
通过Arthas调试调用栈

可以看到最后的结果是StandardContext下返回的响应字符编码。这个类位于tomcat目录下/lib/catalina.jar包,查看源码大致如下👇
private String responseEncoding = null;
@Override
public String getResponseCharacterEncoding() {
return responseEncoding;
}
而这个类在构造的时候并没有初始化字符编码,那么他的属性字段就只可能是通过setter方法设置了,并且在容器启动时候创建的。
通过远程调试可以看到,在容器启动的调用栈中,通过webxml这个对象设置了这个字符编码👇

而这个webxml对象来源则是web.xml这个文件。
默认来源conf/web.xml

而不同的就是在Tomcat 10.x版本之后的/conf/web.xml默认添加了配置元素<response-character-encoding>,这也就是为什么,当我使用Tomcat 10.x版本时候我明明没有设置字符编码但他依旧是UTF-8。
Tomcat 9.x版本

Tomcat 10.x版本

如果你使用的低版本Tomcat,那么则可以通过设置/conf/web.xml,添加以下内容即可设置默认的响应编码了。
/conf/web.xml
<request-character-encoding>UTF-8</request-character-encoding>
<response-character-encoding>UTF-8</response-character-encoding>
6 过滤器Filter
过滤器是一个可重用的代码,可以转换HTTP请求、响应和标头信息的内容。过滤器通常不会像servlet那样创建响应或响应请求,而是修改或调整资源的请求,并修改或调整来自资源的响应。
过滤器的使用场景
- 身份鉴定
- 日志记录
- 图像转换
- 数据压缩
- 加密传输
Token校验
6.1 Filter生命周期
在容器部署应用之后,处理请求之前,容器必须找到过滤器列表,换句话说,容器需要实例化Filter对象并且调用init方法,这点其实跟servlet完全是相似的,我们可以理解为过滤器或者servlet都是用来处理请求的,那么在你能处理之前,你得先正确的准备好。
过滤器同servlet在应用内是单例的,只有一个实例对象引用;在容器调用过滤器时,按照在部署描述符(web.xml)中配置的顺序依次执行。
<filter>
<filter-class>com.clcao.filter.Filter1</filter-class>
<filter-name>filter1</filter-name>
</filter>
<filter-mapping>
<filter-name>filter1</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
<filter-class>com.clcao.filter.Filter2</filter-class>
<filter-name>filter2</filter-name>
</filter>
<filter-mapping>
<filter-name>filter2</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
👆上面将先执行过滤器filter1再执行过滤器filter2。
过滤器链

一般我们将多个过滤器称为过滤器链因为像链子一样一环扣一环,在真正处理请求之前可以针对请求做很多事情,比如从请求头获取Token判断是否有效,也可以对请求进行修改,将原来的请求对象ServletRequest进行封装。同样在处理完请求后,对应用也需要经过一遍过滤器链,然后对响应对应ServletResponse进行调整。
6.2 在应用中的配置
过滤器除了上面的web.xml进行配置,还可以通过注解的方式配置,通过@WebFilter标记这是一个过滤器类。
@WebFilter("/*")
public class EncodingFilter extends HttpFilter {
@Override
protected void doFilter(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws IOException, ServletException {
resp.setContentType("application/json;charset=utf-8");
}
}
/*作为路径匹配会匹配所有的请求,包括对静态资源的访问,比如/user/index.html。
需要注意的是,过滤器并不是只能通过URL做路径匹配,他还可以指定servlet
<filter-mapping>
<filter-name>filter</filter-name>
<servlet-name>hello</servlet-name>
<url-pattern>/foo/*</url-pattern>
</filter-mapping>
在路径进行匹配时会先匹配路径,再根据servlet-name,比如请求为/foo/abc则会调用此过滤器,假如hello这个servlet路径设置为/abc,那么在请求/abc映射到servlet时,则也会先调用此过滤器,然后执行servlet处理请求。
通过注解的方式@WebServlet如果没有指定name属性值,则这个servlet name为该类的全限定名。
6.3 过滤与请求转发
在类DispatcherType中定义了分派的类型,注意分派跟转发这是两个概念,转发只是分派的一种类型。
public enum DispatcherType {
FORWARD, INCLUDE, REQUEST, ASYNC, ERROR
}
一个请求如果直接来自客户端,那么他就是REQUEST,而剩下的类型则对应这个请求的来源,最常用的就是FORWARD。
FORWARD将这次请求分派给另一个servlet,响应内容会被清空。INCLUDE将这次请求分派给另一个servlet,但是响应内容会合并到下个目标。REQUEST从客户端直接发起的请求。ASYNC在异步线程中分派的请求,异步中的输出将直接写到容器输出流中。
栗子🌰
模拟不同Dispatcher类型的请求
@WebServlet(value = "/test",name = "test",asyncSupported = true)
public class TestServlet extends HttpServlet {
boolean forwardAlready = false;
boolean includeAlready = false;
boolean asyncAlready = false;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("请求类型:" + req.getDispatcherType());
// 1.第一次请求直接来自客户端为 REQUEST ==> 分派这次请求 FORWARD
if (!forwardAlready && !req.getDispatcherType().equals(DispatcherType.FORWARD)) {
forwardAlready = true;
// FORWARD 分派的请求响应内容会被清除(最终写啥客户端都不会收到)
resp.getWriter().println("FORWARD部分");
req.getRequestDispatcher("/test").forward(req, resp);
}
// 2.来自分派的请求 FORWARD ==> 分派这次请求 INCLUDE
if (!includeAlready && !req.getDispatcherType().equals(DispatcherType.INCLUDE)) {
includeAlready = true;
// INCLUDE 响应的内容会别合并到下一次分派的请求输出流中(最终响应给客户端)
resp.getWriter().println("INCLUDE部分");
req.getRequestDispatcher("/test").include(req, resp);
}
// 3.来自分派的请求 INCLUDE ==> 开启异步任务
if (!asyncAlready && !req.getDispatcherType().equals(DispatcherType.ASYNC)) {
asyncAlready = true;
AsyncContext ac = req.startAsync();
ac.start(()->{
// 类似INCLUDE,不过这部分的输出流会直接写给容器
try {
resp.getWriter().println("ASYNC响应部分");
// dispatch 又会开启一个新的线程
ac.dispatch(getServletContext(),"/test");
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
if (asyncAlready && req.getDispatcherType().equals(DispatcherType.ASYNC)) {
resp.getWriter().println("异步转发后的响应");
}
}
}
输出

一个可能更难理解的流程图

Note:
- 所有分派都可以视作一次方法调用(调用完还得回来继续执行
dispatcher()之后的代码,如果有的话) - 由客户端直接过来的请求就是
REQUEST FORWARD分派不会携带响应数据给下一次目标(可以是servelt也可以是静态资源)INCLUDE会携带响应数据给下一次目标,因此此处resp写出响应数据会最终给到客户端ASYNC开启AsyncContext后在新线程中执行dispatcher()才是ASYNC的一次请求类型,异步的数据直接输出流到容器,不会经过过滤器。- 主线程(第一次请求进来的线程)需要经过过滤器,最后返回还需要走过滤器。结果同👆输出截图。
7 会话跟踪技术
由于HTTP协议是无状态的,这种无状态表现为,一次请求到一次响应结束,完全没有状态信息,这就导致无论是A的第N次请求还是B的第N次请求,每次请求对于服务器而言都是无差别的。 换句话说,你小明访问我服务器和小红访问我服务器我都不认识,而为了能够记录客户端的信息,于是就有了会话跟踪技术。
7.1 Cookie
一个保存在客户机中的简单的文本文件, 这个文件与特定的 Web 文档关联在一起, 保存了该客户机访问这个Web 文档时的信息, 当客户机再次访问这个 Web 文档时这些信息可供该文档使用,由于“Cookie”具有可以保存在客户机上的神奇特性, 因此它可以帮助我们实现记录用户个人信息的功能。
Note: cookie是存储在客户端的。
cookie的作用

7.1.1 工作原理
要让服务器区别来自客户端的请求是小明还是小红的本质是什么?
答案是记忆或者说鉴别能力,一个人要区别小红还是小明,他就需要记住小明有哪些特征,小红有哪些特征,而当小红再次光临的时候,通过面部特征,他就能区分是小红了。而在HTTP通信中,这种特征就是cookie(确切的说是cookie携带的信息),第一次访问的时候,服务器通过设置cookie(记住特征),然后客户端存储cookie,在之后的每次请求携带cookie,服务器就能区分你是小明而不是小红了。
cookie流程

1️⃣ Cookie的生成和发送
小明第一次反问网站,通过输入用户名和密码登录,服务器收到后比对数据库,发现用户登录正确,于是创建一个cookie,key为user这个字符串,value为用户名小明(需要唯一区分)。
服务器在响应的时候,通过响应头Set-Cookie告诉客户端,我需要添加cookie,你把他存起来。
服务器需要设置cookie

用户名密码校验正确后,服务器通过response对象的方法addCookie()就会在响应头添加Set-Cookie这个响应头了,同时将信息(用户名:小明)存储在服务器内(用于记忆小明这个用户)。
而客户端(浏览器,这里是Api Fox)收到这个响应后,解析时候发现有个请求头Set-Cookie,于是就把user=user=%E5%B0%8F%E6%98%8E保存在客户端(因为HTTP请求头部分只支持ISO-8859-1因此做了URL编码,实际内容就是中文:小明)。

存在本地磁盘的cookie,每个域名(IP+端口)都有自己的Cookie,当访问某个域名下的资源时,浏览器将读取这些cookie并携带发起请求。
2️⃣ Cookie的存储和携带
浏览器只要在响应头在看到Set-Cookie则将这个头的值部分保存到本地,再每次访问的时候(包括第一次登录)会去查找当前请求URL的域是否本地磁盘存在cookie,这里当前请求域就是localhost了,然后将这个域下所有cookie携带进每次请求中。
手动添加cookie

可以看到,即使手动添加一个cookie在登录请求的时候也会携带进去,这就是客户端做的事情;不同的浏览器或者客户端存储cookie的策略都不同,比如我的Mac,在谷歌浏览器的存储位置就是当前用户下/Library/Application Support/Google/Chrome/Default。
在磁盘上的具体位置

3️⃣ 服务器验证
虽然每次请求现在都有了cookie可以携带小明的面部特征,但是服务器要想认证他就需要首先记住他,所以在小明登录后,就需要将这个cookie携带的信息(需要唯一标识小明)在服务器也要保留一份,这样在请求的时候才能够去验证是不是小明发起的请求,而这个存储可以是内存,也可以是磁盘,只要服务器运行期间,能够存取即可!
这样在小明进行请求/hello访问资源时候通过携带的cookie信息 + 服务器存储的信息一比对,芜湖对上了,那服务器就认为这是小明在访问并且已经登录了,我就给他返回响应的资源进行响应。
这就是纯cookie跟踪会话的全部流程。
栗子🌰:纯cookie实现登录认证
过滤器
@WebFilter("/*")
public class LoginFilter extends HttpFilter {
@Override
protected void doFilter(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws IOException, ServletException {
// 1.放行登录请求
if (req.getRequestURI().equals("/login") || req.getRequestURI().equals("/html/login.html")) {
chain.doFilter(req, resp);
// 无论登录成功还是失败,在登录的`LoginServlet`中已经做了请求转发和重定向,所以需要return结束
return;
}
// 2.如果没有携带任何cookie直接重定向登录页面
if (Objects.isNull(req.getCookies())) {
resp.sendRedirect(req.getContextPath() + "/html/login.html");
return; }
// 3.访问任何资源都需要通过`cookie`和存储在服务器的信息比对,只有判断登录状态才能放行访问资源
String user = null;
for (Cookie cookie : req.getCookies()) {
if (cookie.getName().equals("user")) {
user = cookie.getValue();
}
}
if (user != null && user.equals(req.getServletContext().getAttribute("user"))) {
// 登录过,正常访问
chain.doFilter(req, resp);
} else {
// 认证失败 ==> 重定向到登录页面
resp.sendRedirect("/html/login.html");
}
}
}
登录的servlet
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1.获取用户名和密码
String username = req.getParameter("username");
String password = req.getParameter("password");
// 2.比对数据库
if ("小明".equals(username) && "admin".equals(password)) {
System.out.println( username + "已登陆");
// 2.1登录成功就响应添加一个cookie,携带信息为用户名,唯一区分用户
resp.addCookie(new Cookie("user", URLEncoder.encode(username, StandardCharsets.UTF_8)));
// 2.2用ServletContext存储用户信息,用于比对(通常需要在用户Logout登出的时候移除掉)
req.getServletContext().setAttribute("user", URLEncoder.encode(username, StandardCharsets.UTF_8));
// 2.3登录成功后转发请求到首页(不能重定向,改变URL会重新走一遍过滤器)
req.getRequestDispatcher(getServletContext().getContextPath() + "/index.jsp").forward(req, resp);
} else {
// 2.4登录失败就直接重定向登录页面了
resp.sendRedirect("/html/login.html");
}
}
}
7.1.2 Cookie的属性
一个基本的cookie只有键值对name-value,但是存储在本地磁盘,浏览器却需要一些额外的属性,比如域(domain)来区分这个cookie对应哪个网站。
cookie属性表
| 属性 | 名称 | 描述 |
|---|---|---|
name | 名称 | cookie的标识符,每个Cookie都有一个唯一的名称,以便在客户端存储和检索。 |
value | 值 | 用于存储具体的数据或信息。 |
domain | 域 | 指定可以访问Cookie的域名。如果未设置,则默认为创建Cookie的网页的域名。这意味着只有来自该域的请求才能携带这个Cookie。 |
path | 路径 | 指定可以访问Cookie的路径。如果未设置,则默认为创建Cookie的网页所在的路径。这个属性用于进一步限制Cookie的发送范围,确保只有来自指定路径或其子路径的请求才能携带该Cookie。 |
Expires/Max-Age | 过期时间 | 指定Cookie的过期时间,也就是Cookie将被自动删除的时间点。如果设置了Expires属性,则浏览器会在该时间后自动删除Cookie。另外,Max-Age属性也可以用来表示Cookie的有效期,它是一个相对时间(以秒为单位),表示从创建Cookie开始到Cookie失效的时间长度。如果未设置过期时间,则Cookie通常会在用户关闭浏览器时被删除(会话Cookie)。 |
HttpOnly | 是否仅Http | 如果设置了该标志,那么该Cookie只能通过HTTP协议传输,而不能通过JavaScript等脚本语言来访问。这有助于防止跨站点脚本攻击(XSS),因为攻击者无法通过JavaScript来读取或修改设置了HttpOnly标志的Cookie。 |
Size | 大小 | 虽然不是所有浏览器都明确支持通过属性来设置Cookie的大小,但Cookie的大小确实受到浏览器和服务器的限制。一般来说,每个Cookie的大小限制在4KB左右,同时浏览器对单个域名下可以存储的Cookie数量和总大小也有一定限制。 |
Cookie域的工作原理
- 同域原则:默认情况下,Cookie只能被设置它的那个域(包括子域)访问。例如,如果Cookie是在
www.example.com下设置的,那么它只能被www.example.com或其子域(如sub.www.example.com)访问。 - 跨域共享:要实现Cookie的跨域共享,需要服务端和客户端的协作。服务端需要在响应头中设置
Set-Cookie,并可能包括Domain属性来指定哪些域可以访问该Cookie。同时,客户端(浏览器)需要支持并遵循这些设置。
7.1.3 优缺点
通过cookie显然是可以做到跟踪用户状态的,但是他完全依赖于本地存储的cookie信息,如果一个用户设置禁用cookie,这意味着每次访问一个资源都需要进行登录,并且如果仅仅用cookie去认证一个用户,那么我只要获取到你的cookie然后就可以随意访问你的帐户资源了,这种信息存储在本地的方式,因为明文存储,显然是不安全的。
但他的好处在于,传输简单,通过这么一个数据载体就可以解决HTTP协议无状态的本质问题。
7.2 Session
Session一般叫会话控制,用于在多个页面请求或Web站点访问中识别用户,并存储有关该用户的信息;所谓会话这就好比,小明和小红对话,小明每一次说话对应一起请求,一个会话内就会存在多次请求,等到小明离开的时候,这次会话也就结束了。
一次会话

既然一次会话需要区分每次请求都是来自同一个人,这本质上和👆的cookie有什么区别?实际上,要完成session(会话)的功能,本质就是使用cookie,我们已经知道根据cookie存储的key-value,然后每次请求浏览器都会自动的将存储在本地的cookie添加在请求头里边,而服务器则根据指定的key查看携带的信息value。
那如果将用户的所有信息都存放在这个value里边,是不是就记录了这个用户的所有信息了,但是cookie是简单的文本字符串比如 user=Tom,所以就需要一个嵌套的key-value,诸如这样的形式Map<String,Map<String,Object>>这个熟悉吗,那么我们给这个携带所有用户信息的value封装为Session对象模型,然后通过一个指定的JSESSIONID作为这个Session对象的key,那么一个存储session的格式就是:Cookie("JESSIONID",Session)了。
用户的请求每次只需要携带这个JESSIONID,服务器则根据这个JSESSIONID来作为一个会话对象。
Note: 这里有几个关键点
JESSIONID要区分每个会话,就需要记录他的来源,比如谷歌浏览器访问一次,于是存储了一个JESSIONID在磁盘,这代表谷歌浏览器这个会话对象,那如果我换成Safari浏览一次,就得作为新的会话开始,Safari读取不到谷歌浏览器存储的cookie,自然不会携带相同的JESSIONID,但本质上JESSIONID就代表了一次会话。- 服务器要认识这个
JESSIONID就一定需要记住这个ID。
这还是cookie的本质,类似cookie认证一个用户,他的两个核心点在于:请求携带信息,服务器根据存储的信息比对信息。
7.2.1 工作原理
Session的本质就是一个Java类,或者说是一个对象模型,比如我们熟悉的User,Person,我们用User代表用户类,Session则是代表一个会话,而他要代表一个会话就意味着,同一个用户在不同的请求时候,拿到的Session对象需要是同一个对象的引用!我们知道Request代表一个请求,Response代表一个响应,而Session需要代表一个会话(包含多次请求和响应),所以他必然要超出Request的作用范围,那就是Context上下文级别的了,Session的对象的实例化,其实就是Tomcat容器帮我们做的。
具体描述为:容器在接到请求时,将请求解析Cookie,如果存在JESSIONID则不创建直接从Context这个代表容器对象的上下文中取对应的Session对象,然后存放在这个请求对象HttpRequestFacade中,如果不存在,则创建一个Session对象;响应时候同理,如果这个Session是新的,则添加一个响应头Set-cookie,如果还是原来的则不添加。
Session对象的创建

Note:
- 如果你的请求中携带了
JESSIONID,则根据请求头中的JESSIONID获取已创建的Session对象引用赋值给Request对象。 - 如果请求中没有携带,则创建一个
Session对象,默认过期时间30分钟,并为Response对象添加一个cookie,其中key就是字符串JESSIONID,定义在org.apache.catalina.util.SessionConfig中的默认字符串。
配置Session过期时间的文件在tomcat安装目录下/conf/web.xml。
web.xml
<!--单位:分钟-->
<session-config>
<session-timeout>30</session-timeout>
</session-config>
⚡️调试Session创建的一些记录
第一次请求

这里有两点信息:
- 默认就创建了一个名为
JESSIONID的cookie并且添加cookie响应给客户端。 - 这个
HttpSession对象是StandardSessionFacade,这是容器Tomcat创建的实例。
解析cookie

Session对象的存储位置

session对象的实例化

response添加cookie

创建Session对象的机制

总结:Session的本质就是一个Java对象模型,这个模型描述了用户的所有信息,而"这个用户"的标识是存储在磁盘上的名称为JESSIONID的cookie,他之所以能表示一个用户,是因为在一次会话中,你的浏览器存储的JESSIONID会一直在,直到关闭浏览器或者Session过期。
栗子🌰**自定义Session**
定义MySession数据模型
public class MySession {
private String id;
private Map<String, Object> attributes = new HashMap<>();
public Object getAttribute(String key){
return attributes.get(key);
}
public void setAttribute(String key, Object value) {
attributes.put(key,value);
}
// 省略 getter && setter
}
// session容器用于管理session引用
public class SessionManager {
public static final ConcurrentHashMap<String, MySession> sessions = new ConcurrentHashMap<>();
}
过滤器为请求设置MySession对象
@WebFilter("/*")
public class SessionFilter extends HttpFilter {
@Override
protected void doFilter(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws IOException, ServletException {
// 1.解析用户的cookie
boolean create = true;
if (req.getCookies() != null) {
for (Cookie cookie : req.getCookies()) {
if (cookie.getName().equals("MYJSESSIONID")) {
String sessionID = cookie.getValue();
if (SessionManager.sessions.containsKey(sessionID)) {
// 1.1如果存在则从容器中获取引用
MySession mySession = SessionManager.sessions.get(cookie.getValue());
req.setAttribute("session", mySession);
create = false;
System.out.println("携带了MYJESSIONID的cookie,MYJESSIONID为:" + mySession.getId());
System.out.println("MySession的对象为:" + mySession);
}
}
}
}
// 1.2如果不存在则创建一个session
if (create) {
System.out.println("没有携带MYJESSIONID==>创建session");
// 生成一个sessionID
String sessionID = UUID.randomUUID().toString();
MySession session = new MySession();
session.setId(sessionID);
SessionManager.sessions.put(sessionID, session);
req.setAttribute("session", session);
// 为response设置cookie
resp.addCookie(new Cookie("MYJSESSIONID", sessionID));
}
chain.doFilter(req, resp);
}
}
使用MySession
// 用于设置session,另一个servlet拿取数据
@WebServlet("/setSession")
public class SetSessionServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 查看自定义的 session MySession session = (MySession) req.getAttribute("session");
System.out.println("自定义sessionID: " + session.getId());
System.out.println("Session对象:" + session);
// 使用Session
String username = req.getParameter("username");
session.setAttribute("username", username);
}
}
// 用于取session的值
@WebServlet("/getSession")
public class GetSessionServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
MySession session = (MySession) req.getAttribute("session");
String username = (String) session.getAttribute("username");
resp.getWriter().write(username);
}
}
输出

Note:
- 这个例子包含了
Session创建的关键逻辑,以及对Session的理解。 - 从本质上看,
Session就是一个描述用于存储用户信息的模型,这个用户(你是小红还是小明)的标识完全依赖存储在客户端的JESSIONID。 - 如果主动删除
JESSIONID会再创建一个Session对象,但是原来的Session对象引用并不会消失(需要到时间删除)。
7.2.2 Session生命周期
Session的创建
如前面分析的,当容器Tomcat收到一个来自客户端的请求时,只要没有携带名为JESSIONID的Cookie时或者篡改过的JESSIONID值导致与容器内存储的不一致则会创建一个Session对象,该Session对象的引用存储在Tomcat容器内,同时请求对象Request持有这个Session对象的引用,因此在调用doService()处理请求的时候可以获取根据Request对象获取Session,即req.getSession()。
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
HttpSession session = req.getSession();
// 用Session存储信息,格式为 name-Object session.setAttribute("username","Tom");
// 设置session过期时间 30 * 60 = 30分钟
session.setMaxInactiveInterval(30 * 60);
}
一个Session一旦创建,他在指定的SessionID下是唯一的,应该说在有效期内,在整个容器内都是唯一的,因此即使每次请求都是不同的Request对象,但是只要客户端请求每次携带的JESSIONID相同,那么他持有的Session实例的引用就是固定的。
Session的销毁
通常情况下Session在浏览器关闭的时候就消失,但这个说法其实不够准确,因为浏览器关闭,是名为JESSIONID的这个cookie被浏览器删除了,而删除的原因是由Tomcat添加的这个cookie默认设置的过期时间是-1,这意味着浏览器关闭就会删除这个名为JESSIONID的cookie,但是这个Session对象的引用并不会删除,需要等到过期时间,容器才会将它移除。
客户端删除cookie服务端存在的Session对象引用并不会删除

本地删除cookie的操作包括手动删除和关闭浏览器。
Session是在Cookie的基础上进行了扩展,相比于所有数据存储在cookie中,因为Session存储信息是name-object,理论上可以存储更多的信息了,并且更加安全可靠,用户只需要持有一个有效的JESSIONID即可。
关于禁用cookie,谷歌浏览器禁用cookie禁用的是第三方cookie,第三方一般是广告之类的,而第一方是我们直接浏览的网站,这个网站的cookie是不能禁用的,如果禁用,意味着会话完全不可追踪,也就永远无法判断是否登录了。
7.3 Token
设计目的:不需要服务器端存储状态安全的传递非敏感数据。
我们在回想下,要解决HTTP协议无状态的本质是什么?客户端携带信息和服务器比对信息。 客户端携带信息通过cookie本质也是一个请求头,如果双方都约定一个请求头,比如就叫aaa,然后客户端请求每次都携带这个请求头,那他的作用其实完全可以等价Cookie;而对于服务器比对信息,他的本质是需要识别这个信息是可信的。 而Token就是为了鉴定这个信息。
理论上这完全是可行的,但因为客户端和服务器(Tomcat)都对请求头根据RFC规范做了校验,因此设置请求头aaa是不可行的。
Session虽然也能进行身份认证(信息比对),但是很明显,他的信息存储在每一个Tomcat所在的服务器主机上,当分布式部署的时候他没办法共享数据,如果需要共享Session就需要引入第三方容器,可以是缓存Redis也可以是数据库MySQL。
session共享

这种方案在小型访问的确是可行的,但是高并发下是不可靠的,如果数据库挂了,就意味着丢失了所有的Session,而这个Session是状态信息,也就意味着,所有用户都需要重新登录重新生成Session了。这个可以类比一家大公司,存在两家分公司,他们通过用纸张记录的方式登记你的来访信息,你一进门,门卫拿出来登记表来看,哦小红登记过,你进来吧,某一天,你被派去另一个分公司,结果刚进门,门卫一比对,小红你这是新来的啊,先办理一下新人注册吧。你看这里的问题本质在哪?
公司对你的识别来源于登记在册的纸质名单。 那如果这家公司统一发放访客证,无论是分公司A或者分公司B,我只需要认识这个访客证有效期就让你进来,这不就完成身份识别这件事了吗?而这就是Token。
Token理解是令牌(本质就是一个字符串),如上所述token的作用就是访客证,你持有有效期的证件访问就放行,你没有持有或者是无效证件则阻止。
而签发和认证这个访客证的东西就是JWT(JSON Web Tokens),他负责发放Token,同时也负责认证Token。
👉这是官网 jwt

7.3.1 Jwt的组成
token泛指任何用于身份验证的加密字符串,Jwt则是基于json的开放标准,通常我们所说的Token指的就是Jwt;Jwt由固定的三部分组成。

图:因为太长砍断了部分
组成
- Header(头部):主要指定了令牌元数据和签名算法
- Payload(载荷):携带信息的部分,比如之前用
session存name=Tom,过期时间也在这里 - Signature(签名):是对头部和载荷的签名,以防内容被篡改。签名是使用头部中指定的算法,以及一个只有服务器知道的密钥,对头部和载荷的JSON字符串进行加密得到的
7.3.2 Jwt的使用
官网对Jwt的用途介绍有两种,身份验证和信息交换。

而这正对应了Jwt的三种使用
token的生成token的解析token的验证
如果需要信息交换,那么就可以在负载部分携带非敏感信息,相应的在服务端进行解析操作;如果需要身份验证,则相应的在服务器执行验证操作。
使用jwt需要导入对应实现的依赖

关于JWT的实现库,可以参考官网:Jwt的Java实现
快速入门
⚡️以java-jwt为例
添加maven依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
工具类JwtUtils
public class JwtUtils {
// 签名,也叫密钥
public static final String SIGN = "sign";
// 过期时间 12小时
public static final long EXPIRATION = 1000 * 60 * 60 * 12;
public static final String KEY = "KEY";
// 1.生成Token
public static String genToken(Map<String, Object> map) {
return JWT.create()
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION))
.withClaim(KEY, map)
.sign(Algorithm.HMAC256(SIGN));
}
// 2.验证Token
public static boolean verifyToken(String token) {
boolean flag = true;
try {
DecodedJWT verify = JWT.require(Algorithm.HMAC256(SIGN)).build().verify(token);
} catch (JWTVerificationException | IllegalArgumentException e) {
flag = false;
}
return flag;
}
// 3.解析Token
public static Map<String,Object> parseToken(String token) {
DecodedJWT decodedJWT = JWT.decode(token);
return decodedJWT.getClaim(KEY).asMap();
}
}
7.3.3 工作原理
流程图

实现细节:
- 服务器需要对每一次请求都校验是否携带token以及有效。
- token的携带没有放在
cookie里边,而是放在Authrization这个请求头携带的。 - payload载荷部份是简单的
Base64编码负载的内容过大会导致token字符串过长,而请求头对小大是有限制的,因此不能使用token携带过多信息。 - 请求头并不会像
cookie一样每次请求时候自动携带,需要前端js对登录后下发的token保存起来,可以是cookie也可以是localstorage。
⚡️关于加密和解密
学Java大概接触最多的是Base64编码,所谓编码跟我们说的字符编码是一个意思,目的是为了方便信息传输,而不是为了信息加密; 能够编码的信息,意味着能够解码,在JWT中,包含三个部分,其中Header和Payload都是基本的Base64编码,这些信息完全是可以解码的。
解码

所以JWT的关键在于第三部分的签名,其实说签名不如说密钥,而这部分的加密算法就是指定的HS256。
那什么是加密? 一个简单的移位法的例子:小明给小红传纸条“hello world”,但为了让老师即使截获也不知道内容,于是两个人最开始就约定加密通过左移3位,解密对应右移三位,于是加密后的内容为“lo worldhel”,老师初看就得思考这是什么意思了,这就是加密的本质了(以某种特殊的算法改变原有的数据),而双方约定的移动位数3就是密钥,对应的算法就是移位。
对称加密和非对称加密: 对称加密简单理解就是通过同一把密钥进行加密和解密,比如上面的例子就是,JWT也是加密算法(通过SIGN签名生成token,又通过SIGN解析),而非对称加密则是通过私钥加密,再通过公钥解密,如RSA机密算法,SSH远程登录就是应用。
7.4 Session跨域和单点登录
再次回顾解决HTTP协议无状态的本质是什么?请求携带信息和服务器比对信息。
在了解了Token之后,这两个方面就包括多个实现技术了。
请求携带信息:
- 基于
cookie的信息携带,将JESSIONID携带等同于携带了Session对象的引用。 - 基于请求头
Authorization的信息携带,携带了token字符串。
服务器信息比对:
- 基于服务器存储状态的信息对比。
- 基于第三方认证的验证(比如auth0下发token同时负责认证token是否有效)。
Token的出现解决了基于Cookie机制的本质问题:服务器端无存储状态!
而基于Cookie的Session技术存在分布式的情况下,最关键的问题就是Session共享。
Session与Token最大的不同在于,Session依赖于服务器端对Session对象的存储。

7.4.1 解决Session共享问题的方案
解决问题的本质是每次请求到的服务器都持有代表这个用户的Session对象的引用。
1️⃣ Session 数据集中管理
这是最普遍的一种做法,就是将所有的Session对象都存储在第三方容器中,比如基于缓存的Redis和Memecache或者持久存储的MySQL数据库,上面的图示就是这种方案,无论请求到哪台服务器,最终都去同一个地方去Session数据,以此保证Session的共享。
2️⃣ 会话复制
对于分布式集群系统,可以通过主从复制,将一台服务器上的Session复制到另一台服务器上,不过存在主从复制不一致的问题。
3️⃣ 粘滞会话
通过设置Nginx配置,让同一用户每次请求都到同一台服务器上,这个有点像班级管理,你在一班你就由一班负责,其他班级你就不能去访问了,但是很显然这本身就违背了负载均衡的宗旨。
4️⃣ 基于Token(本质不算Session共享)
基于Token认证的方式在于他解决了服务器端需要存储状态的核心问题,Session共享的目的如果只是为了登录认证,那么基于Token的认证方式就是更好的代替方案,但他本质不是解决Session共享问题。
7.4.2 跨域
对
Session共享问题的补充。
前面最开始讲cookie时候简单的把域理解为(IP + 端口)这是有误的。
在Web开发中,一个“域”通常指的是由协议(如http、https)、域名(如www.example.com)和端口号(如80、443) 组成的唯一标识符。这三个部分中的任意一个不同,都表示不同的域。
比如localhost和127.0.0.1都代表本机,但是前一个为域名,后一个为ip,他们就属于不同的域,而不同的域意味着他们的cookie是独立的,如果我访问http://localhost/login登录成功,拿到了JESSIONID,但是我改用http://127.0.0.1/login就需要重新登录了,因为在http://127.0.0.1这个域下没有JESSIONID。

再比如,我通过修改/etc/hosts文件,让127.0.0.1新增两个域名:www.clcao.com和test.clcao.com。
/etc/hosts
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting. Do not change this entry.
##
127.0.0.1 localhost
127.0.0.1 www.clcao.com
127.0.0.1 test.clcao.com
255.255.255.255 broadcasthost
::1 localhost
则请求时候又是不同的域了。

7.4.3 单点登录
单点登录(Single Sign-On) 其实很好理解,当你子系统模块越来越多的时候,每一个系统都有自己的用户管理,然后每个用户使用不同系统时候都需要维护不同的用户名和密码,显然这是繁琐且不好用的操作,这个时候就需要一种集中的认证方式,让用户在一个系统上登录过,在其他系统上都能够使用。
这跟请求是类似的,每次请求对于服务器而言是全新的,然后我们通过cookie携带信息的方式实现一次登录,之后多次请求都不需要登录的功能。把这种本质扩散到单点登录就是,一次登录,在多个子系统都能够正常访问,这又回到了本质问题,多个系统识别我是小明还是小红的本质是什么?客户端携带信息和服务端认证信息。
实现方式思路
客户端携带信息👉
- 如果顶级域相同,比如
www.clcao.com和test.clcao.com,就可以通过共享cookie的方式设置cookie的域为clcao.com,这样无论访问www.clcao.com还是test.clcao.com都会携带这个cookie。 - 重定向,通过重定向到指定的域就会拿到这个域的
cookie,比如使用共同的认证中心,登录时候由认证中心这个域设置cookie,访问系统时候则重定向到认证中心,此时就会携带认证中心这个域的cookie了。
服务端认证信息👉
- 基于存储状态的
Session共享,通过统一的Redis或者数据库,将Session共享。 - 基于
Jwt的鉴定方案,不需要服务端的存储状态即可实现。
主流的实现方案都是基于重定向让客户端携带信息 + 服务端基于Token认证信息的方式,比如CAS和OAuth2.0。
SSO单点登录

单点登录和分布式系统很像,但又存在本质的区别,域的不同意味着在客户端无法携带cookie访问,重定向意味着认证中心认证后还得继续重定向回来;上面的图例是基本思路,并不完整,比如认证之后任何继续访问系统B然后正确返回资源,这个在CAS中是通过CAS Client + CAS Server实现的,即Server端负责认证,重定向到系统B的时候由Client负责鉴定通过还是不通过,以此达到返回资源的目的。
7.4.3.1 单点登录实现方案
1️⃣ 共享Cookie模式
根域名需限制一致,比如www.clcao.com和test.clcao.com在登录之后设置cookie时可以设置域为父域clcao.com。
// 比如 www.clcao.com 则设置 clcao.com 作为这个cookie的域
cookie.setDomain(req.getServerName().substring(req.getServerName().indexOf(".") + 1));
这样无论访问哪个系统,都会携带cookie,客户端携带了信息,然后服务端只需要认证即可,自然就可以实现单点登录了。
2️⃣ 跨域设置cookie模式
如果不是上面的情况下,比如www.ab.com和www.bc.com父域也不相同,正常情况下访问,是一定不会共享cookie的,这个时候就需要跨域设置cookie了,不安全并且麻烦并不推荐。
3️⃣ CAS认证中心模式
Central Authentication Service (CAS) ,就是类似之前例子的jwt的一个东西,如果我把依赖auth0单独部署到一个服务上,然后用它来生成token,就可以做到类似cas认证中心的事情。
4️⃣ OAuth2.0模式
8 注解
在一个web应用中,/WEB-INF/classes和/WEB-INF/lib目录下的注解都可以被解析,这在IDEA中的具像目录为

但是如果在web.xml配置了属性metadata-complete="true"则容器会忽略这些注解。
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
version="5.0" metadata-complete="true">
</web-app>
注解的优先级比web.xml更低,因此如果web.xml配置了,则会覆盖注解的效果。
8.1 常用注解
| 注解 | web.xml | 描述 |
|---|---|---|
@WebServlet | <servlet> | 标记一个类为servlet |
@WebFilter | <filter> | 标记一个类为过滤器Filter |
@WebInitParam | <init-param> | 在Filter或者servlet中的初始化参数 |
@WebListener | <listener> | 注册监听器 |
@MultipartConfig | <multipart-config> | 定义在servlet内用于配置处理多文件的servlet |
8.2 可插拔性
8.2.1 web.xml的模块化
为了更好的插拔性,部署描述符支持片段化的设置,比如web-fragment.xml用于配置某个片段,比如对于框架的/META-INF/lib包就可以通过web-fragment.xml禁用注解然后指定部分servlet,一个web.xml部署描述符并不是必须的,除非你想通过web.xml配置servlet,否则你完全可以不用,这并不是必须的。
基于xml的配置通常都是在容器启动之前就写好的,servlet规范还提供了一种运行时的加载就是ServletContainerInitializer接口(Spring就是用这个接口加载DispatcherServlet的);此接口基于Java的服务提供者机制,通过查找jar包下META-INF/services包里边的文件定义的类全限定名用于在容器启动时候加载,见之前的栗子“Spring中的DispatcherServlet”。
Spring MVC
Spring MVC最早就是叫Spring Web MVC 来源于他的包名spring-webmvc,主要功能就是构建原生的servlet。spring-webmvc不算spring的一个单独产品,比如spring boot,spring-cloud,他同Ioc容器和DI依赖注入概念类似,他属于Spring基础框架Spring Framework的一个子模块,但又略区别于核心概念Ioc容器和DI依赖注入,传统说的SSM其中的两个SS就是Spring和Spring-webmvc可以查看这个包结构

可以看到spring-webmvc 依赖了核心包spring-context和web基础包spring-web,spring-web提供了基于Spring的Web服务的基础架构,包括核心的HTTP集成、Servlet过滤器、Spring HTTP Invoker等功能,以及与其他Web框架和HTTP技术的集成能力。它是Spring框架中用于构建Web应用程序的基础。
由于使用spring通常不会单独使用spring-web,包括文档也是以spring-webmvc来讲的,因此一般直接介绍spring-webmvc。
以下内容参考最新文档:spring framework 6.1.1版本
1 DispatcherServlet
作为一个本质为servlet的类,他本质还是要遵循Jakarta Servlet规范,他的注册需要在容器初始化时候完成。
public class MyWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) {
// Load Spring web application configuration
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(AppConfig.class);
// Create and register the DispatcherServlet
DispatcherServlet servlet = new DispatcherServlet(context);
ServletRegistration.Dynamic registration = servletContext.addServlet("app", servlet);
registration.setLoadOnStartup(1);
registration.addMapping("/app/*");
}
}
之所以能够通过WebApplicationInitializer来添加servlet,是因为在SpringServletContainerInitializer接口中实现了规范类ServletContainerInitializer,而这个类之前讲过,通过Java的服务提供者机制实现的,因此在容器启动时候,就会加载SpringServletContainerInitializer并且调用onStartup方法,而这个方法又调用了WebApplicationInitializer,这就是我们正常使用的一个接口。

1.1 上下文继承体系
在Servlet中一个应用对应一个ServletContext用于描述整个Web应用,同样的在Spring中用ApplicationContext描述一整个Spring应用,其中包含所有的Bean信息。一个普通的Spring应用他的上下文为ApplicationContext,而对于Web应用则是WebApplicationContext(ApplicationContext的扩展,持有ServletContext引用。)。
public interface WebApplicationContext extends ApplicationContext {}
本质还是ApplicationContext为扩展接口。
官网继承图

这里有个很好奇的点,我们知道一个spring应用程序只有唯一一个ApplicationContext这个上下文对象,为什么在Web应用的时候还存在所谓的继承问题呢???这是因为一个Web应用并不一定只存在一个DispatcherServlet,我们完全可以配置多个DispatcherServlet,比如下面的配置:
<!--App1的DispatcherServlet-->
<servlet>
<servlet-name>dispatcherServlet1</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/app1-spring-mvc.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
<servlet-mapping>
<servlet-name>dispatcherServlet1</servlet-name>
<url-pattern>/app1/*</url-pattern>
</servlet-mapping>
<!--App2的DispatcherServlet-->
<servlet>
<servlet-name>dispatcherServlet2</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/app2-spring-mvc.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
<servlet-mapping>
<servlet-name>dispatcherServlet2</servlet-name>
<url-pattern>/app2/*</url-pattern>
</servlet-mapping>
而实际处理请求的处理器需要注册为Bean,那么就需要一个WebApplicationContext,对于共享的Bean就可以添加到Root WebApplicationContext啦,而对于私有的则各自在自己的WebApplicationContext上添加即可。
1.1.2 Root WebApplicationContext的创建
一个最原始的,基于web.xml的spring web应用程序是如何融合SpringContext和ServletContext的?
最基本的Web应用启动流程为:Tomcat容器启动 -> 实例化过滤器 -> 实例化servlet -> 调用init方法。
流程图

而要创建ApplicationContext只能存在两个步骤中,一是实现了ServletContainerInitializer的类中调用,二是通过DispatcherServlet实例化中的构造器或者init方法初始化一个ApplicationContext。
先看现象
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
version="6.0">
<servlet>
<servlet-name>dispatcherServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>servletContextLocation</param-name>
<param-value>/META-INF/mvc-context.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcherServlet</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
</web-app>
只配置了一个DispatcherServlet,并且设置容器启动就实例化,结果如下👇
错误

由此可以知道,SpringContext上下文的创建发生在DispatcherServlet实例化阶段,而通过走读构造器会发现,创建Spring上下文的过程就是发生在init初始化方法,换句话说,Spring的上下文ApplicationContext的创建发生在DispatcherServlet的初始化方法init()中。
没有通过配置类的情况下(定义一个类实现WebApplicationInitializer接口),创建ApplicationContext具体为XmlWebApplicationContext,BeanDefinition源则是基于XML配置的bean,该文件名默认为DispatcherServlet的名称 + -servlet.xml,比如名称为dispatcherServlet则,对应的文件为dispatcherServelet-servlet.xml。
这里有个细节我在设置初始化参数时命名为servletContextLocation,就会出现上面的错误,如果你命名为contextConfigLocation就不会出错,这是DispatcherServlet初始化的时候会读取初始化参数,然后设置参数,关键就在这里设置参数

这个BeanWrapper设置pvs属性键值对时候,是根据属性名跟pvs完全匹配设置的,而在DispatcherServelt的父类FrameworkServelt中就定义了这么一个属性值,因此初始化参数必须是这个名称,如果不是,则按照默认的名称,即上面提到的servlet名称 + “-servlet.xml”。
@Nullable
private String contextConfigLocation;
定义在XmlWebApplicationContext中的方法
/**
根上下文的默认位置是“/WEB-INF/ applicationContext. xml”,命名空间为“test-servlet”的上下文的默认位置是“/ WEB-INF/ test-servlet. xml”(比如servlet名称为“test”的DispatcherServlet实例)。
*/
//@Override
protected String[] getDefaultConfigLocations() {
if (getNamespace() != null) {
return new String[] {DEFAULT_CONFIG_LOCATION_PREFIX + getNamespace() + DEFAULT_CONFIG_LOCATION_SUFFIX};
}
else {
return new String[] {DEFAULT_CONFIG_LOCATION};
}
}
1.2 九大组件
DispatcherServlet的工作依赖九大组件(在Spring中叫特殊的Bean),我们将DispatcherServlet称为前端控制器,就在于一个请求需要怎么处理完全交给DispatcherServlet调度,而他调度的对象就是这九大组件。
Bean Type | 描述 |
|---|---|
HandlerMapping | 处理器映射器,定义了请求和处理器之间的映射关系。 |
HandlerAdapter | 处理器适配器,用于调用处理器真正处理器请求,类比servelt执行doService方法 |
HandlerExceptionResolver | 处理异常的策略 |
ViewResolver | 视图解析器,用于将视图对象View渲染成真正的访问资源,比如JSP页面 |
LocalResolver,LocalContextResolver | 用于解决国际化问题,比如时区问题 |
ThemeResolver | 个性化定制主题 |
MultipartResolver | 用于处理文件类型的请求 |
FlashMapManager | 存储和检索“输入”和“输出”FlashMap,这些FlashMap可用于将属性从一个请求传递给另一个请求,通常通过重定向。 |
以上组件都是接口,需要具体的实现类,因为在spring环境中,所以也叫Bean(类的实例化对象) |
在DispatcherServlet的源码中还包括一个RequestToViewNameTranslator,此列表参考官方文档,实际需要了解的只有加粗的三个组件,其实还有一个需要在概念上理解的:处理器;我觉得很有必要强调处理器本质就是功能上等同servlet的东西,他就是一个方法,同servelt的doService没有任何区别,请求来了,处理请求,然后返回数据,就这一件事,而做这件事情的方法就是处理器,只是在Spring MVC中封装成了其他对象,比如HandlerMethod,他的语义就是处理器方法,具体点的代码如下:
@Controller
public class HelloController{
@GetMapping("/hello")
public String sayHi(){
return "hello!";
}
}
这个HelloController就是处理器,而对应的有一个方法sayHi,就是处理器方法,但是显然我们可以定义多个方法,而真正处理器请求的是某个类的某个具体方法,也就是说类+方法=处理器,像上面的例子HelloController + sayHi就等于一个HandlerMethod,他就是处理器,如果再来个方法hello,那么HelloController + hello就是另一个HandlerMethod,他也是处理器。
1.3 WebMVC配置
有了上面的接口,具体干活的还得是实例对象,这些实例对象我们就可以在spring中定义了,这跟定义任何普通的Bean没有区别,可以基于XML配置,也可以基于注解配置,DispatcherServlet会首先从WebApplicationContext这个容器中查找需要的Bean,如果不存在则采用默认的策略,而默认的这些Bean定义在配置文件DispatcherServlet.properties中,具体位置在spring-mvc.jar包下的org.springframework.web.servelt包下。
默认策略(所谓策略就是指定九大组件的具体实现类是哪个)源码

比如说我注册了一个BeanNameUrlHandlerMapping处理器映射器,那么则会先从WebApplicationContext容器查找,此时自然能找到存在处理器映射器,那么就会返回这一个处理器映射器,如果我们没有注册,那他就来这里找默认的实现类。
1.4 Servlet配置
在Jakarta Servelt时候就特别提到过关于添加Servelt的方式,如果我们没有在web.xml这个部署描述符配置的话,那么添加他还有两种方式,实现ServletContainerInitializer接口和实现ServletContextListener接口。 对应的在Spring中提供了几种配置DispatcherServlet的方法。
方式一 WebApplicationInitializer
之前提到过,这个接口本质和ServletContainerInitializer没有区别,因为SpringServletContainerInitializer在实现此接口时候就是调用WebApplicationInitializer的onStartup方法,换句话说,他就是ServletContainerInitializer的一种平替。
import org.springframework.web.WebApplicationInitializer;
public class MyWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext container) {
XmlWebApplicationContext appContext = new XmlWebApplicationContext();
appContext.setConfigLocation("/WEB-INF/spring/dispatcher-config.xml");
ServletRegistration.Dynamic registration = container.addServlet("dispatcher", new DispatcherServlet(appContext));
registration.setLoadOnStartup(1);
registration.addMapping("/");
}
}
方式二:AbstractAnnotationConfigDispatcherServletInitializer
体系图

本质是对上面接口的扩展,因为配置DispatcherServlet只关注两个核心点,一是配置映射路径,二是设置ApplicationContext的配置。
public class MyWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return null;
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[] { MyWebConfig.class };
}
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
}
上面这种是纯编程式开发了,如果你的Spring是基于XML的方式,则使用下面的例子。
public class MyWebAppInitializer extends AbstractDispatcherServletInitializer {
@Override
protected WebApplicationContext createRootApplicationContext() {
return null;
}
// 设置`Spring`的`Bean`配置文件
@Override
protected WebApplicationContext createServletApplicationContext() {
XmlWebApplicationContext cxt = new XmlWebApplicationContext();
cxt.setConfigLocation("/WEB-INF/spring/dispatcher-config.xml");
return cxt;
}
// 配置路径映射
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
// 配置过滤器
@Override
protected Filter[] getServletFilters() {
return new Filter[] { new HiddenHttpMethodFilter(), new CharacterEncodingFilter() };
}
}
注意基于注解和基于XML配置的区别,AbstractAnnotationConfigDispatcherServletInitializer是AbstractDispatcherServletInitializer的子类。
方式三:web.xml部署描述符
<servlet>
<servlet-name>dispatcherServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<!--名称必须是contextConfigLocation-->
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/applicationContext.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
<servlet-mapping>
<servlet-name>dispatcherServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
1.5 处理过程
DispatcherServelt在处理一个请求的时候:
- 将
WebApplicationContext通过属性Attribute绑定到HttpServeltRequest对象上。 - 将
LocalResolver通过属性Attribute绑定到HttpServeltRequest对象上。 - 将
ThemeResolver通过属性Attribute绑定到HttpServeltRequest对象上。 - 如果设置了
MultipartResolver的Bean(从Spring容器查找名称为multipartResolver的Bean对象),则将请求对象封装为MultipartHttpServletRequest。 - 查找处理器,并根据处理器执行链进行处理(准备模型并且渲染视图)对于
@ResponseBody将直接返回结果,不会进行视图渲染部分。
上面的过程具体化表现如下

HandlerExceptionResolver用来处理异常
自定义一个异常解析器并注册为Bean
public class MyException implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
System.out.println("处理异常");
return new ModelAndView("/static/error.jsp");
}
}
制做异常
@RestController
public class HelloController{
@PostMapping("/hello")
public String hello(@RequestParam MultipartFile file){
System.out.println(file.getOriginalFilename());
int i = 1/0;
return "hello";
}
}
输出

DispatcherServlet支持四种初始化参数:
| 参数名 | 说明 |
|---|---|
contextClass | 指定我们的ApplicationContext,需要实现ConfigurableWebApplicationContext接口,如果不设置,默认为XmlWebApplicationContext这个上下文对象 |
contextConfigLocation | 指定ApplicationContext的配置路径,支持多个路径配置,比如bean1.xml,bean2.xml |
namespace | 指定命名空间名称,默认为[servlet-name]-servlet,这个在contextConfigLocation找不到的时候会使用这个默认的名称 |
throwExceptionIfNoHandlerFound | 找不到处理器的时候是否抛出异常,默认为true,但是自Spring 6.1后这个属性废弃了 |
1.6 拦截器
通过实现HandlerInterceptor接口可以定义一个拦截器,拦截器包括三个方法
public class MyInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle() {
// 返回true则放行,返回false则执行链后续操作都不执行
}
@Override
public void postHandle() {
// 在处理器执行完后执行
}
@Override
public void afterCompletion() {
// 在请求完成后执行
}
}
Note:
对于@ResponseBody或者ResponseEntity的控制器方法而言,由于他们在处理器适配器(HandlerAdapter)时候就将数据写回客户端了,此时还没有执行postHandler,这种情况下意味着一些后置操作将是无效的,如果想对响应进行后续处理可以ResponseBodyAdvice接口,并且标记@ControllerAdvice注册为Bean。
栗子🌰–>自动添加响应头
处理器
// 注意这里没有使用 @RestController@Controller
public class HelloController{
// ResponseEntity 和 @ResponseBody 标记是一样的效果
@GetMapping("/hello")
public ResponseEntity<String> hello(){
return ResponseEntity.ok("hello");
}
}
响应体增强
@ControllerAdvice
public class MyResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 只对 HelloController 这个类进行处理
return HelloController.class == returnType.getContainingClass();
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 对响应对象添加响应头
response.getHeaders().add("MyHeader", "Hello World");
return null; }
}
将此注册为Bean即可。
注册拦截器
基于Java配置
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LocaleChangeInterceptor());
registry.addInterceptor(new ThemeChangeInterceptor()).addPathPatterns("/**").excludePathPatterns("/admin/**");
}
}
基于XML配置
<mvc:interceptors>
<bean class="org.springframework.web.servlet.i18n.LocaleChangeInterceptor"/>
<mvc:interceptor>
<mvc:mapping path="/**"/>
<mvc:exclude-mapping path="/admin/**"/>
<bean class="org.springframework.web.servlet.theme.ThemeChangeInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
1.7 组件:异常解析器
异常的接口为HandlerExceptionResolver他属于九大组件之一,也就是说他在DispatcherServlet初始化的时候遵循一样的策略,即先从Spring容器中找指定的实现类,如果找到了直接返回,如果没找到则采用默认的策略,从DispatcherServlet.properties中定义的。
自定义异常解析器
public class MyException implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
System.out.println("处理异常");
return new ModelAndView("/static/error.jsp");
}
}
然后将其注册Bean即可,其原理就是在DispatcherServlet初始化的时候加载,然后处理异常时候就使用该异常解析器的方法回调。

在处理异常时进行回调

默认异常解析器
| 异常解析器 | 描述 |
|---|---|
SimpleMappingExceptionResolver | 简单的映射错误页面 |
DefaultHandlerExceptionResolver | 提供了丰富的异常类型,根据异常类型匹配响应对应的ModelAndView,比如异常的类型为NoHandlerFoundException则会进行处理 |
ResponseStatusExceptionResolver | 设置异常的状态码,自定义异常时可用 |
ExceptionHandlerExceptionResolver | 通过调用@ControllerAdvice标记的类中@ExceptionHandler标记的方法,相当于给出了一个回调接口供我们使用 |
1️⃣ SimpleMappingExceptionResolver
通过设置错误页面,出现异常时则跳转错误页面。
<bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
<property name="defaultErrorView" value="/static/error.jsp"/>
</bean>
2️⃣ DefaultHandlerExceptionResolver
提供丰富但是有限的异常类型进行处理,比如NoHandlerFoundException的捕获处理。
@Controller
public class HelloController{
@GetMapping("/hello")
public ResponseEntity<String> hello() throws NoHandlerFoundException {
try {
int i = 1/0;
} catch (Exception e) {
throw new NoHandlerFoundException("","", HttpHeaders.EMPTY);
}
return ResponseEntity.ok("hello");
}
}
输出

3️⃣ ResponseStatusExceptionResolver
通过此解析器可以给自己自定义的异常设置状态码。
@ResponseStatus(code = HttpStatus.CONFLICT)
public class MyException extends RuntimeException {
}
// 在捕获异常的时候抛出
@Controller
public class HelloController{
@GetMapping("/hello")
public ResponseEntity<String> hello() throws NoHandlerFoundException {
try {
int i = 1/0;
} catch (Exception e) {
throw new MyException();
}
return ResponseEntity.ok("hello");
}
}
输出

4️⃣ ExceptionHandlerExceptionResolver
通过查找~~@Controller~~(官方文档提到了,但实际源码并没有)或者@ControllerAdvice标记的类中@ExceptionHandler标记的方法调用,进行异常的处理,很像暴露一个回调接口供我们使用,这个是大多数教学时候提到的全局异常处理方式。
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public String handleException(Exception e){
return "/static/error.jsp";
}
}
然后将此类注册为Bean即可。
其原理是ExceptionHandlerExceptionResolver该解析器在生命周期回调InitializingBean接口方法时候查找存在此@ControllerAdvice注解的Bean。

Note: 通常情况下,我们没有注册异常解析器,则会使用默认的异常解析器
- ExceptionHandlerExceptionResolver
- ResponseStatusExceptionResolver
- DefaultHandlerExceptionResolver
按照上面的顺序依次执行,如果第一个解析器正确处理了异常,则下面的异常解析器不会处理,因此上面的例子,全局异常处理通常是最高级别的。
2 DispatcherServlet原理
假如我们用原生的Servlet做一个Web应用,如果不是使用注解,不可避免的我们需要配置一堆servlet-mapping在web.xml,而DispatcherServlet做了什么事呢,调度员!就是说你所有的请求都不要单独处理了,直接全部交给我,而一个调度员的存在,意味着就需要分派dispatcher。
最简单的逻辑

要使用调度员DispatcherServlet最基本的东西是存在一个url和servlet的映射关系表,通过请求URI定位到具体的servlet(处理器),然后交给真正的servlet执行,需要注意的是,在调用栈的角度,servlet1执行完是需要返回DispatcherServlet继续执行转发之后的代码的。为了不需要配置servlet我们定义一个Handler作为处理器请求的实际类(servlet的本质功能就是处理请求)。
// 伪代码
doDispatcher(req,resp) // ==> 转发给 servlet1 处理
// servlet1 处理完还得回来这里,而不是说一次请求就结束了
System.out.println("继续执行...")
代码实例
// 调度器DispatcherServlet
@WebServlet("/*")
public class MyDispatcherServlet extends HttpServlet {
// 用于存放 url对应的处理器
private final Map<String, Handler> handlers = new HashMap<>();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doDispatch(req, resp);
}
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
initHandler();
}
// 在DispatcherServlet实例化之后就应该记录好所有的处理器和路径的映射关系
private void initHandler() {
// 如果是spring容器,则直接getBeanOfType();
handlers.put("/hello", new HelloHandler());
handlers.put("/test", new TestHandler());
}
private void doDispatch(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String requestURI = req.getRequestURI();
if (handlers.containsKey(requestURI)) {
handlers.get(requestURI).handle(req, resp);
}
}
}
// 处理器(等价servlet功能,实际处理请求的就是他们)
public interface Handler {
void handle(HttpServletRequest req, HttpServletResponse resp) throws IOException;
}
public class HelloHandler implements Handler{
@Override
public void handle(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.getWriter().println("HelloHandler");
}
}
public class TestHandler implements Handler{
@Override
public void handle(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.getWriter().println("TestHandler");
}
}
输出

这是DispatcherServlet的最基本的实现思路,虽然实际上Spring的DispatcherServlet要完善和健壮的多,但是基本逻辑是一致的。
2.1 路径映射

在Spring中处理路径映射时,将处理器和对应的处理方法封装为HandlerMethod,这个可以理解为一个servlet只有一个doService方法,这个方法才是真正负责处理某个路径映射的请求的。
@RestController
public class HelloController implements Controller {
@GetMapping("/hello1")
public String hello1(){
return "hello1";
}
@GetMapping("/hello2")
public String hello2(){
return "hello2";
}
}
比如这样的一个Controller,他包含两个请求处理的方法,则对应两个HandlerMethod(完全可以理解为替代的Servlet),一个HandlerMethod他的映射路径为/hello1,另一个对应的则为/hello2。
但实际上你可能接触更多的概念是HandlerMapping(处理器映射),我们有了HandlerMethod之后要怎么找到这个处理器方法(在功能上理解就是一个servlet)呢,那就需要一个映射了,比如我们自己定义的Map<String,Handler>这种,然后通过一个请求的URI去匹配才能找到,而这个就是HandlerMapping做的事情,处理器映射器。
HandlerMapping接口就一个核心方法
public interface HandlerMapping {
HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception;
}
接口意味着存在实现类,而不同的实现类意味着请求映射处理器的方式就不同,这个在官方文档就给了很好的解释👇

三种不同的方式定义请求和处理程序对象之间的映射(按照顺序):
BeanNameUrlHandlerMappingRequestMappingHandlerMappingRouterFunctionMapping(Spring 5.2才开始有)
其中BeanNameUrlHandlerMapping是按照bean的名称进行匹配,比如你的HelloController,则为/hello,必须以/开头命名这个Bean。
<bean class="com.clcao.controller.HelloController" name="/hello"/>
其次为RequestMappingHandlerMapping这应该是最常见的比如上面的例子就是,他需要你的处理器类标注@Controller注解,并且类或者方法存在@RequestMapping注解,这个处理器映射返回的处理器类型就是HandlerMethod。
最后RouterFunctionMapping需要你的处理器类(理解上就想象你常见的Controller类)需要实现RouterFunction接口。
栗子🌰
public class HelloRouterFunction implements HandlerFunction<ServerResponse> {
@Override
public ServerResponse handle(ServerRequest request) throws Exception {
if (RequestPredicates.GET("/router").test(request)) {
return ServerResponse.ok().body("RouterFunction");
}else
return ServerResponse.notFound().build();
}
}
注意需要将HelloRouterFunction注册为Bean交给Spring容器管理。
HandlerExecutionChain包含两层东西,一是我们的servlet,也就是HandlerMethod(处理器方法),二是拦截器HandlerInterceptor。这就是单词的本意,HandlerExecutionChain(处理器执行链)。
spring对处理器的封装

其实到这里已经可以了,但是Spring在HandlerExecutionChain中并没有提供处理器调用方法执行的方法,倒是在这里嵌入了处理器的钩子函数。
HandlerExecutionChain核心源码部分
public class HandlerExecutionChain {
// 处理器对象,这里是Object意味着可以是任何类型的处理器,上面的处理器就是HandlerMethod
private final Object handler;
// 拦截器容器
private final List<HandlerInterceptor> interceptorList = new ArrayList<>();
// 拦截器的前置调用,拦截器接口方法的回调就是在这里
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
for (int i = 0; i < this.interceptorList.size(); i++) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
this.interceptorIndex = i;
}
return true;
// 获取处理器
public Object getHandler() {
return this.handler;
}
}
为什么说到这里就可以了呢,针对这一种处理器(@Controller标记的处理器)完全是可以的,通过HandlerMapping就可以找到对应的处理器,然后让处理器执行对应的方法即可,但是Spring对处理器做了扩展,也就是适配器模式的实现,通过添加一层HandlerAdapter来适配,这样做的好处是处理器方面可以任意扩展,返回值方面规范为ModelAndView。
HandlerAdapter接口
public interface HandlerAdapter {
// 根据处理器来判断适配器是否支持,一般情况下为1对1,即一个处理器映射器只能支持一种处理器
boolean supports(Object handler);
// 核心方法,入参==>请求对象,响应对象,处理器 返回值==>ModelAndView
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
}
既然是接口,意味着他存在不同的实现,这个实现的功能就是去适配不同的处理器(真正处理请求的那个Controller或者叫servlet都行)。
在Spring中存在以下几种处理器(按照顺序):
HttpRequestHandlerAdapterSimpleControllerHandlerAdapterRequestMappingHandlerAdapterHandlerFunctionAdapter(SimpleServletHandlerAdapterDispatcherServlet初始化时候并没有添加这个适配器)
处理器适配器适配条件表
| 处理器适配器 | 适配条件 |
|---|---|
HttpRequestHandlerAdapter | 实现HttpRequestHandler接口的Bean |
SimpleControllerHandlerAdapter | 实现Controller接口的Bean |
RequestMappingHandlerAdapter | 属于HandlerMethod(@Controller注解标记的类,并且@RequestMapping标记的方法或者类) |
HandlerFunctionAdapter | 实现HandlerFunction接口的Bean |
注意上面的适配条件都需要将处理器类注册为Bean交给Spring管理。
到这里MVC(Model And View)中的Model就已经处理完了,剩下的则是适配器调用处理器方法返回视图模型ModelAndView了。
HandlerMapping和HandlerAdapter体系图

Note:
- 处理器映射器和处理器适配器只为了完成一件事,请求找到对应的
servlet的替代(处理器)然后执行。 - 所谓处理器就是对
servlet的取代,包括上面四种适配器支持的类型。 - 映射器和适配器存在顺序问题,比如针对
BeanNameUrlHandlerMapping可以实现多个接口,这意味着在找适配器的时候存在多个,而多个时则会优先第一个然后直接返回。
2.2 处理请求
所谓视图模型就是ModelAndView这个类,他代表了模型数据和视图页面,经典的模型数据是JSP页面中的动态数据,视图页面可以是HTML或者JSP页面。
正式处理请求由HandlerAdapter调用handler方法完成,因为不同的处理器对应的处理器适配器也不同,因此调用处理器方法执行的细节也不相同。
HandlerAdapter的方法
@Nullable
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
可以看到入参都是一样的,请求对象,响应对象,以及处理器。
RequestMappingHandlerAdapter
以常见的用法RequestMappingHandlerAdapter为例,handle()调用的过程最终由定义在其内的方法invokeHandlerMethod完成,因此我们只需要关注这个方法的具体过程就好了。
核心步骤:
- 根据处理器(
HandlerMethod)创建一个HandlerMethod调用程序ServletInvocableHandlerMethod,主要作用就是调用处理器方法的,可以理解为执行servlet中doService方法。 - 设置参数解析器(用来处理参数的比如
@RequestParam()注解要如何解析就是这里定义的解析器处理的) - 设置返回值处理器
- 创建
ModelAndViewContainer并且初始化模型数据(参数@ModelAttribute解析) - 调用处理器方法
invocableMethod.invokeAndHandle(webRequest, mavContainer) - 获取
ModelAndView返回
1️⃣ 创建调用程序
public class InvocableHandlerMethodTest {
@Test
public void testInvocableHandler() throws Exception {
// 1.构建处理器对象
HandlerMethod handlerMethod = new HandlerMethod(new HelloController(), HelloController.class.getMethod("hello"));
// 模拟请求部分
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/hello");
MockHttpServletResponse response = new MockHttpServletResponse();
ServletWebRequest webRequest = new ServletWebRequest(request, response);
// 2.创建处理器调用程序
ServletInvocableHandlerMethod ServletInvocableHandlerMethod invocableMethod = new ServletInvocableHandlerMethod(handlerMethod);
ModelAndViewContainer mac = new ModelAndViewContainer();
// 3.设置返回值处理器
HandlerMethodReturnValueHandlerComposite composite = new HandlerMethodReturnValueHandlerComposite();
composite.addHandler(new RequestResponseBodyMethodProcessor(List.of(new MappingJackson2HttpMessageConverter(new ObjectMapper()))));
invocableMethod.setHandlerMethodReturnValueHandlers(composite);
// 4.执行调用
invocableMethod.invokeAndHandle(webRequest,mac);
// 查看响应结果
System.out.println(response.getContentAsString());
}
}
输出

原则上,@RequestController注解也是可以省略的,也就是说一个HandlerMethod只需要知道哪个类的哪个方法即可,但是考虑到返回值是字符串,这意味着需要@ResponseBody注解,这个是由返回值解析器决定的,例子使用的返回值解析器为RequestResponseBodyMethodProcessor就是处理字符串数据返回的。
2️⃣ 设置参数解析器
参数解析器的接口为HandlerMethodArgumentResolver,以下是RequestMappingHandlerMethodAdapter这个适配器所有的参数解析器。
所有默认添加的参数解析器

熟悉参数解析器之前再看HandlerMapping和HandlerAdapter,我们已经知道这俩高大上的名词实际作用只有一个,就是跟传统的servlet执行doService处理请求没有任何区别,而要通过请求找到对应的处理器,就需要一个映射关系,而这就是用HandlerMapping来表示的,而为了适配不同的处理器又扩展了一个HandlerAdapter适配器接口。那这些都准备好了就该真正执行处理请求了,而处理请求的本质就是一个方法入参,得到一个结果返回,在Spring中规范为ModelAndView一个包含模型数据和视图(JSP、HTML等)的模型视图对象。 这里就带来了两个核心问题:如何解析参数和如何设置返回值。
我们再回顾下最基本的Servlet中HttpServletRequest对象对请求参数的处理规则:
GET请求没有请求体,直接getParameter等方法获取即可POST请求必须设置Content-Type为application/x-www-form-urlencoded,这也是表单提交默认的- 如果是文件上传,则必须指定
Content-Type为multipart/form-data,而servlet解析文件则必须为这个servlet指定multipart-config配置,可以是web.xml也可以是注解。
现在我们将servelt替换为处理器了,对应的我们就需要对处理器方法的入参进行解析,比如:
@RestController
public class HelloController{
// 这里的处理器方法就是 hello() ==> 对应的参数为 name
@GetMapping("/hello")
public String hello(String name){
return "hello";
}
}
而这就需要参数解析器了,那参数解析器又从哪里来呢?
解析器添加流程图

讲到DispatcherServlet这又回到了Jakarta Servlet的内容,DispatcherServlet的本质还是一个Servlet,那么他的生命周期就是一样的,实例化、初始化、处理请求、销毁(Tomcat容器关闭才执行)。因此在处理请求之前可以准备很多事情,在DispatcherServlet初始化阶段,就初始化了九大组件;参数解析器位于HandlerAdapter处理器适配器调用处理器执行的过程中,解析器就是在这里添加的👉initHandlerAdapters,这里加载的几个处理器适配器规则也很简单,通过配置文件预定义的类。
spring-webmvc.jar包下的配置文件

这也是在讲路径映射的时候为什么官方文档提到了这几个默认的处理器适配器。而我们的重点则是RequestMappingHandlerAdapter了,要让适配器实例持有参数解析器的引用,意味着创建此适配器的时候就应该给属性赋值,在Spring中交给容器管理的Bean都遵循同一套流程,即Bean的创建过程,此处理器适配器就是交给Spring容器管理的Bean。
Spring创建Bean的流程

再看RequestMappingHandlerAdapter的类定义
public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
implements BeanFactoryAware, InitializingBean {
// 省略实体部分
}
有意思的是此处理器适配器实现了InitializingBean接口,再对比创建Bean的流程,可以知道此实例Bean会在依赖注入后,进行生命周期函数的回调,在这里就会回调InitializingBean接口的方法afterPropertiesSet,那我们就可以看看这个回调方法做了什么事情了。

可以看到这里做了几个重要的事情:
- 初始化消息转换器
- 设置参数解析器
- 设置返回值处理器
此部分要讲的主角,参数解析器,正是在这里添加的,而这个添加,则是这个章节的开头部分,一堆new出来的参数解析器HandlerMethodArgumentResolver。
3️⃣ 设置返回值处理器
同上,在DispatcherServlet初始化的时候会将ReqeustMappingHandlerAdapter交给Spring作为Bean处理,遵循Spring创建Bean的流程,在生命周期回调函数中设置了返回值处理器。
所有默认添加的返回值处理器

4️⃣ 调用处理器方法(重点部分)
前面准备的参数解析器,返回值处理器,ModelAndViewContainer都是为了这一步实际调用,即
invocableMethod.invokeAndHandle(webRequest, mavContainer);
此方法的调用链核心为
invokeAndHandle
- invokeForRequest
因此我们的重点就在invokeForRequest方法了,此方法的源码如下

这里的文档可以看到解析参数的参数解析器HandlerMethodArgumentResolvers,具体怎么解析呢?看源码第一行
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
核心部分在此,此方法源码如下

核心步骤,一判断支持参数,二解析参数。
这里的解析参数和HandlerAdapter是否支持处理器类型很像,接口都提供了一个方法用于判断是否支持该类型,在这里以RequestParamMapMethodArgumentResolver为例
@Override
public boolean supportsParameter(MethodParameter parameter) {
RequestParam requestParam = parameter.getParameterAnnotation(RequestParam.class);
return (requestParam != null && Map.class.isAssignableFrom(parameter.getParameterType()) &&
!StringUtils.hasText(requestParam.name()));
}
可以看到这个参数解析器支持的是@RequestParam注解指定的参数。通过这样轮训判断是否支持就可以找到对应的参数解析器,然后根据调用其方法resolveArgument解析参数,而默认添加的参数解析器就有15种,还可以支持添加自定义参数解析器,每一个参数解析器在resolveArgument内都定义了解析的核心逻辑,实在太多,就不再深入了。
参数处理完了,就该调用处理器方法了,比如
@GetMapping("/hello")
public String hello(String name){
return "hello";
}
这里会比较简单,我们已经有了参数,以及方法Method对象,再去拿对应的实例对象就可以通过反射调用方法了,即method.invoke(obj,args)。
又一个重点来了,拿到参数调用完处理器方法,得到返回值了,比如上面的返回值字符串hello,要如何响应给客户端呢?

同参数解析器基本原理一样,一判断是否支持返回值类型,二处理返回值类型,以RequestResponseBodyMethodProcessor为例,判断是否支持的规则如下
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) ||
returnType.hasMethodAnnotation(ResponseBody.class));
}
可以看到需要方法所在的类标记@ResponseBody注解或者方法标记都可以,具体例子长下面这样
// 1.在类上标记 @ResponseBody
@RestController
@ResponseBody
public class HelloController{
// 2.在方法上标记 @ResponseBody
@GetMapping("/hello")
@ResponseBody
public String hello( String name){
return "hello";
}
}
他处理的规则则是直接写消息返回

封装Response对象响应数据,这里就包括设置状态码,设置响应头等信息。注意这个返回值处理器会直接返回数据,换句话说他不存在ModelAndView的视图解析过程!
@ResponseBody在处理器是配置调用完方法,请求就已经得到响应了,不存在ModelAndView的解析过程

所以ModelAndView到底是个啥?
2.3 视图解析
要了解ModelAndView不妨先看类定义

这个类其实很简单,可以理解为是一个容器,包括模型数据和视图,那视图又到底是个啥呢,HTML页面?JSP页面?还是字符串?
@Override
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) {
ModelAndView mav = new ModelAndView("hello");
return mav;
}
这样的使用我们说视图名为hello,那视图在哪呢?如文档所说,ModelAndView需要交给DispatcherServlet解析,在上面已经分析了,处理器适配器处理请求返回一个ModelAndView,并且对于@ResponseBody注解的情况,在这一步就已经返回数据了,也就是说请求结束了!但是对于一般没有这个注解的情况呢,就需要对视图进行解析了。
一个具象的例子,如上面的返回,断点查看如下

这个ModelAndView中的view就是一个字符串hello,而模型model因为没设置就是null。
拿到ModelAndView的后续动作就是,视图渲染,怎么个渲染法呢?

这里有句话关键就是“可能涉及按名称解析视图”;该render方法定义在类DispatcherServlet内。
该方法的源码如下

类似处理器映射(HandlerMapping)和处理器适配器(HandlerAdapter),视图解析器(ViewResolver)也是九大组件之一,在DispatcherServlet初始化的时候就定义了。

所有默认添加的视图解析器

可以看到相比较处理器映射器,这要简单的多,只有一个InternalResourceViewResolver,那他解析的规则如何呢,不妨继续深入看源码。

视图解析器通过调用方法resolveViewName返回一个View对象
View resolveViewName(String viewName, Locale locale) throws Exception;
获取View对象的逻辑主要由他的父类UrlBaseViewResolver完成,定义在该类的方法buildView,其源码如下

这里做了一个URL拼接的事情,前缀 + 视图名称 + 后缀,干什么用呢?答案是转发的URL!
通过看这个最基本的视图解析器的父类UrlBasedViewResolver文档,得到的信息会更多。

这就是为什么我们可以使用forward:或者redirect:这种用法的原因,同时这里也说明了,这是一种URL资源,意味着视图==>本质上是找到JSP这个页面,而模型则是JSP页面需要取值的动态数据,所以你看,其实在现在主流开发@RestController这种方式下,我们是用不到视图解析器的,或者说视图解析器,更多的是为了JSP页面技术,这个已经过时的技术。
视图渲染的本质是转发请求

注意:通过ModelAndView跳转页面的本质是请求转发,请求转发会再次被DispatcherServlet拦截,从而导致404错误,因此通过ModelAndView方式跳转视图资源一定需要做对应的配置。

为了解决ModelAndView跳转视图的问题,有几种方式可以解决:
DisPatcherServlet的路径匹配设置/而不是/*,前者只会匹配servlet的请求,后者会匹配所有请求包括静态资源。- 为静态资源添加专门的处理器
这也是为什么通常配置DispatcherServlet的路径映射时候推荐/而不是/*的原因。
那如果使用/*导致视图页面进行forward时候再次进来DispatcherServlet呢,这个时候就需要配置静态资源的处理器了,在Spring中的XML配置中添加
<!--方式一:添加处理器,默认为:org.springframework.web.servlet.resource.ResourceHttpRequestHandler-->
<mvc:resources mapping="/static/**" location="/static/"/>
<!--方式二:添加处理器,默认为:rg.springframework.web.servlet.resource.DefaultServletHttpRequestHandler-->
<mvc:default-servlet-handler/>
<!--如果是`RequestMappingHandlerMapping`则必须加,不然没有`RequestMappingHandlerAdapter这个适配器`-->
<mvc:annotation-driven/>
⚡️关于mvc:标签的解析

之前看<context:annotation-config/>的时候就很好奇,为什么添加这个标签就等价于注册了五种后处理器,而现在在处理器静态资源的时候为什么<mvc:annotation-driven/>不添加就没有RequestMappingHandlerAdapter这个处理器适配器?
实际上,xml中声明的mvc命名空间http://www.springframework.org/schema/mvc是可以直接访问的,这个xsd文件定义和约束了,我们可以怎么写标签,比如<mvc:resources mapping="/static/**" location="/static/"/> 中的mapping和location就是必须属性。

根据规范好的书写,spring就可以正确的解析xml文件了,而对应的,就应该存在解析类,而这个类就会进行一些后处理器的注册,或者mvc中的静态资源处理,不同的标签都有对应的解析器。
<mvc:xxx>的定义

<mvc:annotation-driver>的解析类

存在jackson依赖就会添加此消息转换器

因此如果开启此注解,可以不需要手动声明MappingJackson2HttpMessageConverter这个消息转换器了。
2.4 整体流程
流程图

Note:
- 处理器映射定义了请求和处理器之间的映射关系,通过请求的
URI就可以找到对应的处理器了。 - 处理器可以是任意形式,其本质就是替换
servelt的doService方法。 - 处理器执行链是对处理器和拦截器的封装,可以理解为容器,返回此对象的时候,实际上是返回了处理器对象,供处理器适配器调用执行。
- 处理器适配器执行结果统一返回
ModelAndView对象,如果是@Responsebody标记的处理器则直接返回结果,响应结束。 - 简单理解
ModelAndView就是对模型和视图的容器,其中视图为View对象,可以是字符串(对应JSP页面等),而模型则是JSP页面的动态数据。 - 所谓渲染最简单的一种情况就是拼接字符串,比如视图名称为
hello则根据视图解析器的前缀和后缀拼接一个URL供请求转发使用。 - 请求转发后的视图页面比如
/WEB-INF/hello.jsp,如果DispatcherServlet配置的映射路径为/*则会再次进入DispatcherServlet,因此推荐使用/,则不会匹配到静态资源,如果需要/*则需要配置静态资源的处理器。 - 只有
@ResponseBody标记的处理器或者返回值为ResonseEntity不走ModelAndView,其他情况都是通过ModelAndView返回静态资源。
⚡️ 关于请求参数解析
HandlerMapping和HandlerAdapter体系图

我们写“Controller”层的方式意味着我们的处理器类型属性哪种,具体实现对应上图存在下面几种方式:
- 普通的
Bean,实现HttpRequestHandler接口或者Controller接口
// 方式一: 实现 Controller
public class BeanNameUrlController implements Controller{
@Override
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) {
ModelAndView mav = new ModelAndView("hello");
return mav;
}
}
// 方式二: 实现 HttpRequestHandler
public class BeanNameUrlController implements HttpRequestHandler {
@Override
public void handleRequest(HttpServletRequest request, HttpServletResponse response) {
}
}
这两种方式都有一个通性,都没有定义路径匹配,那么他怎么匹配请求呢,这就是所谓的BeanNameUrlHandlerMapping,通过定义Bean的名字以/开头,来匹配路径。这两个的区别则在于返回值,Controller接口要返返回模型视图对象,而HttpRequestHandler则不返回任何结果这其实是跟我们最原始的Servlet一样的,在处理静态资源的方式上,这就是其中一种,将处理器实现HttpRequestHandler,让请求交给容器处理。
这两种方式对应的处理器适配器都是简单的调用处理器返回结果,不做任何参数解析和返回值处理。
- 标记
@Controller注解,,同时标记@RequestMapping的类。
@Controller
public class HelloController{
@GetMapping("/hello")
public String hello(){
return "hello";
}
}
注意这里没有使用@RestController,意味着没有@ResponseBody注解,所以返回值是当作ModelAndView页面跳转处理,因此会走视图解析器。
这种处理器对应的处理器适配器是最复杂的,也只有RequestMappingHandlerAdapter这个处理器适配器存在参数解析和返回值处理的概念。
参数解析流程

Note:
- 只有
RequstMappingHandlerAdapter才涉及参数解析,而此处理器适配器处理器类型为HandlerMethod,换句话说,只有@Controller+@RequestMapping的方式才涉及参数解析。 - 参数解析的入口为
InvocableHandlerMethod类中的invokeForRequest方法。 - 对于
@RequestParam注解涉及获取参数名称问题,即ParamNameDiscovery策略,包括反射获取和本地变量表两种。 - 对于
@RequestParam注解涉及数据绑定工厂问题,即DataBindFactory。 - 对于
@RequestBody注解涉及消息转换器配置问题,即MessageConverter。
常见参数接收方式
我们不妨回顾下Jakarta Servlet对请求参数的接收存在的几种情况。
- 对于
Get请求,由于没有请求体,参数是直接通过URL携带的,可以直接通过Request对象的方法getParameter等方法获取。 - 对于
Post请求- 如果是表单提交,默认请求体类型为
application/x-www-form-urlencoded,可以直接通过Request对象的方法getParameter等方法获取。 - 如果是表单提交,并且设置请求体类型为
multipart/form-data(文件上传),可以直接通过Request对象的方法getPart等方法获取。 - 其他请求体类型,比如
application/json,则必须通过IO流读取请求体内容,自行转换。
- 如果是表单提交,默认请求体类型为
而与之对应的Spring接收参数也就是上面几种情况。
@RequestParam用于接收所有Request对象可以获取到参数的情况,包括Get请求,Post表单提交数据或者文件。@RequestBody用于接收携带请求体需要消息转换器自行转换的参数。- 额外的对于路径参数通过
@PathVariable接收(这是Jakarta Servlet本身不具备的)。
1️⃣ 方式一:@RequestParam注解接收
下面几种情况都是由RequestParamMethodArgumentResolver参数解析器完成。
@RestController
public class HelloController{
// ----------- GET 普通参数---------------\\
// 方式一:@RequestParam指定名称
@GetMapping("/hello1")
public String hello1(@RequestParam("name") String name){
System.out.println(name);
return "hello1";
}
// 方式二:省略@RequestParam 依赖ParamNameDiscovery参数名发现策略,自spring6.1开始弃用本地变量表,编译需加参数-parameters
@GetMapping("/hello2")
public String hello2(String name){
System.out.println(name );
return "hello2";
}
// 方式三:占位符解析参数${}
@GetMapping("/hello3")
public String hello3(@RequestParam(value = "abc",defaultValue = "${java.home}") String name){
System.out.println(name);
return "hello3";
}
// 方式四:非字符串类型需要数据类型转换
@GetMapping("/hello4")
public String hello4(@RequestParam("age") int age){
System.out.println(age);
return "hello4";
}
// ----------- GET 数组、集合 @RequestParam不可省略 ---------------\\
// 方式五:数组接收参数
@GetMapping("/hello5")
public String hello5(@RequestParam("hobbies") String[] hobbies){
System.out.println(Arrays.toString(hobbies));
return "hello5";
}
// 方式六:List集合接收参数
@GetMapping("/hello6")
public String hello6(@RequestParam("hobbies") List<String> hobbies){
System.out.println(hobbies);
return "hello6";
}
// 方式七:Set集合接收参数
@GetMapping("/hello7")
public String hello7(@RequestParam("hobbies") Set<String> hobbies){
System.out.println(hobbies);
return "hello7";
}
// ----------- POST ---------------\\
// 方式八:接收表单提交
@PostMapping("/hello8")
public String hello8(String username, String password){
System.out.println(username + " : " + password);
return "hello8";
}
// 方式九:接收文件类型
@PostMapping("/hello9")
public String hello9(@RequestParam("file") MultipartFile file){
System.out.println(file.getOriginalFilename());
return "hello9";
}
}
输出

Note:
- 对于非字符串类型,比如接收
Date类型,则需要提供转换器,否则会出现类型转换异常,这本质是Request对象的getParameter()方法返回的永远是字符串,因此需要数据绑定工厂绑定一个转换器。 - 对于文件类型,需要设置
Multipart-Config这个是由Servlet规范决定的,可以加载web.xml上也可以通过基于Java的编程方式添加。 - 省略
@RequestParam注解的方式依赖ParameterNameDiscovery。
编程式添加配置支持处理文件上传
public class AppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
// ...
@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
// Optionally also set maxFileSize, maxRequestSize, fileSizeThreshold
registration.setMultipartConfig(new MultipartConfigElement("/tmp"));
}
}
什么是ParameterNameDiscovery
我们知道Request对象获取参数需要getParameter(name),参数需要一个字符串,即参数名,而这个名称怎么来呢,就是ParameterNameDiscovery,Spring提供了两种发现策略,反射获取参数名和本地变量表。
javac编译存在三种情况:
javac(不记录参数名称信息)javac -g(对应本地变量表)javac -parameters(对应反射获取参数)
栗子🌰
public class Demo {
public void sayHi(String name, int age) {}
}
对应三种编译情况(javap -c -v命令查看)

而在Spring中提供了StandardReflectionParameterNameDiscoverer类用于反射获取参数名称,LocalVariableTableParameterNameDiscoverer类用于本地变量表获取参数名称,二者统一在DefaultParameterNameDiscoverer这个类中,因此默认添加的参数名称发现都是后者,不过需要注意的是,从Spring 6.1开始移除了本地变量表的方式。

如果你使用IDEA集成Tomcat启动,默认情况下不会添加编译参数-parameters,这在Spring 6.1版本以下不会出现问题,但是如果使用最新版本,那么省略@RequestParam注解,准确的说是没有该注解指定参数名称,比如@RequestParam("username"),那么就无法接收参数,此时可以在pom.xml文件指定编译参数。
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
2️⃣ 方式二:@RequestBody接收参数
下面几种情况都是由RequestResponseBodyMethodProcessor参数解析器完成。
@RestController
public class HelloController{
// 方式一:User数据模型接收参数(需要属性名和请求参数名对应)
@PostMapping("/hello1")
public String hello1(@RequestBody User user) {
System.out.println(user);
return "hello1";
}
// 方式二:Map接收参数
@PostMapping("/hello2")
public String hello2(@RequestBody Map<String, String> map) {
System.out.println(map);
return "hello2";
}
// 数据模型 User
@Data
static class User{
private String name;
private int age;
}
}
Note:
@RequestBody接收请求体参数,需要搭配消息转换器MessageConverter。- 单纯的
spring mvc项目中,默认不存在json的消息转换器MappingJackson2HttpMessageConverter,需手手动添加。 - 消息转换器定义在
RequestMappingHandlerAdpater这个处理器适配器对象中,而不是参数解析器内。
3️⃣ 方式三:@PathVariable接收参数
下面几种情况都是由PathVariableArgumentResolver参数解析器完成。
@RestController
public class HelloController{
@GetMapping("/hello/{id}")
public String hello(@PathVariable Integer id){
System.out.println(id);
return "hello";
}
}
4️⃣ 方式四:其他情况
下面几种情况很少见,以及请求头和cookie值获取严格意义上讲不属于请求参数范畴。
@RestController
public class HelloController{
// 情况一:@ModelAttribute 接收模型参数 ==> 解析器:ServletModelAttributeMethodProcessor
@PostMapping("/hello1")
public String hello1(@ModelAttribute User user){
System.out.println(user);
return "hello1";
}
// 情况二:省略@ModelAttribute 接收模型参数,解析器同上
@PostMapping("/hello2")
public String hello2(User user){
System.out.println(user);
return "hello2";
}
// 情况三:@RequestHeader 接收请求头参数 ==> 解析器:RequestHeaderArgumentResolver
@PostMapping("/hello3")
public String hello3(@RequestHeader String token){
System.out.println(token);
return "hello3";
}
// 情况四:@CookieValue 接收cookie参数 ==> 解析器:ServletCookieValueMethodArgumentResolver
@PostMapping("/hello4")
public String hello4(@CookieValue String JSESSIONID){
System.out.println(JSESSIONID);
return "hello4";
}
@Data
static class User{
private String name;
private int age;
}
}



















