Spring Boot(四):Web 开发🥩

963 阅读17分钟

1. Spring Web MVC 自动配置概览

Spring Boot provides auto-configuration for Spring MVC that works well with most applications.(大多数场景我们都无需自己配置)

The auto-configuration adds the following features on top of Spring’s defaults:

  • Inclusion of ContentNegotiatingViewResolver and BeanNameViewResolver beans.
    • 内容协商视图解析器和 BeanName 视图解析器
  • Support for serving static resources, including support for WebJars (covered later in this document).
    • 静态资源(包括 webjars
  • Automatic registration of Converter, GenericConverter, and Formatter beans.
    • 自动注册 ConverterGenericConverterFormatter
  • Support for HttpMessageConverters (covered later in this document).
    • 支持 HttpMessageConverters,配合内容协商理解原理
  • Automatic registration of MessageCodesResolver (covered later in this document).
    • 自动注册 MessageCodesResolver(国际化)
  • Static index.html support.
    • 静态 index.html 页面支持
  • Automatic use of a ConfigurableWebBindingInitializer bean (covered later in this document).
    • 自动使用 ConfigurableWebBindingInitializer ,(DataBinder 负责将请求数据绑定到 JavaBean 上)

If you want to keep those Spring Boot MVC customizations and make more MVC customizations (interceptors, formatters, view controllers, and other features), you can add your own @Configuration class of type WebMvcConfigurer but without @EnableWebMvc.

不使用 @EnableWebMVC 注解,使用 @Configuration + @WebMvcConfigurer 自定义规则

If you want to provide custom instances of RequestMappingHandlerMapping, RequestMappingHandlerAdapter, or ExceptionHandlerExceptionResolver, and still keep the Spring Boot MVC customizations, you can declare a bean of type WebMvcRegistrations and use it to provide custom instances of those components.

声明 WebMvcRegistrations 改变默认底层组件

If you want to take complete control of Spring MVC, you can add your own @Configuration annotated with @EnableWebMvc, or alternatively add your own @Configuration-annotated DelegatingWebMvcConfiguration as described in the Javadoc of @EnableWebMvc.

使用 @EnableWebMvc + @Configuration + DelegatingWebMvcConfiguration 全面接管 SpringMVC

2. 简单功能分析

2.1 静态资源访问

2.1.1 默认静态资源目录

默认的静态资源访问路径:/static/public/resources/META-INF/resources【2.3 小节会进行源码分析,剖析这 4 个路径是由何而来的】

访问方式:当前项目根路径/静态资源名(如:localhost:8080/pic.jpg

⭐若静态资源名与 Controller 请求名相同(pic.jpg@RequestMapping("/pic.jpg")),则先进行业务处理,无法处理再交给静态资源处理器,若没有对应的静态资源,则返回 404 页面

当然,我们也可以改变 Spring Boot 提供的默认静态资源目录位置:

# 改变静态资源文件夹【/public、/resources、/static、/META-INF/resources】为【/place、/ano-place】
spring:
  resources:
    static-locations: [classpath:/place/, classpath:/ano-place/]

2.1.2 静态资源访问前缀

我们可能需要为静态资源添加访问前缀,因为 Filter 会过滤掉静态资源【拦截所有请求:/**】,而我们可以修改前缀从而在拦截的所有请求下配置放行:/res/静态资源名

spring:
  mvc:
    static-path-pattern: /res/**

2.1.3 webjars

<!-- 支持 WebJars: 将 BootStrap、JQuery 等打成了Jar包 -->
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.5.1</version>
</dependency>

访问 http://localhost:8080/webjars/jquery/3.5.1/jquery.js(和依赖的包路径要一致,至于为什么该路径能访问到,详见下文源码分析):

2.2 Welcome Page & favicon

Spring Boot 既支持静态 (static) 欢迎页面,也支持模板 (template) 欢迎页面。 它首先在配置的静态内容位置中查找 index.html 文件。 如果没有找到,则查找索引模板。 如果找到其中一个,它将自动用作应用程序的欢迎页面。

index.html & favicon 都需要放置在静态资源目录下:

注意:不可以配置静态资源的访问前缀 (spring.mvc.static-path-pattern),否则会导致 index.html 无法被默认访问!

2.3 静态资源配置原理【源码分析🔥】

我们都知道 Spring Boot 启动会默认加载 xxxAutoConfiguration 自动配置类,所以 Spring MVC 功能的自动配置类 WebMvcAutoConfiguration 也会生效。

如果配置类只有一个有参构造器,那么该构造器所有的参数的值都会从容器中获取!

Tip:以下源码有标注注释,这些注释都是有助于理解源码内容及其配置原理的!

@Configuration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@AutoConfigureAfter({ DispatcherServletAutoConfiguration.class, TaskExecutionAutoConfiguration.class,
      ValidationAutoConfiguration.class })
public class WebMvcAutoConfiguration {
    ...
    
	@Configuration
	@Import(EnableWebMvcConfiguration.class)
    // @EnableConfigurationProperties 注解绑定了 WebMvcProperties、ResourceProperties 配置文件
    // @EnableConfigurationProperties 同时将 WebMvcProperties 组件与 ResourceProperties 组件加入到容器中,方便下文注入
	@EnableConfigurationProperties({ WebMvcProperties.class, ResourceProperties.class })
	@Order(0)
	public static class WebMvcAutoConfigurationAdapter implements WebMvcConfigurer {
        ...
        // 该类唯一的有参构造函数,所以所有参数的值都会从容器中获取!【包括WebMvcProperties与ResourceProperties】
    	public WebMvcAutoConfigurationAdapter(ResourceProperties resourceProperties, WebMvcProperties mvcProperties,
				ListableBeanFactory beanFactory, ObjectProvider<HttpMessageConverters> messageConvertersProvider,
				ObjectProvider<ResourceHandlerRegistrationCustomizer> resourceHandlerRegistrationCustomizerProvider) {
			this.resourceProperties = resourceProperties;
			this.mvcProperties = mvcProperties;
			this.beanFactory = beanFactory;
			this.messageConvertersProvider = messageConvertersProvider;
			this.resourceHandlerRegistrationCustomizer = resourceHandlerRegistrationCustomizerProvider.getIfAvailable();
		}
    }
    
    ...
}

ResourceProperties 属性绑定以 spring.resources 为前缀的值,WebMvcProperties 属性绑定 spring.mvc 为前缀的值:

image.png

所以现在配置类已经从容器中获取到相应的值了,接下来:

public class WebMvcAutoConfiguration {
    ...
	
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 当 spring.resources.add-mappings=false 时,if语句判断正确,日志打印如下语句,并返回...
    	if (!this.resourceProperties.isAddMappings()) {
            logger.debug("Default resource handling disabled");
            return;
        }
        Duration cachePeriod = this.resourceProperties.getCache().getPeriod();
        CacheControl cacheControl = this.resourceProperties.getCache().getCachecontrol().toHttpCacheControl();
        // webjars下的所有请求都会转到 classPath:/META-INF/resources/webjars 路径下,完美地解释了 2.1.3 小节的疑问!
        if (!registry.hasMappingForPattern("/webjars/**")) {
            customizeResourceHandlerRegistration(registry.addResourceHandler("/webjars/**")
            	.addResourceLocations("classpath:/META-INF/resources/webjars/")
            	.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
        }
        
        // 此处获取到 MvcProperties 绑定的配置文件值,即 spring.mvc.static-path-pattern 的值!
        // 若 yaml 文件中没有配置该值,staticPathPattern 也有默认值:/** 【默认资源访问路径】
        String staticPathPattern = this.mvcProperties.getStaticPathPattern();
        
        // 与上面webjars请求访问原理相同,staticPathPattern【/**】的请求都会到getResourceLocations(this.resourceProperties.getStaticLocations()下去找!
        // getResourceLocations(this.resourceProperties.getStaticLocations() 指向哪里?别急,看下图!
        if (!registry.hasMappingForPattern(staticPathPattern)) {
            customizeResourceHandlerRegistration(registry.addResourceHandler(staticPathPattern)
                .addResourceLocations(getResourceLocations(this.resourceProperties.getStaticLocations()))
                .setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
        }
	}
    
    ...
}

我们进入 this.resourcesProperties.getStaticLocations() 中发现:

是不是茅塞顿开有没有?!原来静态资源的默认访问路径底层早已配置好,其来龙去脉我们也大致搞清楚了。


然后还有 Welcome Page【即 index.html】为什么放在静态资源目录下会被 Spring Boot 自动识别?

为了解释以上这个问题,我们再来翻一翻源码(同样还是在 WebMvcAutoConfiguration 类下查找):

我们在解析这段源码之前,我们得知道 HandlerMapping 是做什么的:处理器映射,其中保存了每一个 Handler 所能处理的请求【某些请求对应某些处理器】,并使用反射调用处理方法。

所以 WelcomePageHandlerMapping 就是专门用于处理 Welcome Page 的处理器。

WebMvcAutoConfiguration.java

@Bean
public WelcomePageHandlerMapping welcomePageHandlerMapping(ApplicationContext applicationContext) {
   WelcomePageHandlerMapping welcomePageHandlerMapping = new WelcomePageHandlerMapping(
         new TemplateAvailabilityProviders(applicationContext), applicationContext, getWelcomePage(),
         this.mvcProperties.getStaticPathPattern());
   welcomePageHandlerMapping.setInterceptors(getInterceptors());
   return welcomePageHandlerMapping;
}

WelcomePageHandlerMapping 实例化并从容器中获取参数,然后我们进去 WelcomePageHandlerMapping 类中:

final class WelcomePageHandlerMapping extends AbstractUrlHandlerMapping {
   ...
   
   WelcomePageHandlerMapping(TemplateAvailabilityProviders templateAvailabilityProviders,
         ApplicationContext applicationContext, Optional<Resource> welcomePage, String staticPathPattern) {
      // 如果欢迎页存在,且静态资源请求为 /**【无前缀】,则可以直接在项目启动时跳转到 index.html
      // 所以当我们配置了 spring.mvc.static-path-pattern 值后,index.html 就无法被自动识别并展示了【因为此时不等于 /**】
      if (welcomePage.isPresent() && "/**".equals(staticPathPattern)) {
         logger.info("Adding welcome page: " + welcomePage.get());
         setRootViewName("forward:index.html");
      }
      else if (welcomeTemplateExists(templateAvailabilityProviders, applicationContext)) {
         logger.info("Adding welcome page template: index");
         setRootViewName("index");
      }
   }
   ...
}

application.yaml(以上注释提及的配置文件属性):

spring:
  resources:
    add-mappings: false   # 禁用所有静态资源!
    static-locations: [classpath:/place1, classpath:/place2]    # 修改默认的静态资源路径
  mvc:
    static-path-pattern: /res/**  # 添加静态资源访问前缀,方便放行

静态资源配置原理分析大致就这些,感兴趣的小伙伴可以自己去详细解读下 WenMvcAutoConfiguration...

3. 请求参数处理

3.1 RESTful 风格

RESTful 风格支持:可以使用 HTTP 请求方式动词表示对资源的操作。

  • Spring MVC:GETPOSTDELETEPUT
  • 普通浏览器只支持 GETPOST 方式,并不支持其他方式,所以 Spring Boot 通过增加过滤器来增加支持 DELETE、PUT 请求的方式。

获取 Resultful 风格的参数juejin.cn/post/699580…

相关注解

注解概述
@RestController@Controller + @ResponseBody 组成(返回 JSON 数据格式)
@PathVariableURL 中的 {xxx} 占位符可以通过 @PathVariable("xxx") 绑定到控制器处理方法的形参中
@RequestMapping请求地址的解析,是最常用的一种注解
@GetMapping查询请求(等价于 @RequestMapping(path = "", method = RequestMethod.GET)
@PostMapping添加请求(同上)
@PutMapping更新请求(同上)
@DeleteMapping删除请求(同上)
@RequestParam参数绑定:juejin.cn/post/699580…

实现 RESTful 风格的核心:HiddenHttpMethodFilter

  • 用法:表单 method=post、隐藏域、 _methoddelete
    • 例如:<input name="_method" type="hidden" value="delete">
  • Spring Boot 中 HiddenHttpMethodFilter 默认开启。
  • Rest 原理(表单提交要使用 REST 的时候)
  • 表单提交会带上 _method=PUT
  • 请求过来被 HiddenHttpMethodFilter 拦截
    • 判断:请求是否正常,是否为 POST
  • 获取到 _method 的值
    • 兼容以下请求:PUTDELETEPATCH
  • 包装模式 requesWrapper 重写了原生 request(post)getMethod 方法,返回的是传入的值
    • 过滤器链放行的时候用 wrapper,以后的方法调用 getMethod 是调用 requesWrapper,即包装后 getMethod
@Bean
@ConditionalOnMissingBean(HiddenHttpMethodFilter.class)	// 用户自定义Filter优先
@ConditionalOnProperty(prefix = "spring.mvc.hiddenmethod.filter", name = "enabled", matchIfMissing = true)
public OrderedHiddenHttpMethodFilter hiddenHttpMethodFilter() {
   return new OrderedHiddenHttpMethodFilter();
}

如上源码显示,如果我们没有配置 spring.mvc.hiddenmethod.filter.enable,则默认开启,所以如下代码可有可无:

spring:
   mvc:
      hiddenmethod:
         filter:
            enable: true	# "defaultValue": true

前后端具体代码

page.html

<form action="/user" method="get">
    <input value="REST-GET 提交" type="submit"/>
</form>

<!--当且仅当在POST方式下提交,PUT、DELETE、POST才有效!【底层源码规定】-->
<form action="/user" method="post">
    <input name="_method" type="hidden" value="POST"/>
    <input value="REST-POST 提交" type="submit"/>
</form>
<form action="/user" method="post">
    <!-- _method这一input标签用于标识提交方法! -->
    <input name="_method" type="hidden" value="PUT"/>
    <input value="REST-PUT 提交" type="submit"/>
</form>
<form action="/user" method="post">
    <input name="_method" type="hidden" value="delete"/>
    <input value="REST-DELETE 提交" type="submit"/>
</form>

Controller.java

//@RequestMapping(value = "/user",method = RequestMethod.GET)
@GetMapping("/user")
public String getUser(){
    return "GET-张三";
}

//@RequestMapping(value = "/user",method = RequestMethod.POST)
@PostMapping("/user")
public String saveUser(){
    return "POST-张三";
}

//@RequestMapping(value = "/user",method = RequestMethod.PUT)
@PutMapping("/user")
public String putUser(){
    return "PUT-张三";
}

//@RequestMapping(value = "/user",method = RequestMethod.DELETE)
@DeleteMapping("/user")
public String deleteUser(){
    return "DELETE-张三";
}

拓展,若我们要自己自定义 FilterMethodParam

@Configuration
public class WebConfig {
	// 替换 _method :自定义
	@Bean
    public HiddenHttpMethodFilter hiddenHttpMethodFilter(){
        HiddenHttpMethodFilter hiddenHttpMethodFilter = new HiddenHttpMethodFilter();
        hiddenHttpMethodFilter.setMethodParam("_meth");
        return hiddenHttpMethodFilter;
    }
}

此时请求为:<input name="_meth" type="hidden" value="delete"> ,后端才能响应 PUTPATCHDELETE

注意:REST 风格的参数只能适用于表单的提交,如果用户直接从客户端提交(如:POSTMAN),那么 request.getMethod() 不再是表单的 POST 然后再进行封装发送,不再会被 HiddenHttpMethodFilter 所拦截, 而是直接以 PUTPATCHDELETE 等方法直接作为 HTTP 请求方式。

Rest 映射及其源码解析www.bilibili.com/video/BV19K…

3.2 请求参数映射原理

handlerMappings = {ArrayList} size=5(5 个 HandlerMapping,其中 RequestMappingHandlerMapping 用于请求映射):

  • RequestMappingHandlerMapping:保存了所有 @RequestMappinghandler 的映射规则
  • WelcomePageHandlerMapping
  • BeanNameUrlHandlerMapping
  • RouteFunctionMapping
  • SimpleUrlHandlerMapping

源码解析:www.bilibili.com/video/BV19K…

3.3 注解参数

回顾一下这些注解:juejin.cn/post/699580…

  • @PathVariable
  • @RequestHeader
  • @RequestParam
  • @CookieValue
  • @RequestBody
  • @RequestAttribute
  • @MatrixVariable

3.3.1 @PathVariable@RequestHeader@RequestParam@CookieValue@RequestBody

index.html

<!-- getParam() -->
<a href="/user/1/owner/kun?age=19&teachers=w&teachers=Q">/user/1/owner/kun?age=19&teachers=w&teachers=Q</a>

<!-- testRequestBody() -->
<form action="/save" method="post">
    测试 @RequestBody 获取表单参数!<br/>
    Username: <input name="username">
    Email: <input name="email">
    <input type="submit" value="提交">
</form>

Controller

@RestController
public class ParamTestController {

    // 请求:/user/1/owner/kun
    @RequestMapping("/user/{id}/owner/{username}")
    public Map<String, Object> getParam(@PathVariable("id") String id,
                                        @PathVariable("username") String username,
                                        @PathVariable Map<String, String> pvMap,
                                        @RequestHeader("User-Agent") String userAgent,
                                        @RequestHeader Map<String, String> headerMap,
                                        @RequestParam("age") String age,
                                        @RequestParam("teachers") String[] teacher,
                                        @RequestParam Map<String, String> requestMap,
                                        @CookieValue("Idea-7eb7317e") String cookieValue,
                                        @CookieValue("Idea-7eb7317e") Cookie cookie) {
        // @PathVariable
        Map<String, Object> map = new HashMap<>();
        map.put("id", id);
        map.put("username", username);
        // pvMap: 所有的 @PathVariable 键值对! (必须是 Map<String, String>)
        map.put("pvMap", pvMap);

        // @RequestHeader
        map.put("userAgent", userAgent);
//        map.put("header", headerMap);   //所有的Header!

        // @RequestParam
        map.put("age", age);
        map.put("teacher", teacher);
        // requestMap: @ReqeustParam 标记的键值对!
        map.put("requestMap", requestMap);

        // @CookieValue
        map.put("cookie", cookieValue);
        System.out.println(cookie.getName() + ": " + cookie.getValue());

        return map;
    }

    @RequestMapping("/save")
    public Map<String, String> testRequestBody(@RequestBody String content) {
        Map<String, String> map = new HashMap<>();

        map.put("表单的所有内容", content);
        return map;
    }

}

3.3.2 @RequestAttribute

@Controller
public class RequestAttributeController {

    @RequestMapping("/goto")
    public String gotoPage(HttpServletRequest request) {
        request.setAttribute("message", "测试@RequestAttribute成功!");
        request.setAttribute("code", "200 OK");

        // 转发给 /success 处理请求
        return "forward:/success";
    }

    @ResponseBody
    @GetMapping("/success")
    public Map<String, Object> success(@RequestAttribute("message") String msg,
                                       @RequestAttribute("code") String code,
                                       HttpServletRequest request) {

        Map<String, Object> map = new HashMap<>();
        map.put("annotMessage", msg);
        map.put("code", code);

        String requestMsg = (String) request.getAttribute("message");
        map.put("requestMsg", requestMsg);

        return map;
    }

}

3.3.3 @MatrixVariableUrlPathHelper

矩阵变量 @MatrixVariable 格式:关于矩阵变量:根据 RFC3985 的规范,矩阵变量【@MatrixVariable】应当绑定在路径变量【@PathVariable】中。若是有多个矩阵变量,应当使用英文符号 ; 进行分隔,若是一个矩阵变量有多个值,则应当使用英文符号 , 及进行分隔,或命名多个重复的 key 也可。

  • /cars/sell;low=34;brand=bm,byd,aodi:一个矩阵变量多个值 , 分隔
  • /boss/1;age=20/2;age=10:把矩阵参数看称是路径变量的一部分,注意每段 URI 都可以有 ;
  • /abc;jsessionid=xxxx:假如页面开发中 Cookie 被禁用了,而 session 是通过 jsessionid 绑定在 cookie 上的,所有 session 同时也被禁用;若想在 Cookie 被禁用的情况下使用 session,则可以使用矩阵变量,区分与一般的路径请求。

测试 @MatrixVariable

@ResponseBody
// 请求URI:/cars/sell;low=199;brand=byd,baoma,yd
@RequestMapping("/cars/{path}")
public Map testMatrixVariable(@MatrixVariable("low") String low,
                              @MatrixVariable("brand") String[] brand,
                              @PathVariable("path") String path) {
    Map<String, Object> map = new HashMap<>();
    map.put("low", low);
    map.put("brand", brand);

    map.put("pathVariable",path);

    return map;
}

结果

嗯?这是怎么回事?这里先给出结论,然后我们再到底层源码去看看为什么矩阵变量失效了!

🔥结论:矩阵变量需要在 Spring Boot 中手动开启(默认关闭)!

我们还是从 WebMvcAutoConfiguration 找起:

@Configuration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@AutoConfigureAfter({ DispatcherServletAutoConfiguration.class, TaskExecutionAutoConfiguration.class,
      ValidationAutoConfiguration.class })
public class WebMvcAutoConfiguration {
    //...
    
    @Configuration
	@Import(EnableWebMvcConfiguration.class)
	@EnableConfigurationProperties({ WebMvcProperties.class, ResourceProperties.class })
	@Order(0)
	public static class WebMvcAutoConfigurationAdapter implements WebMvcConfigurer {
        //...
       
        @Override
		public void configurePathMatch(PathMatchConfigurer configurer) {
			configurer.setUseSuffixPatternMatch(this.mvcProperties.getPathmatch().isUseSuffixPattern());
			configurer.setUseRegisteredSuffixPatternMatch(
					this.mvcProperties.getPathmatch().isUseRegisteredSuffixPattern());
		}
        
        //...
    }
    //...
}

单刀直入,WebMvcAutoConfigurationAdapter 实现了 WebMvcConfigurer 这个接口,并重写了 configurePathMatch(PathMatchConfigurer configurer) ,我们再点击进入 PathMatchConfigurer 这个形参类,得知其中有一个 setter 方法,即 setUrlPathHelper() ,这个 UrlPathHelper 就是该节的 "主角"。

UrlPathHelper 类是 Spring 中的一个帮助类,有很多与 URL 路径有关的实用方法,矩阵变量的失效也毫不例外与它有关;所以我们继续进入到它的源码当中:

既然底层默认移除分号,那么我们要开启矩阵变量的话,就将 UrlPathHelper 下的 removeSemicolonContent 变量设置为 false,然后设置到 PathMatchConfigurer 类中,那么我们就得重写 WebMvcConfigurer 接口下的 configurePathMatch 方法,并将 WebMvcConfigurer 注入到容器中

方法一

@Configuration
public class WebConfig{
    @Bean
    public WebMvcConfigurer getWebMvcConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void configurePathMatch(PathMatchConfigurer configurer) {
                UrlPathHelper urlPathHelper = new UrlPathHelper();
                // 不移除分号: 矩阵变量生效!
                urlPathHelper.setRemoveSemicolonContent(false);

                configurer.setUrlPathHelper(urlPathHelper);
            }
        };
    }
}

方法二

public class WebConfig implements WebMvcConfigurer{
    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        UrlPathHelper urlPathHelper = new UrlPathHelper();
        // 不移除 ; 后面的内容,即开启矩阵变量!
        urlPathHelper.setRemoveSemicolonContent(false);
        configurer.setUrlPathHelper(urlPathHelper);
    }
}

再次用矩阵变量访问试试 (请求URI:/cars/sell;low=199;brand=byd,baoma,yd):

再试试另一种特殊点的矩阵变量 (请求URI:/wpath/1;age=31/143;age=19):

这里的 pathVar 猜也可以猜到是路径变量的名字,因为矩阵变量必须依附在路径变量上,这里用于区分不同段 URI 中矩阵变量名相同的情况。

@MatrixVariable(pathVar = "bossId", value = "age")
@ResponseBody
// 请求 URI: /multiPath/1;age=31/143;age=19
@RequestMapping("/multiPath/{bossId}/{employId}")
public Map testMultiMatrixVariable(@MatrixVariable(pathVar = "bossId", value = "age") int bossAge,
                                   @MatrixVariable(pathVar = "employId", value = "age") int employAge,
                                   @PathVariable("bossId") String path1,
                                   @PathVariable("employId") String path2) {
    Map<String, Object> map = new HashMap<>();
    map.put("bossAge", bossAge);
    map.put("employAge", employAge);

    // 这里也顺便看看路径变量吧【矩阵变量是依附在路径变量上的,切记】
    map.put("bossId", path1);
    map.put("employId", path2);

    return map;
}


这里再补充点 UrlPathHelper 的知识:

public String removeSemicolonContent(String requestUri) {
    return (this.removeSemicolonContent ?
            removeSemicolonContentInternal(requestUri) : removeJsessionid(requestUri));
}

private String removeSemicolonContentInternal(String requestUri) {
    int semicolonIndex = requestUri.indexOf(';');
    while (semicolonIndex != -1) {
        int slashIndex = requestUri.indexOf('/', semicolonIndex);
        String start = requestUri.substring(0, semicolonIndex);
        requestUri = (slashIndex != -1) ? start + requestUri.substring(slashIndex) : start;
        semicolonIndex = requestUri.indexOf(';', semicolonIndex);
    }
    return requestUri;
}

private String removeJsessionid(String requestUri) {
    int startIndex = requestUri.toLowerCase().indexOf(";jsessionid=");
    if (startIndex != -1) {
        int endIndex = requestUri.indexOf(';', startIndex + 12);
        String start = requestUri.substring(0, startIndex);
        requestUri = (endIndex != -1) ? start + requestUri.substring(endIndex) : start;
    }
    return requestUri;
}
  • removeSemicolonContent 方法
    • 根据 removeSemicolonContent 属性决定是移除请求URI中的所有分号内容还是只移除 jsessionid 部分,默认是前者,所以这两种情况都会移除 jsessionid 部分
  • removeSemicolonContentInternal 方法
    • 移除请求 URI 中所有的分号内容,注意 URI 中每段都可以有分号,如 /users/name;v=1.1/gender;value=male 等形式;
  • removeJsessionid 方法
    • 只移除请求 URI 中,jsessionid=xxx 的部分而保留 URI 的其余部分(包括其他分号),移除 jsessionid 时不区分大小写。

3.4 Servlet API

Spring MVC 除了给参数标注一些注解,参数解析器【HandlerMethodArgumentResolver】还可以解析一些作为参数的 Servlet API

  • WebRequest
  • ServletRequest
  • MultipartRequest
  • HttpSession
  • PushBuilder
  • Principal
  • InputStream
  • Reader
  • HttpMethod
  • Locale
  • TimeZone
  • ZoneId
@Override
public boolean supportsParameter(MethodParameter parameter) {
    Class<?> paramType = parameter.getParameterType();
    return (WebRequest.class.isAssignableFrom(paramType) ||
            ServletRequest.class.isAssignableFrom(paramType) ||
            MultipartRequest.class.isAssignableFrom(paramType) ||
            HttpSession.class.isAssignableFrom(paramType) ||
            (pushBuilder != null && pushBuilder.isAssignableFrom(paramType)) ||
            Principal.class.isAssignableFrom(paramType) ||
            InputStream.class.isAssignableFrom(paramType) ||
            Reader.class.isAssignableFrom(paramType) ||
            HttpMethod.class == paramType ||
            Locale.class == paramType ||
            TimeZone.class == paramType ||
            ZoneId.class == paramType);
}

3.5 复杂参数

  • Map:作为参数时,数据会被放到 request 请求域中一同传递,即 request.setAttribute()
  • Model:作为参数时,数据会被放到 request 请求域中一同传递,即 request.setAttribute()
  • Errors/BindingResult
  • RedirectAttributes重定向携带数据
  • ServletResponse
  • SessionStatus
  • UriComponentsBuilder
  • ServletUriComponentsBuilder

⭐举例几个复杂参数的使用方法

@RequestMapping("/request")
public String params(Map<String, Object> map,
                     Model model,
                     HttpServletRequest request,
                     HttpServletRequest response) {
    map.put("hello", "world");
    model.addAttribute("model", "modelValue");
    request.setAttribute("request", "Attribute");
    response.setAttribute("response", "Attribute");

    return "forward:/complex";
}


@ResponseBody
@RequestMapping("/complex")
public Map testComplexParams(HttpServletRequest request) {
    Map<String, Object> map = new HashMap<>();

    Object hello = request.getAttribute("hello");
    Object model = request.getAttribute("model");
    Object request1 = request.getAttribute("request");
    Object response = request.getAttribute("response");

    map.put("hello", hello);
    map.put("model", model);
    map.put("request1", request1);
    map.put("response", response);

    return map;
}

3.6 POJO 参数 & 自定义 Converter

演示一下 POJO 参数的封装。

1)创建一个表单用于提交数据作为参数:

<!-- POJO 参数封装 -->
<form action="/saveUser" type="POST">
    姓名:<input name="username" value="Wukkkkk"/>
    年龄:<input name="age" value="21"/>
    生日:<input name="birth" value="2021/07/26"/>
    颜色名:<input name="color.colorName" value="blue"/>
    颜色ID:<input name="color.id" value="1"/>

    <input type="submit" value="提交">
</form>

2)创建 Controller:

@ResponseBody
@RequestMapping("/saveUser")
public User saveUser(User user) {
    return user;
}

3)提交表单,查看结果:


那如果我们要按照自己的格式来封装参数呢?Spring MVC 底层提供的 Converter 是有限且固定的,所以我们如果要按照自己意愿来传递数据并正确封装到参数中,那么就需要自定义 Converter 了。

@Bean
// WebMvcConfigurer 定制 Spring MVC 的功能!!!
public WebMvcConfigurer getWebMvcConfigurer() {
    return new WebMvcConfigurer() {
        // 这是之前UrlPathHelper的设置,还记得吗
//        @Override
//        public void configurePathMatch(PathMatchConfigurer configurer) {
//            UrlPathHelper urlPathHelper = new UrlPathHelper();
            // 不移除分号: 矩阵变量生效!
//            urlPathHelper.setRemoveSemicolonContent(false);

//            configurer.setUrlPathHelper(urlPathHelper);
//        }

        @Override
        public void addFormatters(FormatterRegistry registry) {
            registry.addConverter(new Converter<String, Color>() {
                @Override
                public Color convert(String source) {
                    if (!StringUtils.isNullOrEmpty(source)) {
                        Color color = new Color();

                        String[] strings = source.split(",");

                        color.setColorName(strings[0]);
                        color.setId(Integer.parseInt(strings[1]));

                        return color;
                    }
                    return null;
                }
            });
        }
    };
}

3.7 其他请求参数

基础请求参数见博客juejin.cn/post/699580…

3.8 参数请求解析的原理分析

HandlerMapping 中找到能处理请求的 Handler,为当前 Handler 找一个适配器 HandlerAdapter,适配器执行目标方法并确定方法参数的每一个值

参数解析器argumentResolvers

4. 数据响应与内容协商

💦 spring-boot-starter-web 底层默认使用的 JSON 解析框架是 Jackson

下面我们看一下默认的 Jackson 框架对常用数据类型的转 JSON 处理:
详见博客小白 の SpringMVC 学习笔记 💦


返回值处理器returnValueHandlers

更多【P37 ~ P42】:www.bilibili.com/video/BV19K…

5. 视图解析与模板引擎

5.1 视图解析

处理方式:

  • 转发
  • 重定向
  • 自定义视图

5.1.1 视图解析原理

5.2 模板引擎

常见的模板引擎包括 Thymeleaf、FreeMarker、Enjoy、Velocity、JSP 等。

为什么我们要学模板引擎

  • 模板引擎可以让(网站)程序实现界面与数据分离,业务代码与逻辑代码的分离,这就大大提升了开发效率,良好的设计也使得代码重用变得更加容易。我们司空见惯的模板安装卸载等概念,基本上都和模板引擎有着千丝万缕的联系。
  • 模板引擎不只是可以让你实现代码分离(业务逻辑代码和用户界面代码),也可以实现数据分离(动态数据与静态数据),还可以实现代码单元共享(代码重用),甚至是多语言、动态页面与静态页面自动均衡(SDE)等等与用户界面可能没有关系的功能。

5.2.1 Thymeleaf 简介

Spring Boot 默认打包方式为 JAR 包,JAR 包是压缩包,JSP 不支持在压缩包内编译,所以 Spring Boot 默认不支持 JSP,需要引入第三方模板引擎技术实现页面渲染、跳转。

Thymeleaf 是目前主流的模板引擎之一,Spring Boot 推荐!

Thymeleaf is a modern server-side Java template engine for both web and standalone environments.

特性

  1. Thymeleaf 在有网络和无网络的环境下皆可运行,即它可以让美工在浏览器查看页面的静态效果,也可以让程序员在服务器查看带数据的动态页面效果。这是由于它支持 html 原型,然后在 html 标签里增加额外的属性来达到模板 + 数据的展示方式。浏览器解释 html 时会忽略未定义的标签属性,所以 thymeleaf 的模板可以静态地运行;当有数据返回到页面时,Thymeleaf 标签会动态地替换掉静态内容,使页面动态显示。
  2. Thymeleaf 开箱即用的特性。它提供标准和 spring 标准两种方言,可以直接套用模板实现 JSTL、 OGNL 表达式效果,避免每天套模板、改 jstl、改标签的困扰。同时开发人员也可以扩展和创建自定义的方言。
  3. Thymeleaf 提供 spring 标准方言和一个与 Spring MVC 完美集成的可选模块,可以快速的实现表单绑定、属性编辑器、国际化等功能。

缺点

  • 后台管理系统若是高并发的系统,应该选取其他模板引擎而非 Thymeleaf,简而言之:性能较低。

5.2.2 Thymeleaf 初体验

底层规定 Thymeleaf 模板都放置在 classpath:/templates/ 目录下,且后缀 .html 也已指定好,返回页面时可以不用添加前后缀。在 Spring MVC 中的视图解析器 InternalResourceViewResolver 也有该功能,只不过需要我们自己配置。这里则自动帮你配置好了。

@ConfigurationProperties(prefix = "spring.thymeleaf")
public class ThymeleafProperties {

   private static final Charset DEFAULT_ENCODING = StandardCharsets.UTF_8;

   public static final String DEFAULT_PREFIX = "classpath:/templates/";

   public static final String DEFAULT_SUFFIX = ".html";
    
   ...
}

我们来快速搭建一个 Thymeleaf 页面吧!

applicationProperties.yaml(设置项目访问路径):

# 仅仅为了测试 th:href="${github}" 与 th:href="@{github}"
server:
  servlet:
    context-path: /springboot

ThymeleafController.java

@Controller
public class ThymeleafController {

    // 访问需要加上项目的访问路径(server.servlet.context-path): /springboot/thymeleaf
    @RequestMapping("/thymeleaf")
    public String success(Model model) {
        model.addAttribute("word","Hello Thymeleaf!");
        model.addAttribute("github","https://github.com/Wu-yikun");
        // 跳转到 classpath:templates/thymeleaf.html
        return "thymeleaf";
    }

}

Thymeleaf.html

<!DOCTYPE html>
<!-- 指定Thymeleaf的名称空间【相当于约束,会给提示】 -->
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Thymeleaf 的使用</title>
</head>
<body>
    <!-- 取出 model 中的 attribute -->
    <h2 th:text="${word}">Hello World!</h2>
    <a href="http://www.baidu.com" th:target="_blank" th:href="${github}">百度首页? No!</a>
    <a href="http://www.baidu.com" th:href="@{github}">百度???</a>
</body>
</html>

启动项目,访问 localhost:8080/springboot/thymeleaf

换种方式:我们直接打开 html 文件,可以发现是未渲染数据的静态文件。

${} vs @{}

5.2.3 Thymeleaf 基本语法

英文文档:www.thymeleaf.org/doc/tutoria…

中文文档:www.docs4dev.com/docs/zh/thy…

5.2.4 Thymeleaf 实操项目⭐

构建 AdminEx 后台管理系统:gitee.com/Wu-Yikun/sp…

后面讲解的所有内容,都是基于该项目进行的!【当然,所有功能都只会完成部分,仅仅为了演示】

  • 基本页面跳转功能

  • 抽取公共页面

  • 遍历数据

  • 拦截器

  • 文件上传

  • 异常处理

  • 原生组件注入(Servlet、Filter、Listener)

  • 数据访问

  • 单元测试

  • 指标监控

  • 高级特性

⭐页面跳转功能

首先一定要注意的是在每一个 html 页面下引入 thymeleaf 空间

<html lang="en" xmlns:th="http://www.thymeleaf.org">

User.java

public class User {
    private String username;
    private String password;
    // setter、getter
}

LoginController.java

@Controller
public class LoginController {

    // 登录页面
    @GetMapping({"/login", "/"})
    public String loginUser() {
        // 跳转到登陆页面
        return "login";
    }

    // @GetMapping("/login"): URI、<a></a>超链接地址、表单(method=get)
    // @PostMapping("/login"): 表单(method=post)
    @PostMapping("/login")
    public String main(User user, HttpSession session, Model model) {
        if (user.getUsername().equals("w") && user.getPassword().equals("123456")) {
            // 添加Session用户,防止有经验的程序员未登录直接从网址进入后台!
            // 其实所有页面都需要验证是否已登录, 登录则加入Session, 退出要注销Session
            session.setAttribute("loginUser", user);
            // 重定向默认为: GET请求
            return "redirect:/main.html";
        } else {
            model.addAttribute("status", "账号或密码错误!");
            return "login";
        }
    }


    // 放置刷新页面时重复提交表单(POST),所以将post重定向到get请求
    @GetMapping("/main.html")
    public String mainPage(HttpSession session, Model model) {
        // 但我们不可能在每一个页面都向以下方法来检查是否登录
        // 所以更快捷的办法就是: 设置全局拦截器,未登录的用户直接跳转到登陆页面(下一节讲解拦截器演示)
        Object loginUser = session.getAttribute("loginUser");
        if (loginUser != null) {
            // 已登录,可跳转
            return "main";
        } else {
            model.addAttribute("status", "请登录");
            return "login";
        }
    }

}

login.html

<form class="form-signin" action="/login" method="post" th:action="@{/login}">
    
...

<label style="color:red" th:text="${status}">Message</label>
<input type="text" class="form-control" placeholder="用户名" name="username" autofocus>
<input type="password" class="form-control" placeholder="密码" name="password">
⭐抽取公共页面功能

了解下 hrefth:href 区别:

在默认项目路径为空时,打 Jar 包单独运行时。二者效果一致。

在使用 Maven 内嵌 Tomcat 或打 War 包部署到 Servlet 容器,或者在项目内执行 App 启动类,且有配置项目路径时。

二者区别如下:

以抽取 footer.html 为例讲解基本语法 th:fragment=""th:insert=" :: "th:replace=" :: "th:include=" :: "

So an HTML fragment like this:

<footer th:fragment="copy">
  &copy; 2011 The Good Thymes Virtual Grocery
</footer>

…included three times in host <div> tags, like this:

<body>

  ...

  <!-- footer指的是footer.html; copy指的是th:fragment -->
  <div th:insert="footer :: copy"></div>

  <div th:replace="footer :: copy"></div>

  <div th:include="footer :: copy"></div>
  
</body>

…will result in:

<body>

  ...

  <div>
    <footer>
      &copy; 2011 The Good Thymes Virtual Grocery
    </footer>
  </div>

  <footer>
    &copy; 2011 The Good Thymes Virtual Grocery
  </footer>

  <div>
    &copy; 2011 The Good Thymes Virtual Grocery
  </div>
  
</body>

Referencing fragments without th:fragment
...
<div id="copy-section">
  &copy; 2011 The Good Thymes Virtual Grocery
</div>
...

We can use the fragment above simply referencing it by its id attribute, in a similar way to a CSS selector:

<body>

  ...

  <div th:insert="~{footer :: #copy-section}"></div>
  
</body>

common.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<!-- 抽取公共页面 -->
<head th:fragment="header-link">
    <!-- common -->
    <link href="css/style.css" rel="stylesheet">
    <link href="css/style-responsive.css" rel="stylesheet">

    <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
    <script src="js/html5shiv.js"></script>
    <script src="js/respond.min.js"></script>
    <![endif]-->
</head>
<body>

<!-- left side start-->
<div th:fragment="common-left-side" class="left-side sticky-left-side">
    <!--logo and iconic logo start-->
    <div class="logo">
        <a th:href="@{/main.html}">
            <img src="images/logo.png" alt=""></a>
    </div>

    <div class="logo-icon text-center">
        <a th:href="@{/main.html}">
            <img src="images/logo_icon.png" alt=""></a>
    </div>
    <!--logo and iconic logo end-->

    
    <!--侧边栏导航开始-->
    <ul class="nav nav-pills nav-stacked custom-nav">
        <!--此处省略1W字-->
        <li class="menu-list nav-active">
            <a href="#">
                <i class="fa fa-th-list"></i>
                <span>数据表</span>
            </a>
            <ul class="sub-menu-list">
                <li class="active">
                    <a th:href="@{/basic_table}">basic_table.html</a>
                </li>
                <li>
                    <a th:href="@{/dynamic_table}">dynamic_table.html</a>
                </li>
                <li>
                    <a th:href="@{/responsive_table}">responsive_table</a>
                </li>
                <li>
                    <a th:href="@{/editable_table}">editable_table</a>
                </li>
            </ul>
        </li>
        <!--此处省略1W字-->
    </ul>
    <!--侧边栏导航结束-->
    <!--此处省略1W字-->
</div>
<!-- left side end-->

<!-- header section start-->
<div th:fragment="common-header-section" class="header-section">
    <a class="toggle-btn"><i class="fa fa-bars"></i></a>

    
    <form class="searchform">
        <!-- 此处省略1W字 -->
    </form>
    
    <div class="menu-right">
        <!-- 此处省略1W字 -->
        <a href="#" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
            <img src="images/photos/user-avatar.png" alt=""/>
            <!-- 登录后在右上角的个人信息中显示名字:由于没有标签,所以无法使用 th:text="${}" -->
            <!-- 直接使用 Thymeleaf 提供的如下引用方式 -->
            [[${session.loginUser.username}]]
            <span class="caret"></span>
        </a>
        <ul class="dropdown-menu dropdown-menu-usermenu pull-right">
            <li><a href="#"><i class="fa fa-user"></i> Profile</a></li>
            <li><a href="#"><i class="fa fa-cog"></i> Settings</a></li>
            <!-- Log Out 要清除Session 才能退出,这里仅做一个简单的返回登录界面的跳转! -->
            <li><a th:href="@{/}"><i class="fa fa-sign-out"></i> Log Out</a></li>
        </ul>
        <!-- 此处省略1W字 -->
    </div>
</div>
<!-- header section end-->

<!--footer section start-->
<footer th:fragment="common-footer">
    2014 &copy; AdminEx by ThemeBucket
</footer>
<!--footer section end-->


<div id="common-script">
    <!-- Placed js at the end of the document so the pages load faster -->
    <script th:src="@{/js/jquery-1.10.2.min.js}"></script>
    <script th:src="@{/js/jquery-ui-1.9.2.custom.min.js}"></script>
    <script th:src="@{/js/jquery-migrate-1.2.1.min.js}"></script>
    <script th:src="@{/js/bootstrap.min.js}"></script>
    <script th:src="@{/js/modernizr.min.js}"></script>
    <script th:src="@{/js/jquery.nicescroll.js}"></script>

    <!--common scripts for all pages-->
    <script th:src="@{/js/scripts.js}"></script>
</div>

</body>
</html>

TableController.java

@Controller
public class TableController {
    @RequestMapping("/basic_table")
    public String basic() {
        return "table/basic_table";
    }

    @RequestMapping("/dynamic_table")
    public String dynamic() {
        return "table/dynamic_table";
    }

    @RequestMapping("/editable_table")
    public String editable() {
        return "table/editable_table";
    }

    @RequestMapping("/pricing_table")
    public String pricing() {
        return "table/pricing_table";
    }

    @RequestMapping("/responsive_table")
    public String responsive() {
        return "table/responsive_table";
    }
}
⭐遍历数据

基本语法:

  • th:each="user,stat:${users}"
  • th:text="${user.xxx}"
  • stat.xxx
<table class="display table table-bordered" id="hidden-table-info">
    <thead>
        <tr>
            <th>#</th>
            <th>UserName</th>
            <th>Password</th>
        </tr>
    </thead>
    <tbody>
        <tr class="gradeX" th:each="user,stat:${users}">
            <!-- th:each="遍历对象,状态:${遍历数组}" -->
            <td th:text="${stat.count}"></td>
            <td th:text="${user.username}">user</td>
            <td th:text="${user.password}">password</td>
            <!-- or -->
            <!--<td>[[${user.password}]]</td>-->
        </tr>
    </tbody>
</table>

6. 拦截器

拦截访问路径 /**/*

  • /**:Spring 家族的写法
  • /*:Servlet 的写法

6.1 HandlerInterceptor 接口

public interface HandlerInterceptor {
   // 方法执行前
   default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
         throws Exception {

      return true;
   }
   
   // 方法返回前
   default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
         @Nullable ModelAndView modelAndView) throws Exception {
   }
   
   // 方法执行后
   default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
         @Nullable Exception ex) throws Exception {
   }

}

6.2 自定义拦截器

1、编写拦截器实现 HandlerInterceptor 接口

@Controller
public class LoginInterceptor implements HandlerInterceptor {
    // 请求方法(/xxx)执行前
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        HttpSession session = request.getSession();

        if (session.getAttribute("loginUser") != null) {
            // 用户已登录,可放行
            return true;
        }

        // 用户未登录,拦截并返回登陆界面
        // 提示信息
        request.setAttribute("status", "请先登录!");
        // 转发到登陆界面(并携带提示信息)
        request.getRequestDispatcher("/").forward(request, response);
        // 重定向无法携带消息!!!所以使用转发!
//        response.sendRedirect("/login");
        // 拦截
        return false;
    }

    // 请求方法(/xxx)返回前
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // 此处可对 ModelAndView 对象进行操作!
        System.out.println("postHandle...");
    }

    // 请求方法(/xxx)执行后
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("afterCompletion...");
    }
}

2、拦截器注册到容器中(实现 WebMvcConfigureraddInterceptors

定制 Spring MVC 功能【必须向容器中注入 WebMvcConfigurer 组件】的两种方法:

1)@Configuration + implements WebMvcConfigurer

@Configuration
public class AdminWebConfig implements WebMvcConfigurer {

   @Override
   ...
}

2)@Bean

@Bean
public WebMvcConfigurer getWebMvcConfigurer() {
   return new WebMvcConfigurer() {
       @Override
       ...
   }
}

这里我们使用第一种,并重写 addInterceptors 方法:

@Configuration
public class AdminWebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/login", "/css/**", "/fonts/**", "/images/**", "/js/**");
    }

}

3、指定拦截规则 /**【如果是拦截所有,静态资源也会被拦截,需放行静态资源】

registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/login", "/css/**", "/fonts/**", "/images/**", "/js/**");

6.3 拦截器原理

7. 文件上传

文件上传 の XML 版本:juejin.cn/post/699580…

7.1 实现文件上传

文件表单固定格式:<form role="form" action="/upload" method="post" enctype="multipart/form-data"></form>

单文件:<input type="file" name="head">

多文件:<input type="file" name="photos" multiple>

form_layouts.html

<!--只截取上传文件的表单部分-->
<form role="form" th:action="@{/upload}" method="post" enctype="multipart/form-data">
    <div class="form-group">
        <label for="exampleInputEmail1">邮箱</label>
        <input type="email" class="form-control" id="exampleInputEmail1" name="email"
               placeholder="Enter email">
    </div>
    <div class="form-group">
        <label for="exampleInputPassword1">密码</label>
        <input type="password" class="form-control" id="exampleInputPassword1" name="password"
               placeholder="Password">
    </div>
    <div class="form-group">
        <label for="exampleInputFile">头像</label>
        <input type="file" id="exampleInputFile" name="head">
        <p class="help-block">Example block-level help text here.</p>
    </div>
    <div class="form-group">
        <label for="exampleInputFile">照片s</label>
        <input type="file" id="exampleInputFiles" name="photos" multiple>
        <p class="help-block">Example block-level help text here.</p>
    </div>
    <button type="submit" class="btn btn-primary">Submit</button>
</form>

FormController.java

@Controller
public class FormController {

    @RequestMapping("/form_layouts")
    public String form_layouts() {
        // 前后缀自动补齐!
        return "form/form_layouts";
    }

    /**
     * MultipartFile 会自动封装上传过来的文件!
     *
     * @param email		String
     * @param password	String
     * @param head		MultipartFile
     * @param photos 	MultipartFile[]
     * @return
     * @throws IOException
     */
    @PostMapping("/upload")
    public String uploadFiles(@RequestParam("email") String email,
                              @RequestParam("password") String password,
                              @RequestPart("head") MultipartFile head,
                              @RequestPart("photos") MultipartFile[] photos) throws IOException {

        System.out.println("邮箱: " + email);
        System.out.println("密码: " + password);

        // 单个文件大小不为空, 则上传到文件服务器(这里上传到本地文件夹)
        if (!head.isEmpty()) {
            String originalFilename = head.getOriginalFilename();
            head.transferTo(new File("D:\\ChromeTemp\\Admin-Spring Boot\\" + originalFilename));
        }

        // 文件组不为空, 则上传到文件服务器(这里上传到本地文件夹)
        if (photos.length > 0) {
            for (MultipartFile file : photos) {
                if (!file.isEmpty()) {
                    String originName = file.getOriginalFilename();
                    file.transferTo(new File("D:\\ChromeTemp\\Admin-Spring Boot\\" + originName));
                }
            }
        }

        // 上传后返回首页
        return "main";
    }
}

首次提交表单,提示上传文件超出限制:

MultipartProperties 默认配置:

所以我们需要更改一下 yaml 配置,像这样:

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 50MB

再次上传,上传成功:

image-20210913135219349

7.2 文件上传原理

文件上传自动配置类:MultipartAutoConfiguration

@ConfigurationProperties(prefix = "spring.servlet.multipart", ignoreUnknownFields = false)
public class MultipartProperties {

8. 异常处理

异常处理 の XML 版本:juejin.cn/post/699580…

8.1 异常处理默认规则

官方文档中对于 Spring Boot 的 Error Handling 是这样解释的:

  • 默认情况下,Spring Boot 提供 /error 来处理所有错误的映射
  • 对于机器客户端,它将生成 JSON 响应,其中包含错误,HTTP 状态和异常消息的详细信息
  • 对于浏览器客户端,则会响应 "Whitelabel Error Page",以 HTML 格式呈现相同的数据

机器客户端:

{
    "timestamp": "2021-09-13T15:23:26.019+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/hello"
}

浏览器客户端:

静态资源目录下的 error 文件夹下的 4xx5xx 页面会被自动解析:

有精确的错误状态码页面 404.html 就匹配精确的错误页面,找不到再匹配 4xx.html 错误页面,若也没有则触发 Whitelabel Error Page

8.2 自定义错误页面

4xx.html

<!-- ${status}:错误状态码 -->
<h2 th:text="${status}">page not found</h2>
<!-- ${message}:错误信息 -->
<h3 th:text="${message}">We Couldn’t Find This Page</h3>

8.2.1 @ControllerAdvice + @ExceptionHandler

GlobalExceptionHandler.java

@ControllerAdvice
public class GlobalExceptionHandler {

    // 处理 ArithmeticException.class, NullPointerException.class 这两类异常
    @ExceptionHandler({ArithmeticException.class, NullPointerException.class})
    public String handlerArithNullException() {
        // 返回登陆页面
        return "login";
    }
}

8.2.2 @ResponseStatus + 自定义异常

UserTooManyException.java

@ResponseStatus(value = HttpStatus.FORBIDDEN, reason = "Too much users")
public class UserTooManyException extends RuntimeException {

    public UserTooManyException() {
    }

    public UserTooManyException(String message) {
        super(message);
    }
}

4xx.html403.html 才能响应 403:

8.3 异常处理自动配置原理

异常处理最终也是返回一个 ModelAndView

底层几个重要的组件:

  • ErrorMvcAutoConfiguration
  • DefaultErrorViewResolver:把状态响应码作为错误页的地址 error/500.html,模板引擎最终响应这个页面 /resources/templates/error/5xx.html
  • DefaultErrorAttributes
  • BasicErrorController
  • ...
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class })
// Load before the main WebMvcAutoConfiguration so that the error View is available
@AutoConfigureBefore(WebMvcAutoConfiguration.class)
@EnableConfigurationProperties({ ServerProperties.class, ResourceProperties.class, WebMvcProperties.class })
public class ErrorMvcAutoConfiguration {

错误页面能包含的基本属性

8.4 Http 状态响应码

public enum HttpStatus {
    CONTINUE(100, "Continue"),
    SWITCHING_PROTOCOLS(101, "Switching Protocols"),
    PROCESSING(102, "Processing"),
    CHECKPOINT(103, "Checkpoint"),
    OK(200, "OK"),
    CREATED(201, "Created"),
    ACCEPTED(202, "Accepted"),
    NON_AUTHORITATIVE_INFORMATION(203, "Non-Authoritative Information"),
    NO_CONTENT(204, "No Content"),
    RESET_CONTENT(205, "Reset Content"),
    PARTIAL_CONTENT(206, "Partial Content"),
    MULTI_STATUS(207, "Multi-Status"),
    ALREADY_REPORTED(208, "Already Reported"),
    IM_USED(226, "IM Used"),
    MULTIPLE_CHOICES(300, "Multiple Choices"),
    MOVED_PERMANENTLY(301, "Moved Permanently"),
    FOUND(302, "Found"),
    /** @deprecated */
    @Deprecated
    MOVED_TEMPORARILY(302, "Moved Temporarily"),
    SEE_OTHER(303, "See Other"),
    NOT_MODIFIED(304, "Not Modified"),
    /** @deprecated */
    @Deprecated
    USE_PROXY(305, "Use Proxy"),
    TEMPORARY_REDIRECT(307, "Temporary Redirect"),
    PERMANENT_REDIRECT(308, "Permanent Redirect"),
    BAD_REQUEST(400, "Bad Request"),
    UNAUTHORIZED(401, "Unauthorized"),
    PAYMENT_REQUIRED(402, "Payment Required"),
    FORBIDDEN(403, "Forbidden"),
    NOT_FOUND(404, "Not Found"),
    METHOD_NOT_ALLOWED(405, "Method Not Allowed"),
    NOT_ACCEPTABLE(406, "Not Acceptable"),
    PROXY_AUTHENTICATION_REQUIRED(407, "Proxy Authentication Required"),
    REQUEST_TIMEOUT(408, "Request Timeout"),
    CONFLICT(409, "Conflict"),
    GONE(410, "Gone"),
    LENGTH_REQUIRED(411, "Length Required"),
    PRECONDITION_FAILED(412, "Precondition Failed"),
    PAYLOAD_TOO_LARGE(413, "Payload Too Large"),
    /** @deprecated */
    @Deprecated
    REQUEST_ENTITY_TOO_LARGE(413, "Request Entity Too Large"),
    URI_TOO_LONG(414, "URI Too Long"),
    /** @deprecated */
    @Deprecated
    REQUEST_URI_TOO_LONG(414, "Request-URI Too Long"),
    UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"),
    REQUESTED_RANGE_NOT_SATISFIABLE(416, "Requested range not satisfiable"),
    EXPECTATION_FAILED(417, "Expectation Failed"),
    I_AM_A_TEAPOT(418, "I'm a teapot"),
    /** @deprecated */
    @Deprecated
    INSUFFICIENT_SPACE_ON_RESOURCE(419, "Insufficient Space On Resource"),
    /** @deprecated */
    @Deprecated
    METHOD_FAILURE(420, "Method Failure"),
    /** @deprecated */
    @Deprecated
    DESTINATION_LOCKED(421, "Destination Locked"),
    UNPROCESSABLE_ENTITY(422, "Unprocessable Entity"),
    LOCKED(423, "Locked"),
    FAILED_DEPENDENCY(424, "Failed Dependency"),
    TOO_EARLY(425, "Too Early"),
    UPGRADE_REQUIRED(426, "Upgrade Required"),
    PRECONDITION_REQUIRED(428, "Precondition Required"),
    TOO_MANY_REQUESTS(429, "Too Many Requests"),
    REQUEST_HEADER_FIELDS_TOO_LARGE(431, "Request Header Fields Too Large"),
    UNAVAILABLE_FOR_LEGAL_REASONS(451, "Unavailable For Legal Reasons"),
    INTERNAL_SERVER_ERROR(500, "Internal Server Error"),
    NOT_IMPLEMENTED(501, "Not Implemented"),
    BAD_GATEWAY(502, "Bad Gateway"),
    SERVICE_UNAVAILABLE(503, "Service Unavailable"),
    GATEWAY_TIMEOUT(504, "Gateway Timeout"),
    HTTP_VERSION_NOT_SUPPORTED(505, "HTTP Version not supported"),
    VARIANT_ALSO_NEGOTIATES(506, "Variant Also Negotiates"),
    INSUFFICIENT_STORAGE(507, "Insufficient Storage"),
    LOOP_DETECTED(508, "Loop Detected"),
    BANDWIDTH_LIMIT_EXCEEDED(509, "Bandwidth Limit Exceeded"),
    NOT_EXTENDED(510, "Not Extended"),
    NETWORK_AUTHENTICATION_REQUIRED(511, "Network Authentication Required");
    ...
}

9. Web 原生组件注入

Web 三大原生组件:

