Spring踩坑二
Spring Web
http 请求处理流程
总览
@RestController
public class HelloWorldController {
@RequestMapping(path = "hi", method = RequestMethod.GET)
public String hi(){
return "helloworld";
};
}
- 请求的 Path: hi
- 请求的方法:Get
- 对应方法的执行:hi()
那么,假设让你自己去实现 HTTP 的请求处理,你可能会写出这样一段伪代码:
public class HttpRequestHandler{
Map<RequestKey, Method> mapper = new HashMap<>();
public Object handle(HttpRequest httpRequest){
RequestKey requestKey = getRequestKey(httpRequest);
Method method = this.mapper.getValue(requestKey);
Object[] args = resolveArgsAccordingToMethod(httpRequest, method);
return method.invoke(controllerObject, args);
};
}
那么现在需要哪些组件来完成一个请求的对应和执行呢?
- 需要有一个地方(例如 Map)去维护从 HTTP path/method 到具体执行方法的映射;
- 当一个请求来临时,根据请求的关键信息来获取对应的需要执行的方法;
- 根据方法定义解析出调用方法的参数值,然后通过反射调用方法,获取返回结果。
除此之外,你还需要一个东西,就是利用底层通信层来解析出你的 HTTP 请求。只有解析出请求了,才能知道 path/method 等信息,才有后续的执行,否则也是“巧妇难为无米之炊”了。
原理
首先,解析 HTTP 请求。对于 Spring 而言,它本身并不提供通信层的支持,它是依赖于Tomcat、Jetty等容器来完成通信层的支持,例如当我们引入Spring Boot时,我们就间接依赖了Tomcat。正是这种自由组合的关系,让我们可以做到直接置换容器而不影响功能。依赖了Tomcat后,Spring Boot在启动的时候,就会把Tomcat启动起来做好接收连接的准备。
调用下述代码行就会启动Tomcat:
SpringApplication.run(Application.class, args);
//org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryConfiguration
class ServletWebServerFactoryConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
public static class EmbeddedTomcat {
@Bean
public TomcatServletWebServerFactory tomcatServletWebServerFactory(
//省略非关键代码
return factory;
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Servlet.class, Server.class, Loader.class, WebAppContext.class })
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
public static class EmbeddedJetty {
@Bean
public JettyServletWebServerFactory JettyServletWebServerFactory(
ObjectProvider<JettyServerCustomizer> serverCustomizers) {
//省略非关键代码
return factory;
}
}
//省略其他容器配置
}
前面我们默认依赖了Tomcat内嵌容器的JAR,所以下面的条件会成立,进而就依赖上了Tomcat:
@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
有了Tomcat后,当一个HTTP请求访问时,会触发Tomcat底层提供的NIO通信来完成数据的接收,这点我们可以从下面的代码(org.apache.tomcat.util.net.NioEndpoint.Poller#run)中看出来:
@Override
public void run() {
while (true) {
//省略其他非关键代码
//轮询注册的兴趣事件
if (wakeupCounter.getAndSet(-1) > 0) {
keyCount = selector.selectNow();
} else {
keyCount = selector.select(selectorTimeout);
//省略其他非关键代码
Iterator<SelectionKey> iterator =
keyCount > 0 ? selector.selectedKeys().iterator() : null;
while (iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
NioSocketWrapper socketWrapper = (NioSocketWrapper)
//处理事件
processKey(sk, socketWrapper);
//省略其他非关键代码
}
//省略其他非关键代码
}
}
上述代码会完成请求事件的监听和处理,最终在processKey中把请求事件丢入线程池去处理。
DispatcherServlet是用来处理HTTP请求的中央调度入口程序,为每一个 Web 请求映射一个请求的处理执行体(API controller/method)。它本质上就是一种Servlet,所以它是由下面的Servlet核心方法触发:
javax.servlet.http.HttpServlet#service(javax.servlet.ServletRequest, javax.servlet.ServletResponse)
最终它执行到的是下面的doService(),这个方法完成了请求的分发和处理:
@Override
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
doDispatch(request, response);
}
我们可以看下它是如何分发和执行的:
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 省略其他非关键代码
// 1. 分发:Determine handler for the current request.
HandlerExecutionChain mappedHandler = getHandler(processedRequest);
// 省略其他非关键代码
//Determine handler adapter for the current request.
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 省略其他非关键代码
// 2. 执行:Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
// 省略其他非关键代码
}
1. 分发,即根据请求寻找对应的执行方法
寻找方法参考DispatcherServlet#getHandler,具体的查找远比开始给出的Map查找来得复杂,但是无非还是一个根据请求寻找候选执行方法的过程,这里的关键映射Map,其实就是上述调试视图中的RequestMappingHandlerMapping。
2. 执行,反射执行寻找到的执行方法
这点可以参考下面的调试视图来验证这个结论,参考代码org.springframework.web.method.support.InvocableHandlerMethod#doInvoke:
最终我们是通过反射来调用执行方法的。
核心关键就是RequestMappingHandlerMapping这个Bean的构建过程。它的构建完成后,会调用afterPropertiesSet来做一些额外的事,其中关键的操作是AbstractHandlerMethodMapping#processCandidateBean方法:
protected void processCandidateBean(String beanName) {
//省略非关键代码
if (beanType != null && isHandler(beanType)) {
detectHandlerMethods(beanName);
}
}
@Override
protected boolean isHandler(Class<?> beanType) {
return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) ||
AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class));
}
Spring在构建RequestMappingHandlerMapping时,会处理所有标记Controller和RequestMapping的注解,然后解析它们构建出请求到处理的映射关系。
请求URL 解析
当 @PathVariable 遇到 /
@RestController
@Slf4j
public class HelloWorldController {
@RequestMapping(path = "/hi1/{name}", method = RequestMethod.GET)
public String hello1(@PathVariable("name") String name){
return name;
};
}
:::danger
假设这个 name 中含有特殊字符/时:aaa/bb。这个接口不会为 name 获取任何值,而是直接报404 Not Found错误。当然这里的“找不到”并不是指name找不到,而是指服务于这个特殊请求的接口。
当 name 的字符串以/结尾时,/会被自动去掉。aaa/,Spring 并不会报错,而是返回 aaa。
:::
解决
方式一
@RequestMapping(path = "/hi1/**", method = RequestMethod.GET)
public String hi1(HttpServletRequest request){
String requestURI = request.getRequestURI();
return requestURI.split("/hi1/")[1];
};
但是这种修改方法还是存在漏洞,假设我们路径的 name 中刚好又含有"/hi1/",则 split 后返回的值就并不是我们想要的。
方式二
private AntPathMatcher antPathMatcher = new AntPathMatcher();
@RequestMapping(path = "/hi1/**", method = RequestMethod.GET)
public String hi1(HttpServletRequest request){
String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
//matchPattern 即为"/hi1/**"
String matchPattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
return antPathMatcher.extractPathWithinPattern(matchPattern, path);
};
原理
都是 URL 匹配执行方法的相关问题,所以我们有必要先了解下 URL 匹配执行方法: AbstractHandlerMethodMapping#lookupHandlerMethod:
@Nullable
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
List<Match> matches = new ArrayList<>();
//尝试按照 URL 进行精准匹配
List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
if (directPathMatches != null) {
//精确匹配上,存储匹配结果
addMatchingMappings(directPathMatches, matches, request);
}
if (matches.isEmpty()) {
//没有精确匹配上,尝试根据请求来匹配
addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request);
}
if (!matches.isEmpty()) {
Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
matches.sort(comparator);
Match bestMatch = matches.get(0);
if (matches.size() > 1) {
//处理多个匹配的情况
}
//省略其他非关键代码
return bestMatch.handlerMethod;
}
else {
//匹配不上,直接报错
return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request);
}
**根据 Path 进行精确匹配 **是"this.mappingRegistry.getMappingsByUrl(lookupPath)",实际上,它是查询 MappingRegistry#urlLookup:

查询 urlLookup 是一个精确匹配 Path 的过程。 http://localhost:8080/hi1/aaa/bb 的 lookupPath 是/hi1/aaa/bb,并不能得到任何精确匹配。/hi1/{name}这种定义本身也没有出现在 urlLookup 中。
假设 Path 没有精确匹配上,则执行模糊匹配.待匹配的匹配方法可参考下图:

显然,"/hi1/{name}"这个匹配方法已经出现在待匹配候选中了。具体匹配过程可以参考方法 RequestMappingInfo#getMatchingCondition: 匹配会查询所有的信息,例如 Header、Body 类型以及URL 等。如果有一项不符合条件,则不匹配。
在我们的案例中,当使用 http://localhost:8080/hi1/xiaoming 访问时,其中 patternsCondition 是可以匹配上的。实际的匹配方法执行是通过 AntPathMatcher#match 来执行,但是当我们使用 http://localhost:8080/hi1/xiao/ming 来访问时,AntPathMatcher 执行的结果是"/hi1/xiao/ming"匹配不上"/hi1/{name}"。
根据匹配情况返回结果:如果找到匹配的方法,则返回方法;如果没有,则返回 null。
另外,我们再回头思考 http://localhost:8080/hi1/xiaoming/ 为什么没有报错而是直接去掉了/。这里我直接贴出了负责执行 AntPathMatcher 匹配的 PatternsRequestCondition#getMatchingPattern 方法的部分关键代码:
private String getMatchingPattern(String pattern, String lookupPath) {
//省略其他非关键代码
if (this.pathMatcher.match(pattern, lookupPath)) {
return pattern;
}
//尝试加一个/来匹配
if (this.useTrailingSlashMatch) {
if (!pattern.endsWith("/") && this.pathMatcher.match(pattern + "/", lookupPath)) {
return pattern + "/";
}
}
return null;
}
在这段代码中,AntPathMatcher 匹配不了"/hi1/xiaoming/"和"/hi1/{name}",所以不会直接返回。进而,在 useTrailingSlashMatch 这个参数启用时(默认启用),会把 Pattern 结尾加上/再尝试匹配一次。如果能匹配上,在最终返回 Pattern 时就隐式自动加/。
很明显,我们的案例符合这种情况,等于说我们最终是用了"/hi1/{name}/"这个 Pattern,而不再是"/hi1/{name}"。所以自然 URL 解析 name 结果是去掉/的。
错误使用@RequestParam、@PathVarible 等注解
我们常常使用 @RequestParam 和 @PathVarible 来获取请求参数以及 path 中的部分。但是在频繁使用这些参数时,不知道你有没有觉得它们的使用方式并不友好,例如我们去获取一个请求参数 name,我们会定义如下:
@RequestParam("name") String name
@RequestParam String name
@RequestMapping(path = "/hi1", method = RequestMethod.GET)
public String hi1(@RequestParam("name") String name){
return name;
};
@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestParam String name){
return name;
};

原理
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<debug>false</debug>
<parameters>false</parameters>
</configuration>
</plugin>
上述配置显示关闭了 parameters 和 debug,这 2 个参数控制了一些 debug 信息是否加进 class 文件中。我们可以开启这两个参数来编译,然后使用下面的命令来查看信息:
javap -verbose HelloWorldController.class
执行完命令后,我们会看到以下 class 信息:

debug 参数开启的部分信息就是 LocalVaribleTable,而 paramters 参数开启的信息就是 MethodParameters。观察它们的信息,你会发现它们都含有参数名name。
如果你关闭这两个参数,则 name 这个名称自然就没有了。而这个方法本身在 @RequestParam 中又没有指定名称,那么 Spring 此时还能找到解析的方法么? 答案是否定的,这里我们可以顺带说下 Spring 解析请求参数名称的过程,参考代码 AbstractNamedValueMethodArgumentResolver#updateNamedValueInfo:
private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) {
String name = info.name;
if (info.name.isEmpty()) {
name = parameter.getParameterName();
if (name == null) {
throw new IllegalArgumentException(
"Name for argument type [" + parameter.getNestedParameterType().getName() +
"] not available, and parameter name information not found in class file either.");
}
}
String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue);
return new NamedValueInfo(name, info.required, defaultValue);
}
其中 NamedValueInfo 的 name 为 @RequestParam 指定的值。很明显,在本案例中,为 null。 所以这里我们就会尝试调用 parameter.getParameterName() 来获取参数名作为解析请求参数的名称。但是,很明显,关掉上面两个开关后,就不可能在 class 文件中找到参数名了,这点可以从下面的调试试图中得到验证:

当参数名不存在,@RequestParam 也没有指明,自然就无法决定到底要用什么名称去获取请求参数,所以就会报本案例的错误。
解决
模拟出了问题是如何发生的,我们自然可以通过开启这两个参数让其工作起来。但是思考这两个参数的作用,很明显,它可以让我们的程序体积更小,所以很多项目都会青睐去关闭这两个参数。
正确的修正方式是 必须显式在**@RequestParam **** 中指定请求参数名 **。具体修改如下:
@RequestParam("name") String name
很多功能貌似可以永远工作,但是实际上,只是在特定的条件下而已。另外,这里再拓展下,IDE 都喜欢开启相关 debug 参数,所以 IDE 里运行的程序不见得对产线适应,例如针对 parameters 这个参数,IDEA 默认就开启了。另外,本案例围绕的都是 @RequestParam,其实 @PathVarible 也有一样的问题。这里你要注意。
那么说到这里,我顺带提一个可能出现的小困惑:我们这里讨论的参数,和 @QueryParam、@PathParam 有什么区别?实际上,后者都是 JAX-RS 自身的注解,不需要额外导包。而 @RequestParam 和 @PathVariable 是 Spring 框架中的注解,需要额外导入依赖包。另外不同注解的参数也不完全一致。
未考虑参数是否可选
@RequestMapping(path = "/hi4", method = RequestMethod.GET)
public String hi4(@RequestParam("name") String name, @RequestParam("address") String address){
return name + ":" + address;
};
在访问 http://localhost:8080/hi4?name=xiaoming&address=beijing 时并不会出问题,但是一旦用户仅仅使用 name 做请求(即 http://localhost:8080/hi4?name=xiaoming )时,则会直接报错如下:

此时,返回错误码 400,提示请求格式错误:此处缺少 address 参数。
原理
实际上,这里我们也能按注解名(@RequestParam)来确定解析发生的位置是在RequestParamMethodArgumentResolver 中。当根据 URL 匹配上要执行的方法是 hi4 后,要反射调用它,必须解析出方法参数 name 和 address 才可以。而它们被 @RequestParam 注解修饰,所以解析器借助 RequestParamMethodArgumentResolver 就成了很自然的事情。 参考其父类方法 AbstractNamedValueMethodArgumentResolver#resolveArgument:
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
MethodParameter nestedParameter = parameter.nestedIfOptional();
//省略其他非关键代码
//获取请求参数
Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
if (arg == null) {
if (namedValueInfo.defaultValue != null) {
arg = resolveStringValue(namedValueInfo.defaultValue);
}
else if (namedValueInfo.required && !nestedParameter.isOptional()) {
handleMissingValue(namedValueInfo.name, nestedParameter, webRequest);
}
arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
}
//省略后续代码:类型转化等工作
return arg;
}
当缺少请求参数的时候,通常我们会按照以下几个步骤进行处理。
1. 查看 namedValueInfo 的默认值,如果存在则使用它
这个变量实际是通过下面的方法来获取的,参考 RequestParamMethodArgumentResolver#createNamedValueInfo:
@Override
protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
RequestParam ann = parameter.getParameterAnnotation(RequestParam.class);
return (ann != null ? new RequestParamNamedValueInfo(ann) : new RequestParamNamedValueInfo());
}
**2. 在 @RequestParam 没有指明默认值时,会查看这个参数是否必须,如果必须,则按错误处理 **
判断参数是否必须的代码即为下述关键代码行:namedValueInfo.required && !nestedParameter.isOptional()。很明显,若要判定一个参数是否是必须的,需要同时满足两个条件:条件 1 是@RequestParam 指明了必须(即属性 required 为 true,实际上它也是默认值),条件 2 是要求 @RequestParam 标记的参数本身不是可选的。 我们可以通过 MethodParameter#isOptional 方法看下可选的具体含义:
public boolean isOptional() {
return (getParameterType() == Optional.class || hasNullableAnnotation() ||
(KotlinDetector.isKotlinReflectPresent() &&
KotlinDetector.isKotlinType(getContainingClass()) &&
KotlinDelegate.isOptional(this)));
}
在不使用 Kotlin 的情况下,所谓可选,就是参数的类型为 Optional,或者任何标记了注解名为 Nullable 且 RetentionPolicy 为 RUNTIM 的注解。
3. 如果不是必须,则按 null 去做具体处理
如果接受类型是 boolean,返回 false,如果是基本类型则直接报错,
判定为必选的条件,所以最终会执行方法 AbstractNamedValueMethodArgumentResolver#handleMissingValue:
protected void handleMissingValue(String name, MethodParameter parameter) throws ServletException {
throw new ServletRequestBindingException("Missing argument '" + name +
"' for method parameter of type " + parameter.getNestedParameterType().getSimpleName());
}
解决
在Spring Web 中,默认情况下,请求参数是必选项。
方式一:设置 @RequestParam 的默认值
@RequestParam(value = "address", defaultValue = "no address") String address
方式二:设置 @RequestParam 的 required 值
@RequestParam(value = "address", required = false) String address)
方式三:标记任何名为 Nullable 且 RetentionPolicy 为 RUNTIME 的注解
@RequestParam(value = "address") @Nullable String address
方式四:修改参数类型为 Optional
@RequestParam(value = "address") Optional address
请求参数格式错误
当我们使用 Spring URL 相关的注解,会发现 Spring 是能够完成自动转化的。而不是必须是 String 类型。
@RequestMapping(path = "/hi5", method = RequestMethod.GET)
public String hi5(@RequestParam("name") String name, @RequestParam("age") int age){
return name + " is " + age + " years old";
};
@RequestMapping(path = "/hi6", method = RequestMethod.GET)
public String hi6(@RequestParam("Date") Date date){
return "date is " + date ;
};
:::danger
然后,我们使用一些看似明显符合日期格式的 URL 来访问,例如 http://localhost:8080/hi6?date=2021-5-1 20:26:53,我们会发现 Spring 并不能完成转化,返回错误码 400,错误信息为"Failed to convert value of type 'java.lang.String' to required type 'java.util.Date"。
:::
不管是使用 @PathVarible 还是 @RequetParam,我们一般解析出的结果都是一个 String 或 String 数组。例如,使用 @RequetParam 解析的关键代码参考 RequestParamMethodArgumentResolver#resolveName 方法:
@Nullable
protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
//省略其他非关键代码
if (arg == null) {
String[] paramValues = request.getParameterValues(name);
if (paramValues != null) {
arg = (paramValues.length == 1 ? paramValues[0] : paramValues);
}
}
return arg;
}
这里我们调用的"request.getParameterValues(name)",返回的是一个 String 数组,最终给上层调用者返回的是单个 String(如果只有一个元素时)或者 String 数组。
所以很明显,在这个测试程序中,我们给上层返回的是一个 String,这个 String 的值最终是需要做转化才能赋值给其他类型。例如对于案例中的"int age"定义,是需要转化为 int 基本类型的。这个基本流程可以通过 AbstractNamedValueMethodArgumentResolver#resolveArgument 的关键代码来验证:
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
//省略其他非关键代码
Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
//以此为界,前面代码为解析请求参数,后续代码为转化解析出的参数
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name);
try {
arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
}
//省略其他非关键代码
}
//省略其他非关键代码
return arg;
}
实际上在前面我们曾经提到过这个转化的基本逻辑,所以这里不再详述它具体是如何发生的。
在这里你只需要回忆出它是需要 根据源类型和目标类型寻找转化器来执行转化的。在这里,对于 age 而言,最终找出的转化器是 StringToNumberConverterFactory。而对于 Date 型的 Date 变量,在本案例中,最终找到的是 ObjectToObjectConverter。它的转化过程参考下面的代码:
public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
if (source == null) {
return null;
}
Class<?> sourceClass = sourceType.getType();
Class<?> targetClass = targetType.getType();
//根据源类型去获取构建出目标类型的方法:可以是工厂方法(例如 valueOf、from 方法)也可以是构造器
Member member = getValidatedMember(targetClass, sourceClass);
try {
if (member instanceof Method) {
//如果是工厂方法,通过反射创建目标实例
}
else if (member instanceof Constructor) {
//如果是构造器,通过反射创建实例
Constructor<?> ctor = (Constructor<?>) member;
ReflectionUtils.makeAccessible(ctor);
return ctor.newInstance(source);
}
}
catch (InvocationTargetException ex) {
throw new ConversionFailedException(sourceType, targetType, source, ex.getTargetException());
}
catch (Throwable ex) {
throw new ConversionFailedException(sourceType, targetType, source, ex);
}
当使用 ObjectToObjectConverter 进行转化时,是根据反射机制带着源目标类型来查找可能的构造目标实例方法,例如构造器或者工厂方法,然后再次通过反射机制来创建一个目标对象。所以对于 Date 而言,最终调用的是下面的 Date 构造器:
public Date(String s) {
this(parse(s));
}
然而,我们传入的 2021-5-1 20:26:53 虽然确实是一种日期格式,但用来作为 Date 构造器参数是不支持的,最终报错,并被上层捕获,转化为 ConversionFailedException 异常。
解决
方式一:使用 Date 支持的格式
http://localhost:8080/hi6?date=Sat, 12 Aug 1995 13:30:00 GMT
方式二:使用好内置格式转化器
在Spring中,要完成 String 对于 Date 的转化,ObjectToObjectConverter 并不是最好的转化器。我们可以使用更强大的AnnotationParserConverter。 在Spring 初始化时,会构建一些针对日期型的转化器,即相应的一些 AnnotationParserConverter 的实例。AnnotationParserConverter 有目标类型的要求,FormattingConversionService#addFormatterForFieldAnnotation
这是适应于 String 到 Date 类型的转化器 AnnotationParserConverter 实例的构造过程,其需要的 annototationType 参数为 DateTimeFormat。

annototationType 的作用正是为了帮助判断是否能用这个转化器 AnnotationParserConverter#matches:
@Override
public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
return targetType.hasAnnotation(this.annotationType);
}
最终构建出来的转化器可以用来转化 String 到 Date,但是它要求我们标记 @DateTimeFormat。很明显,我们的参数 Date 并没有标记这个注解,所以这里为了使用这个转化器,我们可以使用上它并提供合适的格式。这样就可以让原来不工作的 URL 工作起来,具体修改代码如下:
@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") Date date
@DateTimeFormat 只会在GET请求中生效,对于请求体中的转换无能为力,这个时候需要@JsonFormat
Header 解析
Content-Type指定了我们的请求或者响应的内容类型,便于我们去做解码
接受 Header 使用错 Map 类型
@RequestMapping(path = "/hi", method = RequestMethod.GET)
public String hi(@RequestHeader("myHeaderName") String name){
//省略 body 处理
};
定义一个参数,标记上@RequestHeader,指定要解析的 Header 名即可。但是假设我们需要解析的 Header 很多时,按照上面的方式很明显会使得参数越来越多。在这种情况下,我们一般都会使用 Map 去把所有的 Header 都接收到,然后直接对 Map 进行处理:
@RequestMapping(path = "/hi1", method = RequestMethod.GET)
public String hi1(@RequestHeader() Map map){
return map.toString();
};
粗略测试程序,你会发现一切都很好。而且上面的代码也符合针对接口编程的范式,即使用了 Map 这个接口类型。但是上面的接口定义在遇到下面的请求时,就会超出预期。请求如下:
myheader: h1
myheader: h2
这里存在一个 Header 名为 myHeader,不过这个 Header 有两个值。此时我们执行请求,会发现返回的结果并不能将这两个值如数返回。结果示例如下:
{myheader=h1, host=localhost:8080, connection=Keep-Alive, user-agent=Apache-HttpClient/4.5.12 (Java/11.0.6), accept-encoding=gzip,deflate}
解决
//方式 1
@RequestHeader() MultiValueMap map
//方式 2
@RequestHeader() HttpHeaders map
对比来说,方式 2 更值得推荐,因为它使用了大多数人常用的 Header 获取方法,例如获取 Content-Type 直接调用它的 getContentType() 即可,诸如此类,非常好用。
原理
对于一个多值的 Header,两种写法:
- Key: value1,value2
- Key:value1 Key:value2
对于方式 1,我们使用 Map 接口自然不成问题。但是如果使用的是方式 2,我们就不能拿到所有的值。
对于一个 Header 的解析,主要有两种方式,分别实现在 RequestHeaderMethodArgumentResolver 和 RequestHeaderMapMethodArgumentResolver 中,它们都继承于 AbstractNamedValueMethodArgumentResolver,但是应用的场景不同,我们可以对比下它们的 supportsParameter(),来对比它们适合的场景:

