
本文是spring框架源码学习系列的第三篇。
在有了bean和ioc的基础认识之后,这次来学习一个稍微重量级的框架,spring-webmvc(spring-mvc)。
它是用于构建基于传统MVC(Model-View-Controller)设计模式的web应用程序,是对servlet技术栈的高级封装,支持构建动态网页、RESTful API等服务。
一般来说,只要涉及到web服务器开服,就离不开作为基石的spring-mvc,因此,其重要性是不言而喻的。
当然,现在还有用于构建非阻塞、响应式应用的框架,Spring WebFlux,是对传统spring-mvc的补充,但不在今天的讨论范围内
基本的工作流程如下:
图1 spring-mvc处理请求
本文会基于该流程图作进一步分析,尽可能地把工作原理捋清楚。
一、准备阶段
配置本项目的运行环境,包括依赖、代码等。
1、项目结构
整体上,与之前变化不大。
.
|___debug-2.iml
|___pom.xml
|___readme.txt
|___src
| |___test
| | |___java
| |___main
| | |___resources
| | |___java
| | | |___com
| | | | |___err0l
| | | | | |___spring
| | | | | | |___debug2
| | | | | | | |___Map2JsonHttpMessageConverter.java
| | | | | | | |___Debug2Application.java
| | | | | | | |___TestController.java
| | | | | | | |___AppConfig.java
2、添加依赖
在pom.xml文件中添加如下配置,新增了webmvc和tomcat的依赖。
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>6.2.9</version>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>11.0.9</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-juli</artifactId>
<version>11.0.9</version>
</dependency>
</dependencies>
3、基本代码
1)配置类
@EnableWebMvc注解在加载了spring-webmvc后存在,用于导入spring的WebMVC配置。
这个注解主要导入org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport类,该类向ioc容器中注册了必要的组件,如RequestMappingHandlerMapping、ContentNegotiationManager、Validator等。
支持定制化,如下面的代码中,通过重写默认方法来向容器中添加了一个自定义的HttpMessageConverter:一个简易的将Map转为json的实现。
@EnableWebMvc
@Configuration
@ComponentScan
public class AppConfig implements WebMvcConfigurer {
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new Map2JsonHttpMessageConverter());
}
}
2)启动类
main()方法里编程式地创建一个tomcat服务器实例,同时实例化spring应用上下文(ioc容器),以及本文主要的研究对象,中央处理器DispatcherServlet。
将其映射到"/"路径下,以接管全部来自客户端的请求,并挂载到tomcat实例中。
使用嵌入式tomcat的原因是,看起来比较直观;事实上,可以认为spring-webmvc注册的所有组件,都是为了辅助DispatcherServlet运行
public class Debug2Application {
public static void main(String[] args) throws LifecycleException {
Tomcat tomcat = new Tomcat();
tomcat.setPort(8080);
tomcat.getConnector();
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(AppConfig.class);
DispatcherServlet servlet = new DispatcherServlet(context);
Context ctx = tomcat.addContext("", null);
final ErrorPage errorPage = new ErrorPage();
errorPage.setLocation("/error");
ctx.addErrorPage(errorPage);
Tomcat.addServlet(ctx, "dispatcher", servlet);
ctx.addServletMappingDecoded("/", "dispatcher");
tomcat.start();
tomcat.getServer().await();
}
}
3)控制器
包含了两个简单的接口实现,一个返回json数据,另一个返回html页面。
@Controller
public class TestController {
@GetMapping("/test1")
@ResponseBody
public Map<String, String> test1() {
Map<String, String> map = new HashMap<>();
map.put("key", "hello");
map.put("value", "world");
return map;
}
@GetMapping(value = "/test2")
public ModelAndView test2() {
final ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("hello");
return modelAndView;
}
}
4、设置断点
已经配置DispatcherServlet处理所有的请求,因此,只需要在其中设置断点即可。
图1-1 在DispatcherServlet#doDispatch()中设置断点
完成后,启动应用。
二、"/test1"接口
本文是这样安排的,首先分析返回json数据的"/test1"接口,然后在此基础上,分析返回html页面的接口"/test2"。
向服务器发起请求,使得程序执行到断点处。
curl http://localhost:8080/test1 -H 'Accept: application/json'
1、获取请求处理器
首先是调用getHandler()方法,获取到可以处理当前请求的方法,顺便一提,如果没有找到的话,就走404流程。
图2-1 获取请求处理器-1
默认情况下,spring-mvc注册了三个处理器映射(HandlerMapping),分别是RouterFunctionMapping、RequestMappingHandlerMapping、BeanNameUrlHandlerMapping,里面保存了请求路由和请求处理程序的映射关系。
其中,控制器处理方法存储于RequestMappingHandlerMapping中。
图2-2 获取请求处理器-2
找到请求处理方法后,并不直接返回,而是又将其包装为处理器执行链(HandlerExecutionChain)。
所谓执行链,其实就是一系列的拦截器,它们作用于请求处理前后,负责像是鉴权、校验参数这样的工作。
图2-3 获取请求处理器-3
2、将处理器包装为处理器适配器
这一步是spring为了屏蔽不同处理器之间的差异而采取的手段,将返回值统一定义为ModelAndView类型,像是中介一样的角色。
此处适配器的具体类型为RequestMappingHandlerAdapter。
图2-4 获取处理器适配器
3、调用请求处理器
- ha.handle(processedRequest, response, mappedHandler.getHandler());
-- handleInternal(request, response, (HandlerMethod) handler);
--- invokeHandlerMethod(request, response, handlerMethod);
---- invocableMethod.invokeAndHandle(webRequest, mavContainer);
其中,在invokeHandlerMethod(request, response, handlerMethod)里,设置一些必要的数据,如参数解析器(HandlerMethodArgumentResolver)、返回值处理器(HandlerMethodReturnValueHandler),以及ModelAndViewContainer等。
4、解析参数
spring-mvc已经提前注册了参数解析器,因此可以在控制器方法中自定义参数类型,在符合条件的情况下,spring会自动注入。
如果处理方法有参数的话,就在getMethodArgumentValues()方法中,使用参数解析器完成注入。
如比较常见的Request、Response、Session等对象。
此外,也支持声明请求路径、请求url等中的参数,但需要配合@PathVariable和@RequestParam注解使用,假设存在url参数:/test1?name=dd,则可以这样声明:
test1(@RequestParam String name)
一般来说,都是从ServletRequest实例中获取的数据,自定义参数只是一种“快捷方式”。
以Session对象为例,可以在方法中直接声明:test1(HttpSession session)
,也可以间接通过ServletRequest对象获取:test1(HttpServletRequest request) { HttpSession session = request.getSession(); }
。
@Nullable
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer, Object... providedArgs) throws Exception {
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
...
}
5、调用方法
将解析出的参数传入方法,通过反射调用,并拿到返回值。
图2-5 调用控制器方法
6、处理返回值
与参数解析类似,spring-mvc同样注册了大量的返回值处理器,以应对处理器中不同类型返回值。
图2-6 处理控制器方法返回值
7、选择处理器
默认有15个,由于test1()方法使用了@ResponseBody标注,指示返回值应该写入响应体,所以这里选择的是RequestResponseBodyMethodProcessor。
图2-7 获取可处理当前返回值的处理器
8、调用返回值处理器方法
一般情况下,Spring MVC控制器方法的返回值有两种处理方式:
第一种是返回字符串;被当作视图名称(View Name),用于查找模板(如JSP、Thymeleaf)进行渲染;
第二种是返回对象(非ModelAndView等对象);返回对象视为模型(Model),方法名被当做视图名称,然后渲染。
二者都会尝试返回html页面,也就是传统的服务器渲染(SSR)。
但在使用了@ResponseBody注解标准的情况下,将不再是以上的形式,而是会把返回值写入响应体。
其中,”mavContainer.setRequestHandled(true)“将当前请求标记为”已处理“状态,为了规避后续将当前请求使用默认的方式处理。
图2-8 将当前请求标记为"已处理"
9、使用消息转换器将返回值写入响应体
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
显然,java类型的返回值不能直接写入响应体,而必须转换为能在http协议中传输的格式,如字符串、字节等。
消息转换器就是为负责这部分工作而存在,如开头添加的Map2JsonHttpMessageConverter。
1)内容协商
服务器按客户端可接受的媒体类型来响应请求,这样的机制称为“内容协商(Content Negotiation)”。
spring-mvc也提供了这种机制,这里主要针对的是基于Accept请求头字段的内容协商。
大致运行方式是:读取客户端传输的Accept头部字段,与服务器可产出的媒体类型相比较,选出一个最佳的类型。
如,Accept为"application/json",而服务器也可以产出这种类型,且权重最高则"application/json"为最佳类型。
有两个触发内容协商的地方:
第一个是在选择处理器的时候,如果存在多个处理同一路径的处理器时,会进行内容协商,并选择出最佳处理器;
第二个是在处理返回值的时候,如果返回值是ResponseEntity类型,或者使用@ResponseBody注解标注(包括@RestController)时,会进行内容协商。
本文主要说的是第二种情况,对于第一种,一般一个路径只对应一个处理器,所以不做考虑。
这其实是依赖了一系列的消息转换器,它们可以将对应返回值类型转为对应的媒体类型,比如本的文自定义消息转换器,支持将Map类型转为json数据。
public class Map2JsonHttpMessageConverter extends AbstractHttpMessageConverter<Map<Object, Object>> {
Map2JsonHttpMessageConverter() {
super(StandardCharsets.UTF_8, MediaType.APPLICATION_JSON, MediaType.ALL);
}
@Override
protected boolean supports(Class<?> clazz) {
return Map.class.isAssignableFrom(clazz);
}
@Override
protected Map<Object, Object> readInternal(Class<? extends Map<Object, Object>> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
return null;
}
@Override
protected void writeInternal(Map<Object, Object> map, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
HttpHeaders headers = outputMessage.getHeaders();
final MediaType contentType = headers.getContentType();
Charset charset = null;
if (contentType != null) {
charset = contentType.getCharset();
}
if (charset == null) {
charset = getDefaultCharset();
}
StringBuilder sb = new StringBuilder("{");
for (Map.Entry<Object, Object> entry : map.entrySet()) {
sb.append("\"").append(entry.getKey()).append("\":");
Object value = entry.getValue();
if (value instanceof String) {
sb.append("\"").append(value).append("\"");
} else {
sb.append(value);
}
sb.append(",");
}
if (sb.length() > 1) sb.setLength(sb.length() - 1);
sb.append("}");
assert charset != null;
StreamUtils.copy(sb.toString(), charset, outputMessage.getBody());
}
}
获取客户端可接收的,以及服务器可产出的媒体类型,并以此为依据,选出最佳的一个。
图2-9 获取服务器可产出的媒体类型
List<MediaType> compatibleMediaTypes = new ArrayList<>();
// 决策出客户端可接受的媒体类型
determineCompatibleMediaTypes(acceptableTypes, producibleTypes, compatibleMediaTypes);
// ...
// 根据权重排序
MimeTypeUtils.sortBySpecificity(compatibleMediaTypes);
// 选择一个最合适的类型
for (MediaType mediaType : compatibleMediaTypes) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
}
// ...
}
2)调用消息转换器的write()方法
选出支持写入"最佳"媒体类型的消息转换器,调用其write()方法,将返回值(body)写入响应对象中。
图2-10 写入
- converter.write(body, selectedMediaType, outputMessage)
-- writeInternal(t, outputMessage);
--- StreamUtils.copy(sb.toString(), charset, outputMessage.getBody());
调用StreamUtils.copy()方法,将字符串写入响应对象。
图2-11 调用消息转换器的writeInternal()完成写入
由于已经事先将当前请求标记为“已处理”状态,所以后续的getModelAndView()返回null,表示不需要处理视图相关的逻辑。
或换句话说,当前请求的主要流程已经完成,这就是服务器返回json等数据类型的一般流程。
图2-12 客户端成功接收到数据
三、"/test2"接口
虽然两个接口的返回类型不同(一个是json数据,另一个是html页面),但处理流程基本相同。
分歧点在于"处理返回值"的部分,所以这里直接从"选择处理器"部分开始分析,有需要时参考之前的内容。
请求服务器:curl http://localhost:8080/test2 -H 'Accept: text/html'
1、选择处理器
与"/test1"接口不同,这里选择的返回值处理器类型为:ModelAndViewMethodReturnValueHandler。
图3-1 获取到"/test2"接口的返回值处理器
2、调用返回值处理器方法
为mavContainer设置视图名称,并将返回值(即ModelAndView实例)的属性设置到其中。
图3-2 调用返回值处理器的处理方法
3、获取ModelAndView
调用getModelAndView(),又重新创建了一个ModelAndView对象。
图3-3 得到ModelAndView对象
虽然看起来确实有点鸡肋,但实则是为了兼容。
比方说,如果返回值不是ModelAndView,而是字符串时,就变成:String -> ModelAndViewContainer -> ModelAndView。
4、处理ModelAndView
处理器适配器得到返回值后,调用processDispatchResult()方法进行处理。
图3-4 得到ModelAndView对象
如果ModelAndView mv不为空的话,调用其渲染方法。
图3-5 渲染视图
根据视图名称解析出视图,需要用到视图解析器(ViewResolver)。
但由于本项目没有配置任何模板引擎的视图解析器(如jsp、freemaker、thymeleaf等),所以实际上它使用的是spring-mvc内部的视图解析器,解析出的视图的作用就是在内部转发了一次,相当于发起了一次"/hello"请求。
图3-6 解析视图
- resolveViewName(viewName, mv.getModelInternal(), locale, request)
-- resolveViewNameInternal(viewName, locale)
-- view.render(mv.getModelInternal(), request, response)
--- renderMergedOutputModel(mergedModel, getRequestToExpose(request), response)
---- rd.forward(request, response)
因为没有处理"/hello"的接口,所以最终的结果就是返回404。
图3-7 找不到"/hello"路径的处理器
图3-8 客户端收到404页面
5、集成Thymeleaf
在pom.xml文件中新增如下依赖:
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf-spring6</artifactId>
<version>3.1.2.RELEASE</version>
</dependency>
修改配置类:
@EnableWebMvc
@Configuration
@ComponentScan
public class AppConfig implements WebMvcConfigurer, ApplicationContextAware {
ApplicationContext applicationContext;
// ...
@Bean
public SpringResourceTemplateResolver templateResolver(){
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setApplicationContext(applicationContext);
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".html");
templateResolver.setTemplateMode(TemplateMode.HTML);
templateResolver.setCacheable(true);
return templateResolver;
}
@Bean
public SpringTemplateEngine templateEngine(){
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver());
templateEngine.setEnableSpringELCompiler(true);
return templateEngine;
}
@Bean
public ThymeleafViewResolver viewResolver(){
ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
viewResolver.setTemplateEngine(templateEngine());
viewResolver.setOrder(1);
return viewResolver;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
然后在resources/templates/中新增hello.html:
图3-9 hello.html
6、解析视图
集成Thymeleaf后,再次发起请求时,将使用ThymeleafViewResolver来解析视图。
图3-10 使用ThymeleafViewResolver解析视图
在不启用缓存或无缓存的情况下,创建视图。
图3-11 创建视图
7、渲染视图
运行ThymeleafView的渲染逻辑。简单概括就是,定位到模板文件,并完成解析和填充模型中的数据。
- view.render(mv.getModelInternal(), request, response)
-- renderFragment(this.markupSelectors, model, request, response)
--- viewTemplateEngine.process(templateName, processMarkupSelectors, context, templateWriter)
---- process(new TemplateSpec(template, templateSelectors, null, null,null), context, writer)
----- templateManager.parseAndProcess(templateSpec, context, writer)
图3-12 获取到模板文件路径
最后以writer.flush()
结束,以保证所有的数据已写入到输出流中。
图3-13 完成数据写入
最终客户端接收到hello页面,当然,如果是浏览器的话,会渲染这个文档。
图3-14 客户端收到响应
至此,返回html页面的部分也分析完毕。
四、结语
本文先后分析了服务器返回json数据和返回html页面的流程。
在tomcat接收到请求后,通过调用中央处理器DispatcherServlet的doDispatch()方法来完成响应。
基本流程为:tomcat -> 中央处理器 -> 查找处理方法 -> 调用 -> 得到返回值 -> 查找返回值处理器 -> 调用 -> 处理ModelAndView -> 不为空则渲染。
对于返回json数据的接口,如使用@ResponseBody注解标注,其返回值使用RequestResponseBodyMethodProcessor来处理,通过HttpMessageConverter完成数据写入;
对于返回html页面的接口,如本文的"/test2"接口,其返回值ModelAndView则使用ModelAndViewMethodReturnValueHandler进行处理,使用视图解析器解析出视图解后,渲染视图。
尽管有所区别,但整体上保持一致。
如有需要需,请点击这里前往github获取代码。
以上。