  • Servlet
  • Filter
  • Listener

为了使得三大原生组件生效,必须将 Servlet 扫描进容器,所以我们需要在主配置类中添加一个注解 @ServletComponentScan(basePackages = "")

When using an embedded container, automatic registration of classes annotated with @WebServlet, @WebFilter, and @WebListener can be enabled by using @ServletComponentScan.

@ServletComponentScan(basePackages = "com.one.admin")
@SpringBootApplication
public class SpringbootAdminApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringbootAdminApplication.class, args);
    }

}

9.1 @WebServlet

@WebServlet(urlPatterns = {"/servlet","/my"})
public class MyServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.getWriter().write("MyServlet...");
    }
}

9.2 @WebFilter

// Servlet: /*
// Spring 家族: /**
@WebFilter(urlPatterns = {"/css/*","/images/*"})
public class MyFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("init...");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        // ...

        // 满足则放行
        filterChain.doFilter(servletRequest,servletResponse);
    }

    @Override
    public void destroy() {
        System.out.println("destroy...");
    }
}

9.3 @WebListener

@WebListener
public class MyServletContextListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        System.out.println("项目初始化...");
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        System.out.println("项目销毁...");
    }
}

9.4 ServletRegistrationBean、FilterRegistrationBean、ServletListenerRegistrationBean

@Configuration
public class MyRegistrationBean {

    @Bean
    public ServletRegistrationBean myServlet() {
        MyServlet myServlet = new MyServlet();

        return new ServletRegistrationBean(myServlet, "/servlet", "/my");
    }

    @Bean
    public FilterRegistrationBean myFilter() {
        MyFilter myFilter = new MyFilter();

        // 第一种
//        return new FilterRegistrationBean(myFilter, myServlet());

        // 第二种
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(myFilter);
        filterRegistrationBean.setUrlPatterns(Arrays.asList("/servlet", "/my"));
        return filterRegistrationBean;
    }


    @Bean
    public ServletListenerRegistrationBean myListener(){
        MyServletContextListener listener = new MyServletContextListener();
        return new ServletListenerRegistrationBean(listener);
    }

}

原创不易🧠 转载请标明出处🌊
若本文对你有所帮助,点赞支持嗷🔥