在上图中,左边是 RequestHeaderMapMethodArgumentResolver 的方法。通过比较可以发现,对于一个标记了 @RequestHeader 的参数,如果它的类型是 Map,则使用 RequestHeaderMapMethodArgumentResolver,否则一般使用的是 RequestHeaderMethodArgumentResolver。
RequestHeaderMapMethodArgumentResolver 的 resolveArgument():
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
Class<?> paramType = parameter.getParameterType();
if (MultiValueMap.class.isAssignableFrom(paramType)) {
MultiValueMap<String, String> result;
if (HttpHeaders.class.isAssignableFrom(paramType)) {
result = new HttpHeaders();
}
else {
result = new LinkedMultiValueMap<>();
}
for (Iterator<String> iterator = webRequest.getHeaderNames(); iterator.hasNext();) {
String headerName = iterator.next();
String[] headerValues = webRequest.getHeaderValues(headerName);
if (headerValues != null) {
for (String headerValue : headerValues) {
result.add(headerName, headerValue);
}
}
}
return result;
}
else {
Map<String, String> result = new LinkedHashMap<>();
for (Iterator<String> iterator = webRequest.getHeaderNames(); iterator.hasNext();) {
String headerName = iterator.next();
//只取了一个“值”
String headerValue = webRequest.getHeader(headerName);
if (headerValue != null) {
result.put(headerName, headerValue);
}
}
return result;
}
}
针对我们的案例,这里并不是 MultiValueMap,所以我们会走入 else 分支。这个分支首先会定义一个 LinkedHashMap,然后将请求一一放置进去,并返回。其中第 29 行是去解析获取 Header 值的实际调用,在不同的容器下实现不同。例如在 Tomcat 容器下,它的执行方法参考 MimeHeaders#getValue:
public MessageBytes getValue(String name) {
for (int i = 0; i < count; i++) {
if (headers[i].getName().equalsIgnoreCase(name)) {
return headers[i].getValue();
}
}
return null;
}
当一个请求出现多个同名 Header 时,我们只要匹配上任何一个即立马返回。
在 RequestHeaderMapMethodArgumentResolver 的 resolveArgument() 中,假设我们的参数类型是 MultiValueMap,我们一般会创建一个 LinkedMultiValueMap,然后使用下面的语句来获取 Header 的值并添加到 Map 中去:String[] headerValues = webRequest.getHeaderValues(headerName)
错认为 Header 名称首字母可以一直忽略大小写
在 HTTP 协议中,Header 的名称是无所谓大小写的。
@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestHeader("MyHeader") String myHeader){
return myHeader;
};
@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestHeader("MyHeader") String myHeader, @RequestHeader MultiValueMap map){
return myHeader + " compare with : " + map.get("MyHeader");
};
:::danger
myheadervalue compare with : null
直接获取 Header 是可以忽略大小写的,但是如果从接收过来的 Map 中获取 Header 是不能忽略大小写的。稍微不注意,我们就很容易认为 Header 在任何情况下,都可以不区分大小写来获取值。
:::
原理
对于"@RequestHeader("MyHeader") String myHeader",Spring 使用的是 RequestHeaderMethodArgumentResolver 来做解析。解析的方法参考 RequestHeaderMethodArgumentResolver#resolveName,接着调用 request.getHeaderValues(name),找到查找 Header 的最根本方法,即 org.apache.tomcat.util.http.ValuesEnumerator#findNext,可以看出对于简单字符串是忽略大小写的。
如果我们用 Map 来接收所有的 Header,这个 Map 最后存取的 Header 和获取的方法没有忽略大小写。在存取 Header 时,需要的 key 是遍历 webRequest.getHeaderNames() 的返回结果。而这个方法的执行过程参考 org.apache.tomcat.util.http.NamesEnumerator#findNext。这里,返回结果并没有针对 Header 的名称做任何大小写忽略或转化工作。从 Map 中获取的 Header 也没有忽略大小写。这点可以从返回是 LinkedHashMap 类型看出,LinkedHashMap 的 get() 未忽略大小写。
解决
就从接收类型 Map 中获取 Header 时注意下大小写就可以了,修正代码如下:
@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestHeader("MyHeader") String myHeader, @RequestHeader MultiValueMap map){
return myHeader + " compare with : " + map.get("myHeader");
};
如果我们使用 HTTP Headers 来接收请求,那么从它里面获取 Header 是否可以忽略大小写呢?这点你可以通过它的构造器推测出来,其构造器代码如下:
public HttpHeaders() {
this(CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH)));
}
可以看出,它使用的是 LinkedCaseInsensitiveMap,而不是普通的 LinkedHashMap。所以这里是可以忽略大小写的,我们不妨这样修正:
@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestHeader("MyHeader") String myHeader, @RequestHeader HttpHeaders map){
return myHeader + " compare with : " + map.get("MyHeader");
};
试图在 Controller 中随意自定义 CONTENT_TYPE 等
@RequestMapping(path = "/hi3", method = RequestMethod.GET)
public String hi3(HttpServletResponse httpServletResponse){
httpServletResponse.addHeader("myheader", "myheadervalue");
httpServletResponse.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
return "ok";
};
运行程序测试下(访问 GET http://localhost:8080/hi3 ),我们会得到如下结果:
HTTP/1.1 200
myheader: myheadervalue
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
Date: Wed, 17 Mar 2021 08:59:56 GMT
Keep-Alive: timeout=60
Connection: keep-alive
:::danger
可以看到 myHeader 设置成功了,但是 Content-Type 并没有设置成我们想要的"application/json",而是"text/plain;charset=UTF-8"
:::
解决
如果想设置成功,我们就必须让其真正的返回就是 JSON 类型,这样才能刚好生效。而返回符合预期也并非是在 Controller 设置的功劳
方式一:修改请求中的 Accept 头,约束返回类型
GET http://localhost:8080/hi3
Accept:application/json
即带上 Accept 头,这样服务器在最终决定 MediaType 时,会选择 Accept 的值。具体执行可参考方法 AbstractMessageConverterMethodProcessor#getAcceptableMediaTypes。
方式二:标记返回类型
主动显式指明类型,修改方法如下:
@RequestMapping(path = "/hi3", method = RequestMethod.GET, produces = {"application/json"})
即使用 produces 属性来指明即可。这样的方式影响的是可以返回的 Media 类型,一旦设置,下面的方法就可以只返回一个指明的类型了。参考 AbstractMessageConverterMethodProcessor#getProducibleMediaTypes:
protected List<MediaType> getProducibleMediaTypes(
HttpServletRequest request, Class<?> valueClass, @Nullable Type targetType) {
Set<MediaType> mediaTypes =
(Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList<>(mediaTypes);
}
//省略其他非关键代码
}
原理
在 Spring Boot 使用内嵌 Tomcat 容器时,可以查看 org.apache.catalina.connector.Response#addHeader 方法,代码如下:
private void addHeader(String name, String value, Charset charset) {
//省略其他非关键代码
char cc=name.charAt(0);
if (cc=='C' || cc=='c') {
// 判断是不是 Content-Type,
if (checkSpecialHeader(name, value))
return;
}
// 如果是不, 要把这个 Header 作为 header 添加到 org.apache.coyote.Response
getCoyoteResponse().addHeader(name, value, charset);
}
正常添加一个 Header 是可以添加到 Header 集里面去的,但是如果这是一个 Content-Type,会通过 Response#checkSpecialHeader 的调用来设置 org.apache.coyote.Response#contentType 为 application/json,关键代码如下:
private boolean checkSpecialHeader(String name, String value) {
if (name.equalsIgnoreCase("Content-Type")) {
setContentType(value);
return true;
}
return false;
}
对返回结果进行处理,执行方法为RequestResponseBodyMethodProcessor#handleReturnValue,关键代码如下:
@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
mavContainer.setRequestHandled(true);
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
//对返回值(案例中为“ok”)根据返回类型做编码转化处理
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}
而在上述代码的调用中,writeWithMessageConverters 会根据返回值及类型做转化,同时也会做一些额外的事情:
1. 决定用哪一种 MediaType 返回
//决策返回值是何种 MediaType
MediaType selectedMediaType = null;
MediaType contentType = outputMessage.getHeaders().getContentType();
boolean isContentTypePreset = contentType != null && contentType.isConcrete();
//如果 header 中有 contentType,则用其作为选择的 selectedMediaType。
if (isContentTypePreset) {
selectedMediaType = contentType;
}
//没有,则根据“Accept”头、返回值等核算用哪一种
else {
HttpServletRequest request = inputMessage.getServletRequest();
List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
//省略其他非关键代码
List<MediaType> mediaTypesToUse = new ArrayList<>();
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
}
//省略其他关键代码
for (MediaType mediaType : mediaTypesToUse) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
}
//省略其他关键代码
}
这里我解释一下,上述代码是先根据是否具有 Content-Type 头来决定返回的 MediaType,通过前面的分析它是一种特殊的 Header,在 Controller 层并没有被添加到 Header 中去,所以在这里只能根据返回的类型、请求的 Accept 等信息协商出最终用哪种 MediaType。
实际上这里最终使用的是 MediaType#TEXT_PLAIN。这里还需要补充说明下,没有选择 JSON 是因为在都支持的情况下,TEXT_PLAIN 默认优先级更高,参考代码 WebMvcConfigurationSupport#addDefaultHttpMessageConverters 可以看出转化器是有优先顺序的,所以用上述代码中的 getProducibleMediaTypes() 遍历 Converter 来收集可用 MediaType 也是有顺序的。
2. 选择消息转化器并完成转化
决定完 MediaType 信息后,即可去选择转化器并执行转化,关键代码如下:
for (HttpMessageConverter<?> converter : this.messageConverters) {
GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
(GenericHttpMessageConverter<?>) converter : null);
if (genericConverter != null ?
((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
converter.canWrite(valueType, selectedMediaType)) {
//省略其他非关键代码
if (body != null) {
//省略其他非关键代码
if (genericConverter != null) {
genericConverter.write(body, targetType, selectedMediaType, outputMessage);
}
else {
((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage);
}
}
//省略其他非关键代码
}
}
如代码所示,即结合 targetType(String)、valueType(String)、selectedMediaType(MediaType#TEXT_PLAIN)三个信息来决策可以使用哪种消息 Converter。

本案例选择的是 StringHttpMessageConverter,在最终调用父类方法 AbstractHttpMessageConverter#write 执行转化时,会尝试添加 Content-Type。具体代码参考 AbstractHttpMessageConverter#addDefaultHeaders:
protected void addDefaultHeaders(HttpHeaders headers, T t, @Nullable MediaType contentType) throws IOException {
if (headers.getContentType() == null) {
MediaType contentTypeToUse = contentType;
if (contentType == null || contentType.isWildcardType() || contentType.isWildcardSubtype()) {
contentTypeToUse = getDefaultContentType(t);
}
else if (MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) {
MediaType mediaType = getDefaultContentType(t);
contentTypeToUse = (mediaType != null ? mediaType : contentTypeToUse);
}
if (contentTypeToUse != null) {
if (contentTypeToUse.getCharset() == null) {
//尝试添加字符集
Charset defaultCharset = getDefaultCharset();
if (defaultCharset != null) {
contentTypeToUse = new MediaType(contentTypeToUse, defaultCharset);
}
}
headers.setContentType(contentTypeToUse);
}
}
//省略其他非关键代码
}
我们使用的是 MediaType#TEXT_PLAIN 作为 Content-Type 的 Header,毕竟之前我们添加 Content-Type 这个 Header 并没有成功。最终运行结果也就不出意外了,即Content-Type: text/plain;charset=UTF-8。
Body 转化
实际上,在 Spring 中,对于 Body 的处理很多是借助第三方编解码器来完成的。例如常见的 JSON 解析,Spring 都是借助于 Jackson、Gson 等常见工具来完成。所以在 Body 处理中,我们遇到的很多错误都是第三方工具使用中的一些问题。
No converter found for return value of type
在直接用 Spring MVC 而非 Spring Boot 来编写 Web 程序时,我们基本都会遇到 “No converter found for return value of type” 这种错误。
//定义的数据对象
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Student {
private String name;
private Integer age;
}
//定义的 API 借口
@RestController
public class HelloController {
@GetMapping("/hi1")
public Student hi1() {
return new Student("xiaoming", Integer.valueOf(12));
}
}
然后,我们的 pom.xml 文件也都是最基本的必备项,关键配置如下:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.2.3.RELEASE</version>
</dependency>
当我们的请求到达 Controller 层后,我们获取到了一个对象,即案例中的 new Student(“xiaoming”, Integer.valueOf(12)),那么这个对象应该怎么返回给客户端呢?用 JSON 还是用 XML,还是其他类型编码?此时就需要一个决策,我们可以先找到这个决策的关键代码所在,参考方法 AbstractMessageConverterMethodProcessor#writeWithMessageConverters:
HttpServletRequest request = inputMessage.getServletRequest();
List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
if (body != null && producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
"No converter found for return value of type: " + valueType);
}
List<MediaType> mediaTypesToUse = new ArrayList<>();
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
}
实际上节课我们就贴出过相关代码并分析过:
- 查看请求的头中是否有 ACCEPT 头,如果没有则可以使用任何类型;
- 查看当前针对返回类型(即 Student 实例)可以采用的编码类型;
- 取上面两步获取结果的交集来决定用什么方式返回。
比较代码,我们可以看出,假设第2步中就没有找到合适的编码方式,则直接报案例中的错误,具体的关键代码行如下:
if (body != null && producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
"No converter found for return value of type: " + valueType);
}
那么当前可采用的编码类型是怎么决策出来的呢?我们可以进一步查看方法 AbstractMessageConverterMethodProcessor#getProducibleMediaTypes:
protected List<MediaType> getProducibleMediaTypes(
HttpServletRequest request, Class<?> valueClass, @Nullable Type targetType) {
Set<MediaType> mediaTypes =
(Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList<>(mediaTypes);
}
else if (!this.allSupportedMediaTypes.isEmpty()) {
List<MediaType> result = new ArrayList<>();
for (HttpMessageConverter<?> converter : this.messageConverters) {
if (converter instanceof GenericHttpMessageConverter && targetType != null) {
if (((GenericHttpMessageConverter<?>) converter).canWrite(targetType, valueClass, null)) {
result.addAll(converter.getSupportedMediaTypes());
}
}
else if (converter.canWrite(valueClass, null)) {
result.addAll(converter.getSupportedMediaTypes());
}
}
return result;
}
else {
return Collections.singletonList(MediaType.ALL);
}
}
假设当前没有显式指定返回类型(例如给 GetMapping 指定 produces 属性),那么则会遍历所有已经注册的 HttpMessageConverter 查看是否支持当前类型,从而最终返回所有支持的类型。那么这些 MessageConverter 是怎么注册过来的?
在 Spring MVC(非 Spring Boot)启动后,我们都会构建 RequestMappingHandlerAdapter 类型的 Bean 来负责路由和处理请求。
具体而言,当我们使用 mvc:annotation-driven/ 时,我们会通过 AnnotationDrivenBeanDefinitionParser 来构建这个 Bean。而在它的构建过程中,会决策出以后要使用哪些 HttpMessageConverter,相关代码参考 AnnotationDrivenBeanDefinitionParser#getMessageConverters:
messageConverters.add(createConverterDefinition(ByteArrayHttpMessageConverter.class, source));
RootBeanDefinition stringConverterDef = createConverterDefinition(StringHttpMessageConverter.class, source);
stringConverterDef.getPropertyValues().add("writeAcceptCharset", false);
messageConverters.add(stringConverterDef);
messageConverters.add(createConverterDefinition(ResourceHttpMessageConverter.class, source));
//省略其他非关键代码
if (jackson2Present) {
Class<?> type = MappingJackson2HttpMessageConverter.class;
RootBeanDefinition jacksonConverterDef = createConverterDefinition(type, source);
GenericBeanDefinition jacksonFactoryDef = createObjectMapperFactoryDefinition(source);
jacksonConverterDef.getConstructorArgumentValues().addIndexedArgumentValue(0, jacksonFactoryDef);
messageConverters.add(jacksonConverterDef);
}
else if (gsonPresent) { messageConverters.add(createConverterDefinition(GsonHttpMessageConverter.class, source));
}
//省略其他非关键代码
这里我们会默认使用一些编解码器,例如 StringHttpMessageConverter,但是像 JSON、XML 等类型,若要加载编解码,则需要 jackson2Present、gsonPresent 等变量为 true。
这里我们可以选取 gsonPresent 看下何时为 true,参考下面的关键代码行:
gsonPresent = ClassUtils.isPresent(“com.google.gson.Gson”, classLoader);
假设我们依赖了 Gson 包,我们就可以添加上 GsonHttpMessageConverter 这种转化器。但是可惜的是,我们的案例并没有依赖上任何 JSON 的库,所以最终在候选的转换器列表里,并不存在 JSON 相关的转化器。最终候选列表示例如下:

