在《初识 Spring MVC,知道这些就够了》中,我从应用的角度粗略总结了一下Spring MVC的知识,这一篇章我将从更为细致的角度去总结一下相关知识。我打算从处理器映射、上下文参数、数据转换器、数据验证、数据模型、视图和视图解析器、文件上传、拦截器、国际化等方面分别进行总结,并总结一些实际应用案例。
写在前面
强大的Spring已经做到了框架的天花板,作为一个java工程师必须深入了解Spring,我带着学习的心态,将Spring MVC又重新深入了解一下,希望带来更深层次的思索,授业解惑。
处理器映射
在上一章的介绍中,我们已经知道一个请求从客户端过来,会找到请求对应的控制器,那么这一过程是怎么实现的,我接下来就要分析一下这个过程。
我把springboot工程中logging.level.org.springframework日志级别改为trace,这样我们可以看到系统启动时,RequestMappingHandlerMapping组件会检测到所有@Controller控制器的@RequestMapping方法,日志如下图:
14:29:42.373 [restartedMain] TRACE o.s.w.s.m.m.a.RequestMappingHandlerMapping - [detectHandlerMethods,292] -
c.r.w.c.d.c.DemoDialogController:
{GET [/demo/modal/parent]}: parent()
{GET [/demo/modal/layer]}: layer()
{GET [/demo/modal/check]}: check()
{GET [/demo/modal/table]}: table()
{GET [/demo/modal/form]}: form()
{GET [/demo/modal/radio]}: radio()
{GET [/demo/modal/frame2]}: frame2()
{GET [/demo/modal/dialog]}: dialog()
{GET [/demo/modal/frame1]}: frame1()
当一个请求"/captcha/captchaImage"发送到服务器的时候,DispatcherServlet会先根据请求找到"sysCaptchaController"组件,然后找到该组件的"getKaptchaImage"方法进行处理。
这些都归功于@RequestMapping、@PostMapping、@GetMapping、@PutMapping、@DeleteMapping等注解,我以GetMapping为例看下源码:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(
method = {RequestMethod.GET}
)
// 仅限于get请求了
public @interface GetMapping {
@AliasFor(
annotation = RequestMapping.class
)
// 配置请求映射名称
String name() default "";
@AliasFor(
annotation = RequestMapping.class
)
String[] value() default {};
@AliasFor(
annotation = RequestMapping.class
)
// 通过路径映射
String[] path() default {};
@AliasFor(
annotation = RequestMapping.class
)
// 限定参数
String[] params() default {};
@AliasFor(
annotation = RequestMapping.class
)
// 限定请求头
String[] headers() default {};
@AliasFor(
annotation = RequestMapping.class
)
// 限定提交类型,即本方法消费什么类型的数据,如application/json
String[] consumes() default {};
@AliasFor(
annotation = RequestMapping.class
)
// 限定响应内容类型,即指定生产什么类型的数据
String[] produces() default {};
}
上下文参数
在实际应用过程中,获取参数有多种方式,最常见的有如下几种:
- 无注解下获取参数
- 使用注解@RequestParam获取参数
- 数组参数
- JSON参数
- URL包含参数
无注解下获取参数
控制器方法
@Controller
@RequestMapping("/test")
public class TestController {
@RequestMapping("/hello")
@ResponseBody
public String hello(String username) {
return "hello, " + username;
}
}
浏览器请求
http://127.0.0.1:8080/test/hello?username=lilei
postman请求
由此可以看出,只需要控制器接口参数名称和http请求参数名称保持一致。
使用注解@RequestParam获取参数
控制器方法
@RequestMapping("/hi")
@ResponseBody
public String hi(@RequestParam("user_name") String username) {
return "hi, " + username;
}
浏览器请求
http://127.0.0.1:8080/test/hi?user_name=lilei
postman请求
@RequestParam注解实现了控制器参数和http请求参数的映射关系,且默认情况下参数不能为空,可以使用required=false属性进行设置。
数组参数
控制器方法
@RequestMapping("/welcome")
@ResponseBody
public String welcome(String[] usernames) {
for (String username : usernames) {
System.out.println(username);
}
return "welcome";
}
浏览器请求
http://127.0.0.1:8080/test/welcome?usernames=lilei,lili,ligang
postman请求
后台输出
JSON参数
@Data
public class User {
private Long id;
private String name;
private Integer age;
private String email;
private String city;
}
@RequestMapping("/user")
@ResponseBody
public String user(@RequestBody User user) {
System.out.println(user.toString());
return user.toString();
}
postman请求
@RequestBody注解表示控制器方法接收前端提交的JSON数据格式的请求体,JSON请求体参数名称和User属性名称保持一致。
URL包含参数
控制器方法
@RequestMapping("/viewuser/{id}")
@ResponseBody
public String viewuser(@PathVariable("id") String id) {
System.out.println(id);
return id;
}
浏览器请求
http://127.0.0.1:8080/test/viewuser/1
数据转换器
在上下文参数章节,我们得知控制器接收参数时进行了自动转换,这是因为spring内置了很多数据转换器,比如最长用的:StringHttpMessageConverter、MappingJackson2HttpMessageConverter。
参数转换流程
- Converter,普通转换器接口
- Formatter,格式化转换器
- GenericConverter,可以将参数转换为数组
在SpringMVC中,这三类接口实现可通过注册机接口注册,注册后控制器就可以获取对应的转换器来实现参数的转换。
在Spring Boot中,使用WebMvcAutoConfiguration中内部类WebMvcAutoConfigurationAdapter的addFormatters方法来注册。
// WebMvcAutoConfigurationAdapter
public void addFormatters(FormatterRegistry registry) {
ApplicationConversionService.addBeans(registry, this.beanFactory);
}
// ApplicationConversionService
public static void addBeans(FormatterRegistry registry, ListableBeanFactory beanFactory) {
Set<Object> beans = new LinkedHashSet();
beans.addAll(beanFactory.getBeansOfType(GenericConverter.class).values());
beans.addAll(beanFactory.getBeansOfType(Converter.class).values());
beans.addAll(beanFactory.getBeansOfType(Printer.class).values());
beans.addAll(beanFactory.getBeansOfType(Parser.class).values());
Iterator var3 = beans.iterator();
while(var3.hasNext()) {
Object bean = var3.next();
if (bean instanceof GenericConverter) {
registry.addConverter((GenericConverter)bean);
} else if (bean instanceof Converter) {
registry.addConverter((Converter)bean);
} else if (bean instanceof Formatter) {
registry.addFormatter((Formatter)bean);
} else if (bean instanceof Printer) {
registry.addPrinter((Printer)bean);
} else if (bean instanceof Parser) {
registry.addParser((Parser)bean);
}
}
}
在Spring Boot的工程中,只需要定义一个Converter组件即可。
知道了转换器的原理以及注册机制后,那么我们实践一下如何自定义一个数据转换器呢,比如我们传送一个分数参数,我们需要将分数按照A、B、C、D、E分等级(grade)。
Converter实践
只需要定义一个@Component组件即可。
自定义转换器
@Component
public class StringToGradeConverter implements Converter<String, Grade> {
@Override
public Grade convert(String source) {
// http字符串参数
Long s = Long.parseLong(source);
if (s < 60) {
return new Grade(s, "E");
} else if (s < 70) {
return new Grade(s, "D");
} else if (s < 80) {
return new Grade(s, "C");
} else if (s < 90) {
return new Grade(s, "B");
} else if (s < 100) {
return new Grade(s, "A");
} else {
return new Grade(s, "A+");
}
}
}
domain类
public class Grade {
public Grade(Long score, String grade) {
this.fs = score;
this.dj = grade;
}
private Long fs;
private String dj;
public Long getFs() {
return fs;
}
public void setFs(Long fs) {
this.fs = fs;
}
public String getDj() {
return dj;
}
public void setDj(String dj) {
this.dj = dj;
}
@Override
public String toString() {
return "Grade{" +
"fs=" + fs +
", dj='" + dj + '\'' +
'}';
}
}
控制器接口
@RestController
@RequestMapping("/test")
public class GradeController {
public static final Logger logger = LoggerFactory.getLogger(GradeController.class);
@RequestMapping("/converter")
public String converter(Grade grade){
logger.info(grade.toString());
return grade.toString();
}
}
浏览器验证
http://127.0.0.1:8080/test/converter?grade=88
返回结果:
postman验证
HttpMessageConverter说明
HttpMessageConverter接口负责将http请求体转换成对应的java对象。
比如@RequestBody注解,通过HttpMessageConverter来实现请求体和java对象之间的解析。
数据验证
参数转换后,处理器还可以进行参数验证,以保障后边进行的业务逻辑处理。
Hibernate Validator验证
添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
验证实体
public class User {
private Long id;
@NotNull(message = "名称不能为空")
private String name;
private Integer age;
private String email;
private String city;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", name='" + name + '\'' +
", age=" + age +
", email='" + email + '\'' +
", city='" + city + '\'' +
'}';
}
}
引入注解@Valid
@RequestMapping("/user")
@ResponseBody
public String user(@Valid @RequestBody User user) {
System.out.println(user.toString());
return user.toString();
}
模拟访问
后台报org.springframework.web.bind.MethodArgumentNotValidException异常
自定义验证
验证机制
Validator验证器接口,实现这个接口定义自己的验证器。
@InitBinder注解用来绑定验证器,在控制器方法调用前会调用。
验证实体
package com.example.entity;
public class User {
private Long id;
private String name;
private Integer age;
private String email;
private String city;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", name='" + name + '\'' +
", age=" + age +
", email='" + email + '\'' +
", city='" + city + '\'' +
'}';
}
}
自定义验证器
package com.example.config;
import com.example.entity.User;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
public class UserValidator implements Validator {
// 需要验证的实体类型,true验证,false否
@Override
public boolean supports(Class<?> clazz) {
return clazz.equals(User.class);
}
/**
* 验证规则
* @param target 验证对象
* @param errors 错误对象
*/
@Override
public void validate(Object target, Errors errors) {
// 验证年龄必须大于18
User user = (User) target;
if (user.getAge() != null && user.getAge() < 18) {
errors.rejectValue("age", null, "未成年");
return;
}
}
}
控制器方法
package com.example.web;
import com.example.config.UserValidator;
import com.example.entity.User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.validation.Valid;
@Controller
public class UserController {
/**
* 绑定自定义验证器
* 其它方法调用前,先执行该绑定方法
* @param binder
*/
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.setValidator(new UserValidator());
}
/**
* 验证器实践接口
* @param user
* @return
*/
@RequestMapping("/user/validator")
@ResponseBody
public String user(@Valid @RequestBody User user) {
System.out.println(user.toString());
return user.toString();
}
}
如何在控制器方法获取到验证的错误信息,需要使用org.springframework.validation.Errors
// 可以通过Errors获取验证信息
@RequestMapping("/user/validator")
@ResponseBody
public String user(@Valid @RequestBody User user, Errors errors) {
System.out.println(user.toString());
for (ObjectError error : errors.getAllErrors()) {
System.out.println(error.getDefaultMessage());
}
return user.toString();
}
模拟访问
后台日志:
Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.lang.String com.example.web.UserController.user(com.example.entity.User): [Field error in object 'user' on field 'age': rejected value [14]; codes [user.age,age,java.lang.Integer,]; arguments []; default message [未成年]] ]
数据模型
数据模型的作用是绑定数据,为后面的视图渲染做准备。
我在实际项目中常用ModelMap,如下方法所示:
@GetMapping("/edit/{deptId}")
public String edit(@PathVariable("deptId") Long deptId, ModelMap mmap)
{
mmap.put("dept", deptService.selectDeptById(deptId));
return prefix + "/edit";
}
视图和视图解析器
视图的作用是渲染数据模型展示给用户,分为逻辑视图(如InternalResourceViewResolver)和非逻辑视图(如MappingJackson2JsonView)。
默认的视图解析器配置:org.springframework.web.servlet.ViewResolver=org.springframework.web.servlet.view.InternalResourceViewResolver
逻辑视图中我们经常使用的是jthymeleaf和jsp。
thymeleaf配置
spring:
# 模板引擎
thymeleaf:
mode: HTML
encoding: utf-8
# 禁用缓存
cache: false
默认配置:
package org.springframework.boot.autoconfigure.thymeleaf;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.MediaType;
import org.springframework.util.MimeType;
import org.springframework.util.unit.DataSize;
@ConfigurationProperties(
prefix = "spring.thymeleaf"
)
public class ThymeleafProperties {
private static final Charset DEFAULT_ENCODING;
public static final String DEFAULT_PREFIX = "classpath:/templates/";
public static final String DEFAULT_SUFFIX = ".html";
private boolean checkTemplate = true;
private boolean checkTemplateLocation = true;
private String prefix = "classpath:/templates/";
private String suffix = ".html";
private String mode = "HTML";
......
}
jsp配置
spring:
# jsp config
mvc:
view:
prefix: /WEB-INF/views
suffix: .jsp
文件上传
在spring-boot-autoconfigure包内的org.springframework.boot.autoconfigure.web.servlet.MultipartAutoConfiguration类中,已经默认配置了StandardServletMultipartResolver,所以我们只需要简单的配置一下yml参数即可。
StandardServletMultipartResolver
@Configuration(
proxyBeanMethods = false
)
@ConditionalOnClass({Servlet.class, StandardServletMultipartResolver.class, MultipartConfigElement.class})
@ConditionalOnProperty(
prefix = "spring.servlet.multipart",
name = {"enabled"},
matchIfMissing = true
)
@ConditionalOnWebApplication(
type = Type.SERVLET
)
@EnableConfigurationProperties({MultipartProperties.class})
public class MultipartAutoConfiguration {
private final MultipartProperties multipartProperties;
public MultipartAutoConfiguration(MultipartProperties multipartProperties) {
this.multipartProperties = multipartProperties;
}
@Bean
@ConditionalOnMissingBean({MultipartConfigElement.class, CommonsMultipartResolver.class})
public MultipartConfigElement multipartConfigElement() {
return this.multipartProperties.createMultipartConfig();
}
@Bean(
name = {"multipartResolver"}
)
@ConditionalOnMissingBean({MultipartResolver.class})
public StandardServletMultipartResolver multipartResolver() {
StandardServletMultipartResolver multipartResolver = new StandardServletMultipartResolver();
multipartResolver.setResolveLazily(this.multipartProperties.isResolveLazily());
return multipartResolver;
}
}
yml
spring:
servlet:
# 上传下载配置
multipart:
# 单个文件大小
max-file-size: 1MB
# 设置总上传的文件大小
max-request-size: 10MB
控制器方法示例
@PostMapping("/common/upload")
@ResponseBody
public HashMap<String, String> uploadFile(MultipartFile file) throws Exception
{
HashMap ajax = new HashMap<String, String>();
try
{
// 上传文件路径,windows和linux有区别,本示例在windows环境
String filePath = "D:/uploadPath";
// 设定文件名称,实际中需要是动态扩展的,比如添加目录层级
String fileName = file.getOriginalFilename();
File desc = new File(filePath + File.separator + fileName);
// 关键是这句代码,拷贝文件
file.transferTo(desc);
// pathFileName和url实际中也需要动态处理
String pathFileName = "需要返回的路径";
String url = "http://127.0.0.1:8080/" + pathFileName;
ajax.put("code", 0);
ajax.put("fileName", pathFileName);
ajax.put("url", url);
}
catch (Exception e)
{
ajax.put("code", 500);
}
return ajax;
}
接口设计
拦截器
实际项目中常用的拦截器有防跨站攻击(Xss)、防重复提交。
XSS过滤
配置文件
xss:
# 过滤开关
enabled: true
# 非过滤链接(多个用逗号分隔)
excludes: /platform/notice/*
# 过滤链接
urlPatterns: /user/*,/role/*,/dept/*
XssFilterConfig
@Configuration
public class XssFilterConfig
{
@Value("${xss.enabled}")
private String enabled;
@Value("${xss.excludes}")
private String excludes;
@Value("${xss.urlPatterns}")
private String urlPatterns;
@SuppressWarnings({ "rawtypes", "unchecked" })
@Bean
public FilterRegistrationBean xssFilterRegistration()
{
// 注册过滤器到servlet 3.0+容器
FilterRegistrationBean registration = new FilterRegistrationBean();
// 设置dispatcher类型,此处为request,
registration.setDispatcherTypes(DispatcherType.REQUEST);
// 设置过滤器对象
registration.setFilter(new XssFilter());
// 过滤请求路径
registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
registration.setName("xssFilter");
registration.setOrder(Integer.MAX_VALUE);
Map<String, String> initParameters = new HashMap<String, String>();
initParameters.put("excludes", excludes);
initParameters.put("enabled", enabled);
// 设置过滤器初始化参数
registration.setInitParameters(initParameters);
return registration;
}
}
XssFilter
public class XssFilter implements Filter {
/**
* 排除链接
*/
public List<String> excludes = new ArrayList<>();
/**
* xss过滤开关
*/
public boolean enabled = false;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 获取排除链接
String tempExcludes = filterConfig.getInitParameter("excludes");
String tempEnabled = filterConfig.getInitParameter("enabled");
if (StringUtils.isNotEmpty(tempExcludes)) {
String[] url = tempExcludes.split(",");
for (int i = 0; url != null && i < url.length; i++) {
excludes.add(url[i]);
}
}
if (StringUtils.isNotEmpty(tempEnabled)) {
enabled = Boolean.valueOf(tempEnabled);
}
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
// 处理需要排除过滤的链接
if (handleExcludeURL(req, resp)) {
chain.doFilter(request, response);
return;
}
// 包装请求对象
XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper((HttpServletRequest) request);
chain.doFilter(xssRequest, response);
}
/*
* 处理需要排除过滤的链接
* 开关关闭,所有链接都不过滤
* excludes 参数为空,默认全部过滤
* 在excludes 参数中找到具体链接,则进行过滤
* 默认全部过滤
*/
private boolean handleExcludeURL(HttpServletRequest request, HttpServletResponse response) {
// 开关关闭返回true
if (!enabled) {
return true;
}
// 排除链接为空返回false
if (excludes == null || excludes.isEmpty()) {
return false;
}
String url = request.getServletPath();
for (String pattern : excludes) {
Pattern p = Pattern.compile("^" + pattern);
Matcher m = p.matcher(url);
if (m.find()) {
return true;
}
}
// 默认返回false
return false;
}
@Override
public void destroy() {
}
}
XssHttpServletRequestWrapper
/*
*包装请求对象
* 处理参数特殊字符
*/
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
/**
* @param request
*/
public XssHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values != null) {
int length = values.length;
String[] escapseValues = new String[length];
for (int i = 0; i < length; i++) {
// 防xss攻击和过滤前后空格
escapseValues[i] = EscapeUtil.clean(values[i]).trim();
}
return escapseValues;
}
return super.getParameterValues(name);
}
}
EscapeUtil
若依框架工具类:EscapeUtil
HTMLFilter
若依框架工具类:HTMLFilter
防重复提交
主要思路是对相同请求的参数是否一致,以及两次请求(参数一致)的时间差进行对比(小于10秒,可变)。使用缓存或者session以请求url为key,进行存储参数和时间戳,每次请求会更新当前缓存信息。
国际化
请移阅“面向全球的用户,我们该怎么办”
结尾
自此SpringMVC的主要知识点就梳理完成了,我知道了这些,再也不怕别人问我SpringMVC相关知识了。