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
接口中定义了获取参数的方法:
getParameter
getParameterNames
getParamterValues
getParameterMaps
对于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
表示应用上下文路径,对应方法getContextPath
ServletPath
表示Servlet
对应的路径,对应方法getServletPath
PathInfo
表示既不是contextPath
也不是ServletPath
的部分,对应方法getPathInfo
上面所有方法都是HttpServletRequest
中定义的,一个请求路径下面的等式是永远成立的。
requestURI = contextPath + servletPath + pathInfo
栗子🌰:/app/user/hello/index.html
ContextPath
为/app
ServletPath
为/user/hello
PathInfo
为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
中定义了缓冲相关的方法:
getBufferSize
setBufferSize
isCommitted
reset
resetBuffer
flushBuffer
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>
设置响应编码的方法有三种:
setCharacterEncoding
setContentType
setLocale
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;
}
接口意味着存在实现类,而不同的实现类意味着请求映射处理器的方式就不同,这个在官方文档就给了很好的解释👇
三种不同的方式定义请求和处理程序对象之间的映射(按照顺序):
BeanNameUrlHandlerMapping
RequestMappingHandlerMapping
RouterFunctionMapping
(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
中存在以下几种处理器(按照顺序):
HttpRequestHandlerAdapter
SimpleControllerHandlerAdapter
RequestMappingHandlerAdapter
HandlerFunctionAdapter
(SimpleServletHandlerAdapter
DispatcherServlet
初始化时候并没有添加这个适配器)
处理器适配器适配条件表
处理器适配器 | 适配条件 |
---|---|
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;
}
}