由此可见,并没有任何 JSON 相关的编解码器。而针对 Student 类型的返回对象,上面的这些编解码器又不符合要求,所以最终走入了下面的代码行:
if (body != null && producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
"No converter found for return value of type: " + valueType);
}
抛出了 “No converter found for return value of type” 这种错误,结果符合案例中的实际测试情况。
解决
不是每种类型的编码器都会与生俱来,而是根据当前项目的依赖情况决定是否支持。 要解析 JSON,我们就要依赖相关的包,所以这里我们可以以 Gson 为例修正下这个问题:
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.6</version>
</dependency>
变动地返回 Body
在代码并未改动的情况下,返回结果不再和之前相同了。
@RestController
public class HelloController {
@PostMapping("/hi2")
public Student hi2(@RequestBody Student student) {
return student;
}
}
上述代码接受了一个 Student 对象,然后原样返回。我们使用下面的测试请求进行测试:
POST http://localhost:8080/springmvc3_war/app/hi2
Content-Type: application/json
{
“name”: “xiaoming”
}
经过测试,我们会得到以下结果:
{
“name”: “xiaoming”
}
但是随着项目的推进,在代码并未改变时,我们可能会返回以下结果
{
“name”: “xiaoming”,
“age”: null
}
即当 age 取不到值,开始并没有序列化它作为响应 Body 的一部分,后来又序列化成 null 作为 Body 返回了。
原理
如果我们发现上述问题,那么很有可能是这样一种情况造成的。即在后续的代码开发中,我们直接依赖或者间接依赖了新的 JSON 解析器,例如下面这种方式就依赖了Jackson:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.6</version>
</dependency>
当存在多个 Jackson 解析器时,我们的 Spring MVC 会使用哪一种呢?这个决定可以参考
if (jackson2Present) {
Class<?> type = MappingJackson2HttpMessageConverter.class;
RootBeanDefinition jacksonConverterDef = createConverterDefinition(type, source);
GenericBeanDefinition jacksonFactoryDef = createObjectMapperFactoryDefinition(source);
jacksonConverterDef.getConstructorArgumentValues().addIndexedArgumentValue(0, jacksonFactoryDef);
messageConverters.add(jacksonConverterDef);
}
else if (gsonPresent) {
messageConverters.add(createConverterDefinition(GsonHttpMessageConverter.class, source));
}
从上述代码可以看出,Jackson 是优先于 Gson 的。所以我们的程序不知不觉已经从 Gson 编解码切换成了 Jackson。所以此时, 行为就不见得和之前完全一致了。
针对本案例中序列化值为 null 的字段的行为而言,我们可以分别看下它们的行为是否一致。
1. 对于 Gson 而言:
GsonHttpMessageConverter 默认使用new Gson()来构建 Gson,它的构造器中指明了相关配置:
public Gson() {
this(Excluder.DEFAULT, FieldNamingPolicy.IDENTITY,
Collections.<Type, InstanceCreator<?>>emptyMap(), DEFAULT_SERIALIZE_NULLS,
DEFAULT_COMPLEX_MAP_KEYS, DEFAULT_JSON_NON_EXECUTABLE, DEFAULT_ESCAPE_HTML,
DEFAULT_PRETTY_PRINT, DEFAULT_LENIENT, DEFAULT_SPECIALIZE_FLOAT_VALUES,
LongSerializationPolicy.DEFAULT, null, DateFormat.DEFAULT, DateFormat.DEFAULT,
Collections.<TypeAdapterFactory>emptyList(), Collections.<TypeAdapterFactory>emptyList(),
Collections.<TypeAdapterFactory>emptyList());
}
从DEFAULT_SERIALIZE_NULLS可以看出,它是默认不序列化 null 的。
2. 对于 Jackson 而言:
MappingJackson2HttpMessageConverter 使用"Jackson2ObjectMapperBuilder.json().build()"来构建 ObjectMapper,它默认只显式指定了下面两个配置:
MapperFeature.DEFAULT_VIEW_INCLUSION
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
Jackson 默认对于 null 的处理是做序列化的,所以本案例中 age 为 null 时,仍然被序列化了。
通过上面两种 JSON 序列化的分析可以看出, 返回的内容在依赖项改变的情况下确实可能发生变化。
解决
方式一
保持在 Jackson 依赖项添加的情况下,让它和 Gson 的序列化行为一致。
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Student {
private String name;
//或直接加在 age 上:@JsonInclude(JsonInclude.Include.NON_NULL)
private Integer age;
}
我们可以直接使用 @JsonInclude 这个注解,让 Jackson 和 Gson 的默认行为对于 null 的处理变成一致。
方式二
上述修改方案虽然看起来简单,但是假设有很多对象如此,万一遗漏了怎么办呢?所以可以从全局角度来修改,修改的关键代码如下:
//ObjectMapper mapper = new ObjectMapper();
mapper.setSerializationInclusion(Include.NON_NULL);
但是如何修改 ObjectMapper 呢?这个对象是由 MappingJackson2HttpMessageConverter 构建的,看似无法插足去修改。实际上,我们在非 Spring Boot 程序中,可以按照下面这种方式来修改:
@RestController
public class HelloController {
public HelloController(RequestMappingHandlerAdapter requestMappingHandlerAdapter){
List<HttpMessageConverter<?>> messageConverters =
requestMappingHandlerAdapter.getMessageConverters();
for (HttpMessageConverter<?> messageConverter : messageConverters) {
if(messageConverter instanceof MappingJackson2HttpMessageConverter ){
(((MappingJackson2HttpMessageConverter)messageConverter)
.getObjectMapper())
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
}
}
//省略其他非关键代码
}
我们用自动注入的方式获取到 RequestMappingHandlerAdapter,然后找到 Jackson 解析器,进行配置即可。
通过上述两种修改方案,我们就能做到忽略 null 的 age 字段了。
Required request body is missing
public class ReadBodyFilter implements Filter {
//省略其他非关键代码
@Override
public void doFilter(ServletRequest request,
ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String requestBody = IOUtils.toString(request.getInputStream(), "utf-8");
System.out.println("print request body in filter:" + requestBody);
chain.doFilter(request, response);
}
}
然后,我们可以把这个 Filter 添加到 web.xml 并配置如下:
<filter>
<filter-name>myFilter</filter-name>
<filter-class>com.puzzles.ReadBodyFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>myFilter</filter-name>
<url-pattern>/app/*</url-pattern>
</filter-mapping>
再测试下 Controller 层中定义的接口:
@PostMapping("/hi3")
public Student hi3(@RequestBody Student student) {
return student;
}
运行测试,我们会发现下面的日志:
print request body in filter:{
“name”: “xiaoming”,
“age”: 10
}
25-Mar-2021 11:04:44.906 璀﹀憡 [http-nio-8080-exec-5] org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver.logException Resolved [org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public com.puzzles.Student com.puzzles.HelloController.hi3(com.puzzles.Student)]
可以看到,请求的 Body 确实在请求中输出了,但是后续的操作直接报错了,错误提示:Required request body is missing。
原理
要了解这个错误的根本原因,你得知道这个错误抛出的源头。查阅请求 Body 转化的相关代码,有这样一段关键逻辑(参考 RequestResponseBodyMethodProcessor#readWithMessageConverters):
protected <T> Object readWithMessageConverters(NativeWebRequest webRequest, MethodParameter parameter,
Type paramType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class);
ServletServerHttpRequest inputMessage = new ServletServerHttpRequest(servletRequest);
//读取 Body 并进行转化
Object arg = readWithMessageConverters(inputMessage, parameter, paramType);
if (arg == null && checkRequired(parameter)) {
throw new HttpMessageNotReadableException("Required request body is missing: " +
parameter.getExecutable().toGenericString(), inputMessage);
}
return arg;
}
protected boolean checkRequired(MethodParameter parameter) {
RequestBody requestBody = parameter.getParameterAnnotation(RequestBody.class);
return (requestBody != null && requestBody.required() && !parameter.isOptional());
}
当使用了 @RequestBody 且是必须时,如果解析出的 Body 为 null,则报错提示 Required request body is missing。
所以我们要继续追踪代码,来查询什么情况下会返回 body 为 null。关键代码参考 AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters:
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType){
//省略非关键代码
Object body = NO_VALUE;
EmptyBodyCheckingHttpInputMessage message;
try {
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
if (message.hasBody()) {
//省略非关键代码:读取并转化 body
else {
//处理没有 body 情况,默认返回 null
body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
}
break;
}
}
}
catch (IOException ex) {
throw new HttpMessageNotReadableException("I/O error while reading input message", ex, inputMessage);
}
//省略非关键代码
return body;
}
当 message 没有 body 时( message.hasBody()为 false ),则将 body 认为是 null。继续查看 message 本身的定义,它是一种包装了请求 Header 和 Body 流的 EmptyBodyCheckingHttpInputMessage 类型。其代码实现如下:
public EmptyBodyCheckingHttpInputMessage(HttpInputMessage inputMessage) throws IOException {
this.headers = inputMessage.getHeaders();
InputStream inputStream = inputMessage.getBody();
if (inputStream.markSupported()) {
//省略其他非关键代码
}
else {
PushbackInputStream pushbackInputStream = new PushbackInputStream(inputStream);
int b = pushbackInputStream.read();
if (b == -1) {
this.body = null;
}
else {
this.body = pushbackInputStream;
pushbackInputStream.unread(b);
}
}
}
public InputStream getBody() {
return (this.body != null ? this.body : StreamUtils.emptyInput());
}
Body 为空的判断是由 pushbackInputStream.read() 其值为 -1 来判断出的,即没有数据可以读取。
看到这里,你可能会有疑问:假设有Body,read()的执行不就把数据读取走了一点么?确实如此,所以这里我使用了 pushbackInputStream.unread(b) 调用来把读取出来的数据归还回去,这样就完成了是否有 Body 的判断,又保证了 Body 的完整性。
分析到这里,再结合前面的案例,你应该能想到造成 Body 缺失的原因了吧?
- 本身就没有 Body;
- 有Body,但是 Body 本身代表的流已经被前面读取过了。
很明显,我们的案例属于第2种情况,即在过滤器中,我们就已经将 Body 读取完了,关键代码如下:
//request 是 ServletRequest
String requestBody = IOUtils.toString(request.getInputStream(), “utf-8”);
在这种情况下,作为一个普通的流,已经没有数据可以供给后面的转化器来读取了。
解决
定义一个 RequestBodyAdviceAdapter 的 Bean:
@ControllerAdvice
public class PrintRequestBodyAdviceAdapter extends RequestBodyAdviceAdapter {
@Override
public boolean supports(MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
return true;
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage,MethodParameter parameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
System.out.println("print request body in advice:" + body);
return super.afterBodyRead(body, inputMessage, parameter, targetType, converterType);
}
}
我们可以看到方法 afterBodyRead 的命名,很明显,这里的 Body 已经是从数据流中转化过的。
那么它是如何工作起来的呢?我们可以查看下面的代码(参考 AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters):
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType){
//省略其他非关键代码
if (message.hasBody()) {
HttpInputMessage msgToUse = getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) : ((HttpMessageConverter<T>)converter).read(targetClass, msgToUse));
body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
//省略其他非关键代码
}
//省略其他非关键代码
return body;
}
当一个 Body 被解析出来后,会调用 getAdvice() 来获取 RequestResponseBodyAdviceChain;然后在这个 Chain 中,寻找合适的 Advice 并执行。
正好我们前面定义了 PrintRequestBodyAdviceAdapter,所以它的相关方法就被执行了。从执行时机来看,此时 Body 已经解析完毕了,也就是说,传递给 PrintRequestBodyAdviceAdapter 的 Body 对象已经是一个解析过的对象,而不再是一个流了。
通过上面的 Advice 方案,我们满足了类似的需求,又保证了程序的正确执行。至于其他的一些方案,你可以来思考一下。
重点回顾
通过这节课的学习,相信你对 Spring Web 中关于 Body 解析的常见错误已经有所了解了,这里我们再次回顾下关键知识点:
- 不同的 Body 需要不同的编解码器,而使用哪一种是协商出来的,协商过程大体如下:
- 查看请求头中是否有 ACCEPT 头,如果没有则可以使用任何类型;
- 查看当前针对返回类型(即 Student 实例)可以采用的编码类型;
- 取上面两步获取的结果的交集来决定用什么方式返回。
- 在非 Spring Boot 程序中,JSON 等编解码器不见得是内置好的,需要添加相关的 JAR 才能自动依赖上,而自动依赖的实现是通过检查 Class 是否存在来实现的:当依赖上相关的 JAR 后,关键的 Class 就存在了,响应的编解码器功能也就提供上了。
- 不同的编解码器的实现(例如 JSON 工具 Jaskson 和 Gson)可能有一些细节上的不同,所以你一定要注意当依赖一个新的 JAR 时,是否会引起默认编解码器的改变,从而影响到一些局部行为的改变。
- 在尝试读取 HTTP Body 时,你要注意到 Body 本身是一个流对象,不能被多次读取。
参数校验
对象参数校验失效
在构建Web服务时,我们一般都会对一个HTTP请求的 Body 内容进行校验
import lombok.Data;
import javax.validation.constraints.Size;
@Data
public class Student {
@Size(max = 10)
private String name;
private short age;
}
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
@Validated
public class StudentController {
@RequestMapping(path = "students", method = RequestMethod.POST)
public void addStudent(@RequestBody Student student){
log.info("add new student: {}", student.toString());
//省略业务代码
};
}
期待 Spring Validation 能拦截,报错信息是“个数必须在 0 和 10 之间”,
实际测试会发现,使用上述代码构建的Web服务并没有做任何拦截。
原理
要找到这个问题的根源,我们就需要对 Spring Validation 有一定的了解。首先,我们来看下 RequestBody 接受对象校验发生的位置和条件。
假设我们构建Web服务使用的是Spring Boot技术,我们可以参考下面的时序图了解它的核心执行步骤:
如上图所示,当一个请求来临时,都会进入 DispatcherServlet,执行其 doDispatch(),此方法会根据 Path、Method 等关键信息定位到负责处理的 Controller 层方法(即 addStudent 方法),然后通过反射去执行这个方法,具体反射执行过程参考下面的代码(InvocableHandlerMethod#invokeForRequest):
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
//根据请求内容和方法定义获取方法参数实例
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
if (logger.isTraceEnabled()) {
logger.trace("Arguments: " + Arrays.toString(args));
}
//携带方法参数实例去“反射”调用方法
return doInvoke(args);
}
要使用 Java 反射去执行一个方法,需要先获取调用的参数,上述代码正好验证了这一点:getMethodArgumentValues() 负责获取方法执行参数,doInvoke() 负责使用这些获取到的参数去执行。
而具体到getMethodArgumentValues() 如何获取方法调用参数,可以参考 addStudent 的方法定义,我们需要从当前的请求(NativeWebRequest )中构建出 Student 这个方法参数的实例。
public void addStudent(@RequestBody Student student)
那么如何构建出这个方法参数实例?Spring 内置了相当多的 HandlerMethodArgumentResolver
当试图构建出一个方法参数时,会遍历所有支持的解析器(Resolver)以找出适合的解析器,查找代码参考HandlerMethodArgumentResolverComposite#getArgumentResolver:
@Nullable
private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
if (result == null) {
//轮询所有的HandlerMethodArgumentResolver
for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
//判断是否匹配当前HandlerMethodArgumentResolver
if (resolver.supportsParameter(parameter)) {
result = resolver;
this.argumentResolverCache.put(parameter, result);
break;
}
}
}
return result;
}
对于 student 参数而言,它被标记为@RequestBody,当遍历到 RequestResponseBodyMethodProcessor 时就会匹配上。匹配代码参考其 RequestResponseBodyMethodProcessor 的supportsParameter 方法:
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(RequestBody.class);
}
找到 Resolver 后,就会执行 HandlerMethodArgumentResolver#resolveArgument 方法。它首先会根据当前的请求(NativeWebRequest)组装出 Student 对象并对这个对象进行必要的校验,校验的执行参考AbstractMessageConverterMethodArgumentResolver#validateIfApplicable:
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
//判断是否需要校验
if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
//执行校验
binder.validate(validationHints);
break;
}
}
}
如上述代码所示,要对 student 实例进行校验(执行binder.validate(validationHints)方法),必须匹配下面两个条件的其中之一:
- 标记了 org.springframework.validation.annotation.Validated 注解;
- 标记了其他类型的注解,且注解名称以Valid关键字开头。
因此,结合案例程序,我们知道:student 方法参数并不符合这两个条件,所以即使它的内部成员添加了校验(即@Size(max = 10)),也不能生效。
解决
对于 RequestBody 接受的对象参数而言,要启动 Validation,必须将对象参数标记上 @Validated 或者其他以@Valid关键字开头的注解,因此,我们可以采用对应的策略去修正问题。
- 标记 @Validated ,修正后关键代码行如下:
public void addStudent(**@Validated **@RequestBody Student student) - 标记@Valid关键字开头的注解,javax.validation.Valid 注解,修正后关键代码行如下:
public void addStudent(**@Valid **@RequestBody Student student)
另外,我们也可以自定义一个以Valid关键字开头的注解,定义如下:
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidCustomized {
}
定义完成后,将它标记给 student 参数对象,关键代码行如下:public void addStudent( **@**ValidCustomized @RequestBody Student student)
通过上述2种策略、3种具体修正方法,我们最终让参数校验生效且符合预期,不过需要提醒你的是:当使用第3种修正方法时,一定要注意自定义的注解要显式标记@Retention(RetentionPolicy.RUNTIME),否则校验仍不生效。这也是另外一个容易疏忽的地方,究其原因,不显式标记RetentionPolicy 时,默认使用的是 RetentionPolicy.CLASS,而这种类型的注解信息虽然会被保留在字节码文件(.class)中,但在加载进 JVM 时就会丢失了。所以在运行时,依据这个注解来判断是否校验,肯定会失效。
嵌套校验失效
public class Student {
@Size(max = 10)
private String name;
private short age;
private Phone phone;
}
@Data
class Phone {
@Size(max = 10)
private String number;
}
测试校验会发现手机号这个约束并不生效。
原理
在解析案例 1 时,我们提及只要给对象参数 student 加上@Valid(或@Validated 等注解)就可以开启这个对象的校验。但实际上,关于 student 本身的 Phone 类型成员是否校验是在校验过程中(即案例1中的代码行binder.validate(validationHints))决定的。
在校验执行时,首先会根据 Student 的类型定义找出所有的校验点,然后对 Student 对象实例执行校验,这个逻辑过程可以参考代码 ValidatorImpl#validate:
@Override
public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
//省略部分非关键代码
Class<T> rootBeanClass = (Class<T>) object.getClass();
//获取校验对象类型的“信息”(包含“约束”)
BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );
if ( !rootBeanMetaData.hasConstraints() ) {
return Collections.emptySet();
}
//省略部分非关键代码
//执行校验
return validateInContext( validationContext, valueContext, validationOrder );
}
这里语句"beanMetaDataManager.getBeanMetaData( rootBeanClass )"根据 Student 类型组装出 BeanMetaData,BeanMetaData 即包含了需要做的校验(即 Constraint)。
在组装 BeanMetaData 过程中,会根据成员字段是否标记了@Valid 来决定(记录)这个字段以后是否做级联校验,参考代码 AnnotationMetaDataProvider#getCascadingMetaData:
private CascadingMetaDataBuilder getCascadingMetaData(Type type, AnnotatedElement annotatedElement,
Map<TypeVariable<?>, CascadingMetaDataBuilder> containerElementTypesCascadingMetaData) {
return CascadingMetaDataBuilder.annotatedObject( type, annotatedElement.isAnnotationPresent( Valid.class ), containerElementTypesCascadingMetaData,
getGroupConversions( annotatedElement ) );
}
在上述代码中"annotatedElement.isAnnotationPresent( Valid.class )"决定了 CascadingMetaDataBuilder#cascading 是否为 true。如果是,则在后续做具体校验时,做级联校验,而级联校验的过程与宿主对象(即Student)的校验过程大体相同,即先根据对象类型获取定义再来做校验。
在当前案例代码中,phone字段并没有被@Valid标记,所以关于这个字段信息的 cascading 属性肯定是false,因此在校验Student时并不会级联校验它。
解决
@Valid
private Phone phone;
@Validated 的定义是不允许修饰一个 Field 的
误解校验执行
@Size(min = 1, max = 10)
private String name;
然后,我们以下面的 JSON Body 做测试:
{
"name": "",
"age": 10,
"phone": {"number":"12306"}
}
测试结果符合我们的预期,但是假设更进一步,用下面的 JSON Body(去除 name 字段)做测试呢?
{
"age": 10,
"phone": {"number":"12306"}
}
我们会发现校验失败了。这结果难免让我们有一些惊讶,也倍感困惑:[@Size(min ](/Size(min ) = 1, max = 10) 都已经要求最小字节为 1 了,难道还只能约束空字符串(即“”),不能约束 null?
原理
如果我们稍微留心点的话,就会发现其实 @Size 的 Javadoc 已经明确了这种情况,"null elements are considered valid" 。这里我们找到了完成@Size 约束的执行方法,参考 SizeValidatorForCharSequence#isValid 方法: 当字符串为 null 时,直接通过了校验,而不会做任何进一步的约束检查。
解决
@NotNull 或@NotEmpty:
@NotEmpty
@Size(min = 1, max = 10)
private String name;
总结
关于@Valid 和@Validation 是我们经常犯迷糊的地方,不知道到底有什么区别。同时我们也经常产生一些困惑,例如能用其中一种时,能不能用另外一种呢?
在很多场景下,我们不一定要寄希望于搜索引擎去区别,只需要稍微研读下代码,反而更容易理解。例如,对于案例 1,研读完代码后,我们发现它们不仅可以互换,而且完全可以自定义一个以@Valid开头的注解来使用;而对于案例 2,只能用@Valid 去开启级联校验。
过滤器
我们都知道,过滤器是 Servlet 的重要标准之一,其在请求和响应的统一处理、访问日志记录、请求权限审核等方面都有着不可替代的作用。在 Spring 编程中,我们主要就是配合使用 @ServletComponentScan 和 @WebFilter 这两个注解来构建过滤器。
@WebFilter 过滤器无法被自动注入
@WebFilter
@Slf4j
public class TimeCostFilter implements Filter {
public TimeCostFilter(){
System.out.println("construct");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("开始计算接口耗时");
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println("执行时间(ms):" + time);
}
}
这个过滤器标记了@WebFilter。所以在启动程序中,我们需要加上扫描注解(即@ServletComponentScan)让其生效,启动程序如下:
@SpringBootApplication
@ServletComponentScan
@Slf4j
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
log.info("启动成功");
}
}
上述程序完成后,你会发现一切按预期执行。但是假设有一天,我们可能需要把 TimeCostFilter 记录的统计数据输出到专业的度量系统(ElasticeSearch/InfluxDB 等)里面去,我们可能会添加这样一个 Service 类:
@Service
public class MetricsService {
@Autowired
public TimeCostFilter timeCostFilter;
//省略其他非关键代码
}
完成后你会发现,Spring Boot 都无法启动了,既然 TimeCostFilter 生效了,看起来也像一个普通的 Bean,为什么不能被自动注入?
原理
本质上,过滤器被 @WebFilter 修饰后,TimeCostFilter 只会被包装为 FilterRegistrationBean,而 TimeCostFilter 自身,只会作为一个 InnerBean 被实例化,这意味着 **TimeCostFilter**** 实例并不会作为 Bean 注册到 Spring 容器**。所以当我们想自动注入 TimeCostFilter 时,就会失败了。
WebFilter 的全名是 javax.servlet.annotation.WebFilter,它并不属于 Spring,而是 Servlet 的规范。当 Spring Boot 项目中使用它时,Spring Boot 使用了 org.springframework.boot.web.servlet.FilterRegistrationBean 来包装 @WebFilter 标记的实例。从实现上来说,即 FilterRegistrationBean#Filter 属性就是 @WebFilter 标记的实例。
当我们定义一个 Filter 类时,我们可能想的是,我们会自动生成它的实例,然后以 Filter 的名称作为 Bean 的名字来指向它。但是调试下你会发现,在 Spring Boot 中,Bean 名字确实是对的,只是 Bean 实例其实是 FilterRegistrationBean。
使用 @WebFilter 时,Filter 被加载有两个条件:
- 声明了 @WebFilter;
- 在能被 @ServletComponentScan 扫到的路径之下。
这里我们直接检索对 @WebFilter 的使用,可以发现 WebFilterHandler 类使用了它,直接在 doHandle()中加入断点,从堆栈上,我们可以看出对@WebFilter 的处理是在 Spring Boot 启动时,而处理的触发点是 ServletComponentRegisteringPostProcessor 这个类。它继承了 BeanFactoryPostProcessor 接口,实现对 @WebFilter、@WebListener、@WebServlet 的扫描和处理,其中对于@WebFilter 的处理使用的就是上文中提到的 WebFilterHandler。这个逻辑可以参考下面的关键代码:
class ServletComponentRegisteringPostProcessor implements BeanFactoryPostProcessor, ApplicationContextAware {
private static final List<ServletComponentHandler> HANDLERS;
static {
List<ServletComponentHandler> servletComponentHandlers = new ArrayList<>();
servletComponentHandlers.add(new WebServletHandler());
servletComponentHandlers.add(new WebFilterHandler());
servletComponentHandlers.add(new WebListenerHandler());
HANDLERS = Collections.unmodifiableList(servletComponentHandlers);
}
// 省略非关键代码
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
if (isRunningInEmbeddedWebServer()) {
ClassPathScanningCandidateComponentProvider componentProvider = createComponentProvider();
for (String packageToScan : this.packagesToScan) {
scanPackage(componentProvider, packageToScan);
}
}
}
private void scanPackage(ClassPathScanningCandidateComponentProvider componentProvider, String packageToScan) {
// 扫描注解
for (BeanDefinition candidate : componentProvider.findCandidateComponents(packageToScan)) {
if (candidate instanceof AnnotatedBeanDefinition) {
// 使用 WebFilterHandler 等进行处理
for (ServletComponentHandler handler : HANDLERS) {
handler.handle(((AnnotatedBeanDefinition) candidate),
(BeanDefinitionRegistry) this.applicationContext);
}
}
}
}
最终,WebServletHandler 通过父类 ServletComponentHandler 的模版方法模式,处理了所有被 @WebFilter 注解的类,关键代码如下:
public void doHandle(Map<String, Object> attributes, AnnotatedBeanDefinition beanDefinition,
BeanDefinitionRegistry registry) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(FilterRegistrationBean.class);
builder.addPropertyValue("asyncSupported", attributes.get("asyncSupported"));
builder.addPropertyValue("dispatcherTypes", extractDispatcherTypes(attributes));
builder.addPropertyValue("filter", beanDefinition);
//省略其他非关键代码
builder.addPropertyValue("urlPatterns", extractUrlPatterns(attributes));
registry.registerBeanDefinition(name, builder.getBeanDefinition());
}
从这里,我们第一次看到了 FilterRegistrationBean。通过调试上述代码的最后一行,可以看到,最终我们注册的 FilterRegistrationBean,其名字就是我们定义的 WebFilter 的名字。
TimeCostFilter 何时被实例化?
此时,我们想要的 Bean 被“张冠李戴”成 FilterRegistrationBean,但是 TimeCostFilter 是何时实例化的呢?为什么它没有成为一个普通的 Bean?
关于这点,我们可以在 TimeCostFilter 的构造器中加个断点,然后使用调试的方式快速定位到它的初始化时机。在上述的关键调用栈中,结合源码,你可以找出一些关键信息:
- Tomcat 等容器启动时,才会创建 FilterRegistrationBean;
- FilterRegistrationBean 在被创建时(createBean)会创建 TimeCostFilter 来装配自身,TimeCostFilter 是通过 ResolveInnerBean 来创建的;
- TimeCostFilter 实例最终是一种 InnerBean,我们可以通过下面的调试视图看到它的一些关键信息:
通过上述分析,你可以看出 最终 TimeCostFilter 实例是一种 InnerBean,所以自动注入不到也就非常合理了。
解决
@Controller
@Slf4j
public class StudentController {
@Autowired
@Qualifier("com.spring.puzzle.filter.TimeCostFilter")
FilterRegistrationBean timeCostFilter;
}
这里的关键点在于:
- 注入的类型是 FilterRegistrationBean 类型,而不是 TimeCostFilter 类型;
- 注入的名称是包含包名的长名称,即 com.spring.puzzle.filter.TimeCostFilter(不能用 TimeCostFilter),以便于存在多个过滤器时进行精确匹配。
Filter 中不小心多次执行 doFilter()
在实际生产过程中,如果我们需要构建的过滤器是针对全局路径有效,且没有任何特殊需求(主要是指对 Servlet 3.0 的一些异步特性支持),那么你完全可以直接使用 Filter 接口(或者继承 Spring 对 Filter 接口的包装类 OncePerRequestFilter),并使用 @Component 将其包装为 Spring 中的普通 Bean,也是可以达到预期的需求。
@SpringBootApplication()
public class LearningApplication {
public static void main(String[] args) {
SpringApplication.run(LearningApplication.class, args);
System.out.println("启动成功");
}
}
@Component
public class DemoFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
//模拟异常
System.out.println("Filter 处理中时发生异常");
throw new RuntimeException();
} catch (Exception e) {
chain.doFilter(request, response);
}
chain.doFilter(request, response);
}
}
Filter 处理中时发生异常
......用户注册成功
......用户注册成功
这里我们可以看出,业务代码被执行了两次,这并不符合我们的预期。
我们本来的设计目标是希望 Filter 的业务执行不会影响到核心业务的执行,所以当抛出异常时,我们还是会调用chain.doFilter。不过往往有时候,我们会忘记及时返回而误入其他的chain.doFilter,最终导致我们的 Filter 执行多次。
原理
在解析之前,我先给你讲下 Filter 背后的机制,即责任链模式。
以 Tomcat 为例,我们先来看下它的 Filter 实现中最重要的类 ApplicationFilterChain。它采用的是责任(职责)链设计模式,在形式上很像一种递归调用。
但区别在于递归调用是同一个对象把子任务交给同一个方法本身去完成,而 职责链则是一个对象把子任务交给其他对象的同名方法去完成。其核心在于上下文 FilterChain 在不同对象 Filter 间的传递与状态的改变,通过这种链式串联,我们就可以对同一种对象资源实现不同业务场景的处理,达到业务解耦。整个 FilterChain 的结构就像这张图一样:
这里我们不妨还是带着两个问题去理解 FilterChain:
- FilterChain 在何处被创建,又是在何处进行初始化调用,从而激活责任链开始链式调用?
- FilterChain 为什么能够被链式调用,其内在的调用细节是什么?
接下来我们直接查看负责请求处理的 StandardWrapperValve#invoke(),快速解决第一个问题:
public final void invoke(Request request, Response response)
throws IOException, ServletException {
// 省略非关键代码
// 创建filterChain
ApplicationFilterChain filterChain =
ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
// 省略非关键代码
try {
if ((servlet != null) && (filterChain != null)) {
// Swallow output if needed
if (context.getSwallowOutput()) {
// 省略非关键代码
//执行filterChain
filterChain.doFilter(request.getRequest(),
response.getResponse());
// 省略非关键代码
}
// 省略非关键代码
}
通过代码可以看出,Spring 通过 ApplicationFilterFactory.createFilterChain() 创建FilterChain,然后调用其 doFilter() 执行责任链。而这些步骤的起始点正是StandardWrapperValve#invoke()。
接下来,我们来一起研究第二个问题,即 FilterChain 能够被链式调用的原因和内部细节。
首先查看 ApplicationFilterFactory.createFilterChain(),来看下FilterChain如何被创建,如下所示:
public static ApplicationFilterChain createFilterChain(ServletRequest request,
Wrapper wrapper, Servlet servlet) {
// 省略非关键代码
ApplicationFilterChain filterChain = null;
if (request instanceof Request) {
// 省略非关键代码
// 创建Chain
filterChain = new ApplicationFilterChain();
// 省略非关键代码
}
// 省略非关键代码
// Add the relevant path-mapped filters to this filter chain
for (int i = 0; i < filterMaps.length; i++) {
// 省略非关键代码
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig)
context.findFilterConfig(filterMaps[i].getFilterName());
if (filterConfig == null) {
continue;
}
// 增加filterConfig到Chain
filterChain.addFilter(filterConfig);
}
// 省略非关键代码
return filterChain;
}
它创建 FilterChain,并将所有 Filter 逐一添加到 FilterChain 中。然后我们继续查看 ApplicationFilterChain 类及其 addFilter():
// 省略非关键代码
private ApplicationFilterConfig[] filters = new ApplicationFilterConfig[0];
private int pos = 0;
private int n = 0;
// 省略非关键代码
void addFilter(ApplicationFilterConfig filterConfig) {
for(ApplicationFilterConfig filter:filters)
if(filter==filterConfig)
return;
if (n == filters.length) {
ApplicationFilterConfig[] newFilters =
new ApplicationFilterConfig[n + INCREMENT];
System.arraycopy(filters, 0, newFilters, 0, n);
filters = newFilters;
}
filters[n++] = filterConfig;
}
在 ApplicationFilterChain 里,声明了3个变量,类型为 ApplicationFilterConfig 的数组 Filters、过滤器总数计数器 n,以及标识运行过程中被执行过的过滤器个数 pos。
每个被初始化的 Filter 都会通过 filterChain.addFilter(),加入到类型为 ApplicationFilterConfig 的类成员数组 Filters 中,并同时更新 Filter 总数计数器 n,使其等于 Filters 数组的长度。到这, Spring 就完成了 FilterChain 的创建准备工作。
接下来,我们继续看 FilterChain 的执行细节,即 ApplicationFilterChain 的 doFilter():
public void doFilter(ServletRequest request, ServletResponse response)
throws IOException, ServletException {
if( Globals.IS_SECURITY_ENABLED ) {
//省略非关键代码
internalDoFilter(request,response);
//省略非关键代码
} else {
internalDoFilter(request,response);
}
}
这里逻辑被委派到了当前类的私有方法 internalDoFilter,具体实现如下:
private void internalDoFilter(ServletRequest request,
ServletResponse response){
if (pos < n) {
// pos会递增
ApplicationFilterConfig filterConfig = filters[pos++];
try {
Filter filter = filterConfig.getFilter();
// 省略非关键代码
// 执行filter
filter.doFilter(request, response, this);
// 省略非关键代码
}
// 省略非关键代码
return;
}
// 执行真正实际业务
servlet.service(request, response);
}
// 省略非关键代码
}
我们可以归纳下核心知识点:
- ApplicationFilterChain的internalDoFilter() 是过滤器逻辑的核心;
- ApplicationFilterChain的成员变量 Filters 维护了所有用户定义的过滤器;
- ApplicationFilterChain的类成员变量 n 为过滤器总数,变量 pos 是运行过程中已经执行的过滤器个数;
- internalDoFilter() 每被调用一次,pos 变量值自增 1,即从类成员变量 Filters 中取下一个 Filter;
- filter.doFilter(request, response, this) 会调用过滤器实现的 doFilter(),注意第三个参数值为 this,即为当前ApplicationFilterChain 实例 ,这意味着:用户需要在过滤器中显式调用一次javax.servlet.FilterChain#doFilter,才能完成整个链路;
- pos < n 意味着执行完所有的过滤器,才能通过servlet.service(request, response) 去执行真正的业务。
执行完所有的过滤器后,代码调用了 servlet.service(request, response) 方法。从下面这张调用栈的截图中,可以看到,经历了一个很长的看似循环的调用栈,我们终于从 internalDoFilter() 执行到了Controller层的saveUser()。这个过程就不再一一细讲了。
分析了这么多,最后我们再来思考一下这个问题案例。
DemoFilter 代码中的 doFilter() 在捕获异常的部分执行了一次,随后在 try 外面又执行了一次,因而当抛出异常的时候,doFilter() 明显会被执行两次,相对应的 servlet.service(request, response) 方法以及对应的 Controller 处理方法也被执行了两次。
解决
@Component
public class DemoFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
//模拟异常
System.out.println("Filter 处理中时发生异常");
throw new RuntimeException();
} catch (Exception e) {
//去掉下面这行调用
//chain.doFilter(request, response);
}
chain.doFilter(request, response);
}
}
不管怎么调用,不能多次调用 FilterChain#doFilter()
@WebFilter过滤器使用@Order无效
@SpringBootApplication
@ServletComponentScan
@Slf4j
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
log.info("启动成功");
}
}
@Controller
@Slf4j
public class StudentController {
@PostMapping("/regStudent/{name)}")
@ResponseBody
public String saveUser(String name) throws Exception {
System.out.println("......用户注册成功");
return "success";
}
}
- AuthFilter:例如,限制特定IP地址段(例如校园网内)的用户方可注册为新用户,当然这里我们仅仅Sleep 1秒来模拟这个过程。
@WebFilter
@Slf4j
@Order(2)
public class AuthFilter implements Filter {
@SneakyThrows
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
if(isPassAuth()){
System.out.println("通过授权");
chain.doFilter(request, response);
}else{
System.out.println("未通过授权");
((HttpServletResponse)response).sendError(401);
}
}
private boolean isPassAuth() throws InterruptedException {
System.out.println("执行检查权限");
Thread.sleep(1000);
return true;
}
}
- TimeCostFilter:计算注册学生的执行耗时,需要包括授权过程。
@WebFilter
@Slf4j
@Order(1)
public class TimeCostFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("#开始计算接口耗时");
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println("#执行时间(ms):" + time);
}
}
在上述代码中,我们使用了@Order,期望TimeCostFilter先被执行,
执行检查权限
通过授权
#开始计算接口耗时
......用户注册成功
#执行时间(ms):33
从结果来看,执行时间并不包含授权过程,所以这并不符合我们的预期,毕竟我们是加了@Order的。但是如果我们交换Order指定的值,你会发现也不见效果,为什么会如此?难道Order不能用来排序WebFilter么?
原理
当一个请求来临时,会执行到 StandardWrapperValve 的 invoke(),这个方法会创建 ApplicationFilterChain,并通过ApplicationFilterChain#doFilter() 触发过滤器执行,并最终执行到内部私有方法internalDoFilter(), 我们可以尝试在internalDoFilter()中寻找一些启示:
private void internalDoFilter(ServletRequest request,
ServletResponse response)
throws IOException, ServletException {
// Call the next filter if there is one
if (pos < n) {
ApplicationFilterConfig filterConfig = filters[pos++];
try {
Filter filter = filterConfig.getFilter();
从上述代码我们得知:过滤器的执行顺序是由类成员变量Filters决定的,而Filters变量则是createFilterChain()在容器启动时顺序遍历StandardContext中的成员变量FilterMaps获得的:
public static ApplicationFilterChain createFilterChain(ServletRequest request,
Wrapper wrapper, Servlet servlet) {
// 省略非关键代码
// Acquire the filter mappings for this Context
StandardContext context = (StandardContext) wrapper.getParent();
FilterMap filterMaps[] = context.findFilterMaps();
// 省略非关键代码
// Add the relevant path-mapped filters to this filter chain
for (int i = 0; i < filterMaps.length; i++) {
if (!matchDispatcher(filterMaps[i] ,dispatcher)) {
continue;
}
if (!matchFiltersURL(filterMaps[i], requestPath))
continue;
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig)
context.findFilterConfig(filterMaps[i].getFilterName());
if (filterConfig == null) {
continue;
}
filterChain.addFilter(filterConfig);
}
// 省略非关键代码
// Return the completed filter chain
return filterChain;
}
下面继续查找对StandardContext成员变量FilterMaps的写入引用,我们找到了addFilterMapBefore():
public void addFilterMapBefore(FilterMap filterMap) {
validateFilterMap(filterMap);
// Add this filter mapping to our registered set
filterMaps.addBefore(filterMap);
fireContainerEvent("addFilterMap", filterMap);
}
到这,我们已经知道过滤器的执行顺序是由StandardContext类成员变量FilterMaps的顺序决定,而FilterMaps则是一个包装过的数组,所以我们只要进一步弄清楚 FilterMaps中各元素的排列顺序 即可。
我们继续在addFilterMapBefore()中加入断点,尝试从调用栈中找到一些线索:
addFilterMapBefore:2992, StandardContext
addMappingForUrlPatterns:107, ApplicationFilterRegistration
configure:229, AbstractFilterRegistrationBean
configure:44, AbstractFilterRegistrationBean
register:113, DynamicRegistrationBean
onStartup:53, RegistrationBean
selfInitialize:228, ServletWebServerApplicationContext
// 省略非关键代码
可知,Spring从selfInitialize()一直依次调用到addFilterMapBefore(),稍微分析下selfInitialize(),我们可以了解到,这里是通过调用getServletContextInitializerBeans(),获取所有的ServletContextInitializer类型的Bean,并调用该Bean的onStartup(),从而一步步以调用栈显示的顺序,最终调用到 addFilterMapBefore()。
private void selfInitialize(ServletContext servletContext) throws ServletException {
prepareWebApplicationContext(servletContext);
registerApplicationScope(servletContext);
WebApplicationContextUtils.registerEnvironmentBeans(getBeanFactory(), servletContext);
for (ServletContextInitializer beans : getServletContextInitializerBeans()) {
beans.onStartup(servletContext);
}
}
那么上述的selfInitialize()又从何处调用过来呢?这里你可以先想想,我会在思考题中给你做进一步解释。
现在我们继续查看selfInitialize()的细节。
首先,查看上述代码中的getServletContextInitializerBeans(),因为此方法返回的ServletContextInitializer类型的Bean集合顺序决定了addFilterMapBefore()调用的顺序,从而决定了FilterMaps内元素的顺序,最终决定了过滤器的执行顺序。
getServletContextInitializerBeans()的实现非常简单,只是返回了ServletContextInitializerBeans类的一个实例,参考代码如下:
protected Collection<ServletContextInitializer> getServletContextInitializerBeans() {
return new ServletContextInitializerBeans(getBeanFactory());
}
上述方法的返回值是个Collection,可见ServletContextInitializerBeans类是一个集合类,它继承了AbstractCollection抽象类。也因为如此,上述selfInitialize()才可以遍历 ServletContextInitializerBeans的实例对象。
既然ServletContextInitializerBeans是集合类,那么我们就可以先查看其iterator(),看看它遍历的是什么。
@Override
public Iterator<ServletContextInitializer> iterator() {
return this.sortedList.iterator();
}
此集合类对外暴露的集合遍历元素为sortedList成员变量,也就是说,上述selfInitialize()最终遍历的即为sortedList成员变量。
到这,我们可以进一步确定下结论:selfInitialize()中是通过getServletContextInitializerBeans()获取到的ServletContextInitializer类型的Beans集合,即为ServletContextInitializerBeans的类型成员变量sortedList。反过来说, sortedList中的过滤器Bean元素顺序,决定了最终过滤器的执行顺序。
现在我们继续查看ServletContextInitializerBeans的构造方法如下:
public ServletContextInitializerBeans(ListableBeanFactory beanFactory,
Class<? extends ServletContextInitializer>... initializerTypes) {
this.initializers = new LinkedMultiValueMap<>();
this.initializerTypes = (initializerTypes.length != 0) ? Arrays.asList(initializerTypes)
: Collections.singletonList(ServletContextInitializer.class);
addServletContextInitializerBeans(beanFactory);
addAdaptableBeans(beanFactory);
List<ServletContextInitializer> sortedInitializers = this.initializers.values().stream()
.flatMap((value) -> value.stream().sorted(AnnotationAwareOrderComparator.INSTANCE))
.collect(Collectors.toList());
this.sortedList = Collections.unmodifiableList(sortedInitializers);
logMappings(this.initializers);
}
通过第8行,可以得知:我们关心的类成员变量this.sortedList,其元素顺序是由类成员变量this.initializers的values通过比较器AnnotationAwareOrderComparator进行排序的。
继续查看AnnotationAwareOrderComparator比较器,忽略比较器调用的细节过程,其最终是通过两种方式获取比较器需要的order值,来决定sortedInitializers的排列顺序:
- 待排序的对象元素自身实现了Order接口,则直接通过getOrder()获取order值;
- 否则执行OrderUtils.findOrder()获取该对象类@Order的属性。
这里多解释一句,因为this.initializers的values类型为ServletContextInitializer,其实现了Ordered接口,所以这里的比较器显然是使用了getOrder()获取比较器所需的order值,对应的类成员变量即为order。
继续查看this.initializers中的元素在何处被添加,我们最终得知,addServletContextInitializerBeans()以及addAdaptableBeans()这两个方法均构建了ServletContextInitializer子类的实例,并添加到了this.initializers成员变量中。在这里,我们只研究addServletContextInitializerBeans,毕竟我们使用的添加过滤器方式(使用@WebFilter标记)最终只会通过这个方法生效。
在这个方法中,Spring通过getOrderedBeansOfType()实例化了所有ServletContextInitializer的子类:
private void addServletContextInitializerBeans(ListableBeanFactory beanFactory) {
for (Class<? extends ServletContextInitializer> initializerType : this.initializerTypes) {
for (Entry<String, ? extends ServletContextInitializer> initializerBean : getOrderedBeansOfType(beanFactory,
initializerType)) {
addServletContextInitializerBean(initializerBean.getKey(), initializerBean.getValue(), beanFactory);
}
}
}
根据其不同类型,调用addServletContextInitializerBean(),我们可以看出ServletContextInitializer的子类包括了ServletRegistrationBean、FilterRegistrationBean以及ServletListenerRegistrationBean,正好对应了Servlet的三大要素。
而这里我们只需要关心对应于Filter的FilterRegistrationBean,显然,FilterRegistrationBean是ServletContextInitializer的子类(实现了Ordered接口),同样由 成员变量order的值决定其执行的优先级。
private void addServletContextInitializerBean(String beanName, ServletContextInitializer initializer,
ListableBeanFactory beanFactory) {
if (initializer instanceof ServletRegistrationBean) {
Servlet source = ((ServletRegistrationBean<?>) initializer).getServlet();
addServletContextInitializerBean(Servlet.class, beanName, initializer, beanFactory, source);
}
else if (initializer instanceof FilterRegistrationBean) {
Filter source = ((FilterRegistrationBean<?>) initializer).getFilter();
addServletContextInitializerBean(Filter.class, beanName, initializer, beanFactory, source);
}
else if (initializer instanceof DelegatingFilterProxyRegistrationBean) {
String source = ((DelegatingFilterProxyRegistrationBean) initializer).getTargetBeanName();
addServletContextInitializerBean(Filter.class, beanName, initializer, beanFactory, source);
}
else if (initializer instanceof ServletListenerRegistrationBean) {
EventListener source = ((ServletListenerRegistrationBean<?>) initializer).getListener();
addServletContextInitializerBean(EventListener.class, beanName, initializer, beanFactory, source);
}
else {
addServletContextInitializerBean(ServletContextInitializer.class, beanName, initializer, beanFactory,
initializer);
}
}
最终添加到this.initializers成员变量中:
private void addServletContextInitializerBean(Class<?> type, String beanName, ServletContextInitializer initializer,
ListableBeanFactory beanFactory, Object source) {
this.initializers.add(type, initializer);
// 省略非关键代码
}
通过上述代码,我们再次看到了FilterRegistrationBean。但问题来了,我们没有定义FilterRegistrationBean,那么这里的FilterRegistrationBean是在哪里被定义的呢?其order类成员变量是否有特定的取值逻辑?
不妨回想下上节课的案例1,它是在WebFilterHandler类的doHandle()动态构建了FilterRegistrationBean的BeanDefinition:
class WebFilterHandler extends ServletComponentHandler {
WebFilterHandler() {
super(WebFilter.class);
}
@Override
public void doHandle(Map<String, Object> attributes, AnnotatedBeanDefinition beanDefinition,
BeanDefinitionRegistry registry) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(FilterRegistrationBean.class);
builder.addPropertyValue("asyncSupported", attributes.get("asyncSupported"));
builder.addPropertyValue("dispatcherTypes", extractDispatcherTypes(attributes));
builder.addPropertyValue("filter", beanDefinition);
builder.addPropertyValue("initParameters", extractInitParameters(attributes));
String name = determineName(attributes, beanDefinition);
builder.addPropertyValue("name", name);
builder.addPropertyValue("servletNames", attributes.get("servletNames"));
builder.addPropertyValue("urlPatterns", extractUrlPatterns(attributes));
registry.registerBeanDefinition(name, builder.getBeanDefinition());
}
// 省略非关键代码
这里我再次贴出了WebFilterHandler中doHandle()的逻辑(即通过 BeanDefinitionBuilder动态构建了FilterRegistrationBean类型的BeanDefinition)。然而遗憾的是, 此处并没有设置order的值,更没有根据@Order指定的值去设置。
到这里我们终于看清楚了问题的本质,所有被@WebFilter注解的类,最终都会在此处被包装为FilterRegistrationBean类的BeanDefinition。虽然FilterRegistrationBean也拥有Ordered接口,但此处却并没有填充值,因为这里所有的属性都是从@WebFilter对应的属性获取的,而@WebFilter本身没有指定可以辅助排序的属性。
现在我们来总结下,过滤器的执行顺序是由下面这个串联决定的:
RegistrationBean中order属性的值->
ServletContextInitializerBeans类成员变量sortedList中元素的顺序->
ServletWebServerApplicationContext 中selfInitialize()遍历FilterRegistrationBean的顺序->
addFilterMapBefore()调用的顺序->
filterMaps内元素的顺序->
过滤器的执行顺序
可见,RegistrationBean中order属性的值最终可以决定过滤器的执行顺序。但是可惜的是:当使用@WebFilter时,构建的FilterRegistrationBean并没有依据@Order的值去设置order属性,所以@Order失效了。
解决
实现自己的FilterRegistrationBean来配置添加过滤器,不再使用@WebFilter。
@Configuration
public class FilterConfiguration {
@Bean
public FilterRegistrationBean authFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new AuthFilter());
registration.addUrlPatterns("/*");
registration.setOrder(2);
return registration;
}
@Bean
public FilterRegistrationBean timeCostFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new TimeCostFilter());
registration.addUrlPatterns("/*");
registration.setOrder(1);
return registration;
}
}
按照我们查看的源码中的逻辑,虽然WebFilterHandler中doHandle()构建了FilterRegistrationBean类型的BeanDefinition,但 没有设置order的值。所以在这里,我们直接手工实例化了FilterRegistrationBean实例,而且设置了其setOrder()。同时不要忘记去掉AuthFilter和TimeCostFilter类中的@WebFilter,这样问题就得以解决了。
过滤器被多次执行
@WebFilter
@Slf4j
@Order(2)
@Component
public class AuthFilter implements Filter {
@SneakyThrows
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain){
if(isPassAuth()){
System.out.println("通过授权");
chain.doFilter(request, response);
}else{
System.out.println("未通过授权");
((HttpServletResponse)response).sendError(401);
}
}
private boolean isPassAuth() throws InterruptedException {
System.out.println("执行检查权限");
Thread.sleep(1000);
return true;
}
}
@WebFilter
@Slf4j
@Order(1)
@Component
public class TimeCostFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("#开始计算接口耗时");
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println("#执行时间(ms):" + time);
}
}
最终执行结果如下:
#开始计算接口耗时
执行检查权限
通过授权
执行检查权限
通过授权
#开始计算接口耗时
......用户注册成功
#执行时间(ms):73
#执行时间(ms):2075
更改 AuthFilter 类中的Order值为0,继续测试,得到结果如下:
执行检查权限
通过授权
#开始计算接口耗时
执行检查权限
通过授权
#开始计算接口耗时
......用户注册成功
#执行时间(ms):96
#执行时间(ms):1100
显然,通过Order的值,我们已经可以随意调整Filter的执行顺序,但是我们会惊奇地发现,过滤器本身被执行了2次
原理
从案例1中我们已经得知被@WebFilter的过滤器,会在WebServletHandler类中被重新包装为FilterRegistrationBean类的BeanDefinition,而并非是Filter类型。
而当我们在自定义过滤器中增加@Component时,我们可以大胆猜测下:理论上Spring会根据当前类再次包装一个新的过滤器,因而doFIlter()被执行两次。因此看似奇怪的测试结果,也在情理之中了。
我们继续从源码中寻找真相,继续查阅ServletContextInitializerBeans的构造方法如下:
public ServletContextInitializerBeans(ListableBeanFactory beanFactory,
Class<? extends ServletContextInitializer>... initializerTypes) {
this.initializers = new LinkedMultiValueMap<>();
this.initializerTypes = (initializerTypes.length != 0) ? Arrays.asList(initializerTypes)
: Collections.singletonList(ServletContextInitializer.class);
addServletContextInitializerBeans(beanFactory);
addAdaptableBeans(beanFactory);
List<ServletContextInitializer> sortedInitializers = this.initializers.values().stream()
.flatMap((value) -> value.stream().sorted(AnnotationAwareOrderComparator.INSTANCE))
.collect(Collectors.toList());
this.sortedList = Collections.unmodifiableList(sortedInitializers);
logMappings(this.initializers);
}
上一个案例中,我们关注了addServletContextInitializerBeans(),了解了它的作用是实例化并注册了所有FilterRegistrationBean类型的过滤器(严格说,是实例化并注册了所有的ServletRegistrationBean、FilterRegistrationBean以及ServletListenerRegistrationBean,但这里我们只关注FilterRegistrationBean)。
而第7行的addAdaptableBeans(),其作用则是实例化所有实现Filter接口的类(严格说,是实例化并注册了所有实现Servlet、Filter以及EventListener接口的类),然后再逐一包装为FilterRegistrationBean。
之所以Spring能够直接实例化FilterRegistrationBean类型的过滤器,这是因为:
- WebFilterHandler相关类通过扫描@WebFilter,动态构建了FilterRegistrationBean类型的BeanDefinition,并注册到Spring;
- 或者我们自己使用@Bean来显式实例化FilterRegistrationBean并注册到Spring,如案例1中的解决方案。
但Filter类型的过滤器如何才能被Spring直接实例化呢?相信你已经有答案了: 任何通过@Component修饰的的类,都可以自动注册到Spring,且能被Spring直接实例化。
现在我们直接查看addAdaptableBeans(),其调用了addAsRegistrationBean(),其beanType为Filter.class:
protected void addAdaptableBeans(ListableBeanFactory beanFactory) {
// 省略非关键代码
addAsRegistrationBean(beanFactory, Filter.class, new FilterRegistrationBeanAdapter());
// 省略非关键代码
}
继续查看最终调用到的方法addAsRegistrationBean():
private <T, B extends T> void addAsRegistrationBean(ListableBeanFactory beanFactory, Class<T> type,
Class<B> beanType, RegistrationBeanAdapter<T> adapter) {
List<Map.Entry<String, B>> entries = getOrderedBeansOfType(beanFactory, beanType, this.seen);
for (Entry<String, B> entry : entries) {
String beanName = entry.getKey();
B bean = entry.getValue();
if (this.seen.add(bean)) {
// One that we haven't already seen
RegistrationBean registration = adapter.createRegistrationBean(beanName, bean, entries.size());
int order = getOrder(bean);
registration.setOrder(order);
this.initializers.add(type, registration);
if (logger.isTraceEnabled()) {
logger.trace("Created " + type.getSimpleName() + " initializer for bean '" + beanName + "'; order="
+ order + ", resource=" + getResourceDescription(beanName, beanFactory));
}
}
}
}
主要逻辑如下:
- 通过getOrderedBeansOfType()创建了所有 Filter 子类的实例,即所有实现Filter接口且被@Component修饰的类;
- 依次遍历这些Filter类实例,并通过RegistrationBeanAdapter将这些类包装为RegistrationBean;
- 获取Filter类实例的Order值,并设置到包装类 RegistrationBean中;
- 将RegistrationBean添加到this.initializers。
到这,我们了解到,当过滤器同时被@WebFilter和@Component修饰时,会导致两个FilterRegistrationBean实例的产生。addServletContextInitializerBeans()和addAdaptableBeans()最终都会创建FilterRegistrationBean的实例,但不同的是:
- @WebFilter会让addServletContextInitializerBeans()实例化,并注册所有动态生成的FilterRegistrationBean类型的过滤器;
- @Component会让addAdaptableBeans()实例化所有实现Filter接口的类,然后再逐一包装为FilterRegistrationBean类型的过滤器。
解决
//@WebFilter
@Slf4j
@Order(1)
@Component
public class TimeCostFilter implements Filter {
//省略非关键代码
}
总结
@WebFilter 这种方式构建的 Filter 是无法直接根据过滤器定义类型来自动注入的,因为这种Filter本身是以内部Bean来呈现的,它最终是通过FilterRegistrationBean来呈现给Spring的。所以我们可以通过自动注入FilterRegistrationBean类型来完成装配工作,示例如下:
@Autowired
@Qualifier("com.spring.puzzle.filter.TimeCostFilter")
FilterRegistrationBean timeCostFilter;
在过滤器的执行中,一定要注意避免不要多次调用doFilter(),否则可能会出现业务代码执行多次的问题。这个问题出现的根源往往在于“不小心”,但是要理解这个问题呈现的现象,就必须对过滤器的流程有所了解。可以看过滤器执行的核心流程图:

- 当一个请求来临时,会执行到
StandardWrapperValve的invoke(),这个方法会创建ApplicationFilterChain,并通过ApplicationFilterChain#doFilter()触发过滤器执行; ApplicationFilterChain的doFilter()会执行其私有方法internalDoFilter;- 在
internalDoFilter方法中获取下一个Filter,并使用 request、response、this(当前ApplicationFilterChain实例)作为参数来调用 doFilter():
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException;
- 在 Filter 类的 doFilter() 中,执行Filter定义的动作并继续传递,获取第三个参数 ApplicationFilterChain,并执行其 doFilter();
- 此时会循环执行进入第 2 步、第 3 步、第 4 步,直到第3步中所有的 Filter 类都被执行完毕为止;
- 所有的Filter过滤器都被执行完毕后,会执行 servlet.service(request, response) 方法,最终调用对应的 Controller 层方法 。
@WebFilter和@Component的相同点是:
- 它们最终都被包装并实例化成为了FilterRegistrationBean;
- 它们最终都是在 ServletContextInitializerBeans的构造器中开始被实例化。
@WebFilter和@Component的不同点是:
- 被@WebFilter修饰的过滤器会被提前在BeanFactoryPostProcessors扩展点包装成FilterRegistrationBean类型的BeanDefinition,然后在ServletContextInitializerBeans.addServletContextInitializerBeans() 进行实例化;而使用@Component修饰的过滤器类,是在ServletContextInitializerBeans.addAdaptableBeans() 中被实例化成Filter类型后,再包装为RegistrationBean类型。
- 被@WebFilter修饰的过滤器不会注入Order属性,但被@Component修饰的过滤器会在ServletContextInitializerBeans.addAdaptableBeans() 中注入Order属性。
SpringSecurity
遗忘 PasswordEncoder
当我们第一次尝试使用 Spring Security 时,我们经常会忘记定义一个 PasswordEncoder。因为这在 Spring Security 旧版本中是允许的。而一旦使用了新版本,则必须要提供一个 PasswordEncoder。这里我们可以先写一个反例来感受下:
首先我们在 Spring Boot 项目中直接开启 Spring Security:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
添加完这段依赖后,Spring Security 就已经生效了。然后我们配置下安全策略,如下:
@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
//
// @Bean
// public PasswordEncoder passwordEncoder() {
// return new PasswordEncoder() {
// @Override
// public String encode(CharSequence charSequence) {
// return charSequence.toString();
// }
//
// @Override
// public boolean matches(CharSequence charSequence, String // s) {
// return Objects.equals(charSequence.toString(), s);
// }
// };
// }
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin").password("pass").roles("ADMIN");
}
// 配置 URL 对应的访问权限
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin().loginProcessingUrl("/login").permitAll()
.and().csrf().disable();
}
}
这里,我们故意“注释”掉 PasswordEncoder 类型 Bean 的定义。然后我们定义一个 SpringApplication 启动程序来启动服务,我们会发现启动成功了:
INFO 8628 --- [ restartedMain] c.s.p.web.security.example1.Application : Started Application in 3.637 seconds (JVM running for 4.499)
但是当我们发送一个请求时(例如 http://localhost:8080/admin ),就会报错java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
原理
我们可以反思下,为什么需要一个 PasswordEncoder。实际上,这是安全保护的范畴。
假设我们没有这样的一个东西,那么当用户输入登录密码之后,我们如何判断密码和内存或数据库中存储的密码是否一致呢?假设就是简单比较下是否相等,那么必然要求存储起来的密码是非加密的,这样其实就存在密码泄露的风险了。
反过来思考,为了安全,我们一般都会将密码加密存储起来。那么当用户输入密码时,我们就不是简单的字符串比较了。我们需要根据存储密码的加密算法来比较用户输入的密码和存储的密码是否一致。所以我们需要一个 PasswordEncoder 来满足这个需求。这就是为什么我们需要自定义一个 PasswordEncoder 的原因。
再看下它的两个关键方法 encode() 和 matches(),相信你就能理解它们的作用了。
思考下,假设我们默认提供一个出来并集成到 Spring Security 里面去,那么很可能隐藏错误,所以还是强制要求起来比较合适。
我们再从源码上看下 "no PasswordEncoder" 异常是如何被抛出的?当我们不指定PasswordEncoder去启动我们的案例程序时,我们实际指定了一个默认的PasswordEncoder,这点我们可以从构造器DaoAuthenticationProvider看出来:
public DaoAuthenticationProvider() {
setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}
我们可以看下PasswordEncoderFactories.createDelegatingPasswordEncoder()的实现:
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
我们可以换一个视角来看下这个DelegatingPasswordEncoder长什么样:
通过上图可以看出,其实它是多个内置的 PasswordEncoder 集成在了一起。
当我们校验用户时,我们会通过下面的代码来匹配,参考DelegatingPasswordEncoder#matches:
private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();
@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
}
String id = extractId(prefixEncodedPassword);
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
if (delegate == null) {
return this.defaultPasswordEncoderForMatches
.matches(rawPassword, prefixEncodedPassword);
}
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
return delegate.matches(rawPassword, encodedPassword);
}
private String extractId(String prefixEncodedPassword) {
if (prefixEncodedPassword == null) {
return null;
}
//{
int start = prefixEncodedPassword.indexOf(PREFIX);
if (start != 0) {
return null;
}
//}
int end = prefixEncodedPassword.indexOf(SUFFIX, start);
if (end < 0) {
return null;
}
return prefixEncodedPassword.substring(start + 1, end);
}
可以看出,假设我们的 prefixEncodedPassword 中含有 id,则根据 id 到 DelegatingPasswordEncoder 的 idToPasswordEncoder 找出合适的 Encoder;假设没有 id,则使用默认的UnmappedIdPasswordEncoder。我们来看下它的实现:
private class UnmappedIdPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
throw new UnsupportedOperationException("encode is not supported");
}
@Override
public boolean matches(CharSequence rawPassword,
String prefixEncodedPassword) {
String id = extractId(prefixEncodedPassword);
throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");
}
}
从上述代码可以看出,no PasswordEncoder for the id "null" 异常就是这样被 UnmappedIdPasswordEncoder 抛出的。那么这个可能含有 id 的 prefixEncodedPassword 是什么?其实它就是存储的密码,在我们的案例中由下面代码行中的 password() 指定:
auth.inMemoryAuthentication() .withUser("admin").password("pass").roles("ADMIN");
这里我们不妨测试下,修改下上述代码行,给密码指定一个加密方式,看看之前的异常还存在与否:
auth.inMemoryAuthentication() .withUser("admin").password("{MD5}pass").roles("ADMIN");
此时,以调试方式运行程序,你会发现,这个时候已经有了 id,且取出了合适的 PasswordEncoder。
说到这里,相信你已经知道问题的来龙去脉了。问题的根源还是在于我们需要一个PasswordEncoder,而当前案例没有给我们指定出来。
解决
auth.inMemoryAuthentication()
.withUser("admin").password("{noop}pass").roles("ADMIN");
然后定位到这个方式,实际上就等于指定 PasswordEncoder 为NoOpPasswordEncoder了,它的实现如下:
public final class NoOpPasswordEncoder implements PasswordEncoder {
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(encodedPassword);
}
//省略部分非关键代码
}
不过,这种修正方式比较麻烦,毕竟每个密码都加个前缀也不合适。所以综合比较来看,还是第一种修正方式更普适。当然如果你的需求是不同的用户有不同的加密,或许这种方式也是不错的。
ROLE_ 前缀与角色
我们再来看一个 Spring Security 中关于权限角色的案例,ROLE_ 前缀加还是不加?不过这里我们需要提供稍微复杂一些的功能,即模拟授权时的角色相关控制。所以我们需要完善下案例,这里我先提供一个接口,这个接口需要管理的操作权限:
@RestController
public class HelloWorldController {
@RequestMapping(path = "admin", method = RequestMethod.GET)
public String admin(){
return "admin operation";
};
然后我们使用 Spring Security 默认的内置授权来创建一个授权配置类:
@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
//同案例1,这里省略掉
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("fujian").password("pass").roles("USER")
.and()
.withUser("admin1").password("pass").roles("ADMIN")
.and()
.withUser(new UserDetails() {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("ADMIN"));
}
//省略其他非关键“实现”方法
public String getUsername() {
return "admin2";
}
});
}
// 配置 URL 对应的访问权限
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin().loginProcessingUrl("/login").permitAll()
.and().csrf().disable();
}
}
通过上述代码,我们添加了 3 个用户:
- 用户 fujian:角色为 USER
- 用户 admin1:角色为 ADMIN
- 用户 admin2:角色为 ADMIN
然后我们从浏览器访问我们的接口 http://localhost:8080/admin,使用上述 3 个用户登录,你会发现用户 admin1 可以登录,而 admin2 设置了同样的角色却不可以登陆
原理
//admin1 的添加
.withUser("admin").password("pass").roles("ADMIN")
//admin2 的添加
.withUser(new UserDetails() {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("ADMIN"));
}
@Override
public String getUsername() {
return "admin2";
}
//省略其他非关键代码
});
查看上面这两种添加方式,你会发现它们真的仅仅是两种风格而已,所以最终构建出用户的代码肯定是相同的。我们先来查看下 admin1 的添加最后对 Role 的处理(参考 User.UserBuilder#roles):
public UserBuilder roles(String... roles) {
List<GrantedAuthority> authorities = new ArrayList<>(
roles.length);
for (String role : roles) {
Assert.isTrue(!role.startsWith("ROLE_"), () -> role
+ " cannot start with ROLE_ (it is automatically added)");
//添加“ROLE_”前缀
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
return authorities(authorities);
}
public UserBuilder authorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = new ArrayList<>(authorities);
return this;
}
可以看出,当 admin1 添加 ADMIN 角色时,实际添加进去的是 ROLE_ADMIN。但是我们再来看下 admin2 的角色设置,最终设置的方法其实就是 User#withUserDetails:
public static UserBuilder withUserDetails(UserDetails userDetails) {
return withUsername(userDetails.getUsername())
//省略非关键代码
.authorities(userDetails.getAuthorities())
.credentialsExpired(!userDetails.isCredentialsNonExpired())
.disabled(!userDetails.isEnabled());
}
public UserBuilder authorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = new ArrayList<>(authorities);
return this;
}
所以,admin2 的添加,最终设置进的 Role 就是 ADMIN。
此时我们可以得出一个结论:通过上述两种方式设置的相同 Role(即 ADMIN),最后存储的 Role 却不相同,分别为 ROLE_ADMIN 和 ADMIN。那么为什么只有 ROLE_ADMIN 这种用户才能通过授权呢?这里我们不妨通过调试视图看下授权的调用栈,截图如下:
对于案例的代码,最终是通过 "UsernamePasswordAuthenticationFilter" 来完成授权的。而且从调用栈信息可以大致看出,授权的关键其实就是查找用户,然后校验权限。查找用户的方法可参考 InMemoryUserDetailsManager#loadUserByUsername,即根据用户名查找已添加的用户:
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
UserDetails user = users.get(username.toLowerCase());
if (user == null) {
throw new UsernameNotFoundException(username);
}
return new User(user.getUsername(), user.getPassword(), user.isEnabled(),
user.isAccountNonExpired(), user.isCredentialsNonExpired(),
user.isAccountNonLocked(), user.getAuthorities());
}
完成账号是否过期、是否锁定等检查后,我们会把这个用户转化为下面的 Token(即 UsernamePasswordAuthenticationToken)供后续使用,关键信息如下:
最终在判断角色时,我们会通过 UsernamePasswordAuthenticationToken 的父类方法 AbstractAuthenticationToken#getAuthorities 来取到上述截图中的 ADMIN。而判断是否具备某个角色时,使用的关键方法是 SecurityExpressionRoot#hasAnyAuthorityName:
private boolean hasAnyAuthorityName(String prefix, String... roles) {
//通过 AbstractAuthenticationToken#getAuthorities 获取“role”
Set<String> roleSet = getAuthoritySet();
for (String role : roles) {
String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
if (roleSet.contains(defaultedRole)) {
return true;
}
}
return false;
}
//尝试添加“prefix”,即“ROLE_”
private static String getRoleWithDefaultPrefix(String defaultRolePrefix, String role) {
if (role == null) {
return role;
}
if (defaultRolePrefix == null || defaultRolePrefix.length() == 0) {
return role;
}
if (role.startsWith(defaultRolePrefix)) {
return role;
}
return defaultRolePrefix + role;
}
在上述代码中,prefix 是 ROLE_(默认值,即 SecurityExpressionRoot#defaultRolePrefix),Roles 是待匹配的角色 ROLE_ADMIN,产生的 defaultedRole 是 ROLE_ADMIN,而我们的 role-set 是从 UsernamePasswordAuthenticationToken 中获取到 ADMIN,所以最终判断的结果是 false。
最终这个结果反映给上层来决定是否通过授权,可参考 WebExpressionVoter#vote:
public int vote(Authentication authentication, FilterInvocation fi,
Collection<ConfigAttribute> attributes) {
//省略非关键代码
return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED
: ACCESS_DENIED;
}
很明显,当是否含有某个角色(表达式 Expression:hasRole('ROLE_ADMIN'))的判断结果为 false 时,返回的结果是 ACCESS_DENIED。
解决
//admin2 的添加
.withUser(new UserDetails() {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
@Override
public String getUsername() {
return "admin2";
}
//省略其他非关键代码
})
Spring Exception
小心过滤器异常
@Controller
@Slf4j
public class StudentController {
public StudentController(){
System.out.println("construct");
}
@PostMapping("/regStudent/{name}")
@ResponseBody
public String saveUser(String name) throws Exception {
System.out.println("......用户注册成功");
return "success";
}
}
当 Token 校验失败时,就会抛出一个自定义的 NotAllowException,交由 Spring 处理:
@WebFilter
@Component
public class PermissionFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader("token");
if (!"111111".equals(token)) {
System.out.println("throw NotAllowException");
throw new NotAllowException();
}
chain.doFilter(request, response);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
public class NotAllowException extends RuntimeException {
public NotAllowException() {
super();
}
}
同时,新增了一个 RestControllerAdvice 来处理这个异常,处理方式也很简单,就是返回一个 403 的 resultCode:
@RestControllerAdvice
public class NotAllowExceptionHandler {
@ExceptionHandler(NotAllowException.class)
@ResponseBody
public String handle() {
System.out.println("403");
return "{\"resultCode\": 403}";
}
}
上面的无法拦截过滤器的异常
原理
当所有的过滤器被执行完毕以后,Spring 才会进入 Servlet 相关的处理,而 DispatcherServlet 才是整个 Servlet 处理的核心,它是前端控制器设计模式的实现,提供 Spring Web MVC 的集中访问点并负责职责的分派。正是在这里,Spring 处理了请求和处理器之间的对应关系,以及统一异常处理。
ControllerAdvice是如何被Spring加载并对外暴露的?在Spring Web 的核心配置类 WebMvcConfigurationSupport 中,被 @Bean 修饰的 handlerExceptionResolver(),会调用addDefaultHandlerExceptionResolvers() 来添加默认的异常解析器。
@Bean
public HandlerExceptionResolver handlerExceptionResolver(
@Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager) {
List<HandlerExceptionResolver> exceptionResolvers = new ArrayList<>();
configureHandlerExceptionResolvers(exceptionResolvers);
if (exceptionResolvers.isEmpty()) {
addDefaultHandlerExceptionResolvers(exceptionResolvers, contentNegotiationManager);
}
extendHandlerExceptionResolvers(exceptionResolvers);
HandlerExceptionResolverComposite composite = new HandlerExceptionResolverComposite();
composite.setOrder(0);
composite.setExceptionResolvers(exceptionResolvers);
return composite;
}
Spring 实例化了ExceptionHandlerExceptionResolver类。ExceptionHandlerExceptionResolver 类实现了InitializingBean 接口,并覆写了afterPropertiesSet()。
public void afterPropertiesSet() {
// Do this first, it may add ResponseBodyAdvice beans
initExceptionHandlerAdviceCache();
//省略非关键代码
}
并在 initExceptionHandlerAdviceCache() 中完成了所有 ControllerAdvice 中的ExceptionHandler 的初始化。其具体操作,就是查找所有 @ControllerAdvice 注解的 Bean,把它们放到成员变量 exceptionHandlerAdviceCache 中。
private void initExceptionHandlerAdviceCache() {
//省略非关键代码
List<ControllerAdviceBean> adviceBeans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
for (ControllerAdviceBean adviceBean : adviceBeans) {
Class<?> beanType = adviceBean.getBeanType();
if (beanType == null) {
throw new IllegalStateException("Unresolvable type for ControllerAdviceBean: " + adviceBean);
}
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(beanType);
if (resolver.hasExceptionMappings()) {
this.exceptionHandlerAdviceCache.put(adviceBean, resolver);
}
//省略非关键代码
}
到这,我们可以总结一下,WebMvcConfigurationSupport 中的handlerExceptionResolver() 实例化并注册了一个ExceptionHandlerExceptionResolver 的实例,而所有被 @ControllerAdvice 注解修饰的异常处理器,都会在 ExceptionHandlerExceptionResolver 实例化的时候自动扫描并装载在其类成员变量 exceptionHandlerAdviceCache 中。
当第一次请求发生时,DispatcherServlet 中的 initHandlerExceptionResolvers() 将获取所有注册到 Spring 的 HandlerExceptionResolver 类型的实例,而ExceptionHandlerExceptionResolver 恰好实现了 HandlerExceptionResolver 接口,这些 HandlerExceptionResolver 类型的实例则会被写入到类成员变量handlerExceptionResolvers中。
private void initHandlerExceptionResolvers(ApplicationContext context) {
this.handlerExceptionResolvers = null;
if (this.detectAllHandlerExceptionResolvers) {
// Find all HandlerExceptionResolvers in the ApplicationContext, including ancestor contexts.
Map<String, HandlerExceptionResolver> matchingBeans = BeanFactoryUtils
.beansOfTypeIncludingAncestors(context, HandlerExceptionResolver.class, true, false);
if (!matchingBeans.isEmpty()) {
this.handlerExceptionResolvers = new ArrayList<>(matchingBeans.values());
// We keep HandlerExceptionResolvers in sorted order.
AnnotationAwareOrderComparator.sort(this.handlerExceptionResolvers);
}
//省略非关键代码
}
接着我们再来了解下ControllerAdvice是如何被Spring消费并处理异常的。 下文贴出的是核心类 DispatcherServlet 中的核心方法 doDispatch() 的部分代码:
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
//省略非关键代码
try {
ModelAndView mv = null;
Exception dispatchException = null;
try {
//省略非关键代码
//查找当前请求对应的 handler,并执行
//省略非关键代码
}
catch (Exception ex) {
dispatchException = ex;
}
catch (Throwable err) {
dispatchException = new NestedServletException("Handler dispatch failed", err);
}
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}
//省略非关键代码
Spring 在执行用户请求时,当在“查找”和“执行”请求对应的 handler 过程中发生异常,就会把异常赋值给 dispatchException,再交给 processDispatchResult() 进行处理。
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
@Nullable Exception exception) throws Exception {
boolean errorView = false;
if (exception != null) {
if (exception instanceof ModelAndViewDefiningException) {
mv = ((ModelAndViewDefiningException) exception).getModelAndView();
}
else {
Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
mv = processHandlerException(request, response, handler, exception);
errorView = (mv != null);
}
}
//省略非关键代码
进一步处理后,即当 Exception 不为 null 时,继续交给 processHandlerException处理。
protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
@Nullable Object handler, Exception ex) throws Exception {
//省略非关键代码
ModelAndView exMv = null;
if (this.handlerExceptionResolvers != null) {
for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
exMv = resolver.resolveException(request, response, handler, ex);
if (exMv != null) {
break;
}
}
}
//省略非关键代码
}
然后,processHandlerException 会从类成员变量 handlerExceptionResolvers 中获取有效的异常解析器,对异常进行解析。
显然,这里的 handlerExceptionResolvers 一定包含我们声明的NotAllowExceptionHandler#NotAllowException 的异常处理器的 ExceptionHandlerExceptionResolver 包装类。
解决
对 Filter 做一些改造。手动捕获异常,并将异常 HandlerExceptionResolver 进行解析。修改 PermissionFilter,注入 HandlerExceptionResolver:
@Autowired
@Qualifier("handlerExceptionResolver")
private HandlerExceptionResolver resolver;
然后,在 doFilter 里捕获异常并交给 HandlerExceptionResolver 处理:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
String token = httpServletRequest.getHeader("token");
if (!"111111".equals(token)) {
System.out.println("throw NotAllowException");
resolver.resolveException(httpServletRequest, httpServletResponse, null, new NotAllowException());
return;
}
chain.doFilter(request, response);
}
特殊的 404 异常
一般使用 RESTful 接口时我们会统一返回 JSON 数据,返回值格式如下:
{"resultCode": 404}
但是 Spring 对 404 异常是进行了默认资源映射的,并不会返回我们想要的结果,也不会对这种错误做记录。
于是我们添加了一个 ExceptionHandlerController,它被声明成@RestControllerAdvice来全局捕获 Spring MVC 中抛出的异常。ExceptionHandler 的作用正是用来捕获指定的异常:
@RestControllerAdvice
public class MyExceptionHandler {
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(Exception.class)
@ResponseBody
public String handle404() {
System.out.println("404");
return "{\"resultCode\": 404}";
}
}
我们尝试发送一个错误的 URL 请求到之前实现过的 /regStudent 接口,并把请求地址换成 /regStudent1,得到了以下结果:
{"timestamp":"2021-05-19T22:24:01.559+0000","status":404,"error":"Not Found","message":"No message available","path":"/regStudent1"}
很显然,这个结果不是我们想要的,看起来应该是 Spring 默认的返回结果。那是什么原因导致 Spring 没有使用我们定义的异常处理器呢?
原理
DispatcherServlet 中的 doDispatch() 核心代码如下:
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
//省略非关键代码
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
//省略非关键代码
}
首先调用 getHandler() 获取当前请求的处理器,如果获取不到,则调用noHandlerFound():
protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
if (this.throwExceptionIfNoHandlerFound) {
throw new NoHandlerFoundException(request.getMethod(), getRequestUri(request),
new ServletServerHttpRequest(request).getHeaders());
}
else {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
noHandlerFound() 的逻辑非常简单,如果 throwExceptionIfNoHandlerFound 属性为 true,则直接抛出 NoHandlerFoundException 异常,反之则会进一步获取到对应的请求处理器执行,并将执行结果返回给客户端。
到这,真相离我们非常近了,我们只需要将 throwExceptionIfNoHandlerFound 默认设置为 true 即可,这样就会抛出 NoHandlerFoundException 异常,从而被 doDispatch()内的 catch 俘获。进而就像案例1介绍的一样,最终能够执行我们自定义的异常处理器MyExceptionHandler。
于是,我们开始尝试,因为 throwExceptionIfNoHandlerFound 对应的 Spring 配置项为 throw-exception-if-no-handler-found,我们将其加入到 application.properties 配置文件中,设置其值为 true。
设置完毕后,重启服务并再次尝试,你会发现结果没有任何变化,这个问题也没有被解决。
实际上这里还存在另一个坑,在 Spring Web 的 WebMvcAutoConfiguration 类中,其默认添加的两个 ResourceHandler,一个是用来处理请求路径/webjars/* *,而另一个是/**。
即便当前请求没有定义任何对应的请求处理器,getHandler() 也一定会获取到一个 Handler 来处理当前请求,因为第二个匹配 /** 路径的 ResourceHandler 决定了任何请求路径都会被其处理。mappedHandler == null 判断条件永远不会成立,显然就不可能走到 noHandlerFound(),那么就不会抛出 NoHandlerFoundException 异常,也无法被后续的异常处理器进一步处理。
下面让我们通过源码进一步了解下这个默认被添加的 ResourceHandler 的详细逻辑 。
首先我们来了解下ControllerAdvice是如何被Spring加载并对外暴露的。
同样是在 WebMvcConfigurationSupport 类中,被 @Bean 修饰的 resourceHandlerMapping(),它新建了 ResourceHandlerRegistry 类实例,并通过 addResourceHandlers() 将 ResourceHandler 注册到 ResourceHandlerRegistry 类实例中:
@Bean
@Nullable
public HandlerMapping resourceHandlerMapping(
@Qualifier("mvcUrlPathHelper") UrlPathHelper urlPathHelper,
@Qualifier("mvcPathMatcher") PathMatcher pathMatcher,
@Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager,
@Qualifier("mvcConversionService") FormattingConversionService conversionService,
@Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider) {
Assert.state(this.applicationContext != null, "No ApplicationContext set");
Assert.state(this.servletContext != null, "No ServletContext set");
ResourceHandlerRegistry registry = new ResourceHandlerRegistry(this.applicationContext,
this.servletContext, contentNegotiationManager, urlPathHelper);
addResourceHandlers(registry);
AbstractHandlerMapping handlerMapping = registry.getHandlerMapping();
if (handlerMapping == null) {
return null;
}
handlerMapping.setPathMatcher(pathMatcher);
handlerMapping.setUrlPathHelper(urlPathHelper);
handlerMapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
handlerMapping.setCorsConfigurations(getCorsConfigurations());
return handlerMapping;
}
最终通过 ResourceHandlerRegistry 类实例中的 getHandlerMapping() 返回了 SimpleUrlHandlerMapping 实例,它装载了所有 ResourceHandler 的集合并注册到了 Spring 容器中:
protected AbstractHandlerMapping getHandlerMapping() {
//省略非关键代码
Map<String, HttpRequestHandler> urlMap = new LinkedHashMap<>();
for (ResourceHandlerRegistration registration : this.registrations) {
for (String pathPattern : registration.getPathPatterns()) {
ResourceHttpRequestHandler handler = registration.getRequestHandler();
//省略非关键代码
urlMap.put(pathPattern, handler);
}
}
return new SimpleUrlHandlerMapping(urlMap, this.order);
}
可以了解到,当前方法中的 addResourceHandlers() 最终执行到了 WebMvcAutoConfiguration 类中的 addResourceHandlers(),通过这个方法,我们可以知道当前有哪些 ResourceHandler 的集合被注册到了Spring容器中:
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (!this.resourceProperties.isAddMappings()) {
logger.debug("Default resource handling disabled");
return;
}
Duration cachePeriod = this.resourceProperties.getCache().getPeriod();
CacheControl cacheControl = this.resourceProperties.getCache().getCachecontrol().toHttpCacheControl();
if (!registry.hasMappingForPattern("/webjars/**")) {
customizeResourceHandlerRegistration(registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/")
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
}
String staticPathPattern = this.mvcProperties.getStaticPathPattern();
if (!registry.hasMappingForPattern(staticPathPattern)) {
customizeResourceHandlerRegistration(registry.addResourceHandler(staticPathPattern)
.addResourceLocations(getResourceLocations(this.resourceProperties.getStaticLocations()))
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
}
}
从而验证我们一开始得出的结论,此处添加了两个 ResourceHandler,一个是用来处理请求路径/webjars/* *, 而另一个是/**。
这里你可以注意一下方法最开始的判断语句,如果 this.resourceProperties.isAddMappings() 为 false,那么会直接返回,后续的两个 ResourceHandler 也不会被添加。
if (!this.resourceProperties.isAddMappings()) {
logger.debug("Default resource handling disabled");
return;
}
至此,有两个 ResourceHandler 被实例化且注册到了 Spirng 容器中,一个处理路径为/webjars/* * 的请求,另一个处理路径为 /**的请求 。
同样,当第一次请求发生时,DispatcherServlet 中的 initHandlerMappings() 将会获取所有注册到 Spring 的 HandlerMapping 类型的实例,而 SimpleUrlHandlerMapping 恰好实现了 HandlerMapping 接口,这些 SimpleUrlHandlerMapping 类型的实例则会被写入到类成员变量 handlerMappings 中。
private void initHandlerMappings(ApplicationContext context) {
this.handlerMappings = null;
//省略非关键代码
if (this.detectAllHandlerMappings) {
// Find all HandlerMappings in the ApplicationContext, including ancestor contexts.
Map<String, HandlerMapping> matchingBeans =
BeanFactoryUtils.beansOfTypeIncludingAncestors(context, HandlerMapping.class, true, false);
if (!matchingBeans.isEmpty()) {
this.handlerMappings = new ArrayList<>(matchingBeans.values());
// We keep HandlerMappings in sorted order.
AnnotationAwareOrderComparator.sort(this.handlerMappings);
}
}
//省略非关键代码
}
接着我们再来了解下被包装为 handlerMappings 的 ResourceHandler 是如何被 Spring 消费并处理的。
我们来回顾一下 DispatcherServlet 中的 doDispatch() 核心代码:
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
//省略非关键代码
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
//省略非关键代码
}
这里的 getHandler() 将会遍历成员变量 handlerMappings:
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
if (this.handlerMappings != null) {
for (HandlerMapping mapping : this.handlerMappings) {
HandlerExecutionChain handler = mapping.getHandler(request);
if (handler != null) {
return handler;
}
}
}
return null;
}
因为此处有一个 SimpleUrlHandlerMapping,它会拦截所有路径的请求:
所以最终在 doDispatch() 的 getHandler() 将会获取到此 handler,从而 mappedHandler==null 条件不能得到满足,因而无法走到 noHandlerFound(),不会抛出 NoHandlerFoundException 异常,进而无法被后续的异常处理器进一步处理。
WebMvcAutoConfiguration 类中 addResourceHandlers() 的前两行代码吗?如果 this.resourceProperties.isAddMappings() 为 false,那么此处直接返回,后续的两个 ResourceHandler 也不会被添加。
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (!this.resourceProperties.isAddMappings()) {
logger.debug("Default resource handling disabled");
return;
}
//省略非关键代码
}
其调用 ResourceProperties 中的 isAddMappings() 的代码如下:
public boolean isAddMappings() {
return this.addMappings;
}
解决
增加两个配置文件如下:
spring.resources.add-mappings=false
spring.mvc.throwExceptionIfNoHandlerFound=true
修改 MyExceptionHandler 的 @ExceptionHandler 为 NoHandlerFoundException 即可:
@ExceptionHandler(NoHandlerFoundException.class)
总结
- DispatcherServlet 类中的 doDispatch() 是整个 Servlet 处理的核心,它不仅实现了请求的分发,也提供了异常统一处理等等一系列功能;
- WebMvcConfigurationSupport 是 Spring Web 中非常核心的一个配置类,无论是异常处理器的包装注册(HandlerExceptionResolver),还是资源处理器的包装注册(SimpleUrlHandlerMapping),都是依靠这个类来完成的。
Spring Data
注意读与取的一致性
当使用 Spring Data Redis 时,我们有时候会在项目升级的过程中,发现存储后的数据有读取不到的情况;另外,还会出现解析出错的情况。这里我们不妨直接写出一个错误案例来模拟下:
@SpringBootApplication
public class SpringdataApplication {
SpringdataApplication(RedisTemplate redisTemplate,
StringRedisTemplate stringRedisTemplate){
String key = "mykey";
stringRedisTemplate.opsForValue().set(key, "myvalue");
Object valueGotFromStringRedisTemplate = stringRedisTemplate.opsForValue().get(key);
System.out.println(valueGotFromStringRedisTemplate);
Object valueGotFromRedisTemplate = redisTemplate.opsForValue().get(key);
System.out.println(valueGotFromRedisTemplate);
}
public static void main(String[] args) {
SpringApplication.run(SpringdataApplication.class, args);
}
}
使用了 Redis 提供的两种 Template,一种 RedisTemplate,一种 stringRedisTemplate。但是当我们使用后者去存一个数据后,你会发现使用前者是取不到对应的数据的。
原理
我们不可能直接将数据存取到 Redis 中,毕竟一些数据是一个对象型,例如 String,甚至是一些自定义对象。我们需要在存取前对数据进行序列化或者反序列化操作。
当带着key去存取数据时,它会执行 AbstractOperations#rawKey,使得在执行存储 key-value 到 Redis,或从 Redis 读取数据之前,对 key 进行序列化操作:
byte[] rawKey(Object key) {
Assert.notNull(key, "non null key required");
if (keySerializer() == null && key instanceof byte[]) {
return (byte[]) key;
}
return keySerializer().serialize(key);
}
从上述代码可以看出,假设存在 keySerializer,则利用它将 key 序列化。而对于 StringRedisSerializer 来说,它指定的其实是 StringRedisSerializer。具体实现如下:
public class StringRedisSerializer implements RedisSerializer<String> {
private final Charset charset;
@Override
public byte[] serialize(@Nullable String string) {
return (string == null ? null : string.getBytes(charset));
}
}
而如果我们使用的是 RedisTemplate,则使用的是 JDK 序列化:
public class JdkSerializationRedisSerializer implements RedisSerializer<Object> {
@Override
public byte[] serialize(@Nullable Object object) {
if (object == null) {
return SerializationUtils.EMPTY_ARRAY;
}
try {
return serializer.convert(object);
} catch (Exception ex) {
throw new SerializationException("Cannot serialize", ex);
}
}
}
很明显,上面对 key 的处理,采用的是 JDK 的序列化,最终它调用的方法如下:
public interface Serializer<T> {
void serialize(T var1, OutputStream var2) throws IOException;
default byte[] serializeToByteArray(T object) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
this.serialize(object, out);
return out.toByteArray();
}
}
你可以直接将"mykey"这个字符串分别用上面提到的两种序列化器进行序列化,你会发现它们的结果确实不同。这也就解释了为什么它们不能读取到"mykey"设置的"myvalue"。
至于它们是如何指定 RedisSerializer 的,我们可以以 StringRedisSerializer 为例简单看下。查看下面的代码,它是 StringRedisSerializer 的构造器,在构造器中,它直接指定了KeySerializer为 RedisSerializer.string():
public class StringRedisTemplate extends RedisTemplate<String, String> {
public StringRedisTemplate() {
setKeySerializer(RedisSerializer.string());
setValueSerializer(RedisSerializer.string());
setHashKeySerializer(RedisSerializer.string());
setHashValueSerializer(RedisSerializer.string());
}
}
其中 RedisSerializer.string()最终返回的实例如下:
public static final StringRedisSerializer UTF_8 = new StringRedisSerializer(StandardCharsets.UTF_8);
解决
是否使用了相同的 RedisTemplate,就是相同,也要检查所指定的各种Serializer是否完全一致,否则就会出现各式各样的错误。
Spring 事务
Spring 事务管理包含两种配置方式,第一种是使用 XML 进行模糊匹配,绑定事务管理;第二种是使用注解,这种方式可以对每个需要进行事务处理的方法进行单独配置,你只需要添加上@Transactional,然后在注解内添加属性配置即可。
Spring 在初始化时,会通过扫描拦截对事务的方法进行增强。如果目标方法存在事务,Spring 就会创建一个 Bean 对应的代理(Proxy)对象,并进行相关的事务处理操作。
unchecked 异常与事务回滚
@Data
public class Student implements Serializable {
private Integer id;
private String realname;
}
Student 对应的 Mapper 类定义如下:
@Mapper
public interface StudentMapper {
@Insert("INSERT INTO `student`(`realname`) VALUES (#{realname})")
void saveStudent(Student student);
}
对应数据库表的 Schema 如下:
CREATE TABLE `student` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`realname` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
业务类 StudentService,其中包括一个保存的方法 saveStudent。执行一下保存,一切正常。
测试一下这个事务会不会回滚,于是就写了这样一段逻辑:如果发现用户名是小明,就直接抛出异常,触发事务的回滚操作。
@Service
public class StudentService {
@Autowired
private StudentMapper studentMapper;
@Transactional
public void saveStudent(String realname) throws Exception {
Student student = new Student();
student.setRealname(realname);
studentMapper.saveStudent(student);
if (student.getRealname().equals("小明")) {
throw new Exception("该学生已存在");
}
}
}
然后使用下面的代码来测试一下,保存一个叫小明的学生,看会不会触发事务的回滚。
public class AppConfig {
public static void main(String[] args) throws Exception {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
StudentService studentService = (StudentService) context.getBean("studentService");
studentService.saveStudent("小明");
}
}
执行结果打印出了这样的信息:
Exception in thread "main" java.lang.Exception: 该学生已存在
at com.spring.puzzle.others.transaction.example1.StudentService.saveStudent(StudentService.java:23)
异常也抛了,回滚却没有如期而至
原理
我们通过 debug 沿着 saveStudent 继续往下跟,看到了熟悉的 CglibAopProxy,另外事务本质上也是一种特殊的切面,在创建的过程中,被 CglibAopProxy 代理。事务处理的拦截器是 TransactionInterceptor,它支撑着整个事务功能的架构。
首先,TransactionInterceptor 继承类 TransactionAspectSupport,实现了接口 MethodInterceptor。当执行代理类的目标方法时,会触发invoke()。当它 catch 到异常时,会调用 completeTransactionAfterThrowing 方法做进一步处理。
protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
//省略非关键代码
Object retVal;
try {
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
//省略非关键代码
}
在 completeTransactionAfterThrowing 的代码中,有这样一个方法 rollbackOn(),这是事务的回滚的关键判断条件。当这个条件满足时,会触发 rollback 操作,事务回滚。
protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
//省略非关键代码
//判断是否需要回滚
if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
try {
//执行回滚
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
}
catch (TransactionSystemException ex2) {
ex2.initApplicationException(ex);
throw ex2;
}
catch (RuntimeException | Error ex2) {
throw ex2;
}
}
//省略非关键代码
}
rollbackOn()其实包括了两个层级,具体可参考如下代码:
public boolean rollbackOn(Throwable ex) {
// 层级 1:根据"rollbackRules"及当前捕获异常来判断是否需要回滚
RollbackRuleAttribute winner = null;
int deepest = Integer.MAX_VALUE;
if (this.rollbackRules != null) {
for (RollbackRuleAttribute rule : this.rollbackRules) {
// 当前捕获的异常可能是回滚“异常”的继承体系中的“一员”
int depth = rule.getDepth(ex);
if (depth >= 0 && depth < deepest) {
deepest = depth;
winner = rule;
}
}
}
// 层级 2:调用父类的 rollbackOn 方法来决策是否需要 rollback
if (winner == null) {
return super.rollbackOn(ex);
}
return !(winner instanceof NoRollbackRuleAttribute);
}
- RuleBasedTransactionAttribute 自身的 rollbackOn()
当我们在 @Transactional 中配置了 rollbackFor,这个方法就会用捕获到的异常和 rollbackFor 中配置的异常做比较。如果捕获到的异常是 rollbackFor 配置的异常或其子类,就会直接 rollback。在我们的案例中,由于在事务的注解中没有加任何规则,所以这段逻辑处理其实找不到规则(即 winner == null),进而走到下一步。
- RuleBasedTransactionAttribute 父类 DefaultTransactionAttribute 的 rollbackOn()
如果没有在 @Transactional 中配置 rollback 属性,或是捕获到的异常和所配置异常的类型不一致,就会继续调用父类的 rollbackOn() 进行处理。
而在父类的 rollbackOn() 中,我们发现了一个重要的线索,只有在异常类型为 RuntimeException 或者 Error 的时候才会返回 true,此时,会触发 completeTransactionAfterThrowing 方法中的 rollback 操作,事务被回滚。
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
查到这里,真相大白,Spring 处理事务的时候,如果没有在 @Transactional 中配置 rollback 属性,那么只有捕获到 RuntimeException 或者 Error 的时候才会触发回滚操作。而我们案例抛出的异常是 Exception,又没有指定与之匹配的回滚规则,所以我们不能触发回滚。
解决
方式一
Spring 在处理事务过程中,并不会对 Exception 进行回滚,而会对 RuntimeException 或者 Error 进行回滚。只需要把抛出的异常类型改成 RuntimeException 就可以了。于是这部分代码就可以修改如下:
@Service
public class StudentService {
@Autowired
private StudentMapper studentMapper;
@Transactional
public void saveStudent(String realname) throws Exception {
Student student = new Student();
student.setRealname(realname);
studentMapper.saveStudent(student);
if (student.getRealname().equals("小明")) {
throw new RuntimeException("该用户已存在");
}
}
方式二
在解析 RuleBasedTransactionAttribute.rollbackOn 的代码时提到过 rollbackFor 属性的处理规则。也就是我们在 @Transactional 的 rollbackFor 加入需要支持的异常类型(在这里是 Exception)就可以匹配上我们抛出的异常,进而在异常抛出时进行回滚。 于是我们可以完善下案例中的注解,修改后代码如下:
@Transactional(rollbackFor = Exception.class)
试图给 private 方法添加事务
@Service
public class StudentService {
@Autowired
private StudentMapper studentMapper;
@Autowired
private StudentService studentService;
public void saveStudent(String realname) throws Exception {
Student student = new Student();
student.setRealname(realname);
studentService.doSaveStudent(student);
}
@Transactional
private void doSaveStudent(Student student) throws Exception {
studentMapper.saveStudent(student);
if (student.getRealname().equals("小明")) {
throw new RuntimeException("该用户已存在");
}
}
}
异常正常抛出,事务却没有回滚。
原理
通过 debug,我们一步步寻找到了问题的根源,得到了以下调用栈。我们通过 Spring 的源码来解析一下完整的过程。
前一段是 Spring 创建 Bean 的过程。当 Bean 初始化之后,开始尝试代理操作,这个过程是从 AbstractAutoProxyCreator 里的 postProcessAfterInitialization 方法开始处理的:
public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
if (bean != null) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
if (this.earlyProxyReferences.remove(cacheKey) != bean) {
return wrapIfNecessary(bean, beanName, cacheKey);
}
}
return bean;
}
我们一路往下找,暂且略过那些非关键要素的代码,直到到了 AopUtils 的 canApply 方法。这个方法就是针对切面定义里的条件,确定这个方法是否可以被应用创建成代理。其中有一段 methodMatcher.matches(method, targetClass) 是用来判断这个方法是否符合这样的条件:
public static boolean canApply(Pointcut pc, Class<?> targetClass, boolean hasIntroductions) {
//省略非关键代码
for (Class<?> clazz : classes) {
Method[] methods = ReflectionUtils.getAllDeclaredMethods(clazz);
for (Method method : methods) {
if (introductionAwareMethodMatcher != null ?
introductionAwareMethodMatcher.matches(method, targetClass, hasIntroductions) :
methodMatcher.matches(method, targetClass)) {
return true;
}
}
}
return false;
}
从 matches() 调用到了 AbstractFallbackTransactionAttributeSource 的 getTransactionAttribute:
public boolean matches(Method method, Class<?> targetClass) {
//省略非关键代码
TransactionAttributeSource tas = getTransactionAttributeSource();
return (tas == null || tas.getTransactionAttribute(method, targetClass) != null);
}
其中,getTransactionAttribute 这个方法是用来获取注解中的事务属性,根据属性确定事务采用什么样的策略。
public TransactionAttribute getTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
//省略非关键代码
TransactionAttribute txAttr = computeTransactionAttribute(method, targetClass);
//省略非关键代码
}
}
接着调用到 computeTransactionAttribute 这个方法,其主要功能是根据方法和类的类型确定是否返回事务属性,执行代码如下:
protected TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
//省略非关键代码
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null;
}
//省略非关键代码
}
这里有这样一个判断 allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers()) ,当这个判断结果为 true 的时候返回 null,也就意味着这个方法不会被代理,从而导致事务的注解不会生效。那此处的判断值到底是不是 true 呢?我们可以分别看一下。
条件1:allowPublicMethodsOnly()
allowPublicMethodsOnly 返回了 AnnotationTransactionAttributeSource 的 publicMethodsOnly 属性。
protected boolean allowPublicMethodsOnly() {
return this.publicMethodsOnly;
}
而这个 publicMethodsOnly 属性是通过 AnnotationTransactionAttributeSource 的构造方法初始化的,默认为 true。
public AnnotationTransactionAttributeSource() {
this(true);
}
条件2:Modifier.isPublic()
这个方法根据传入的 method.getModifiers() 获取方法的修饰符。该修饰符是 java.lang.reflect.Modifier 的静态属性,对应的几类修饰符分别是:PUBLIC: 1,PRIVATE: 2,PROTECTED: 4。这里面做了一个位运算,只有当传入的方法修饰符是 public 类型的时候,才返回 true。
public static boolean isPublic(int mod) {
return (mod & PUBLIC) != 0;
}
综合上述两个条件,你会发现,只有当注解为事务的方法被声明为 public 的时候,才会被 Spring 处理。
解决
修饰符从 private 改成 public 就可以了。
调用这个加了事务注解的方法,必须是调用被 Spring AOP 代理过的方法,也就是不能通过类的内部调用或者通过 this 的方式调用。所以我们的案例的StudentService,它含有一个自动装配(Autowired)了自身(StudentService)的实例来完成代理方法的调用。
@Service
public class StudentService {
@Autowired
private StudentMapper studentMapper;
@Autowired
private StudentService studentService;
public void saveStudent(String realname) throws Exception {
Student student = new Student();
student.setRealname(realname);
studentService.doSaveStudent(student);
}
@Transactional
public void doSaveStudent(Student student) throws Exception {
studentMapper.saveStudent(student);
if (student.getRealname().equals("小明")) {
throw new RuntimeException("该学生已存在");
}
}
}
嵌套事务回滚错误
@Service
public class CourseService {
@Autowired
private CourseMapper courseMapper;
@Autowired
private StudentCourseMapper studentCourseMapper;
//注意这个方法标记了“Transactional”
@Transactional(rollbackFor = Exception.class)
public void regCourse(int studentId) throws Exception {
studentCourseMapper.saveStudentCourse(studentId, 1);
courseMapper.addCourseNumber(1);
}
}
我们在之前的 StudentService.saveStudent() 中调用了 regCourse(),实现了完整的业务逻辑。为了避免注册课程的业务异常导致学生信息无法保存,在这里 catch 了注册课程方法中抛出的异常。我们希望的结果是,当注册课程发生错误时,只回滚注册课程部分,保证学生信息依然正常。
@Service
public class StudentService {
//省略非关键代码
@Transactional(rollbackFor = Exception.class)
public void saveStudent(String realname) throws Exception {
Student student = new Student();
student.setRealname(realname);
studentService.doSaveStudent(student);
try {
courseService.regCourse(student.getId());
} catch (Exception e) {
e.printStackTrace();
}
}
//省略非关键代码
}
为了验证异常是否符合预期,我们在 regCourse() 里抛出了一个注册失败的异常:
@Transactional(rollbackFor = Exception.class)
public void regCourse(int studentId) throws Exception {
studentCourseMapper.saveStudentCourse(studentId, 1);
courseMapper.addCourseNumber(1);
throw new Exception("注册失败");
}
运行一下这段代码,在控制台里我们看到了以下提示信息:
java.lang.Exception: 注册失败
at com.spring.puzzle.others.transaction.example3.CourseService.regCourse(CourseService.java:22)
//......省略非关键代码.....
Exception in thread "main" org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processRollback(AbstractPlatformTransactionManager.java:873)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:710)
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:533)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:304)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:98)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:688)
at com.spring.puzzle.others.transaction.example3.StudentService$$EnhancerBySpringCGLIB$$50cda404.saveStudent(<generated>)
at com.spring.puzzle.others.transaction.example3.AppConfig.main(AppConfig.java:22)
其中,注册失败部分的异常符合预期,但是后面又多了一个这样的错误提示:Transaction rolled back because it has been marked as rollback-only。
最后的结果是,学生和选课的信息都被回滚了,显然这并不符合我们的预期。
原理
在做进一步的解析之前,我们可以先通过伪代码把整个事务的结构梳理一下:
// 外层事务
@Transactional(rollbackFor = Exception.class)
public void saveStudent(String realname) throws Exception {
//......省略逻辑代码.....
studentService.doSaveStudent(student);
try {
// 嵌套的内层事务
@Transactional(rollbackFor = Exception.class)
public void regCourse(int studentId) throws Exception {
//......省略逻辑代码.....
}
} catch (Exception e) {
e.printStackTrace();
}
}
可以看出来,整个业务是包含了 2 层事务,外层的 saveStudent() 的事务和内层的 regCourse() 事务。
在 Spring 声明式的事务处理中,有一个属性 propagation,表示打算对这些方法怎么使用事务,即一个带事务的方法调用了另一个带事务的方法,被调用的方法它怎么处理自己事务和调用方法事务之间的关系。
其中 propagation 有7种配置:REQUIRED、SUPPORTS、MANDATORY、REQUIRES_NEW、NOT_SUPPORTED、NEVER、NESTED。默认是 REQUIRED,它的含义是:如果本来有事务,则加入该事务,如果没有事务,则创建新的事务。
结合我们的伪代码示例,因为在 saveStudent() 上声明了一个外部的事务,就已经存在一个事务了,在propagation值为默认的REQUIRED的情况下, regCourse() 就会加入到已有的事务中,两个方法共用一个事务。
我们再来看下 Spring 事务处理的核心,其关键实现参考TransactionAspectSupport.invokeWithinTransaction():
protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
final PlatformTransactionManager tm = determineTransactionManager(txAttr);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
// 是否需要创建一个事务
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
Object retVal = null;
try {
// 调用具体的业务方法
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// 当发生异常时进行处理
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
// 正常返回时提交事务
commitTransactionAfterReturning(txInfo);
return retVal;
}
//......省略非关键代码.....
}
整个方法完成了事务的一整套处理逻辑,如下:
- 检查是否需要创建事务;
- 调用具体的业务方法进行处理;
- 提交事务;
- 处理异常。
这里要格外注意的是,当前案例是两个事务嵌套的场景,外层事务 doSaveStudent()和内层事务 regCourse(),每个事务都会调用到这个方法。所以,这个方法会被调用两次。下面我们来具体来看下内层事务对异常的处理。
当捕获了异常,会调用TransactionAspectSupport.completeTransactionAfterThrowing() 进行异常处理:
protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
if (txInfo != null && txInfo.getTransactionStatus() != null) {
if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
try {
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
}
catch (TransactionSystemException ex2) {
logger.error("Application exception overridden by rollback exception", ex);
ex2.initApplicationException(ex);
throw ex2;
}
catch (RuntimeException | Error ex2) {
logger.error("Application exception overridden by rollback exception", ex);
throw ex2;
}
}
//......省略非关键代码.....
}
}
在这个方法里,我们对异常类型做了一些检查,当符合声明中的定义后,执行了具体的 rollback 操作,这个操作是通过 TransactionManager.rollback() 完成的:
public final void rollback(TransactionStatus status) throws TransactionException {
if (status.isCompleted()) {
throw new IllegalTransactionStateException(
"Transaction is already completed - do not call commit or rollback more than once per transaction");
}
DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
processRollback(defStatus, false);
}
而 rollback() 是在 AbstractPlatformTransactionManager 中实现的,继续调用了 processRollback():
private void processRollback(DefaultTransactionStatus status, boolean unexpected) {
try {
boolean unexpectedRollback = unexpected;
if (status.hasSavepoint()) {
// 有保存点
status.rollbackToHeldSavepoint();
}
else if (status.isNewTransaction()) {
// 是否为一个新的事务
doRollback(status);
}
else {
// 处于一个更大的事务中
if (status.hasTransaction()) {
// 分支1
if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()) {
doSetRollbackOnly(status);
}
}
if (!isFailEarlyOnGlobalRollbackOnly()) {
unexpectedRollback = false;
}
}
// 省略非关键代码
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction rolled back because it has been marked as rollback-only");
}
}
finally {
cleanupAfterCompletion(status);
}
}
这个方法里区分了三种不同类型的情况:
- 是否有保存点;
- 是否为一个新的事务;
- 是否处于一个更大的事务中。
在这里,因为我们用的是默认的传播类型REQUIRED,嵌套的事务并没有开启一个新的事务,所以在这种情况下,当前事务是处于一个更大的事务中,所以会走到情况3分支1的代码块下。
这里有两个判断条件来确定是否设置为仅回滚:
if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure())
满足任何一个,都会执行 doSetRollbackOnly() 操作。isLocalRollbackOnly 在当前的情况下是 false,所以是否分设置为仅回滚就由 isGlobalRollbackOnParticipationFailure() 这个方法来决定了,其默认值为 true, 即是否回滚交由外层事务统一决定 。
显然这里的条件得到了满足,从而执行 doSetRollbackOnly:
protected void doSetRollbackOnly(DefaultTransactionStatus status) {
DataSourceTransactionObject txObject = (DataSourceTransactionObject) status.getTransaction();
txObject.setRollbackOnly();
}
以及最终调用到的 DataSourceTransactionObject中的setRollbackOnly():
public void setRollbackOnly() {
getConnectionHolder().setRollbackOnly();
}
到这一步,内层事务的操作基本执行完毕,它处理了异常,并最终调用到了 DataSourceTransactionObject中的setRollbackOnly()。
接下来,我们来看外层事务。因为在外层事务中,我们自己的代码捕获了内层抛出来的异常,所以这个异常不会继续往上抛,最后的事务会在 TransactionAspectSupport.invokeWithinTransaction() 中的 commitTransactionAfterReturning() 中进行处理:
protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
if (txInfo != null && txInfo.getTransactionStatus() != null) { txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
}
在这个方法里我们执行了 commit 操作,代码如下:
public final void commit(TransactionStatus status) throws TransactionException {
//......省略非关键代码.....
if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
processRollback(defStatus, true);
return;
}
processCommit(defStatus);
}
在 AbstractPlatformTransactionManager.commit()中,当满足了 shouldCommitOnGlobalRollbackOnly() 和 defStatus.isGlobalRollbackOnly(),就会回滚,否则会继续提交事务。其中shouldCommitOnGlobalRollbackOnly()的作用为,如果发现了事务被标记了全局回滚,并且在发生了全局回滚的情况下,判断是否应该提交事务,这个方法的默认实现是返回了 false,这里我们不需要关注它,继续查看isGlobalRollbackOnly()的实现:
public boolean isGlobalRollbackOnly() {
return ((this.transaction instanceof SmartTransactionObject) &&
((SmartTransactionObject) this.transaction).isRollbackOnly());
}
这个方法最终进入了 DataSourceTransactionObject类中的isRollbackOnly():
public boolean isRollbackOnly() {
return getConnectionHolder().isRollbackOnly();
}
现在让我们再次回顾一下之前的内部事务处理结果,其最终调用到的是 DataSourceTransactionObject中的setRollbackOnly():
public void setRollbackOnly() {
getConnectionHolder().setRollbackOnly();
}
isRollbackOnly()和setRollbackOnly()这两个方法的执行本质都是对ConnectionHolder中rollbackOnly属性标志位的存取,而ConnectionHolder则存在于DefaultTransactionStatus类实例的transaction属性之中。
至此,答案基本浮出水面了,我们把整个逻辑串在一起就是:外层事务是否回滚的关键,最终取决于 DataSourceTransactionObject类中的isRollbackOnly(),而该方法的返回值,正是我们在内层异常的时候设置的。
所以最终外层事务也被回滚了,从而在控制台中打印出异常信息:"Transaction rolled back because it has been marked as rollback-only"。
所以到这里,问题也就清楚了,Spring默认的事务传播属性为REQUIRED,如我们之前介绍的,它的含义是:如果本来有事务,则加入该事务,如果没有事务,则创建新的事务,因而内外两层事务都处于同一个事务中。所以,当我们在 regCourse()中抛出异常,并触发了回滚操作时,这个回滚会进一步传播,从而把 saveStudent() 也回滚了。最终导致整个事务都被回滚了。
解决
Spring 在处理事务过程中,有个默认的传播属性 REQUIRED,在整个事务的调用链上,任何一个环节抛出的异常都会导致全局回滚。对传播属性进行修改,把类型改成 REQUIRES_NEW 就可以了。
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void regCourse(int studentId) throws Exception {
studentCourseMapper.saveStudentCourse(studentId, 1);
courseMapper.addCourseNumber(1);
throw new Exception("注册失败");
}
运行一下看看:
java.lang.Exception: 注册失败
at com.spring.puzzle.others.transaction.example3.CourseService.regCourse(CourseService.java:22)
异常正常抛出,注册课程部分的数据没有保存,但是学生还是正常注册成功。这意味着此时Spring 只对注册课程这部分的数据进行了回滚,并没有传播到上一级。
这里我简单解释下这个过程:
- 当子事务声明为 Propagation.REQUIRES_NEW 时,在 TransactionAspectSupport.invokeWithinTransaction() 中调用 createTransactionIfNecessary() 就会创建一个新的事务,独立于外层事务。
- 而在 AbstractPlatformTransactionManager.processRollback() 进行 rollback 处理时,因为 status.isNewTransaction() 会因为它处于一个新的事务中而返回 true,所以它走入到了另一个分支,执行了 doRollback() 操作,让这个子事务单独回滚,不会影响到主事务。
多数据源间切换之谜
在前面的案例中,我们完成了学生注册功能和课程注册功能。假设新需求又来了,每个学生注册的时候,需要给他们发一张校园卡,并给校园卡里充入 50 元钱。但是这个校园卡管理系统是一个第三方系统,使用的是另一套数据库,这样我们就需要在一个事务中同时操作两个数据库。
第三方的 Card 表如下:
CREATE TABLE `card` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`student_id` int(11) DEFAULT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
对应的 Card 对象如下:
public class Card {
private Integer id;
private Integer studentId;
private Integer balance;
//省略 Get/Set 方法
}
对应的 Mapper 接口如下,里面包含了一个 saveCard 的 insert 语句,用于创建一条校园卡记录:
@Mapper
public interface CardMapper {
@Insert("INSERT INTO `card`(`student_id`, `balance`) VALUES (#{studentId}, #{balance})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int saveCard(Card card);
}
Card 的业务类如下,里面实现了卡与学生 ID 关联,以及充入 50 元的操作:
@Service
public class CardService {
@Autowired
private CardMapper cardMapper;
@Transactional
public void createCard(int studentId) throws Exception {
Card card = new Card();
card.setStudentId(studentId);
card.setBalance(50);
cardMapper.saveCard(card);
}
}
原理
这是一个相对常见的需求,学生注册和发卡都要在一个事务里完成,但是我们都默认只会连一个数据源,之前我们一直连的都是学生信息这个数据源,在这里,我们还需要对校园卡的数据源进行操作。于是,我们需要在一个事务里完成对两个数据源的操作,该如何实现这样的功能呢?
我们继续从 Spring 的源码中寻找答案。在 Spring 里有这样一个抽象类 AbstractRoutingDataSource,这个类相当于 DataSource 的路由中介,在运行时根据某种 key 值来动态切换到所需的 DataSource 上。通过实现这个类就可以实现我们期望的动态数据源切换。
这里强调一下,这个类里有这么几个关键属性:
- targetDataSources 保存了 key 和数据库连接的映射关系;
- defaultTargetDataSource 标识默认的连接;
- resolvedDataSources 存储数据库标识和数据源的映射关系。
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
@Nullable
private Map<Object, Object> targetDataSources;
@Nullable
private Object defaultTargetDataSource;
private boolean lenientFallback = true;
private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();
@Nullable
private Map<Object, DataSource> resolvedDataSources;
@Nullable
private DataSource resolvedDefaultDataSource;
//省略非关键代码
}
AbstractRoutingDataSource 实现了 InitializingBean 接口,并覆写了 afterPropertiesSet()。该方法会在初始化 Bean 的时候执行,将多个 DataSource 初始化到 resolvedDataSources。这里的 targetDataSources 属性存储了将要切换的多数据源 Bean 信息。
@Override
public void afterPropertiesSet() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
this.resolvedDataSources = new HashMap<>(this.targetDataSources.size());
this.targetDataSources.forEach((key, value) -> {
Object lookupKey = resolveSpecifiedLookupKey(key);
DataSource dataSource = resolveSpecifiedDataSource(value);
this.resolvedDataSources.put(lookupKey, dataSource);
});
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
}
}
获取数据库连接的是 getConnection(),它调用了 determineTargetDataSource()来创建连接:
@Override
public Connection getConnection() throws SQLException {
return determineTargetDataSource().getConnection();
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
return determineTargetDataSource().getConnection(username, password);
}
determineTargetDataSource()是整个部分的核心,它的作用就是动态切换数据源。有多少个数据源,就存多少个数据源在 targetDataSources 中。
targetDataSources 是一个 Map 类型的属性,key 表示每个数据源的名字,value 对应的是每个数据源 DataSource。
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
Object lookupKey = determineCurrentLookupKey();
DataSource dataSource = this.resolvedDataSources.get(lookupKey);
if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
dataSource = this.resolvedDefaultDataSource;
}
if (dataSource == null) {
throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
}
return dataSource;
}
而选择哪个数据源又是由 determineCurrentLookupKey()来决定的,此方法是抽象方法,需要我们继承 AbstractRoutingDataSource 抽象类来重写此方法。该方法返回一个 key,该 key 是 Bean 中的 beanName,并赋值给 lookupKey,由此 key 可以通过 resolvedDataSources 属性的键来获取对应的 DataSource 值,从而达到数据源切换的效果。
protected abstract Object determineCurrentLookupKey();
这样看来,这个方法的实现就得由我们完成了。接下来我们将会完成一系列相关的代码,解决这个问题。
解决
首先,我们创建一个 MyDataSource 类,继承了 AbstractRoutingDataSource,并覆写了 determineCurrentLookupKey():
public class MyDataSource extends AbstractRoutingDataSource {
private static final ThreadLocal<String> key = new ThreadLocal<String>();
@Override
protected Object determineCurrentLookupKey() {
return key.get();
}
public static void setDataSource(String dataSource) {
key.set(dataSource);
}
public static String getDatasource() {
return key.get();
}
public static void clearDataSource() {
key.remove();
}
}
其次,我们需要修改 JdbcConfig。这里我新写了一个 dataSource,将原来的 dataSource 改成 dataSourceCore,再将新定义的 dataSourceCore 和 dataSourceCard 放进一个 Map,对应的 key 分别是 core 和 card,并把 Map 赋值给 setTargetDataSources
public class JdbcConfig {
//省略非关键代码
@Value("${card.driver}")
private String cardDriver;
@Value("${card.url}")
private String cardUrl;
@Value("${card.username}")
private String cardUsername;
@Value("${card.password}")
private String cardPassword;
@Autowired
@Qualifier("dataSourceCard")
private DataSource dataSourceCard;
@Autowired
@Qualifier("dataSourceCore")
private DataSource dataSourceCore;
//省略非关键代码
@Bean(name = "dataSourceCore")
public DataSource createCoreDataSource() {
DriverManagerDataSource ds = new DriverManagerDataSource();
ds.setDriverClassName(driver);
ds.setUrl(url);
ds.setUsername(username);
ds.setPassword(password);
return ds;
}
@Bean(name = "dataSourceCard")
public DataSource createCardDataSource() {
DriverManagerDataSource ds = new DriverManagerDataSource();
ds.setDriverClassName(cardDriver);
ds.setUrl(cardUrl);
ds.setUsername(cardUsername);
ds.setPassword(cardPassword);
return ds;
}
@Bean(name = "dataSource")
public MyDataSource createDataSource() {
MyDataSource myDataSource = new MyDataSource();
Map<Object, Object> map = new HashMap<>();
map.put("core", dataSourceCore);
map.put("card", dataSourceCard);
myDataSource.setTargetDataSources(map);
myDataSource.setDefaultTargetDataSource(dataSourceCore);
return myDataSource;
}
//省略非关键代码
}
最后还剩下一个问题,setDataSource 这个方法什么时候执行呢?
我们可以用 Spring AOP 来设置,把配置的数据源类型都设置成注解标签, Service层中在切换数据源的方法上加上注解标签,就会调用相应的方法切换数据源。
我们定义了一个新的注解 @DataSource,可以直接加在 Service()上,实现数据库切换:
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {
String value();
String core = "core";
String card = "card";
}
声明方法如下:
@DataSource(DataSource.card)
另外,我们还需要写一个 Spring AOP 来对相应的服务方法进行拦截,完成数据源的切换操作。特别要注意的是,这里要加上一个 @Order(1) 标记它的初始化顺序。这个 Order 值一定要比事务的 AOP 切面的值小,这样可以获得更高的优先级,否则自动切换数据源将会失效。
@Aspect
@Service
@Order(1)
public class DataSourceSwitch {
@Around("execution(* com.spring.puzzle.others.transaction.example3.CardService.*(..))")
public void around(ProceedingJoinPoint point) throws Throwable {
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
if (method.isAnnotationPresent(DataSource.class)) {
DataSource dataSource = method.getAnnotation(DataSource.class);
MyDataSource.setDataSource(dataSource.value());
System.out.println("数据源切换至:" + MyDataSource.getDatasource());
}
point.proceed();
MyDataSource.clearDataSource();
System.out.println("数据源已移除!");
}
}
最后,我们实现了 Card 的发卡逻辑,在方法前声明了切换数据库:
@Service
public class CardService {
@Autowired
private CardMapper cardMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
@DataSource(DataSource.card)
public void createCard(int studentId) throws Exception {
Card card = new Card();
card.setStudentId(studentId);
card.setBalance(50);
cardMapper.saveCard(card);
}
}
并在 saveStudent() 里调用了发卡逻辑:
@Transactional(rollbackFor = Exception.class)
public void saveStudent(String realname) throws Exception {
Student student = new Student();
student.setRealname(realname);
studentService.doSaveStudent(student);
try {
courseService.regCourse(student.getId());
cardService.createCard(student.getId());
} catch (Exception e) {
e.printStackTrace();
}
}
执行一下,一切正常,两个库的数据都可以正常保存了。
最后我们来看一下整个过程的调用栈,重新过一遍流程(这里我略去了不重要的部分)。

在创建了事务以后,会通过 DataSourceTransactionManager.doBegin()获取相应的数据库连接:
protected void doBegin(Object transaction, TransactionDefinition definition) {
DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
Connection con = null;
try {
if (!txObject.hasConnectionHolder() ||
txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
Connection newCon = obtainDataSource().getConnection();
txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
}
//省略非关键代码
}
这里的 obtainDataSource().getConnection() 调用到了 AbstractRoutingDataSource.getConnection(),这就与我们实现的功能顺利会师了。
public Connection getConnection() throws SQLException {
return determineTargetDataSource().getConnection();
}
总结
通过以上两个案例,相信你对 Spring 的声明式事务机制已经有了进一步的了解,最后总结下重点:
- Spring 支持声明式事务机制,它通过在方法上加上@Transactional,表明该方法需要事务支持。于是,在加载的时候,根据 @Transactional 中的属性,决定对该事务采取什么样的策略;
- @Transactional 对 private 方法不生效,所以我们应该把需要支持事务的方法声明为 public 类型;
- Spring 处理事务的时候,默认只对 RuntimeException 和 Error 回滚,不会对Exception 回滚,如果有特殊需要,需要额外声明,例如指明 Transactional 的属性 rollbackFor 为Exception.class。
- Spring 在事务处理中有一个很重要的属性 Propagation,主要用来配置当前需要执行的方法如何使用事务,以及与其它事务之间的关系。
- Spring 默认的传播属性是 REQUIRED,在有事务状态下执行,如果当前没有事务,则创建新的事务;
- Spring 事务是可以对多个数据源生效,它提供了一个抽象类 AbstractRoutingDataSource,通过实现这个抽象类,我们可以实现自定义的数据库切换。
Spring restTemplate
参数类型是 MultiValueMap
@RestController
public class HelloWorldController {
@RequestMapping(path = "hi", method = RequestMethod.POST)
public String hi(@RequestParam("para1") String para1, @RequestParam("para2") String para2){
return "helloworld:" + para1 + "," + para2;
};
}
这里我们想完成的功能是接受一个 Form 表单请求,读取表单定义的两个参数 para1 和 para2,然后作为响应返回给客户端。定义完这个接口后,我们使用 RestTemplate 来发送一个这样的表单请求,代码示例如下:
RestTemplate template = new RestTemplate();
Map<String, Object> paramMap = new HashMap<String, Object>();
paramMap.put("para1", "001");
paramMap.put("para2", "002");
String url = "http://localhost:8080/hi";
String result = template.postForObject(url, paramMap, String.class);
System.out.println(result);
上述代码定义了一个 Map,包含了 2 个表单参数,然后使用 RestTemplate 的 postForObject 提交这个表单。
测试后你会发现事与愿违,返回提示 400 错误,即请求出错:
具体而言,就是缺少 para1 表单参数。为什么会出现这个错误呢?我们提交的表单最后又成了什么?
原理
在具体解析这个问题之前,我们先来直观地了解下,当我们使用上述的 RestTemplate 提交表单,最后的提交请求长什么样?这里我使用 Wireshark 抓包工具直接给你抓取出来:

从上图可以看出,我们实际上是将定义的表单数据以 JSON 请求体(Body)的形式提交过去了,所以我们的接口处理自然取不到任何表单参数。
那么为什么会以 JSON 请求体来提交数据呢?这里我们不妨扫一眼 RestTemplate 中执行上述代码时的关键几处代码调用。
首先,我们看下上述代码的调用栈:

确实可以验证,我们最终使用的是 Jackson 工具来对表单进行了序列化。使用到 JSON 的关键之处在于其中的关键调用 RestTemplate.HttpEntityRequestCallback#doWithRequest:
public void doWithRequest(ClientHttpRequest httpRequest) throws IOException {
super.doWithRequest(httpRequest);
Object requestBody = this.requestEntity.getBody();
if (requestBody == null) {
//省略其他非关键代码
}
else {
Class<?> requestBodyClass = requestBody.getClass();
Type requestBodyType = (this.requestEntity instanceof RequestEntity ?
((RequestEntity<?>)this.requestEntity).getType() : requestBodyClass);
HttpHeaders httpHeaders = httpRequest.getHeaders();
HttpHeaders requestHeaders = this.requestEntity.getHeaders();
MediaType requestContentType = requestHeaders.getContentType();
for (HttpMessageConverter<?> messageConverter : getMessageConverters()) {
if (messageConverter instanceof GenericHttpMessageConverter) {
GenericHttpMessageConverter<Object> genericConverter =
(GenericHttpMessageConverter<Object>) messageConverter;
if (genericConverter.canWrite(requestBodyType, requestBodyClass, requestContentType)) {
if (!requestHeaders.isEmpty()) {
requestHeaders.forEach((key, values) -> httpHeaders.put(key, new LinkedList<>(values)));
}
logBody(requestBody, requestContentType, genericConverter);
genericConverter.write(requestBody, requestBodyType, requestContentType, httpRequest);
return;
}
}
else if (messageConverter.canWrite(requestBodyClass, requestContentType)) {
if (!requestHeaders.isEmpty()) {
requestHeaders.forEach((key, values) -> httpHeaders.put(key, new LinkedList<>(values)));
}
logBody(requestBody, requestContentType, messageConverter);
((HttpMessageConverter<Object>) messageConverter).write(
requestBody, requestContentType, httpRequest);
return;
}
}
String message = "No HttpMessageConverter for " + requestBodyClass.getName();
if (requestContentType != null) {
message += " and content type \"" + requestContentType + "\"";
}
throw new RestClientException(message);
}
}
上述代码看起来比较复杂,实际上功能很简单:根据当前要提交的 Body 内容,遍历当前支持的所有编解码器,如果找到合适的编解码器,就使用它来完成 Body 的转化。这里我们不妨看下 JSON 的编解码器对是否合适的判断,参考 AbstractJackson2HttpMessageConverter#canWrite:

可以看出,当我们使用的 Body 是一个 HashMap 时,是可以完成 JSON 序列化的。所以在后续将这个表单序列化为请求 Body 也就不奇怪了。
但是这里你可能会有一个疑问,为什么适应表单处理的编解码器不行呢?这里我们不妨继续看下对应的编解码器判断是否支持的实现,即 FormHttpMessageConverter#canWrite:
public boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType) {
if (!MultiValueMap.class.isAssignableFrom(clazz)) {
return false;
}
if (mediaType == null || MediaType.ALL.equals(mediaType)) {
return true;
}
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
if (supportedMediaType.isCompatibleWith(mediaType)) {
return true;
}
}
return false;
}
从上述代码可以看出,实际上,只有当我们发送的 Body 是 MultiValueMap 才能使用表单来提交。学到这里,你可能会豁然开朗。原来使用 RestTemplate 提交表单必须是 MultiValueMap,而我们案例定义的就是普通的 HashMap,最终是按请求 Body 的方式发送出去的。
解决
//错误:
//Map<String, Object> paramMap = new HashMap<String, Object>();
//paramMap.put("para1", "001");
//paramMap.put("para2", "002");
//修正代码:
MultiValueMap<String, Object> paramMap = new LinkedMultiValueMap<String, Object>();
paramMap.add("para1", "001");
paramMap.add("para2", "002");
最终你会发现,当完成上述修改后,表单数据最终使用下面的代码进行了编码,参考 FormHttpMessageConverter#write:
public void write(MultiValueMap<String, ?> map, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
if (isMultipart(map, contentType)) {
writeMultipart((MultiValueMap<String, Object>) map, contentType, outputMessage);
}
else {
writeForm((MultiValueMap<String, Object>) map, contentType, outputMessage);
}
}
这样就满足我们的需求了。实际上,假设你仔细看文档的话,你可能也会规避这个问题,文档关键行如下:
The body of the entity, or request itself, can be a MultiValueMap to create a multipart request. The values in the MultiValueMap can be any Object representing the body of the part, or an HttpEntity
当 URL 中含有特殊字符
@RestController
public class HelloWorldController {
@RequestMapping(path = "hi", method = RequestMethod.GET)
public String hi(@RequestParam("para1") String para1){
return "helloworld:" + para1;
};
}
String url = "http://localhost:8080/hi?para1=1#2";
HttpEntity<?> entity = new HttpEntity<>(null);
RestTemplate restTemplate = new RestTemplate();
HttpEntity<String> response = restTemplate.exchange(url, HttpMethod.GET,entity,String.class);
System.out.println(response.getBody());
helloworld:1#2
但是实际上,事与愿违,结果是:
helloworld:1
即服务器并不认为 #2 是 para1 的内容。如何理解这个现象呢?
原理
类似案例 1 解析的套路,在具体解析之前,我们可以先直观感受下问题出在什么地方。我们使用调试方式去查看解析后的 URL,截图如下:
可以看出,para1 丢掉的 #2 实际是以 Fragment 的方式被记录下来了。这里顺便科普下什么是 Fragment,这得追溯到 URL 的格式定义:
protocol://hostname[:port]/path/[?query]#fragment
本案例中涉及到的两个关键元素解释如下:
- Query(查询参数)
页面加载请求数据时需要的参数,用 & 符号隔开,每个参数的名和值用 = 符号隔开。
- Fragment(锚点)
开始,字符串,用于指定网络资源中的片断。例如一个网页中有多个名词解释,可使用 Fragment 直接定位到某一名词的解释。例如定位网页滚动的位置,可以参考下面一些使用示例:
http://example.com/data.csv#row=4 – Selects the 4th row.
http://example.com/data.csv#col=2 – Selects 2nd column.
了解了这些补充知识后,我们其实就能知道问题出在哪了。不过本着严谨的态度,我们还是翻阅下源码。首先,我们先看下 URL 解析的调用栈,示例如下:

参考上述调用栈,解析 URL 的关键点在于 UriComponentsBuilder#fromUriString 实现:
private static final Pattern URI_PATTERN = Pattern.compile(
"^(" + SCHEME_PATTERN + ")?" + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN +
")?" + ")?" + PATH_PATTERN + "(\\?" + QUERY_PATTERN + ")?" + "(#" + LAST_PATTERN + ")?");
public static UriComponentsBuilder fromUriString(String uri) {
Matcher matcher = URI_PATTERN.matcher(uri);
if (matcher.matches()) {
UriComponentsBuilder builder = new UriComponentsBuilder();
String scheme = matcher.group(2);
String userInfo = matcher.group(5);
String host = matcher.group(6);
String port = matcher.group(8);
String path = matcher.group(9);
String query = matcher.group(11);
String fragment = matcher.group(13);
//省略非关键代码
else {
builder.userInfo(userInfo);
builder.host(host);
if (StringUtils.hasLength(port)) {
builder.port(port);
}
builder.path(path);
builder.query(query);
}
if (StringUtils.hasText(fragment)) {
builder.fragment(fragment);
}
return builder;
}
else {
throw new IllegalArgumentException("[" + uri + "] is not a valid URI");
}
}
从上述代码实现中,我们可以看到关键的几句,这里我摘取了出来:
String query = matcher.group(11);
String fragment = matcher.group(13);
很明显,Query 和 Fragment 都有所处理。最终它们根据 URI_PATTERN 各自找到了相应的值 (1和2),虽然这并不符合我们的原始预期。
解决
String url = "http://localhost:8080/hi?para1=1#2";
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
URI uri = builder.build().encode().toUri();
HttpEntity<?> entity = new HttpEntity<>(null);
RestTemplate restTemplate = new RestTemplate();
HttpEntity<String> response = restTemplate.exchange(uri, HttpMethod.GET,entity,String.class);
System.out.println(response.getBody());
如果你想了解更多的话,还可以参考 UriComponentsBuilder#fromHttpUrl,并与之前使用的 UriComponentsBuilder#fromUriString 进行比较:
private static final Pattern HTTP_URL_PATTERN = Pattern.compile(
"^" + HTTP_PATTERN + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN + ")?" + ")?" +
PATH_PATTERN + "(\\?" + LAST_PATTERN + ")?")
public static UriComponentsBuilder fromHttpUrl(String httpUrl) {
Assert.notNull(httpUrl, "HTTP URL must not be null");
Matcher matcher = HTTP_URL_PATTERN.matcher(httpUrl);
if (matcher.matches()) {
UriComponentsBuilder builder = new UriComponentsBuilder();
String scheme = matcher.group(1);
builder.scheme(scheme != null ? scheme.toLowerCase() : null);
builder.userInfo(matcher.group(4));
String host = matcher.group(5);
if (StringUtils.hasLength(scheme) && !StringUtils.hasLength(host)) {
throw new IllegalArgumentException("[" + httpUrl + "] is not a valid HTTP URL");
}
builder.host(host);
String port = matcher.group(7);
if (StringUtils.hasLength(port)) {
builder.port(port);
}
builder.path(matcher.group(8));
builder.query(matcher.group(10));
return builder;
}
else {
throw new IllegalArgumentException("[" + httpUrl + "] is not a valid HTTP URL");
}
}
可以看出,这里只解析了Query并没有去尝试解析 Fragment,所以最终获取到的结果符合预期。
通过这个例子我们可以知道,当 URL 中含有特殊字符时,一定要注意 URL 的组装方式,尤其是要区别下面这两种方式:
UriComponentsBuilder#fromHttpUrl
UriComponentsBuilder#fromUriString
小心多次 URL Encoder
@RestController
public class HelloWorldController {
@RequestMapping(path = "hi", method = RequestMethod.GET)
public String hi(@RequestParam("para1") String para1){
return "helloworld:" + para1;
};
}
RestTemplate restTemplate = new RestTemplate();
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hi");
builder.queryParam("para1", "开发测试 001");
String url = builder.toUriString();
ResponseEntity<String> forEntity = restTemplate.getForEntity(url, String.class);
System.out.println(forEntity.getBody());
我们期待的结果是"helloworld:开发测试 001",但是运行上述代码后,你会发现结果却是下面这样:
helloworld:%E5%BC%80%E5%8F%91%E6%B5%8B%E8%AF%95001
解决
要了解这个案例,我们就需要对上述代码中关于 URL 的处理有个简单的了解。首先我们看下案例中的代码调用:
String url = builder.toUriString();
它执行的方式是 UriComponentsBuilder#toUriString:
public String toUriString() {
return this.uriVariables.isEmpty() ?
build().encode().toUriString() :
buildInternal(EncodingHint.ENCODE_TEMPLATE).toUriString();
}
可以看出,它最终执行了 URL Encode:
public final UriComponents encode() {
return encode(StandardCharsets.UTF_8);
}
查询调用栈,结果如下:
而当我们把 URL 转化成 String,再通过下面的语句来发送请求时:
//url 是一个 string
restTemplate.getForEntity(url, String.class);
我们会发现,它会再进行一次编码:

看到这里,你或许已经明白问题出在哪了,即我们按照案例的代码会执行 2 次编码(Encode),所以最终我们反而获取不到想要的结果了。
另外,我们还可以分别查看下两次编码后的结果,示例如下:
1 次编码后:

2 次编码后:

解决
RestTemplate restTemplate = new RestTemplate();
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hi");
builder.queryParam("para1", "开发测试 001");
URI url = builder.encode().build().toUri();
ResponseEntity<String> forEntity = restTemplate.getForEntity(url, String.class);
System.out.println(forEntity.getBody());
Spring Test
资源文件扫描不到
先来定义一个 Controller:
@RestController
public class HelloController {
@Autowired
HelloWorldService helloWorldService;
@RequestMapping(path = "hi", method = RequestMethod.GET)
public String hi() throws Exception{
return helloWorldService.toString() ;
};
}
public class HelloWorldService {
}
定义一个 spring.xml,在这个 XML 中定义 HelloWorldServic 的Bean,并把这个 spring.xml 文件放置在/src/main/resources 中:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="helloWorldService" class="com.spring.puzzle.others.test.example1.HelloWorldService">
</bean>
</beans>
定义一个 Configuration 引入上述定义 XML,具体实现方式如下:
@Configuration
@ImportResource(locations = {"spring.xml"})
public class Config {
}
完成上述步骤后,我们就可以使用 main() 启动起来。测试这个接口,一切符合预期。那么接下来,我们来写一个测试:
@SpringBootTest()
class ApplicationTests {
@Autowired
public HelloController helloController;
@Test
public void testController() throws Exception {
String response = helloController.hi();
Assert.notNull(response, "not null");
}
}
当我们运行上述测试的时候,会发现测试失败了,为什么单独运行应用程序没有问题,但是运行测试就不行了呢?我们需要研究一下 Spring 的源码,来找找答案。
原理
在了解这个问题的根本原因之前,我们先从调试的角度来对比下启动程序和测试加载spring.xml的不同之处。
- 启动程序加载spring.xml
首先看下调用栈:

可以看出,它最终以 ClassPathResource 形式来加载,这个资源的情况如下:

而具体到加载实现,它使用的是 ClassPathResource#getInputStream 来加载spring.xml文件:

从上述调用及代码实现,可以看出最终是可以加载成功的。
- 测试加载spring.xml
首先看下调用栈:

可以看出它是按 ServletContextResource 来加载的,这个资源的情况如下:

具体到实现,它最终使用的是 MockServletContext#getResourceAsStream 来加载文件:
@Nullable
public InputStream getResourceAsStream(String path) {
String resourceLocation = this.getResourceLocation(path);
Resource resource = null;
try {
resource = this.resourceLoader.getResource(resourceLocation);
return !resource.exists() ? null : resource.getInputStream();
} catch (IOException | InvalidPathException var5) {
if (this.logger.isWarnEnabled()) {
this.logger.warn("Could not open InputStream for resource " + (resource != null ? resource : resourceLocation), var5);
}
return null;
}
}
你可以继续跟踪它的加载位置相关代码,即 getResourceLocation():
protected String getResourceLocation(String path) {
if (!path.startsWith("/")) {
path = "/" + path;
}
//加上前缀:/src/main/resources
String resourceLocation = this.getResourceBasePathLocation(path);
if (this.exists(resourceLocation)) {
return resourceLocation;
} else {
//{"classpath:META-INF/resources", "classpath:resources", "classpath:static", "classpath:public"};
String[] var3 = SPRING_BOOT_RESOURCE_LOCATIONS;
int var4 = var3.length;
for(int var5 = 0; var5 < var4; ++var5) {
String prefix = var3[var5];
resourceLocation = prefix + path;
if (this.exists(resourceLocation)) {
return resourceLocation;
}
}
return super.getResourceLocation(path);
}
}
你会发现,它尝试从下面的一些位置进行加载:
classpath:META-INF/resources
classpath:resources
classpath:static
classpath:public
src/main/webapp
如果你仔细看这些目录,你还会发现,这些目录都没有spring.xml。或许你认为源文件src/main/resource下面不是有一个 spring.xml 么?那上述位置中的classpath:resources不就能加载了么?
那你肯定是忽略了一点:当程序运行起来后,src/main/resource 下的文件最终是不带什么resource的。关于这点,你可以直接查看编译后的目录(本地编译后是 target\classes 目录),示例如下:

所以,最终我们在所有的目录中都找不到spring.xml,并且会报错提示加载不了文件。报错的地方位于 ServletContextResource#getInputStream 中:
@Override
public InputStream getInputStream() throws IOException {
InputStream is = this.servletContext.getResourceAsStream(this.path);
if (is == null) {
throw new FileNotFoundException("Could not open " + getDescription());
}
return is;
}
解决
- 在加载目录上放置 spring.xml。加载目录有很多,所以修正方式也不少,我们可以建立一个 src/main/webapp,然后把 spring.xml 复制一份进去就可以了。也可以在/src/main/resources 下面再建立一个 resources 目录,然后放置进去也可以。
- 在 @ImportResource 使用classpath加载方式
@Configuration
//@ImportResource(locations = {"spring.xml"})
@ImportResource(locations = {"classpath:spring.xml"})
public class Config {
}
很明显,我们一般都不会使用本案例的方式(即locations = {"spring.xml"},无任何“前缀”的方式),毕竟它已经依赖于使用的 ApplicationContext。而 classPath 更为普适些,而一旦你按上述方式修正后,你会发现它加载的资源已经不再是 ServletContextResource,而是和应用程序一样的 ClassPathResource,这样自然可以加载到了。
容易出错的Mock
接下来,我们再来看一个非功能性的错误案例。有时候,我们会发现 Spring Test 运行起来非常缓慢,寻根溯源之后,你会发现主要是因为很多测试都启动了Spring Context,那么为什么有的测试会多次启动 Spring Context?我们先在 Spring Boot 程序中写几个被测试类:
@Service
public class ServiceOne {
}
@Service
public class ServiceTwo {
}
然后分别写出对应的测试类:
@SpringBootTest()
class ServiceOneTests {
@MockBean
ServiceOne serviceOne;
@Test
public void test(){
System.out.println(serviceOne);
}
}
@SpringBootTest()
class ServiceTwoTests {
@MockBean
ServiceTwo serviceTwo;
@Test
public void test(){
System.out.println(serviceTwo);
}
}
在上述测试类中,我们都使用了@MockBean。写完这些程序,批量运行测试,你会发现Spring Context 果然会被运行多次。那么如何理解这个现象,是错误还是符合预期?
原理
当我们运行一个测试的时候,正常情况是不会重新创建一个 Spring Context 的。这是因为 Spring Test 使用了 Context 的缓存以避免重复创建 Context。那么这个缓存是怎么维护的呢?我们可以通过DefaultCacheAwareContextLoaderDelegate#loadContext来看下 Context 的获取和缓存逻辑:
public ApplicationContext loadContext(MergedContextConfiguration mergedContextConfiguration) {
synchronized(this.contextCache) {
ApplicationContext context = this.contextCache.get(mergedContextConfiguration);
if (context == null) {
try {
context = this.loadContextInternal(mergedContextConfiguration);
//省略非关键代码
this.contextCache.put(mergedContextConfiguration, context);
} catch (Exception var6) {
//省略非关键代码
}
} else if (logger.isDebugEnabled()) {
//省略非关键代码
}
this.contextCache.logStatistics();
return context;
}
}
从上述代码可以看出,缓存的 Key 是 MergedContextConfiguration。所以一个测试要不要启动一个新的 Context,就取决于根据这个测试 Class 构建的 MergedContextConfiguration 是否相同。而是否相同取决于它的 hashCode() 实现:
public int hashCode() {
int result = Arrays.hashCode(this.locations);
result = 31 * result + Arrays.hashCode(this.classes);
result = 31 * result + this.contextInitializerClasses.hashCode();
result = 31 * result + Arrays.hashCode(this.activeProfiles);
result = 31 * result + Arrays.hashCode(this.propertySourceLocations);
result = 31 * result + Arrays.hashCode(this.propertySourceProperties);
result = 31 * result + this.contextCustomizers.hashCode();
result = 31 * result + (this.parent != null ? this.parent.hashCode() : 0);
result = 31 * result + nullSafeClassName(this.contextLoader).hashCode();
return result;
}
从上述方法,你可以看出只要上述元素中的任何一个不同都会导致一个 Context 会重新创建出来。关于这个缓存机制和 Key 的关键因素你可以参考 Spring 的官方文档,也有所提及,这里我直接给出了链接,你可以对照着去阅读。
现在回到本案例,为什么会创建一个新的 Context 而不是复用?根源在于两个测试的contextCustomizers这个元素的不同。如果你不信的话,你可以调试并对比下。
ServiceOneTests 的 MergedContextConfiguration 示例如下:

ServiceTwoTests 的 MergedContextConfiguration 示例如下:

很明显,MergedContextConfiguration(即 Context Cache 的 Key)的 ContextCustomizer 是不同的,所以 Context 没有共享起来。而追溯到 ContextCustomizer 的创建,我们可以具体来看下。
当我们运行一个测试(testClass)时,我们会使用 MockitoContextCustomizerFactory#createContextCustomizer 来创建一个 ContextCustomizer,代码示例如下:
class MockitoContextCustomizerFactory implements ContextCustomizerFactory {
MockitoContextCustomizerFactory() {
}
public ContextCustomizer createContextCustomizer(Class<?> testClass, List<ContextConfigurationAttributes> configAttributes) {
DefinitionsParser parser = new DefinitionsParser();
parser.parse(testClass);
return new MockitoContextCustomizer(parser.getDefinitions());
}
}
创建的过程是由 DefinitionsParser 来解析这个测试 Class(例如案例中的 ServiceOneTests),如果这个测试 Class 中包含了 MockBean 或者 SpyBean 标记的情况,则将对应标记的情况转化为 MockDefinition,最终添加到 ContextCustomizer 中。解析的过程参考 DefinitionsParser#parse:
void parse(Class<?> source) {
this.parseElement(source);
ReflectionUtils.doWithFields(source, this::parseElement);
}
private void parseElement(AnnotatedElement element) {
MergedAnnotations annotations = MergedAnnotations.from(element, SearchStrategy.SUPERCLASS);
//MockBean 处理 annotations.stream(MockBean.class).map(MergedAnnotation::synthesize).forEach((annotation) -> {
this.parseMockBeanAnnotation(annotation, element);
});
//SpyBean 处理 annotations.stream(SpyBean.class).map(MergedAnnotation::synthesize).forEach((annotation) -> {
this.parseSpyBeanAnnotation(annotation, element);
});
}
private void parseMockBeanAnnotation(MockBean annotation, AnnotatedElement element) {
Set<ResolvableType> typesToMock = this.getOrDeduceTypes(element, annotation.value());
//省略非关键代码
Iterator var4 = typesToMock.iterator();
while(var4.hasNext()) {
ResolvableType typeToMock = (ResolvableType)var4.next();
MockDefinition definition = new MockDefinition(annotation.name(), typeToMock, annotation.extraInterfaces(), annotation.answer(), annotation.serializable(), annotation.reset(), QualifierDefinition.forElement(element));
//添加到 DefinitionsParser#definitions
this.addDefinition(element, definition, "mock");
}
}
那说了这么多,Spring Context 重新创建的根本原因还是在于使用了@MockBean 且不同,从而导致构建的 MergedContextConfiguration 不同,而 MergedContextConfiguration 正是作为 Cache 的 Key,Key 不同,Context 不能被复用,所以被重新创建了。这就是为什么在案例介绍部分,你会看到多次 Spring Context 的启动过程。而正因为“重启”,测试速度变缓慢了。
解决
你会发现其实这种缓慢的根源是使用了@MockBean 带来的一个正常现象。但是假设你非要去提速下,那么你可以尝试使用 Mockito 去手工实现类似的功能。当然你也可以尝试使用下面的方式来解决,即把相关的 MockBean 都定义到一个地方去。例如针对本案例,修正方案如下:
public class ServiceTests {
@MockBean
ServiceOne serviceOne;
@MockBean
ServiceTwo serviceTwo;
}
@SpringBootTest()
class ServiceOneTests extends ServiceTests{
@Test
public void test(){
System.out.println(serviceOne);
}
}
@SpringBootTest()
class ServiceTwoTests extends ServiceTests{
@Test
public void test(){
System.out.println(serviceTwo);
}
}
重新运行测试,你会发现 Context 只会被创建一次,速度也有所提升了。相信,你也明白这么改能工作的原因了,现在每个测试对应的 Context 缓存 Key 已经相同了。
posted on 2025-10-14 23:46 chuchengzhi 阅读(28) 评论(0) 收藏 举报
浙公网安备 33010602011771号