This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
<audio id="audio" title="01Spring Bean 定义常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dd/02/dd84yyf22495ed8ed1de64ae94eaf402.mp3"></audio>
你好,我是傅健。
从导读中我们已知Spring 的核心是围绕 Bean 进行的。不管是 Spring Boot 还是 Spring Cloud只要名称中带有Spring关键字的技术都脱离不了 Bean而要使用一个 Bean 少不了要先定义出来,所以**定义一个Bean 就变得格外重要了**。
当然对于这么重要的工作Spring 自然给我们提供了很多简单易用的方式。然而,这种简单易用得益于 Spring 的“**约定大于配置**”,但我们往往不见得会对所有的约定都了然于胸,所以仍然会在 Bean 的定义上犯一些经典的错误。
接下来我们就来了解下那些经典错误以及它们背后的原理,你也可以对照着去看看自己是否也曾犯过,后来又是如何解决的。
## 案例 1隐式扫描不到 Bean 的定义
在构建 Web 服务时,我们常使用 Spring Boot 来快速构建。例如,使用下面的包结构和相关代码来完成一个简易的 Web 版 HelloWorld
<img src="https://static001.geekbang.org/resource/image/63/48/63f7d08fb89653e12b9946c4dca31c48.png" alt="">
其中,负责启动程序的 Application 类定义如下:
```
package com.spring.puzzle.class1.example1.application
//省略 import
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
```
提供接口的 HelloWorldController 代码如下:
```
package com.spring.puzzle.class1.example1.application
//省略 import
@RestController
public class HelloWorldController {
@RequestMapping(path = &quot;hi&quot;, method = RequestMethod.GET)
public String hi(){
return &quot;helloworld&quot;;
};
}
```
上述代码即可实现一个简单的功能:访问[http://localhost:8080/hi](http://localhost:8080/hi) 返回helloworld。两个关键类位于同一个包即 application中。其中 HelloWorldController 因为添加了@RestController,最终被识别成一个 Controller 的 Bean。
但是,假设有一天,当我们需要添加多个类似的 Controller同时又希望用更清晰的包层次和结构来管理时我们可能会去单独建立一个独立于 application 包之外的 Controller 包,并调整类的位置。调整后结构示意如下:
<img src="https://static001.geekbang.org/resource/image/f6/30/f6080f4e2b10e7f54e79040b8362c230.png" alt="">
实际上,我们没有改变任何代码,只是改变了包的结构,但是我们会发现这个 Web 应用失效了,即不能识别出 HelloWorldController 了。也就是说,我们找不到 HelloWorldController 这个 Bean 了。这是为何?
### 案例解析
要了解 HelloWorldController 为什么会失效,就需要先了解之前是如何生效的。对于 Spring Boot 而言,关键点在于 Application.java 中使用了 SpringBootApplication 注解。而这个注解继承了另外一些注解,具体定义如下:
```
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
//省略非关键代码
}
```
从定义可以看出SpringBootApplication开启了很多功能其中一个关键功能就是 ComponentScan参考其配置如下
>
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class)
当 Spring Boot 启动时ComponentScan 的启用意味着会去扫描出所有定义的 Bean那么扫描什么位置呢这是由 ComponentScan 注解的 basePackages 属性指定的,具体可参考如下定义:
```
public @interface ComponentScan {
/**
* Base packages to scan for annotated components.
* &lt;p&gt;{@link #value} is an alias for (and mutually exclusive with) this
* attribute.
* &lt;p&gt;Use {@link #basePackageClasses} for a type-safe alternative to
* String-based package names.
*/
@AliasFor(&quot;value&quot;)
String[] basePackages() default {};
//省略其他非关键代码
}
```
而在我们的案例中,我们直接使用的是 SpringBootApplication 注解定义的 ComponentScan它的 basePackages 没有指定,所以默认为空(即{})。此时扫描的是什么包?这里不妨带着这个问题去调试下(调试位置参考 ComponentScanAnnotationParser#parse 方法),调试视图如下:
<img src="https://static001.geekbang.org/resource/image/75/c6/75a8abe6d5854f4f4a8d6c9a5655f3c6.png" alt="">
从上图可以看出,当 basePackages 为空时,扫描的包会是 declaringClass 所在的包在本案例中declaringClass 就是 Application.class所以扫描的包其实就是它所在的包即com.spring.puzzle.class1.example1.application。
对比我们重组包结构前后我们自然就找到了这个问题的根源在调整前HelloWorldController 在扫描范围内,而调整后,它已经远离了扫描范围(不和 Application.java 一个包了),虽然代码没有一丝丝改变,但是这个功能已经失效了。
所以,综合来看,这个问题是因为我们不够了解 Spring Boot 的默认扫描规则引起的。我们仅仅享受了它的便捷,但是并未了解它背后的故事,所以稍作变化,就可能玩不转了。
### 问题修正
针对这个案例,有了源码的剖析,我们可以快速找到解决方案了。当然了,我们所谓的解决方案肯定不是说把 HelloWorldController 移动回原来的位置,而是**真正去满足需求**。在这里,真正解决问题的方式是显式配置@ComponentScan。具体修改方式如下:
```
@SpringBootApplication
@ComponentScan(&quot;com.spring.puzzle.class1.example1.controller&quot;)
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
```
通过上述修改我们显式指定了扫描的范围为com.spring.puzzle.class1.example1.controller。不过需要注意的是显式指定后默认的扫描范围即com.spring.puzzle.class1.example1.application就不会被添加进去了。另外我们也可以使用@ComponentScans 来修复问题,使用方式如下:
>
@ComponentScans(value = { @ComponentScan(value = "com.spring.puzzle.class1.example1.controller") })
顾名思义,可以看出 ComponentScans 相比较 ComponentScan 多了一个s支持多个包的扫描范围指定。
此时,细心的你可能会发现:如果对源码缺乏了解,很容易会顾此失彼。以 ComponentScan 为例,原有的代码扫描了默认包而忽略了其它包;而**一旦显式指定其它包,原来的默认扫描包就被忽略了。**
## 案例 2定义的 Bean 缺少隐式依赖
初学 Spring 时,我们往往不能快速转化思维。例如,在程序开发过程中,有时候,一方面我们把一个类定义成 Bean同时又觉得这个 Bean 的定义除了加了一些 Spring 注解外,并没有什么不同。所以在后续使用时,有时候我们会不假思索地去随意定义它,例如我们会写出下面这样的代码:
```
@Service
public class ServiceImpl {
private String serviceName;
public ServiceImpl(String serviceName){
this.serviceName = serviceName;
}
}
```
ServiceImpl 因为标记为@Service而成为一个 Bean。另外我们ServiceImpl 显式定义了一个构造器。但是,上面的代码不是永远都能正确运行的,有时候会报下面这种错误:
>
Parameter 0 of constructor in com.spring.puzzle.class1.example2.ServiceImpl required a bean of type 'java.lang.String' that could not be found.
那这种错误是怎么发生的呢?下面我们来分析一下。
### 案例解析
当创建一个 Bean 时,调用的方法是 AbstractAutowireCapableBeanFactory#createBeanInstance。它主要包含两大基本步骤:寻找构造器和通过反射调用构造器创建实例。对于这个案例,最核心的代码执行,你可以参考下面的代码片段:
```
// Candidate constructors for autowiring?
Constructor&lt;?&gt;[] ctors = determineConstructorsFromBeanPostProcessors(beanClass, beanName);
if (ctors != null || mbd.getResolvedAutowireMode() == AUTOWIRE_CONSTRUCTOR ||
mbd.hasConstructorArgumentValues() || !ObjectUtils.isEmpty(args)) {
return autowireConstructor(beanName, mbd, ctors, args);
}
```
Spring 会先执行 determineConstructorsFromBeanPostProcessors 方法来获取构造器,然后通过 autowireConstructor 方法带着构造器去创建实例。很明显,在本案例中只有一个构造器,所以非常容易跟踪这个问题。
autowireConstructor 方法要创建实例,不仅需要知道是哪个构造器,还需要知道构造器对应的参数,这点从最后创建实例的方法名也可以看出,参考如下(即 ConstructorResolver#instantiate
```
private Object instantiate(
String beanName, RootBeanDefinition mbd, Constructor&lt;?&gt; constructorToUse, Object[] argsToUse)
```
那么上述方法中存储构造参数的 argsToUse 如何获取呢换言之当我们已经知道构造器ServiceImpl(String serviceName),要创建出 ServiceImpl 实例,如何确定 serviceName 的值是多少?
很明显,这里是在使用 Spring我们**不能直接显式使用 new 关键字来创建实例**。Spring只能是去寻找依赖来作为构造器调用参数。
那么这个参数如何获取呢?可以参考下面的代码片段(即 ConstructorResolver#autowireConstructor
```
argsHolder = createArgumentArray(beanName, mbd, resolvedValues, bw, paramTypes, paramNames,
getUserDeclaredConstructor(candidate), autowiring, candidates.length == 1);
```
我们可以调用 createArgumentArray 方法来构建调用构造器的参数数组,而这个方法的最终实现是从 BeanFactory 中获取 Bean可以参考下述调用
```
return this.beanFactory.resolveDependency(
new DependencyDescriptor(param, true), beanName, autowiredBeanNames, typeConverter);
```
如果用调试视图,我们则可以看到更多的信息:
<img src="https://static001.geekbang.org/resource/image/51/a3/5113cfc71ec8dab37e254c5c5e9abba3.png" alt="">
如图所示,上述的调用即是根据参数来寻找对应的 Bean在本案例中如果找不到对应的 Bean 就会抛出异常,提示装配失败。
### 问题修正
从源码级别了解了错误的原因后,现在反思为什么会出现这个错误。追根溯源,正如开头所述,因为不了解很多隐式的规则:我们定义一个类为 Bean如果再显式定义了构造器那么这个 Bean 在构建时,会自动根据构造器参数定义寻找对应的 Bean然后反射创建出这个 Bean。
了解了这个隐式规则后,解决这个问题就简单多了。我们可以直接定义一个能让 Spring 装配给 ServiceImpl 构造器参数的 Bean例如定义如下
```
//这个bean装配给ServiceImpl的构造器参数“serviceName”
@Bean
public String serviceName(){
return &quot;MyServiceName&quot;;
}
```
再次运行程序,发现一切正常了。
所以,我们在使用 Spring 时,**不要总想着定义的Bean 也可以在非 Spring 场合直接用 new 关键字显式使用,这种思路是不可取的**。
另外,类似的,假设我们不了解 Spring 的隐式规则,在修正问题后,我们可能写出更多看似可以运行的程序,代码如下:
```
@Service
public class ServiceImpl {
private String serviceName;
public ServiceImpl(String serviceName){
this.serviceName = serviceName;
}
public ServiceImpl(String serviceName, String otherStringParameter){
this.serviceName = serviceName;
}
}
```
如果我们仍用非 Spring 的思维去审阅这段代码,可能不会觉得有什么问题,毕竟 String 类型可以自动装配了,无非就是增加了一个 String 类型的参数而已。
但是如果你了解 Spring 内部是用反射来构建 Bean 的话,就不难发现问题所在:存在两个构造器,都可以调用时,到底应该调用哪个呢?最终 Spring 无从选择,只能尝试去调用默认构造器,而这个默认构造器又不存在,所以测试这个程序它会出错。
## 案例 3原型 Bean 被固定
接下来,我们再来看另外一个关于 Bean 定义不生效的案例。在定义 Bean 时,有时候我们会使用原型 Bean例如定义如下
```
@Service
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ServiceImpl {
}
```
然后我们按照下面的方式去使用它:
```
@RestController
public class HelloWorldController {
@Autowired
private ServiceImpl serviceImpl;
@RequestMapping(path = &quot;hi&quot;, method = RequestMethod.GET)
public String hi(){
return &quot;helloworld, service is : &quot; + serviceImpl;
};
}
```
结果,我们会发现,不管我们访问多少次[http://localhost:8080/hi](http://localhost:8080/hi),访问的结果都是不变的,如下:
>
helloworld, service is : com.spring.puzzle.class1.example3.error.ServiceImpl@4908af
很明显,这很可能和我们定义 ServiceImpl 为原型 Bean 的初衷背道而驰,如何理解这个现象呢?
### 案例解析
当一个属性成员 serviceImpl 声明为@Autowired 后,那么在创建 HelloWorldController 这个 Bean 时,会先使用构造器反射出实例,然后来装配各个标记为@Autowired 的属性成员(装配方法参考 AbstractAutowireCapableBeanFactory#populateBean)。
具体到执行过程,它会使用很多 BeanPostProcessor 来做完成工作,其中一种是 AutowiredAnnotationBeanPostProcessor它会通过 DefaultListableBeanFactory#findAutowireCandidates 寻找到 ServiceImpl 类型的 Bean然后设置给对应的属性即 serviceImpl成员
关键执行步骤可参考 AutowiredAnnotationBeanPostProcessor.AutowiredFieldElement#inject
```
protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {
Field field = (Field) this.member;
Object value;
//寻找“bean”
if (this.cached) {
value = resolvedCachedArgument(beanName, this.cachedFieldValue);
}
else {
//省略其他非关键代码
value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter);
}
if (value != null) {
//将bean设置给成员字段
ReflectionUtils.makeAccessible(field);
field.set(bean, value);
}
}
```
待我们寻找到要自动注入的 Bean 后即可通过反射设置给对应的field。这个field的执行只发生了一次所以后续就固定起来了它并不会因为 ServiceImpl 标记了 SCOPE_PROTOTYPE 而改变。
所以,**当一个单例的Bean使用 autowired 注解标记其属性时,你一定要注意这个属性值会被固定下来。**
### 问题修正
通过上述源码分析,我们可以知道要修正这个问题,肯定是不能将 ServiceImpl 的 Bean 固定到属性上的,而应该是每次使用时都会重新获取一次。所以这里我提供了两种修正方式:
**1. 自动注入 Context**
即自动注入 ApplicationContext然后定义 getServiceImpl() 方法,在方法中获取一个新的 ServiceImpl 类型实例。修正代码如下:
```
@RestController
public class HelloWorldController {
@Autowired
private ApplicationContext applicationContext;
@RequestMapping(path = &quot;hi&quot;, method = RequestMethod.GET)
public String hi(){
return &quot;helloworld, service is : &quot; + getServiceImpl();
};
public ServiceImpl getServiceImpl(){
return applicationContext.getBean(ServiceImpl.class);
}
}
```
**2. 使用 Lookup 注解**
类似修正方法 1也添加一个 getServiceImpl 方法,不过这个方法是被 Lookup 标记的。修正代码如下:
```
@RestController
public class HelloWorldController {
@RequestMapping(path = &quot;hi&quot;, method = RequestMethod.GET)
public String hi(){
return &quot;helloworld, service is : &quot; + getServiceImpl();
};
@Lookup
public ServiceImpl getServiceImpl(){
return null;
}
}
```
通过这两种修正方式,再次测试程序,我们会发现结果已经符合预期(每次访问这个接口,都会创建新的 Bean
这里我们不妨再拓展下,讨论下 Lookup 是如何生效的。毕竟在修正代码中我们看到getServiceImpl方法的实现返回值是 null这或许很难说服自己。
首先,我们可以通过调试方式看下方法的执行,参考下图:
<img src="https://static001.geekbang.org/resource/image/3d/91/3d0e125b9d9e0711489d3a6aeff88c91.png" alt="">
从上图我们可以看出,我们最终的执行因为标记了 Lookup 而走入了 CglibSubclassingInstantiationStrategy.LookupOverrideMethodInterceptor这个方法的关键实现参考 LookupOverrideMethodInterceptor#intercept
```
private final BeanFactory owner;
public Object intercept(Object obj, Method method, Object[] args, MethodProxy mp) throws Throwable {
LookupOverride lo = (LookupOverride) getBeanDefinition().getMethodOverrides().getOverride(method);
Assert.state(lo != null, &quot;LookupOverride not found&quot;);
Object[] argsToUse = (args.length &gt; 0 ? args : null); // if no-arg, don't insist on args at all
if (StringUtils.hasText(lo.getBeanName())) {
return (argsToUse != null ? this.owner.getBean(lo.getBeanName(), argsToUse) :
this.owner.getBean(lo.getBeanName()));
}
else {
return (argsToUse != null ? this.owner.getBean(method.getReturnType(), argsToUse) :
this.owner.getBean(method.getReturnType()));
}
}
```
我们的方法调用最终并没有走入案例代码实现的return null语句而是通过 BeanFactory 来获取 Bean。所以从这点也可以看出其实**在我们的 getServiceImpl 方法实现中,随便怎么写都行,这不太重要。**
例如,我们可以使用下面的实现来测试下这个结论:
```
@Lookup
public ServiceImpl getServiceImpl(){
//下面的日志会输出么?
log.info(&quot;executing this method&quot;);
return null;
}
```
以上代码,添加了一行代码输出日志。测试后,我们会发现并没有日志输出。这也验证了,当使用 Lookup 注解一个方法时,这个方法的具体实现已并不重要。
再回溯下前面的分析为什么我们走入了CGLIB 搞出的类,这是因为我们有方法标记了 Lookup。我们可以从下面的这段代码得到验证参考 SimpleInstantiationStrategy#instantiate
```
@Override
public Object instantiate(RootBeanDefinition bd, @Nullable String beanName, BeanFactory owner) {
// Don't override the class with CGLIB if no overrides.
if (!bd.hasMethodOverrides()) {
//
return BeanUtils.instantiateClass(constructorToUse);
}
else {
// Must generate CGLIB subclass.
return instantiateWithMethodInjection(bd, beanName, owner);
}
}
```
在上述代码中,当 hasMethodOverrides 为 true 时,则使用 CGLIB。而在本案例中这个条件的成立在于解析HelloWorldController 这个 Bean 时,我们会发现有方法标记了 Lookup此时就会添加相应方法到属性methodOverrides 里面去(此过程由 AutowiredAnnotationBeanPostProcessor#determineCandidateConstructors 完成)。
添加后效果图如下:
<img src="https://static001.geekbang.org/resource/image/bc/f0/bc917a82f62e8686a3c4eca64f89yyf0.png" alt="">
以上即为 Lookup 的一些关键实现思路。还有很多细节例如CGLIB子类如何产生无法一一解释有兴趣的话可以进一步深入研究留言区等你。
## 重点回顾
这节课我们介绍了3个关于Bean定义的经典错误并分析了其背后原理。
不难发现要使用好Spring就**一定要了解它的一些潜规则**例如默认扫描Bean的范围、自动装配构造器等等。如果我们不了解这些规则大多情况下虽然也能工作但是稍微变化则可能完全失效例如在案例1中我们也只是把Controller从一个包移动到另外一个包接口就失效了。
另外,通过这三个案例的分析,我们也能感受到**Spring的很多实现是通过反射来完成的**了解了这点对于理解它的源码实现会大有帮助。例如在案例2中为什么定义了多个构造器就可能报错因为使用反射方式来创建实例必须要明确使用的是哪一个构造器。
最后我想说在Spring框架中解决问题的方式往往有多种不要拘泥于套路。就像案例3使用ApplicationContext和Lookup注解都能解决原型 Bean 被固定的问题一样。
## 思考题
在案例 2 中,显示定义构造器,这会发生根据构造器参数寻找对应 Bean 的行为。这里请你思考一个问题,假设寻找不到对应的 Bean一定会如案例 2 那样直接报错么?
尝试解决一下,我们留言区见!

View File

@@ -0,0 +1,486 @@
<audio id="audio" title="02Spring Bean 依赖注入常见错误(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/d7/d895b3b7fd9bb04343689dc1e3ff3fd7.mp3"></audio>
你好,我是傅健,这节课我们来聊聊 Spring @Autowired
提及Spring的优势或特性我们都会立马想起“**控制反转、依赖注入**”这八字真言。而@Autowired正是用来支持依赖注入的核心利器之一。表面上看,它仅仅是一个注解,在使用上不应该出错。但是,在实际使用中,我们仍然会出现各式各样的错误,而且都堪称经典。所以这节课我就带着你学习下这些经典错误及其背后的原因,以防患于未然。
## 案例1过多赠予无所适从
在使用@Autowired时不管你是菜鸟级还是专家级的Spring使用者都应该制造或者遭遇过类似的错误
>
required a single bean, but 2 were found
顾名思义我们仅需要一个Bean但实际却提供了2个这里的“2”在实际错误中可能是其它大于1的任何数字
为了重现这个错误我们可以先写一个案例来模拟下。假设我们在开发一个学籍管理系统案例需要提供一个API根据学生的学号ID来移除学生学生的信息维护肯定需要一个数据库来支撑所以大体上可以实现如下
```
@RestController
@Slf4j
@Validated
public class StudentController {
@Autowired
DataService dataService;
@RequestMapping(path = &quot;students/{id}&quot;, method = RequestMethod.DELETE)
public void deleteStudent(@PathVariable(&quot;id&quot;) @Range(min = 1,max = 100) int id){
dataService.deleteStudent(id);
};
}
```
其中DataService是一个接口其实现依托于Oracle代码示意如下
```
public interface DataService {
void deleteStudent(int id);
}
@Repository
@Slf4j
public class OracleDataService implements DataService{
@Override
public void deleteStudent(int id) {
log.info(&quot;delete student info maintained by oracle&quot;);
}
}
```
截止目前运行并测试程序是毫无问题的。但是需求往往是源源不断的某天我们可能接到节约成本的需求希望把一些部分非核心的业务从Oracle迁移到社区版Cassandra所以我们自然会先添加上一个新的DataService实现代码如下
```
@Repository
@Slf4j
public class CassandraDataService implements DataService{
@Override
public void deleteStudent(int id) {
log.info(&quot;delete student info maintained by cassandra&quot;);
}
}
```
实际上,当我们完成支持多个数据库的准备工作时,程序就已经无法启动了,报错如下:
<img src="https://static001.geekbang.org/resource/image/80/36/80b14cf13b383e48f64f7052d6747836.png" alt="">
很显然,上述报错信息正是我们这一小节讨论的错误,那么这个错误到底是怎么产生的呢?接下来我们具体分析下。
### 案例解析
要找到这个问题的根源,我们就需要对@Autowired实现的依赖注入的原理有一定的了解。首先,我们先来了解下 @Autowired 发生的位置和核心过程。
当一个Bean被构建时核心包括两个基本步骤
1. 执行AbstractAutowireCapableBeanFactory#createBeanInstance方法通过构造器反射构造出这个Bean在此案例中相当于构建出StudentController的实例
1. 执行AbstractAutowireCapableBeanFactory#populate方法填充即设置这个Bean在本案例中相当于设置StudentController实例中被@Autowired标记的dataService属性成员
在步骤2中“填充”过程的关键就是执行各种BeanPostProcessor处理器关键代码如下
```
protected void populateBean(String beanName, RootBeanDefinition mbd, @Nullable BeanWrapper bw) {
//省略非关键代码
for (BeanPostProcessor bp : getBeanPostProcessors()) {
if (bp instanceof InstantiationAwareBeanPostProcessor) {
InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp;
PropertyValues pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName);
//省略非关键代码
}
}
}
}
```
在上述代码执行过程中因为StudentController含有标记为Autowired的成员属性dataService所以会使用到AutowiredAnnotationBeanPostProcessorBeanPostProcessor中的一种来完成“装配”过程找出合适的DataService的bean并设置给StudentController#dataService。如果深究这个装配过程,又可以细分为两个步骤:
1. 寻找出所有需要依赖注入的字段和方法参考AutowiredAnnotationBeanPostProcessor#postProcessProperties中的代码行
```
InjectionMetadata metadata = findAutowiringMetadata(beanName, bean.getClass(), pvs);
```
1. 根据依赖信息寻找出依赖并完成注入以字段注入为例参考AutowiredFieldElement#inject方法
```
@Override
protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {
Field field = (Field) this.member;
Object value;
//省略非关键代码
try {
DependencyDescriptor desc = new DependencyDescriptor(field, this.required);
//寻找“依赖”desc为&quot;dataService&quot;的DependencyDescriptor
value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter);
}
}
//省略非关键代码
if (value != null) {
ReflectionUtils.makeAccessible(field);
//装配“依赖”
field.set(bean, value);
}
}
```
说到这里,我们基本了解了@Autowired过程发生的位置和过程。而且很明显我们案例中的错误就发生在上述“寻找依赖”的过程中上述代码的第9行那么到底是怎么发生的呢我们可以继续刨根问底。
为了更清晰地展示错误发生的位置我们可以采用调试的视角展示其位置即DefaultListableBeanFactory#doResolveDependency中代码片段),参考下图:
<img src="https://static001.geekbang.org/resource/image/4c/f9/4cb99e17967847995bfe1d7ec0fe75f9.png" alt="">
如上图所示当我们根据DataService这个类型来找出依赖时我们会找出2个依赖分别为CassandraDataService和OracleDataService。在这样的情况下如果同时满足以下两个条件则会抛出本案例的错误
1. 调用determineAutowireCandidate方法来选出优先级最高的依赖但是发现并没有优先级可依据。具体选择过程可参考DefaultListableBeanFactory#determineAutowireCandidate
```
protected String determineAutowireCandidate(Map&lt;String, Object&gt; candidates, DependencyDescriptor descriptor) {
Class&lt;?&gt; requiredType = descriptor.getDependencyType();
String primaryCandidate = determinePrimaryCandidate(candidates, requiredType);
if (primaryCandidate != null) {
return primaryCandidate;
}
String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType);
if (priorityCandidate != null) {
return priorityCandidate;
}
// Fallback
for (Map.Entry&lt;String, Object&gt; entry : candidates.entrySet()) {
String candidateName = entry.getKey();
Object beanInstance = entry.getValue();
if ((beanInstance != null &amp;&amp; this.resolvableDependencies.containsValue(beanInstance)) ||
matchesBeanName(candidateName, descriptor.getDependencyName())) {
return candidateName;
}
}
return null;
}
```
如代码所示,优先级的决策是先根据@Primary来决策,其次是@Priority决策最后是根据Bean名字的严格匹配来决策。如果这些帮助决策优先级的注解都没有被使用名字也不精确匹配则返回null告知无法决策出哪种最合适。
1. @Autowired要求是必须注入的即required保持默认值为true或者注解的属性类型并不是可以接受多个Bean的类型例如数组、Map、集合。这点可以参考DefaultListableBeanFactory#indicatesMultipleBeans的实现
```
private boolean indicatesMultipleBeans(Class&lt;?&gt; type) {
return (type.isArray() || (type.isInterface() &amp;&amp;
(Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type))));
}
```
对比上述两个条件和我们的案例,很明显,案例程序能满足这些条件,所以报错并不奇怪。而如果我们把这些条件想得简单点,或许更容易帮助我们去理解这个设计。就像我们遭遇多个无法比较优劣的选择,却必须选择其一时,与其偷偷地随便选择一种,还不如直接报错,起码可以避免更严重的问题发生。
### 问题修正
针对这个案例,有了源码的剖析,我们可以很快找到解决问题的方法:**打破上述两个条件中的任何一个即可,即让候选项具有优先级或压根可以不去选择。**不过需要你注意的是,不是每一种条件的打破都满足实际需求,例如我们可以通过使用标记@Primary的方式来让被标记的候选者有更高优先级,从而避免报错,但是它并不一定符合业务需求,这就好比我们本身需要两种数据库都能使用,而不是顾此失彼。
```
@Repository
@Primary
@Slf4j
public class OracleDataService implements DataService{
//省略非关键代码
}
```
现在请你仔细研读上述的两个条件要同时支持多种DataService且能在不同业务情景下精确匹配到要选择到的DataService我们可以使用下面的方式去修改
```
@Autowired
DataService oracleDataService;
```
如代码所示修改方式的精髓在于将属性名和Bean名字精确匹配这样就可以让注入选择不犯难需要Oracle时指定属性名为oracleDataService需要Cassandra时则指定属性名为cassandraDataService。
## 案例 2显式引用Bean时首字母忽略大小写
针对案例1的问题修正实际上还存在另外一种常用的解决办法即采用@Qualifier来显式指定引用的是那种服务,例如采用下面的方式:
```
@Autowired()
@Qualifier(&quot;cassandraDataService&quot;)
DataService dataService;
```
这种方式之所以能解决问题在于它能让寻找出的Bean只有一个即精确匹配所以压根不会出现后面的决策过程可以参考DefaultListableBeanFactory#doResolveDependency
```
@Nullable
public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
@Nullable Set&lt;String&gt; autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {
//省略其他非关键代码
//寻找bean过程
Map&lt;String, Object&gt; matchingBeans = findAutowireCandidates(beanName, type, descriptor);
if (matchingBeans.isEmpty()) {
if (isRequired(descriptor)) {
raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);
}
return null;
}
//省略其他非关键代码
if (matchingBeans.size() &gt; 1) {
//省略多个bean的决策过程即案例1重点介绍内容
}
//省略其他非关键代码
}
```
我们会使用@Qualifier指定的名称去匹配,最终只找到了唯一一个。
不过在使用@Qualifier时我们有时候会犯另一个经典的小错误就是我们可能会忽略Bean的名称首字母大小写。这里我们把校正后的案例稍稍变形如下
```
@Autowired
@Qualifier(&quot;CassandraDataService&quot;)
DataService dataService;
```
运行程序,我们会报错如下:
>
Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'studentController': Unsatisfied dependency expressed through field 'dataService'; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.spring.puzzle.class2.example2.DataService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true), @org.springframework.beans.factory.annotation.Qualifier(value=CassandraDataService)}
这里我们很容易得出一个结论:**对于Bean的名字如果没有显式指明就应该是类名不过首字母应该小写。**但是这个轻松得出的结论成立么?
不妨再测试下假设我们需要支持SQLite这种数据库我们定义了一个命名为SQLiteDataService的实现然后借鉴之前的经验我们很容易使用下面的代码来引用这个实现
```
@Autowired
@Qualifier(&quot;sQLiteDataService&quot;)
DataService dataService;
```
满怀信心运行完上面的程序依然会出现之前的错误而如果改成SQLiteDataService则运行通过了。这和之前的结论又矛盾了。所以显式引用Bean时首字母到底是大写还是小写呢
### 案例解析
对于这种错误的报错位置其实我们正好在本案例的开头就贴出了即第二段代码清单的第9行
```
raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);
```
即当因为名称问题例如引用Bean首字母搞错了找不到Bean时会直接抛出NoSuchBeanDefinitionException。
在这里我们真正需要关心的问题是不显式设置名字的Bean其默认名称首字母到底是大写还是小写呢
看案例的话当我们启动基于Spring Boot的应用程序时会自动扫描我们的Package以找出直接或间接标记了@Component的Bean的定义即BeanDefinition。例如CassandraDataService、SQLiteDataService都被标记了@Repository而Repository本身被@Component标记,所以它们都是间接标记了@Component
一旦找出这些Bean的信息就可以生成这些Bean的名字然后组合成一个个BeanDefinitionHolder返回给上层。这个过程关键步骤可以查看下图的代码片段ClassPathBeanDefinitionScanner#doScan
<img src="https://static001.geekbang.org/resource/image/27/49/277f3b2421a0e173a0eee56b7d65f849.png" alt="">
基本匹配我们前面描述的过程其中方法调用BeanNameGenerator#generateBeanName即用来产生Bean的名字它有两种实现方式。因为DataService的实现都是使用注解标记的所以Bean名称的生成逻辑最终调用的其实是AnnotationBeanNameGenerator#generateBeanName这种实现方式,我们可以看下它的具体实现,代码如下:
```
@Override
public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) {
if (definition instanceof AnnotatedBeanDefinition) {
String beanName = determineBeanNameFromAnnotation((AnnotatedBeanDefinition) definition);
if (StringUtils.hasText(beanName)) {
// Explicit bean name found.
return beanName;
}
}
// Fallback: generate a unique default bean name.
return buildDefaultBeanName(definition, registry);
}
```
大体流程只有两步看Bean有没有显式指明名称如果有则用显式名称如果没有则产生一个默认名称。很明显在我们的案例中是没有给Bean指定名字的所以产生的Bean的名称就是生成的默认名称查看默认名的产生方法buildDefaultBeanName其实现如下
```
protected String buildDefaultBeanName(BeanDefinition definition) {
String beanClassName = definition.getBeanClassName();
Assert.state(beanClassName != null, &quot;No bean class name set&quot;);
String shortClassName = ClassUtils.getShortName(beanClassName);
return Introspector.decapitalize(shortClassName);
}
```
首先获取一个简短的ClassName然后调用Introspector#decapitalize方法,设置首字母大写或小写,具体参考下面的代码实现:
```
public static String decapitalize(String name) {
if (name == null || name.length() == 0) {
return name;
}
if (name.length() &gt; 1 &amp;&amp; Character.isUpperCase(name.charAt(1)) &amp;&amp;
Character.isUpperCase(name.charAt(0))){
return name;
}
char chars[] = name.toCharArray();
chars[0] = Character.toLowerCase(chars[0]);
return new String(chars);
}
```
到这,我们很轻松地明白了前面两个问题出现的原因:**如果一个类名是以两个大写字母开头的,则首字母不变,其它情况下默认首字母变成小写。**结合我们之前的案例SQLiteDataService的Bean其名称应该就是类名本身而CassandraDataService的Bean名称则变成了首字母小写cassandraDataService
### 问题修正
现在我们已经从源码级别了解了Bean名字产生的规则就可以很轻松地修正案例中的两个错误了。以引用CassandraDataService类型的Bean的错误修正为例可以采用下面这两种修改方式
1. 引用处纠正首字母大小写问题:
```
@Autowired
@Qualifier(&quot;cassandraDataService&quot;)
DataService dataService;
```
1. 定义处显式指定Bean名字我们可以保持引用代码不变而通过显式指明CassandraDataService 的Bean名称为CassandraDataService来纠正这个问题。
```
@Repository(&quot;CassandraDataService&quot;)
@Slf4j
public class CassandraDataService implements DataService {
//省略实现
}
```
现在我们的程序就可以精确匹配到要找的Bean了。比较一下这两种修改方法的话如果你不太了解源码不想纠结于首字母到底是大写还是小写建议你用第二种方法去避免困扰。
## 案例 3引用内部类的Bean遗忘类名
解决完案例2是不是就意味着我们能搞定所有Bean的显式引用不再犯错了呢天真了。我们可以沿用上面的案例稍微再添加点别的需求例如我们需要定义一个内部类来实现一种新的DataService代码如下
```
public class StudentController {
@Repository
public static class InnerClassDataService implements DataService{
@Override
public void deleteStudent(int id) {
//空实现
}
}
//省略其他非关键代码
}
```
遇到这种情况我们一般都会很自然地用下面的方式直接去显式引用这个Bean
```
@Autowired
@Qualifier(&quot;innerClassDataService&quot;)
DataService innerClassDataService;
```
很明显有了案例2的经验我们上来就直接采用了**首字母小写**以避免案例2中的错误但这样的代码是不是就没问题了呢实际上仍然会报错“找不到Bean”这是为什么
### 案例解析
实际上我们遭遇的情况是“如何引用内部类的Bean”。解析案例2的时候我曾经贴出了如何产生默认Bean名的方法即AnnotationBeanNameGenerator#buildDefaultBeanName当时我们只关注了首字母是否小写的代码片段而在最后变换首字母之前有一行语句是对class名字的处理代码如下
>
String shortClassName = ClassUtils.getShortName(beanClassName);
我们可以看下它的实现参考ClassUtils#getShortName方法
```
public static String getShortName(String className) {
Assert.hasLength(className, &quot;Class name must not be empty&quot;);
int lastDotIndex = className.lastIndexOf(PACKAGE_SEPARATOR);
int nameEndIndex = className.indexOf(CGLIB_CLASS_SEPARATOR);
if (nameEndIndex == -1) {
nameEndIndex = className.length();
}
String shortName = className.substring(lastDotIndex + 1, nameEndIndex);
shortName = shortName.replace(INNER_CLASS_SEPARATOR, PACKAGE_SEPARATOR);
return shortName;
}
```
很明显,假设我们是一个内部类,例如下面的类名:
>
com.spring.puzzle.class2.example3.StudentController.InnerClassDataService
在经过这个方法的处理后,我们得到的其实是下面这个名称:
>
StudentController.InnerClassDataService
最后经过Introspector.decapitalize的首字母变换最终获取的Bean名称如下
>
studentController.InnerClassDataService
所以我们在案例程序中,直接使用 innerClassDataService 自然找不到想要的Bean。
### 问题修正
通过案例解析我们很快就找到了这个内部类Bean的引用问题顺手就修正了如下
```
@Autowired
@Qualifier(&quot;studentController.InnerClassDataService&quot;)
DataService innerClassDataService;
```
这个引用看起来有些许奇怪,但实际上是可以工作的,反而直接使用 innerClassDataService 来引用倒是真的不可行。
通过这个案例我们可以看出,**对源码的学习是否全面决定了我们以后犯错的可能性大小。**如果我们在学习案例2时就对class名称的变化部分的源码进行了学习那么这种错误是不容易犯的。不过有时候我们确实很难一上来就把学习开展的全面而深入总是需要时间和错误去锤炼的。
## 重点回顾
看完这三个案例我们会发现这些错误的直接结果都是找不到合适的Bean但是原因却不尽相同。例如案例1是因为提供的Bean过多又无法决策选择谁案例2和案例3是因为指定的名称不规范导致引用的Bean找不到。
实际上这些错误在一些“聪明的”IDE会被提示出来但是它们在其它一些不太智能的主流IDE中并不能被告警出来。不过悲剧的是即使聪明的IDE也存在误报的情况所以**完全依赖IDE是不靠谱的**,毕竟这些错误都能编译过去。
另外我们的案例都是一些简化的场景很容易看出和发现问题而真实的场景往往复杂得多。例如对于案例1我们的同种类型的实现可能不是同时出现在自己的项目代码中而是有部分实现出现在依赖的Jar库中。所以你一定要对案例背后的源码实现有一个扎实的了解这样才能在复杂场景中去规避这些问题。
## 思考题
我们知道了通过@Qualifier可以引用想匹配的Bean也可以直接命名属性的名称为Bean的名称来引用这两种方式如下
```
//方式1属性命名为要装配的bean名称
@Autowired
DataService oracleDataService;
//方式2使用@Qualifier直接引用
@Autowired
@Qualifier(&quot;oracleDataService&quot;)
DataService dataService;
```
那么对于案例3的内部类引用你觉得可以使用第1种方式做到么例如使用如下代码
>
<p>@Autowired<br>
DataService studentController.InnerClassDataService;</p>
期待在留言区看到你的答案,我们下节课见!

View File

@@ -0,0 +1,465 @@
<audio id="audio" title="03Spring Bean 依赖注入常见错误(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/8a/15363de02655604db4d3e0f4ccca978a.mp3"></audio>
你好我是傅健这节课我们接着聊Spring的自动注入。
上一讲我们介绍了3个Spring编程中关于依赖注入的错误案例这些错误都是比较常见的。如果你仔细分析的话你会发现它们大多都是围绕着@Autowired@Qualifier的使用而发生,而且自动注入的类型也都是普通对象类型。
那在实际应用中,我们也会使用@Value等不太常见的注解来完成自动注入,同时也存在注入到集合、数组等复杂类型的场景。这些情况下,我们也会遇到一些问题。所以这一讲我们不妨来梳理下。
## 案例1@Value没有注入预期的值
在装配对象成员属性时,我们常常会使用@Autowired来装配。但是,有时候我们也使用@Value进行装配。不过这两种注解使用风格不同,使用@Autowired一般都不会设置属性值,而@Value必须指定一个字符串值,因为其定义做了要求,定义代码如下:
```
public @interface Value {
/**
* The actual value expression &amp;mdash; for example, &lt;code&gt;#{systemProperties.myProp}&lt;/code&gt;.
*/
String value();
}
```
另外在比较这两者的区别时,**我们一般都会因为@Value常用于String类型的装配而误以为@Value不能用于非内置对象的装配,实际上这是一个常见的误区**。例如我们可以使用下面这种方式来Autowired一个属性成员
```
@Value(&quot;#{student}&quot;)
private Student student;
```
其中student这个Bean定义如下
```
@Bean
public Student student(){
Student student = createStudent(1, &quot;xie&quot;);
return student;
}
```
当然,正如前面提及,我们使用@Value更多是用来装配String,而且它支持多种强大的装配方式,典型的方式参考下面的示例:
```
//注册正常字符串
@Value(&quot;我是字符串&quot;)
private String text;
//注入系统参数、环境变量或者配置文件中的值
@Value(&quot;${ip}&quot;)
private String ip
//注入其他Bean属性其中student为bean的IDname为其属性
@Value(&quot;#{student.name}&quot;)
private String name;
```
上面我给你简单介绍了@Value的强大功能,以及它和@Autowired的区别。那么在使用@Value时可能会遇到那些错误呢?这里分享一个最为典型的错误,即使用@Value可能会注入一个不是预期的值
我们可以模拟一个场景我们在配置文件application.properties配置了这样一个属性
```
username=admin
password=pass
```
然后我们在一个Bean中分别定义两个属性来引用它们
```
@RestController
@Slf4j
public class ValueTestController {
@Value(&quot;${username}&quot;)
private String username;
@Value(&quot;${password}&quot;)
private String password;
@RequestMapping(path = &quot;user&quot;, method = RequestMethod.GET)
public String getUser(){
return username + &quot;:&quot; + password;
};
}
```
当我们去打印上述代码中的username和password时我们会发现password正确返回了但是username返回的并不是配置文件中指明的admin而是运行这段程序的计算机用户名。很明显使用@Value装配的值没有完全符合我们的预期
### 案例解析
通过分析运行结果,我们可以知道@Value的使用方式应该是没有错的毕竟password这个字段装配上了但是为什么username没有生效成正确的值接下来我们就来具体解析下。
我们首先了解下对于@ValueSpring是如何根据@Value来查询“值”的。我们可以先通过方法DefaultListableBeanFactory#doResolveDependency来了解@Value的核心工作流程,代码如下:
```
@Nullable
public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
@Nullable Set&lt;String&gt; autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {
//省略其他非关键代码
Class&lt;?&gt; type = descriptor.getDependencyType();
//寻找@Value
Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
if (value != null) {
if (value instanceof String) {
//解析Value值
String strVal = resolveEmbeddedValue((String) value);
BeanDefinition bd = (beanName != null &amp;&amp; containsBean(beanName) ?
getMergedBeanDefinition(beanName) : null);
value = evaluateBeanDefinitionString(strVal, bd);
}
//转化Value解析的结果到装配的类型
TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
try {
return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
}
catch (UnsupportedOperationException ex) {
//异常处理
}
}
//省略其他非关键代码
}
```
​可以看到,@Value的工作大体分为以下三个核心步骤
**1. 寻找@Value**
在这步中,主要是判断这个属性字段是否标记为@Value依据的方法参考QualifierAnnotationAutowireCandidateResolver#findValue
```
@Nullable
protected Object findValue(Annotation[] annotationsToSearch) {
if (annotationsToSearch.length &gt; 0) {
AnnotationAttributes attr = AnnotatedElementUtils.getMergedAnnotationAttributes(
AnnotatedElementUtils.forAnnotations(annotationsToSearch), this.valueAnnotationType);
//valueAnnotationType即为@Value
if (attr != null) {
return extractValue(attr);
}
}
return null;
}
```
**2. 解析@Value的字符串值**
如果一个字段标记了@Value,则可以拿到对应的字符串值,然后就可以根据字符串值去做解析,最终解析的结果可能是一个字符串,也可能是一个对象,这取决于字符串怎么写。
**3. 将解析结果转化为要装配的对象的类型**
当拿到第二步生成的结果后我们会发现可能和我们要装配的类型不匹配。假设我们定义的是UUID而我们获取的结果是一个字符串那么这个时候就会根据目标类型来寻找转化器执行转化字符串到UUID的转化实际上发生在UUIDEditor中
```
public class UUIDEditor extends PropertyEditorSupport {
@Override
public void setAsText(String text) throws IllegalArgumentException {
if (StringUtils.hasText(text)) {
//转化操作
setValue(UUID.fromString(text.trim()));
}
else {
setValue(null);
}
}
//省略其他非关代码
}
```
通过对上面几个关键步骤的解析,我们大体了解了@Value的工作流程。结合我们的案例很明显问题应该发生在第二步即解析Value指定字符串过程执行过程参考下面的关键代码行
```
String strVal = resolveEmbeddedValue((String) value);
```
这里其实是在解析嵌入的值实际上就是“替换占位符”工作。具体而言它采用的是PropertySourcesPlaceholderConfigurer根据PropertySources来替换。不过当使用 ${username} 来获取替换值时其最终执行的查找并不是局限在application.property文件中的。通过调试我们可以看到下面的这些“源”都是替换依据
<img src="https://static001.geekbang.org/resource/image/25/40/25d4242bc0dae8fa730663b9122b7840.png" alt="">
```
[ConfigurationPropertySourcesPropertySource {name='configurationProperties'},
StubPropertySource {name='servletConfigInitParams'}, ServletContextPropertySource {name='servletContextInitParams'}, PropertiesPropertySource {name='systemProperties'}, OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'}, RandomValuePropertySource {name='random'},
OriginTrackedMapPropertySource {name='applicationConfig: classpath:/application.properties]'},
MapPropertySource {name='devtools'}]
```
而具体的查找执行我们可以通过下面的代码PropertySourcesPropertyResolver#getProperty)来获取它的执行方式:
```
@Nullable
protected &lt;T&gt; T getProperty(String key, Class&lt;T&gt; targetValueType, boolean resolveNestedPlaceholders) {
if (this.propertySources != null) {
for (PropertySource&lt;?&gt; propertySource : this.propertySources) {
Object value = propertySource.getProperty(key);
if (value != null) {
//查到value即退出
return convertValueIfNecessary(value, targetValueType);
}
}
}
return null;
}
```
从这可以看出在解析Value字符串时其实是有顺序的查找的源是存在CopyOnWriteArrayList中在启动时就被有序固定下来一个一个“源”执行查找在其中一个源找到后就可以直接返回了。
如果我们查看systemEnvironment这个源会发现刚好有一个username和我们是重合的且值不是pass。
<img src="https://static001.geekbang.org/resource/image/eb/28/eb48b0d27dc7d0dyy32a548934edc728.png" alt="">
所以讲到这里你应该知道问题所在了吧这是一个误打误撞的例子刚好系统环境变量systemEnvironment中含有同名的配置。实际上对于系统参数systemProperties也是一样的这些参数或者变量都有很多如果我们没有意识到它的存在起了一个同名的字符串作为@Value的值,则很容易引发这类问题。
### 问题修正
针对这个案例,有了源码的剖析,我们就可以很快地找到解决方案了。例如我们可以避免使用同一个名称,具体修改如下:
```
user.name=admin
user.password=pass
```
但是如果我们这么改的话其实还是不行的。实际上通过之前的调试方法我们可以找到类似的原因在systemProperties这个PropertiesPropertySource源中刚好存在user.name真是无巧不成书。所以命名时我们一定要注意**不仅要避免和环境变量冲突,也要注意避免和系统变量等其他变量冲突**,这样才能从根本上解决这个问题。
通过这个案例我们可以知道Spring给我们提供了很多好用的功能但是这些功能交织到一起后就有可能让我们误入一些坑只有了解它的运行方式我们才能迅速定位问题、解决问题。
## 案例2错乱的注入集合
前面我们介绍了很多自动注入的错误案例,但是这些案例都局限在单个类型的注入,对于集合类型的注入并无提及。实际上,**集合类型的自动注入是Spring提供的另外一个强大功能。**
假设我们存在这样一个需求存在多个学生Bean我们需要找出来并存储到一个List里面去。多个学生Bean的定义如下
```
@Bean
public Student student1(){
return createStudent(1, &quot;xie&quot;);
}
@Bean
public Student student2(){
return createStudent(2, &quot;fang&quot;);
}
private Student createStudent(int id, String name) {
Student student = new Student();
student.setId(id);
student.setName(name);
return student;
}
```
有了集合类型的自动注入后我们就可以把零散的学生Bean收集起来了代码示例如下
```
@RestController
@Slf4j
public class StudentController {
private List&lt;Student&gt; students;
public StudentController(List&lt;Student&gt; students){
this.students = students;
}
@RequestMapping(path = &quot;students&quot;, method = RequestMethod.GET)
public String listStudents(){
return students.toString();
};
}
```
通过上述代码,我们就可以完成集合类型的注入工作,输出结果如下:
>
[Student(id=1, name=xie), Student(id=2, name=fang)]
然而业务总是复杂的需求也是一直变动的。当我们持续增加一些student时可能就不喜欢用这种方式来注入集合类型了而是倾向于用下面的方式去完成注入工作
```
@Bean
public List&lt;Student&gt; students(){
Student student3 = createStudent(3, &quot;liu&quot;);
Student student4 = createStudent(4, &quot;fu&quot;);
return Arrays.asList(student3, student4);
}
```
为了好记,这里我们不妨将上面这种方式命名为“直接装配方式”,而将之前的那种命名为“收集方式”。
实际上如果这两种方式是非此即彼的存在自然没有任何问题都能玩转。但是如果我们不小心让这2种方式同时存在了结果会怎样
这时候很多人都会觉得Spring很强大肯定会合并上面的结果或者认为肯定是以直接装配结果为准。然而当我们运行起程序就会发现后面的注入方式根本没有生效。即依然返回的是前面定义的2个学生。为什么会出现这样的错误呢
### 案例解析
要了解这个错误的根本原因你就得先清楚这两种注入风格在Spring中是如何实现的。对于收集装配风格Spring使用的是DefaultListableBeanFactory#resolveMultipleBeans来完成装配工作,针对本案例关键的核心代码如下:
```
private Object resolveMultipleBeans(DependencyDescriptor descriptor, @Nullable String beanName,
@Nullable Set&lt;String&gt; autowiredBeanNames, @Nullable TypeConverter typeConverter) {
final Class&lt;?&gt; type = descriptor.getDependencyType();
if (descriptor instanceof StreamDependencyDescriptor) {
//装配stream
return stream;
}
else if (type.isArray()) {
//装配数组
return result;
}
else if (Collection.class.isAssignableFrom(type) &amp;&amp; type.isInterface()) {
//装配集合
//获取集合的元素类型
Class&lt;?&gt; elementType = descriptor.getResolvableType().asCollection().resolveGeneric();
if (elementType == null) {
return null;
}
//根据元素类型查找所有的bean
Map&lt;String, Object&gt; matchingBeans = findAutowireCandidates(beanName, elementType,
new MultiElementDescriptor(descriptor));
if (matchingBeans.isEmpty()) {
return null;
}
if (autowiredBeanNames != null) {
autowiredBeanNames.addAll(matchingBeans.keySet());
}
//转化查到的所有bean放置到集合并返回
TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
Object result = converter.convertIfNecessary(matchingBeans.values(), type);
//省略非关键代码
return result;
}
else if (Map.class == type) {
//解析map
return matchingBeans;
}
else {
return null;
}
}
```
到这,我们就不难概括出这种收集式集合装配方式的大体过程了。
**1. 获取集合类型的元素类型**
针对本案例目标类型定义为List&lt;Student&gt; students所以元素类型为Student获取的具体方法参考代码行
>
Class&lt;?&gt; elementType = descriptor.getResolvableType().asCollection().resolveGeneric();
**2. 根据元素类型找出所有的Bean**
有了上面的元素类型即可根据元素类型来找出所有的Bean关键代码行如下
>
Map&lt;String, Object&gt; matchingBeans = findAutowireCandidates(beanName, elementType, new MultiElementDescriptor(descriptor));
**3. 将匹配的所有的Bean按目标类型进行转化**
经过步骤2我们获取的所有的Bean都是以java.util.LinkedHashMap.LinkedValues形式存储的和我们的目标类型大概率不同所以最后一步需要做的是**按需转化**。在本案例中我们就需要把它转化为List转化的关键代码如下
>
Object result = converter.convertIfNecessary(matchingBeans.values(), type);
如果我们继续深究执行细节就可以知道最终是转化器CollectionToCollectionConverter来完成这个转化过程。
学习完收集方式的装配原理我们再来看下直接装配方式的执行过程实际上这步在前面的课程中我们就提到过即DefaultListableBeanFactory#findAutowireCandidates方法执行),具体的执行过程这里就不多说了。
知道了执行过程接下来无非就是根据目标类型直接寻找匹配的Bean。在本案例中就是将Bean名称为students的List&lt;Student&gt;装配给StudentController#students属性
了解了这两种方式我们再来思考这两种方式的关系当同时满足这两种装配方式时Spring是如何处理的这里我们可以参考方法DefaultListableBeanFactory#doResolveDependency的几行关键代码,代码如下:
```
Object multipleBeans = resolveMultipleBeans(descriptor, beanName, autowiredBeanNames, typeConverter);
if (multipleBeans != null) {
return multipleBeans;
}
Map&lt;String, Object&gt; matchingBeans = findAutowireCandidates(beanName, type, descriptor);
```
很明显,这两种装配集合的方式是**不能同存**的结合本案例当使用收集装配方式来装配时能找到任何一个对应的Bean则返回如果一个都没有找到才会采用直接装配的方式。说到这里你大概能理解为什么后期以List方式直接添加的Student Bean都不生效了吧。
### 问题修正
现在如何纠正这个问题就变得简单多了就是你一定要下意识地避免这2种方式共存去装配集合只用一个这个问题就迎刃而解了。例如在这里我们可以使用直接装配的方式去修正问题代码如下
```
@Bean
public List&lt;Student&gt; students(){
Student student1 = createStudent(1, &quot;xie&quot;);
Student student2 = createStudent(2, &quot;fang&quot;);
Student student3 = createStudent(3, &quot;liu&quot;);
Student student4 = createStudent(4, &quot;fu&quot;);
return Arrays.asList(student1student2student3, student4);
}
```
也可以使用收集方式来修正问题时,代码如下:
```
@Bean
public Student student1(){
return createStudent(1, &quot;xie&quot;);
}
@Bean
public Student student2(){
return createStudent(2, &quot;fang&quot;);
}
@Bean
public Student student3(){
return createStudent(3, &quot;liu&quot;);
}
@Bean
public Student student4(){
return createStudent(4, &quot;fu&quot;);
}
```
总之,都是可以的。还有一点要注意:**在对于同一个集合对象的注入上,混合多种注入方式是不可取的,这样除了错乱,别无所得。**
## 重点回顾
今天我们又学习了关于Spring自动注入的两个典型案例。
通过案例1的学习我们了解到@Value不仅可以用来注入String类型也可以注入自定义对象类型。同时在注入String时你一定要意识到它不仅仅可以用来引用配置文件里配置的值也可能引用到环境变量、系统参数等。
而通过案例2的学习我们了解到集合类型的注入支持两种常见的方式即上文中我们命名的收集装配式和直接装配式。这两种方式共同装配一个属性时后者就会失效。
综合上一讲的内容我们一共分析了5个问题以及背后的原理通过这些案例的分析我们不难看出Spring的自动注入非常强大围绕@Autowired@Qualifier@Value等内置注解,我们可以完成不同的注入目标和需求。不过这种强大,正如我在[开篇词](https://time.geekbang.org/column/article/364661)中提及的,它建立在很多隐性的规则之上。只有你把这些规则都烂熟于心了,才能很好地去规避问题。
## 思考题
在案例2中我们初次运行程序获取的结果如下
>
[Student(id=1, name=xie), Student(id=2, name=fang)]
那么如何做到让学生2优先输出呢
我们留言区见!

View File

@@ -0,0 +1,520 @@
<audio id="audio" title="04Spring Bean 生命周期常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/c9/a444280c3283aa2f5b6f19e919e674c9.mp3"></audio>
你好,我是傅健,这节课我们来聊一聊 Spring Bean 的初始化过程及销毁过程中的一些问题。
虽然说 Spring 容器上手简单,可以仅仅通过学习一些有限的注解,即可达到快速使用的目的。但在工程实践中,我们依然会从中发现一些常见的错误。尤其当你对 Spring 的生命周期还没有深入了解时,类初始化及销毁过程中潜在的约定就不会很清楚。
这会导致这样一些状况发生:有些错误,我们可以在 Spring 的异常提示下快速解决,但却不理解背后的原理;而另一些错误,并不容易在开发环境下被发现,从而在产线上造成较为严重的后果。
接下来我们就具体解析下这些常见案例及其背后的原理。
## 案例 1构造器内抛空指针异常
先看个例子。在构建宿舍管理系统时,有 LightMgrService 来管理 LightService从而控制宿舍灯的开启和关闭。我们希望在 LightMgrService 初始化时能够自动调用 LightService 的 check 方法来检查所有宿舍灯的电路是否正常,代码如下:
```
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class LightMgrService {
@Autowired
private LightService lightService;
public LightMgrService() {
lightService.check();
}
}
```
我们在 LightMgrService 的默认构造器中调用了通过 @Autoware 注入的成员变量 LightService 的 check 方法:
```
@Service
public class LightService {
public void start() {
System.out.println(&quot;turn on all lights&quot;);
}
public void shutdown() {
System.out.println(&quot;turn off all lights&quot;);
}
public void check() {
System.out.println(&quot;check all lights&quot;);
}
}
```
以上代码定义了 LightService 对象的原始类。
从整个案例代码实现来看,我们的期待是在 LightMgrService 初始化过程中LightService 因为标记为 @Autowired,所以能被自动装配好;然后在 LightMgrService 的构造器执行中LightService 的 shutdown() 方法能被自动调用;最终打印出 check all lights。
然而事与愿违,我们得到的只会是 NullPointerException错误示例如下
<img src="https://static001.geekbang.org/resource/image/4d/4e/4d4cecc9c82abaaa4f04cdb274c05e4e.png" alt="">
这是为什么呢?
### 案例解析
显然这是新手最常犯的错误,但是问题的根源,是我们**对Spring类初始化过程没有足够的了解**。下面这张时序图描述了 Spring 启动时的一些关键结点:
<img src="https://static001.geekbang.org/resource/image/6f/8a/6ff70ab627711065bc17c54c001ef08a.png" alt="">
这个图初看起来复杂,我们不妨将其分为三部分:
- 第一部分,将一些必要的系统类,比如 Bean 的后置处理器类,注册到 Spring 容器,其中就包括我们这节课关注的 CommonAnnotationBeanPostProcessor 类;
- 第二部分,将这些后置处理器实例化,并注册到 Spring 的容器中;
- 第三部分,实例化所有用户定制类,调用后置处理器进行辅助装配、类初始化等等。
第一部分和第二部分并非是我们今天要讨论的重点,这里仅仅是为了让你知道 CommonAnnotationBeanPostProcessor 这个后置处理类是何时被 Spring 加载和实例化的。
**这里我顺便给你拓展两个知识点:**
1. 很多必要的系统类,尤其是 Bean 后置处理器比如CommonAnnotationBeanPostProcessor、AutowiredAnnotationBeanPostProcessor 等),都是被 Spring 统一加载和管理的,并在 Spring 中扮演了非常重要的角色;
1. 通过 Bean 后置处理器Spring 能够非常灵活地在不同的场景调用不同的后置处理器,比如接下来我会讲到示例问题如何修正,修正方案中提到的 PostConstruct 注解,它的处理逻辑就需要用到 CommonAnnotationBeanPostProcessor继承自 InitDestroyAnnotationBeanPostProcessor这个后置处理器。
现在我们重点看下第三部分,即 Spring 初始化单例类的一般过程,基本都是 getBean()-&gt;doGetBean()-&gt;getSingleton(),如果发现 Bean 不存在,则调用 createBean()-&gt;doCreateBean() 进行实例化。
查看 doCreateBean() 的源代码如下:
```
protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
throws BeanCreationException {
//省略非关键代码
if (instanceWrapper == null) {
instanceWrapper = createBeanInstance(beanName, mbd, args);
}
final Object bean = instanceWrapper.getWrappedInstance();
//省略非关键代码
Object exposedObject = bean;
try {
populateBean(beanName, mbd, instanceWrapper);
exposedObject = initializeBean(beanName, exposedObject, mbd);
}
catch (Throwable ex) {
//省略非关键代码
}
```
上述代码完整地展示了 Bean 初始化的三个关键步骤,按执行顺序分别是第 5 行的 createBeanInstance第 12 行的 populateBean以及第 13 行的 initializeBean分别对应实例化 Bean注入 Bean 依赖,以及初始化 Bean (例如执行 @PostConstruct 标记的方法 )这三个功能,这也和上述时序图的流程相符。
而用来实例化 Bean 的 createBeanInstance 方法通过依次调用DefaultListableBeanFactory.instantiateBean() &gt;SimpleInstantiationStrategy.instantiate(),最终执行到 BeanUtils.instantiateClass(),其代码如下:
```
public static &lt;T&gt; T instantiateClass(Constructor&lt;T&gt; ctor, Object... args) throws BeanInstantiationException {
Assert.notNull(ctor, &quot;Constructor must not be null&quot;);
try {
ReflectionUtils.makeAccessible(ctor);
return (KotlinDetector.isKotlinReflectPresent() &amp;&amp; KotlinDetector.isKotlinType(ctor.getDeclaringClass()) ?
KotlinDelegate.instantiateClass(ctor, args) : ctor.newInstance(args));
}
catch (InstantiationException ex) {
throw new BeanInstantiationException(ctor, &quot;Is it an abstract class?&quot;, ex);
}
//省略非关键代码
}
```
这里因为当前的语言并非 Kotlin所以最终将调用 ctor.newInstance() 方法实例化用户定制类 LightMgrService而默认构造器显然是在类实例化的时候被自动调用的Spring 也无法控制。而此时负责自动装配的 populateBean 方法还没有被执行LightMgrService 的属性 LightService 还是 null因而得到空指针异常也在情理之中。
### 问题修正
通过源码分析,现在我们知道了问题的根源,就是在于**使用 @Autowired 直接标记在成员属性上而引发的装配行为是发生在构造器执行之后的**。所以这里我们可以通过下面这种修订方法来纠正这个问题:
```
@Component
public class LightMgrService {
private LightService lightService;
public LightMgrService(LightService lightService) {
this.lightService = lightService;
lightService.check();
}
}
```
在[第02课](https://time.geekbang.org/column/article/366170)的案例 2 中,我们就提到了构造器参数的隐式注入。当使用上面的代码时,构造器参数 LightService 会被自动注入LightService 的 Bean从而在构造器执行时不会出现空指针。可以说**使用构造器参数来隐式注入是一种 Spring 最佳实践**因为它成功地规避了案例1中的问题。
另外,除了这种纠正方式,有没有别的方式?
实际上Spring 在类属性完成注入之后,会回调用户定制的初始化方法。即在 populateBean 方法之后,会调用 initializeBean 方法,我们来看一下它的关键代码:
```
protected Object initializeBean(final String beanName, final Object bean, @Nullable RootBeanDefinition mbd) {
//省略非关键代码
if (mbd == null || !mbd.isSynthetic()) {
wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
}
try {
invokeInitMethods(beanName, wrappedBean, mbd);
}
//省略非关键代码
}
```
这里你可以看到 applyBeanPostProcessorsBeforeInitialization 和 invokeInitMethods 这两个关键方法的执行,它们分别处理了 @PostConstruct 注解和 InitializingBean 接口这两种不同的初始化方案的逻辑。这里我再详细地给你讲讲。
**1. applyBeanPostProcessorsBeforeInitialization 与@PostConstruct**
applyBeanPostProcessorsBeforeInitialization 方法最终执行到后置处理器 InitDestroyAnnotationBeanPostProcessor 的 buildLifecycleMetadata 方法CommonAnnotationBeanPostProcessor 的父类):
```
private LifecycleMetadata buildLifecycleMetadata(final Class&lt;?&gt; clazz) {
//省略非关键代码
do {
//省略非关键代码
final List&lt;LifecycleElement&gt; currDestroyMethods = new ArrayList&lt;&gt;();
ReflectionUtils.doWithLocalMethods(targetClass, method -&gt; {
//此处的 this.initAnnotationType 值,即为 PostConstruct.class
if (this.initAnnotationType != null &amp;&amp; method.isAnnotationPresent(this.initAnnotationType)) {
LifecycleElement element = new LifecycleElement(method);
currInitMethods.add(element);
//非关键代码
}
```
在这个方法里Spring 将遍历查找被 PostConstruct.class 注解过的方法,返回到上层,并最终调用此方法。
**2. invokeInitMethods 与 InitializingBean 接口**
invokeInitMethods 方法会判断当前 Bean 是否实现了 InitializingBean 接口只有在实现了该接口的情况下Spring 才会调用该 Bean 的接口实现方法 afterPropertiesSet()。
```
protected void invokeInitMethods(String beanName, final Object bean, @Nullable RootBeanDefinition mbd)
throws Throwable {
boolean isInitializingBean = (bean instanceof InitializingBean);
if (isInitializingBean &amp;&amp; (mbd == null || !mbd.isExternallyManagedInitMethod(&quot;afterPropertiesSet&quot;))) {
// 省略非关键代码
else {
((InitializingBean) bean).afterPropertiesSet();
}
}
// 省略非关键代码
}
```
学到此处,答案也就呼之欲出了。我们还有两种方式可以解决此问题。
1. 添加 init 方法,并且使用 PostConstruct 注解进行修饰:
```
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class LightMgrService {
@Autowired
private LightService lightService;
@PostConstruct
public void init() {
lightService.check();
}
}
```
1. 实现 InitializingBean 接口,在其 afterPropertiesSet() 方法中执行初始化代码:
```
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class LightMgrService implements InitializingBean {
@Autowired
private LightService lightService;
@Override
public void afterPropertiesSet() throws Exception {
lightService.check();
}
}
```
对比最开始提出的解决方案,很明显,针对本案例而言,后续的两种方案并不是最优的。但是在一些场景下,这两种方案各有所长,不然 Spring 为什么要提供这个功能呢?对吧!
## 案例 2意外触发 shutdown 方法
上述实例我给你讲解了类初始化时最容易遇到的问题,同样,在类销毁时,也会有一些相对隐蔽的约定,导致一些难以察觉的错误。
接下来我们再来看一个案例还是沿用之前的场景。这里我们可以简单复习一下LightService 的实现,它包含了 shutdown 方法,负责关闭所有的灯,关键代码如下:
```
import org.springframework.stereotype.Service;
@Service
public class LightService {
//省略其他非关键代码
public void shutdown(){
System.out.println(&quot;shutting down all lights&quot;);
}
//省略其他非关键代码
}
```
在之前的案例中,如果我们的宿舍管理系统在重启时,灯是不会被关闭的。但是随着业务的需求变化,我们可能会去掉 @Service 注解,而是使用另外一种产生 Bean 的方式:创建一个配置类 BeanConfiguration标记 @Configuration)来创建一堆 Bean其中就包含了创建 LightService 类型的 Bean并将其注册到 Spring 容器:
```
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BeanConfiguration {
@Bean
public LightService getTransmission(){
return new LightService();
}
}
```
复用案例 1 的启动程序,稍作修改,让 Spring 启动完成后立马关闭当前 Spring 上下文。这样等同于模拟宿舍管理系统的启停:
```
@SpringBootApplication
public class Application {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Application.class, args);
context.close();
}
}
```
以上代码没有其他任何方法的调用,仅仅是将所有符合约定的类初始化并加载到 Spring 容器,完成后再关闭当前的 Spring 容器。按照预期,这段代码运行后不会有任何的 log 输出,毕竟我们只是改变了 Bean 的产生方式。
但实际运行这段代码后,我们可以看到控制台上打印了 shutting down all lights。显然 shutdown 方法未按照预期被执行了,这导致一个很有意思的 bug在使用新的 Bean 生成方式之前,每一次宿舍管理服务被重启时,宿舍里所有的灯都不会被关闭。但是修改后,只有服务重启,灯都被意外关闭了。如何理解这个 bug?
### 案例解析
通过调试,我们发现只有通过使用 Bean 注解注册到 Spring 容器的对象,才会在 Spring 容器被关闭的时候自动调用 shutdown 方法,而使用 @ComponentService 也是一种 Component将当前类自动注入到 Spring 容器时shutdown 方法则不会被自动执行。
我们可以尝试到 Bean 注解类的代码中去寻找一些线索,可以看到属性 destroyMethod 有非常大段的注释,基本上解答了我们对于这个问题的大部分疑惑。
使用 Bean 注解的方法所注册的 Bean 对象,如果用户不设置 destroyMethod 属性,则其属性值为 AbstractBeanDefinition.INFER_METHOD。此时 Spring 会检查当前 Bean 对象的原始类中是否有名为 shutdown 或者 close 的方法,如果有,此方法会被 Spring 记录下来,并在容器被销毁时自动执行;当然如若没有,那么自然什么都不会发生。
下面我们继续查看 Spring 的源代码来进一步分析此问题。
首先我们可以查找 INFER_METHOD 枚举值的引用,很容易就找到了使用该枚举值的方法 DisposableBeanAdapter#inferDestroyMethodIfNecessary
```
private String inferDestroyMethodIfNecessary(Object bean, RootBeanDefinition beanDefinition) {
String destroyMethodName = beanDefinition.getDestroyMethodName();
if (AbstractBeanDefinition.INFER_METHOD.equals(destroyMethodName) ||(destroyMethodName == null &amp;&amp; bean instanceof AutoCloseable)) {
if (!(bean instanceof DisposableBean)) {
try {
//尝试查找 close 方法
return bean.getClass().getMethod(CLOSE_METHOD_NAME).getName();
}
catch (NoSuchMethodException ex) {
try {
//尝试查找 shutdown 方法
return bean.getClass().getMethod(SHUTDOWN_METHOD_NAME).getName();
}
catch (NoSuchMethodException ex2) {
// no candidate destroy method found
}
}
}
return null;
}
return (StringUtils.hasLength(destroyMethodName) ? destroyMethodName : null);
}
```
我们可以看到,代码逻辑和 Bean 注解类中对于 destroyMethod 属性的注释完全一致destroyMethodName 如果等于 INFER_METHOD且当前类没有实现 DisposableBean 接口,那么首先查找类的 close 方法,如果找不到,就在抛出异常后继续查找 shutdown 方法如果找到了则返回其方法名close 或者 shutdown
接着,继续逐级查找引用,最终得到的调用链从上到下为 doCreateBean-&gt;registerDisposableBeanIfNecessary-&gt;registerDisposableBean(new DisposableBeanAdapter)-&gt;inferDestroyMethodIfNecessary。
然后,我们追溯到了顶层的 doCreateBean 方法,代码如下:
```
protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
throws BeanCreationException {
//省略非关键代码
if (instanceWrapper == null) {
instanceWrapper = createBeanInstance(beanName, mbd, args);
}
//省略非关键代码
// Initialize the bean instance.
Object exposedObject = bean;
try {
populateBean(beanName, mbd, instanceWrapper);
exposedObject = initializeBean(beanName, exposedObject, mbd);
}
//省略非关键代码
// Register bean as disposable.
try {
registerDisposableBeanIfNecessary(beanName, bean, mbd);
}
catch (BeanDefinitionValidationException ex) {
throw new BeanCreationException(
mbd.getResourceDescription(), beanName, &quot;Invalid destruction signature&quot;, ex);
}
return exposedObject;
}
```
到这,我们就可以对 doCreateBean 方法做一个小小的总结了。可以说 **doCreateBean 管理了Bean的整个生命周期中几乎所有的关键节点**,直接负责了 Bean 对象的生老病死,其主要功能包括:
- Bean 实例的创建;
- Bean 对象依赖的注入;
- 定制类初始化方法的回调;
- Disposable 方法的注册。
接着,继续查看 registerDisposableBean 方法:
```
public void registerDisposableBean(String beanName, DisposableBean bean) {
//省略其他非关键代码
synchronized (this.disposableBeans) {
this.disposableBeans.put(beanName, bean);
}
//省略其他非关键代码
}
```
在 registerDisposableBean 方法内DisposableBeanAdapter 类其属性destroyMethodName 记录了使用哪种 destory 方法)被实例化并添加到 DefaultSingletonBeanRegistry#disposableBeans 属性内disposableBeans 将暂存这些 DisposableBeanAdapter 实例,直到 AnnotationConfigApplicationContext 的 close 方法被调用。
而当 AnnotationConfigApplicationContext 的 close 方法被调用时,即当 Spring 容器被销毁时,最终会调用到 DefaultSingletonBeanRegistry#destroySingleton。此方法将遍历 disposableBeans 属性逐一获取 DisposableBean依次调用其中的 close 或者 shutdown 方法:
```
public void destroySingleton(String beanName) {
// Remove a registered singleton of the given name, if any.
removeSingleton(beanName);
// Destroy the corresponding DisposableBean instance.
DisposableBean disposableBean;
synchronized (this.disposableBeans) {
disposableBean = (DisposableBean) this.disposableBeans.remove(beanName);
}
destroyBean(beanName, disposableBean);
}
```
很明显,最终我们的案例调用了 LightService#shutdown 方法,将所有的灯关闭了。
### 问题修正
现在,我们已经知道了问题的根源,解决起来就非常简单了。
我们可以通过**避免在Java类中定义一些带有特殊意义动词的方法来解决**,当然如果一定要定义名为 close 或者 shutdown 方法,也可以通过将 Bean 注解内 destroyMethod 属性设置为空的方式来解决这个问题。
第一种修改方式比较简单,所以这里只展示第二种修改方式,代码如下:
```
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BeanConfiguration {
@Bean(destroyMethod=&quot;&quot;)
public LightService getTransmission(){
return new LightService();
}
}
```
另外,针对这个问题我想再多提示一点。如果我们能**养成良好的编码习惯**,在使用某个不熟悉的注解之前,认真研读一下该注解的注释,也可以大概率规避这个问题。
不过说到这里,你也可能还是会疑惑,为什么 @Service 注入的 LightService其 shutdown 方法不能被执行?这里我想补充说明下。
想要执行,则必须要添加 DisposableBeanAdapter而它的添加是有条件的
```
protected void registerDisposableBeanIfNecessary(String beanName, Object bean, RootBeanDefinition mbd) {
AccessControlContext acc = (System.getSecurityManager() != null ? getAccessControlContext() : null);
if (!mbd.isPrototype() &amp;&amp; requiresDestruction(bean, mbd)) {
if (mbd.isSingleton()) {
// Register a DisposableBean implementation that performs all destruction
// work for the given bean: DestructionAwareBeanPostProcessors,
// DisposableBean interface, custom destroy method.
registerDisposableBean(beanName,
new DisposableBeanAdapter(bean, beanName, mbd, getBeanPostProcessors(), acc));
}
else {
//省略非关键代码
}
}
}
```
参考上述代码,关键的语句在于:
>
!mbd.isPrototype() &amp;&amp; requiresDestruction(bean, mbd)
很明显在案例代码修改前后我们都是单例所以区别仅在于是否满足requiresDestruction 条件。翻阅它的代码最终的关键调用参考DisposableBeanAdapter#hasDestroyMethod
```
public static boolean hasDestroyMethod(Object bean, RootBeanDefinition beanDefinition) {
if (bean instanceof DisposableBean || bean instanceof AutoCloseable) {
return true;
}
String destroyMethodName = beanDefinition.getDestroyMethodName();
if (AbstractBeanDefinition.INFER_METHOD.equals(destroyMethodName)) {
return (ClassUtils.hasMethod(bean.getClass(), CLOSE_METHOD_NAME) ||
ClassUtils.hasMethod(bean.getClass(), SHUTDOWN_METHOD_NAME));
}
return StringUtils.hasLength(destroyMethodName);
}
```
如果我们是使用 @Service 来产生 Bean 的那么在上述代码中我们获取的destroyMethodName 其实是 null而使用 @Bean 的方式默认值为AbstractBeanDefinition.INFER_METHOD参考 Bean 的定义:
```
public @interface Bean {
//省略其他非关键代码
String destroyMethod() default AbstractBeanDefinition.INFER_METHOD;
}
```
继续对照代码,你就会发现 @Service 标记的 LightService 也没有实现 AutoCloseable、DisposableBean最终没有添加一个 DisposableBeanAdapter。所以最终我们定义的 shutdown 方法没有被调用。
## 重点回顾
通过以上两个案例,相信你对 Spring 生命周期,尤其是对于 Bean 的初始化和销毁流程已经有了一定的了解。这里带你再次回顾下重点:
1. DefaultListableBeanFactory 类是 Spring Bean 的灵魂,而核心就是其中的 doCreateBean 方法,它掌控了 Bean 实例的创建、Bean 对象依赖的注入、定制类初始化方法的回调以及 Disposable 方法的注册等全部关键节点。
1. 后置处理器是 Spring 中最优雅的设计之一对于很多功能注解的处理都是借助于后置处理器来完成的。虽然这节课对其没有过多介绍但在第一个案例中Bean 对象“补充”初始化动作却是在 CommonAnnotationBeanPostProcessor继承自 InitDestroyAnnotationBeanPostProcessor这个后置处理器中完成的。
## 思考题
案例 2 中的类 LightService当我们不在 Configuration 注解类中使用 Bean 方法将其注入 Spring 容器,而是坚持使用 @Service 将其自动注入到容器,同时实现 Closeable 接口,代码如下:
```
import org.springframework.stereotype.Component;
import java.io.Closeable;
@Service
public class LightService implements Closeable {
public void close() {
System.out.println(&quot;turn off all lights);
}
//省略非关键代码
}
```
接口方法 close() 也会在 Spring 容器被销毁的时候自动执行么?
我在留言区期待你的答案!

View File

@@ -0,0 +1,549 @@
<audio id="audio" title="05Spring AOP 常见错误(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/e2/b5def29f36db744c283fff8d7066c2e2.mp3"></audio>
你好我是傅健。这节课开始我们聊聊Spring AOP使用中常遇到的一些问题。
Spring AOP是Spring中除了依赖注入外DI最为核心的功能顾名思义AOP即Aspect Oriented Programming翻译为面向切面编程。
而Spring AOP则利用CGlib和JDK动态代理等方式来实现运行期动态方法增强其目的是将与业务无关的代码单独抽离出来使其逻辑不再与业务代码耦合从而降低系统的耦合性提高程序的可重用性和开发效率。因而AOP便成为了日志记录、监控管理、性能统计、异常处理、权限管理、统一认证等各个方面被广泛使用的技术。
追根溯源我们之所以能无感知地在容器对象方法前后任意添加代码片段那是由于Spring在运行期帮我们把切面中的代码逻辑动态“织入”到了容器对象方法内所以说**AOP本质上就是一个代理模式**。然而在使用这种代理模式时,我们常常会用不好,那么这节课我们就来解析下有哪些常见的问题,以及背后的原理是什么。
## 案例1this调用的当前类方法无法被拦截
假设我们正在开发一个宿舍管理系统这个模块包含一个负责电费充值的类ElectricService它含有一个充电方法charge()
```
@Service
public class ElectricService {
public void charge() throws Exception {
System.out.println(&quot;Electric charging ...&quot;);
this.pay();
}
public void pay() throws Exception {
System.out.println(&quot;Pay with alipay ...&quot;);
Thread.sleep(1000);
}
}
```
在这个电费充值方法charge()中我们会使用支付宝进行充值。因此在这个方法中我加入了pay()方法。为了模拟pay()方法调用耗时代码执行了休眠1秒并在charge()方法里使用 this.pay()的方式调用这种支付方法。
但是因为支付宝支付是第三方接口,我们需要记录下接口调用时间。这时候我们就引入了一个@Around的增强 分别记录在pay()方法执行前后的时间并计算出执行pay()方法的耗时。
```
@Aspect
@Service
@Slf4j
public class AopConfig {
@Around(&quot;execution(* com.spring.puzzle.class5.example1.ElectricService.pay()) &quot;)
public void recordPayPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
joinPoint.proceed();
long end = System.currentTimeMillis();
System.out.println(&quot;Pay method time costms: &quot; + (end - start));
}
}
```
最后我们再通过定义一个Controller来提供电费充值接口定义如下
```
@RestController
public class HelloWorldController {
@Autowired
ElectricService electricService;
@RequestMapping(path = &quot;charge&quot;, method = RequestMethod.GET)
public void charge() throws Exception{
electricService.charge();
};
}
```
完成代码后,我们访问上述接口,会发现这段计算时间的切面并没有执行到,输出日志如下:
>
<p>Electric charging ...<br>
Pay with alipay ...</p>
回溯之前的代码可知,在@Around的切面类中我们很清晰地定义了切面对应的方法但是却没有被执行到。这说明了在类的内部通过this方式调用的方法是没有被Spring AOP增强的。这是为什么呢我们来分析一下。
### 案例解析
我们可以从源码中找到真相。首先来设置个断点调试看看this对应的对象是什么样的
<img src="https://static001.geekbang.org/resource/image/e0/5f/e0f4b047228fac437d57f56dcd18185f.png" alt="">
可以看到this对应的就是一个普通的ElectricService对象并没有什么特别的地方。再看看在Controller层中自动装配的ElectricService对象是什么样
<img src="https://static001.geekbang.org/resource/image/b2/f9/b24f00b4b96c46983295da05180174f9.png" alt="">
可以看到这是一个被Spring增强过的Bean所以执行charge()方法时会执行记录接口调用时间的增强操作。而this对应的对象只是一个普通的对象并没有做任何额外的增强。
为什么this引用的对象只是一个普通对象呢这还要从Spring AOP增强对象的过程来看。但在此之前有些基础我需要在这里强调下。
**1. Spring AOP的实现**
Spring AOP的底层是动态代理。而创建代理的方式有两种**JDK的方式和CGLIB的方式**。JDK动态代理只能对实现了接口的类生成代理而不能针对普通类。而CGLIB是可以针对类实现代理主要是对指定的类生成一个子类覆盖其中的方法来实现代理对象。具体区别可参考下图
<img src="https://static001.geekbang.org/resource/image/99/a1/99c74d82d811ec567b28a24ccd6e85a1.png" alt="">
**2. 如何使用Spring AOP**
在Spring Boot中我们一般只要添加以下依赖就可以直接使用AOP功能
>
<p>&lt;dependency&gt;<br>
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>
&lt;artifactId&gt;spring-boot-starter-aop&lt;/artifactId&gt;<br>
&lt;/dependency&gt;</p>
而对于非Spring Boot程序除了添加相关AOP依赖项外我们还常常会使用@EnableAspectJAutoProxy来开启AOP功能。这个注解类引入ImportAspectJAutoProxyRegistrar它通过实现ImportBeanDefinitionRegistrar的接口方法来完成AOP相关Bean的准备工作。
补充完最基本的Spring底层知识和使用知识后我们具体看下创建代理对象的过程。先来看下调用栈
<img src="https://static001.geekbang.org/resource/image/1f/2a/1fb3735e51a8e06833f065a175517c2a.png" alt="">
创建代理对象的时机就是创建一个Bean的时候而创建的的关键工作其实是由AnnotationAwareAspectJAutoProxyCreator完成的。它本质上是一种BeanPostProcessor。所以它的执行是在完成原始Bean构建后的初始化BeaninitializeBean过程中。而它到底完成了什么工作呢我们可以看下它的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;
}
```
上述代码中的关键方法是wrapIfNecessary顾名思义**在需要使用AOP时它会把创建的原始的Bean对象wrap成代理对象作为Bean返回**。具体到这个wrap过程可参考下面的关键代码行
```
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
// 省略非关键代码
Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
if (specificInterceptors != DO_NOT_PROXY) {
this.advisedBeans.put(cacheKey, Boolean.TRUE);
Object proxy = createProxy(
bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
this.proxyTypes.put(cacheKey, proxy.getClass());
return proxy;
}
// 省略非关键代码
}
```
上述代码中第6行的createProxy调用是创建代理对象的关键。具体到执行过程它首先会创建一个代理工厂然后将通知器advisors、被代理对象等信息加入到代理工厂最后通过这个代理工厂来获取代理对象。一些关键过程参考下面的方法
```
protected Object createProxy(Class&lt;?&gt; beanClass, @Nullable String beanName,
@Nullable Object[] specificInterceptors, TargetSource targetSource) {
// 省略非关键代码
ProxyFactory proxyFactory = new ProxyFactory();
if (!proxyFactory.isProxyTargetClass()) {
if (shouldProxyTargetClass(beanClass, beanName)) {
proxyFactory.setProxyTargetClass(true);
}
else {
evaluateProxyInterfaces(beanClass, proxyFactory);
}
}
Advisor[] advisors = buildAdvisors(beanName, specificInterceptors);
proxyFactory.addAdvisors(advisors);
proxyFactory.setTargetSource(targetSource);
customizeProxyFactory(proxyFactory);
// 省略非关键代码
return proxyFactory.getProxy(getProxyClassLoader());
}
```
经过这样一个过程一个代理对象就被创建出来了。我们从Spring中获取到的对象都是这个代理对象所以具有AOP功能。而之前直接使用this引用到的只是一个普通对象自然也就没办法实现AOP的功能了。
### 问题修正
从上述案例解析中,我们知道,**只有引用的是被动态代理创建出来的对象才会被Spring增强具备AOP该有的功能**。那什么样的对象具备这样的条件呢?
有两种。一种是被@Autowired注解的,于是我们的代码可以改成这样,即通过@Autowired的方式,在类的内部,自己引用自己:
```
@Service
public class ElectricService {
@Autowired
ElectricService electricService;
public void charge() throws Exception {
System.out.println(&quot;Electric charging ...&quot;);
//this.pay();
electricService.pay();
}
public void pay() throws Exception {
System.out.println(&quot;Pay with alipay ...&quot;);
Thread.sleep(1000);
}
}
```
另一种方法就是直接从AopContext获取当前的Proxy。那你可能会问了AopContext是什么简单说它的核心就是通过一个ThreadLocal来将Proxy和线程绑定起来这样就可以随时拿出当前线程绑定的Proxy。
不过使用这种方法有个小前提,就是需要在@EnableAspectJAutoProxy里加一个配置项exposeProxy = true表示将代理对象放入到ThreadLocal这样才可以直接通过 AopContext.currentProxy()的方式获取到,否则会报错如下:
<img src="https://static001.geekbang.org/resource/image/0e/98/0e42f3129e1c098b0f860f1f7f2e6298.png" alt="">
按这个思路,我们修改下相关代码:
```
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
@Service
public class ElectricService {
public void charge() throws Exception {
System.out.println(&quot;Electric charging ...&quot;);
ElectricService electric = ((ElectricService) AopContext.currentProxy());
electric.pay();
}
public void pay() throws Exception {
System.out.println(&quot;Pay with alipay ...&quot;);
Thread.sleep(1000);
}
}
```
同时不要忘记修改EnableAspectJAutoProxy注解的exposeProxy属性示例如下
```
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class Application {
// 省略非关键代码
}
```
这两种方法的效果其实是一样的,最终我们打印出了期待的日志,到这,问题顺利解决了。
```
Electric charging ...
Pay with alipay ...
Pay method time cost(ms): 1005
```
## 案例2直接访问被拦截类的属性抛空指针异常
接上一个案例在宿舍管理系统中我们使用了charge()方法进行支付。在统一结算的时候我们会用到一个管理员用户付款编号,这时候就用到了几个新的类。
User类包含用户的付款编号信息
```
public class User {
private String payNum;
public User(String payNum) {
this.payNum = payNum;
}
public String getPayNum() {
return payNum;
}
public void setPayNum(String payNum) {
this.payNum = payNum;
}
}
```
AdminUserService类包含一个管理员用户User其付款编号为202101166另外这个服务类有一个login()方法,用来登录系统。
```
@Service
public class AdminUserService {
public final User adminUser = new User(&quot;202101166&quot;);
public void login() {
System.out.println(&quot;admin user login...&quot;);
}
}
```
我们需要修改ElectricService类实现这个需求在电费充值时需要管理员登录并使用其编号进行结算。完整代码如下
```
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class ElectricService {
@Autowired
private AdminUserService adminUserService;
public void charge() throws Exception {
System.out.println(&quot;Electric charging ...&quot;);
this.pay();
}
public void pay() throws Exception {
adminUserService.login();
String payNum = adminUserService.adminUser.getPayNum();
System.out.println(&quot;User pay num : &quot; + payNum);
System.out.println(&quot;Pay with alipay ...&quot;);
Thread.sleep(1000);
}
}
```
代码完成后执行charge()操作,一切正常:
```
Electric charging ...
admin user login...
User pay num : 202101166
Pay with alipay ...
```
这时候由于安全需要就需要管理员在登录时记录一行日志以便于以后审计管理员操作。所以我们添加一个AOP相关配置类具体如下
```
@Aspect
@Service
@Slf4j
public class AopConfig {
@Before(&quot;execution(* com.spring.puzzle.class5.example2.AdminUserService.login(..)) &quot;)
public void logAdminLogin(JoinPoint pjp) throws Throwable {
System.out.println(&quot;! admin login ...&quot;);
}
}
```
添加这段代码后我们执行charge()操作发现不仅没有相关日志而且在执行下面这一行代码的时候直接抛出了NullPointerException
>
String payNum = dminUserService.user.getPayNum();
本来一切正常的代码因为引入了一个AOP切面抛出了NullPointerException。这会是什么原因呢我们先debug一下来看看加入AOP后调用的对象是什么样子。
<img src="https://static001.geekbang.org/resource/image/cd/a2/cd48479a45c2b06621c2e07a33f519a2.png" alt="">
可以看出加入AOP后我们的对象已经是一个代理对象了如果你眼尖的话就会发现在上图中属性adminUser确实为null。为什么会这样为了解答这个诡异的问题我们需要进一步理解Spring使用CGLIB生成Proxy的原理。
### 案例解析
我们在上一个案例中解析了创建Spring Proxy的大体过程在这里我们需要进一步研究一下通过Proxy创建出来的是一个什么样的对象。正常情况下AdminUserService只是一个普通的对象而AOP增强过的则是一个AdminUserService $$EnhancerBySpringCGLIB$$xxxx。
这个类实际上是AdminUserService的一个子类。它会overwrite所有public和protected方法并在内部将调用委托给原始的AdminUserService实例。
从具体实现角度看CGLIB中AOP的实现是基于org.springframework.cglib.proxy包中 Enhancer和MethodInterceptor两个接口来实现的。
**整个过程,我们可以概括为三个步骤:**
- 定义自定义的MethodInterceptor负责委托方法执行
- 创建Enhance并设置Callback为上述MethodInterceptor
- enhancer.create()创建代理。
接下来我们来具体分析一下Spring的相关实现源码。
在上个案例分析里我们简要提及了Spring的动态代理对象的初始化机制。在得到Advisors之后会通过ProxyFactory.getProxy获取代理对象
```
public Object getProxy(ClassLoader classLoader) {
return createAopProxy().getProxy(classLoader);
}
```
在这里我们以CGLIB的Proxy的实现类CglibAopProxy为例来看看具体的流程
```
public Object getProxy(@Nullable ClassLoader classLoader) {
// 省略非关键代码
// 创建及配置 Enhancer
Enhancer enhancer = createEnhancer();
// 省略非关键代码
// 获取Callback包含DynamicAdvisedInterceptor亦是MethodInterceptor
Callback[] callbacks = getCallbacks(rootClass);
// 省略非关键代码
// 生成代理对象并创建代理(设置 enhancer 的 callback 值)
return createProxyClassAndInstance(enhancer, callbacks);
// 省略非关键代码
}
```
上述代码中的几个关键步骤大体符合之前提及的三个步骤其中最后一步一般都会执行到CglibAopProxy子类ObjenesisCglibAopProxy的createProxyClassAndInstance()方法:
```
protected Object createProxyClassAndInstance(Enhancer enhancer, Callback[] callbacks) {
//创建代理类Class
Class&lt;?&gt; proxyClass = enhancer.createClass();
Object proxyInstance = null;
//spring.objenesis.ignore默认为false
//所以objenesis.isWorthTrying()一般为true
if (objenesis.isWorthTrying()) {
try {
// 创建实例
proxyInstance = objenesis.newInstance(proxyClass, enhancer.getUseCache());
}
catch (Throwable ex) {
// 省略非关键代码
}
}
if (proxyInstance == null) {
// 尝试普通反射方式创建实例
try {
Constructor&lt;?&gt; ctor = (this.constructorArgs != null ?
proxyClass.getDeclaredConstructor(this.constructorArgTypes) :
proxyClass.getDeclaredConstructor());
ReflectionUtils.makeAccessible(ctor);
proxyInstance = (this.constructorArgs != null ?
ctor.newInstance(this.constructorArgs) : ctor.newInstance());
//省略非关键代码
}
}
// 省略非关键代码
((Factory) proxyInstance).setCallbacks(callbacks);
return proxyInstance;
}
```
这里我们可以了解到Spring会默认尝试使用objenesis方式实例化对象如果失败则再次尝试使用常规方式实例化对象。现在我们可以进一步查看objenesis方式实例化对象的流程。
<img src="https://static001.geekbang.org/resource/image/42/34/422160a6fd0c3ee1af8b05769a015834.png" alt="">
参照上述截图所示调用栈objenesis方式最后使用了JDK的ReflectionFactory.newConstructorForSerialization()完成了代理对象的实例化。而如果你稍微研究下这个方法,你会惊讶地发现,这种方式创建出来的对象是不会初始化类成员变量的。
所以说到这里,聪明的你可能已经觉察到真相已经暴露了,我们这个案例的核心是代理类实例的默认构建方式很特别。在这里,我们可以总结和对比下通过反射来实例化对象的方式,包括:
- java.lang.Class.newInsance()
- java.lang.reflect.Constructor.newInstance()
- sun.reflect.ReflectionFactory.newConstructorForSerialization().newInstance()
前两种初始化方式都会同时初始化类成员变量但是最后一种通过ReflectionFactory.newConstructorForSerialization().newInstance()实例化类则不会初始化类成员变量,这就是当前问题的最终答案了。
### 问题修正
了解了问题的根本原因后修正起来也就不困难了。既然是无法直接访问被拦截类的成员变量那我们就换个方式在UserService里写个getUser()方法,从内部访问获取变量。
我们在AdminUserService里加了个getUser()方法:
```
public User getUser() {
return user;
}
```
在ElectricService里通过getUser()获取User对象
>
<p>//原来出错的方式:<br>
//String payNum = = adminUserService.adminUser.getPayNum();<br>
//修改后的方式:<br>
String payNum = adminUserService.getAdminUser().getPayNum();</p>
运行下来,一切正常,可以看到管理员登录日志了:
```
Electric charging ...
! admin login ...
admin user login...
User pay num : 202101166
Pay with alipay ...
```
但你有没有产生另一个困惑呢既然代理类的类属性不会被初始化那为什么可以通过在AdminUserService里写个getUser()方法来获取代理类实例的属性呢?
我们再次回顾createProxyClassAndInstance的代码逻辑创建代理类后我们会调用setCallbacks来设置拦截后需要注入的代码
```
protected Object createProxyClassAndInstance(Enhancer enhancer, Callback[] callbacks) {
Class&lt;?&gt; proxyClass = enhancer.createClass();
Object proxyInstance = null;
if (objenesis.isWorthTrying()) {
try {
proxyInstance = objenesis.newInstance(proxyClass, enhancer.getUseCache());
}
// 省略非关键代码
((Factory) proxyInstance).setCallbacks(callbacks);
return proxyInstance;
}
```
通过代码调试和分析我们可以得知上述的callbacks中会存在一种服务于AOP的DynamicAdvisedInterceptor它的接口是MethodInterceptorcallback的子接口实现了拦截方法intercept()。我们可以看下它是如何实现这个方法的:
```
public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
// 省略非关键代码
TargetSource targetSource = this.advised.getTargetSource();
// 省略非关键代码
if (chain.isEmpty() &amp;&amp; Modifier.isPublic(method.getModifiers())) {
Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
retVal = methodProxy.invoke(target, argsToUse);
}
else {
// We need to create a method invocation...
retVal = new CglibMethodInvocation(proxy, target, method, args, targetClass, chain, methodProxy).proceed();
}
retVal = processReturnType(proxy, target, method, retVal);
return retVal;
}
//省略非关键代码
}
```
当代理类方法被调用会被Spring拦截从而进入此intercept(),并在此方法中获取被代理的原始对象。而在原始对象中,类属性是被实例化过且存在的。因此代理类是可以通过方法拦截获取被代理对象实例的属性。
说到这里我们已经解决了问题。但如果你看得仔细就会发现其实你改变一个属性也可以让产生的代理对象的属性值不为null。例如修改启动参数spring.objenesis.ignore如下
<img src="https://static001.geekbang.org/resource/image/83/7e/83e34cbd460ac74c5d623905dce0497e.png" alt="">
此时再调试程序你会发现adminUser已经不为null了
<img src="https://static001.geekbang.org/resource/image/3b/b1/3b2dd77392c3b439d0a182f5817045b1.png" alt="">
所以这也是解决这个问题的一种方法,相信聪明的你已经能从前文贴出的代码中找出它能够工作起来的原理了。
## 重点回顾
通过以上两个案例的介绍相信你对Spring AOP动态代理的初始化机制已经有了进一步的了解这里总结重点如下
<li>
使用AOP实际上就是让Spring自动为我们创建一个Proxy使得调用者能无感知地调用指定方法。而Spring有助于我们在运行期里动态织入其它逻辑因此AOP本质上就是一个动态代理。
</li>
<li>
我们只有访问这些代理对象的方法才能获得AOP实现的功能所以通过this引用是无法正确使用AOP功能的。在不能改变代码结果前提下我们可以通过@Autowired、AopContext.currentProxy()等方式获取相应的代理对象来实现所需的功能。
</li>
<li>
我们一般不能直接从代理类中去拿被代理类的属性这是因为除非我们显示设置spring.objenesis.ignore为true否则代理类的属性是不会被Spring初始化的我们可以通过在被代理类中增加一个方法来间接获取其属性。
</li>
## 思考题
第二个案例中,我们提到了通过反射来实例化类的三种方式:
- java.lang.Class.newInsance()
- java.lang.reflect.Constructor.newInstance()
- sun.reflect.ReflectionFactory.newConstructorForSerialization().newInstance()
其中第三种方式不会初始化类属性,你能够写一个例子来证明这一点吗?
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,506 @@
<audio id="audio" title="06Spring AOP 常见错误(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/61/38a05d0d652ec8ae40959310f4dc7261.mp3"></audio>
你好,我是傅健。
上一节课,我们介绍了 Spring AOP 常遇到的几个问题,通过具体的源码解析,相信你对 Spring AOP 的基本原理已经有所了解了。不过AOP 毕竟是 Spring 的核心功能之一,不可能规避那零散的两三个问题就一劳永逸了。所以这节课,我们继续聊聊 Spring AOP 中还会有哪些易错点。实际上,当一个系统采用的切面越来越多时,因为执行顺序而导致的问题便会逐步暴露出来,下面我们就重点看一下。
## 案例1错乱混合不同类型的增强
还是沿用上节课的宿舍管理系统开发场景。
这里我们先回顾下,你就不用去翻代码了。这个宿舍管理系统保护了一个电费充值模块,它包含了一个负责电费充值的类 ElectricService还有一个充电方法 charge()
```
@Service
public class ElectricService {
public void charge() throws Exception {
System.out.println(&quot;Electric charging ...&quot;);
}
}
```
为了在执行 charge() 之前,鉴定下调用者的权限,我们增加了针对于 Electric 的切面类 AopConfig其中包含一个 @Before 增强。这里的增强没有做任何事情,仅仅是打印了一行日志,然后模拟执行权限校验功能(占用 1 秒钟)。
```
//省略 imports
@Aspect
@Service
@Slf4j
public class AspectService {
@Before(&quot;execution(* com.spring.puzzle.class6.example1.ElectricService.charge()) &quot;)
public void checkAuthority(JoinPoint pjp) throws Throwable {
System.out.println(&quot;validating user authority&quot;);
Thread.sleep(1000);
}
}
```
执行后,我们得到以下 log接着一切按照预期继续执行
```
validating user authority
Electric charging ...
```
一段时间后由于业务发展ElectricService 中的 charge() 逻辑变得更加复杂了,我们需要仅仅针对 ElectricService 的 charge() 做性能统计。为了不影响原有的业务逻辑,我们在 AopConfig 中添加了另一个增强,代码更改后如下:
```
//省略 imports
@Aspect
@Service
public class AopConfig {
@Before(&quot;execution(* com.spring.puzzle.class6.example1.ElectricService.charge()) &quot;)
public void checkAuthority(JoinPoint pjp) throws Throwable {
System.out.println(&quot;validating user authority&quot;);
Thread.sleep(1000);
}
@Around(&quot;execution(* com.spring.puzzle.class6.example1.ElectricService.charge()) &quot;)
public void recordPerformance(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
pjp.proceed();
long end = System.currentTimeMillis();
System.out.println(&quot;charge method time cost: &quot; + (end - start));
}
}
```
执行后得到日志如下:
>
<p>validating user authority<br>
Electric charging ...<br>
charge method time cost 1022 (ms)</p>
通过性能统计打印出的日志,我们可以得知 charge() 执行时间超过了 1 秒钟。然而,该方法仅打印了一行日志,它的执行不可能需要这么长时间。
因此我们很容易看出问题所在:当前 ElectricService 中 charge() 的执行时间,包含了权限验证的时间,即包含了通过 @Around 增强的 checkAuthority() 执行的所有时间。这并不符合我们的初衷,我们需要统计的仅仅是 ElectricService.charge() 的性能统计,它并不包含鉴权过程。
当然,这些都是从日志直接观察出的现象。实际上,这个问题出现的根本原因和 AOP 的执行顺序有关。针对这个案例而言当同一个切面Aspect中同时包含多个不同类型的增强时Around、Before、After、AfterReturning、AfterThrowing 等),它们的执行是有顺序的。那么顺序如何?我们不妨来解析下。
### 案例解析
其实一切都可以从源码中得到真相!在[第04课](https://time.geekbang.org/column/article/367876)我们曾经提到过Spring 初始化单例类的一般过程,基本都是 getBean()-&gt;doGetBean()-&gt;getSingleton(),如果发现 Bean 不存在,则调用 createBean()-&gt;doCreateBean() 进行实例化。
而如果我们的代码里使用了 Spring AOPdoCreateBean() 最终会返回一个代理对象。至于代理对象如何创建,大体流程我们在上一讲已经概述过了。如果你记忆力比较好的话,应该记得在代理对象的创建过程中,我们贴出过这样一段代码(参考 AbstractAutoProxyCreator#createProxy
```
protected Object createProxy(Class&lt;?&gt; beanClass, @Nullable String beanName,
@Nullable Object[] specificInterceptors, TargetSource targetSource) {
//省略非关键代码
Advisor[] advisors = buildAdvisors(beanName, specificInterceptors);
proxyFactory.addAdvisors(advisors);
proxyFactory.setTargetSource(targetSource);
//省略非关键代码
return proxyFactory.getProxy(getProxyClassLoader());
}
```
其中 advisors 就是增强方法对象,它的顺序决定了面临多个增强时,到底先执行谁。而这个集合对象本身是由 specificInterceptors 构建出来的,而 specificInterceptors 又是由 AbstractAdvisorAutoProxyCreator#getAdvicesAndAdvisorsForBean 方法构建:
```
@Override
@Nullable
protected Object[] getAdvicesAndAdvisorsForBean(
Class&lt;?&gt; beanClass, String beanName, @Nullable TargetSource targetSource) {
List&lt;Advisor&gt; advisors = findEligibleAdvisors(beanClass, beanName);
if (advisors.isEmpty()) {
return DO_NOT_PROXY;
}
return advisors.toArray();
}
```
简单说,其实就是根据当前的 beanClass、beanName 等信息,结合所有候选的 advisors最终找出匹配Eligible的 Advisor为什么如此毕竟 AOP 拦截点可能会配置多个,而我们执行的方法不见得会被所有的拦截配置拦截。寻找匹配 Advisor 的逻辑参考 AbstractAdvisorAutoProxyCreator#findEligibleAdvisors
```
protected List&lt;Advisor&gt; findEligibleAdvisors(Class&lt;?&gt; beanClass, String beanName) {
//寻找候选的 Advisor
List&lt;Advisor&gt; candidateAdvisors = findCandidateAdvisors();
//根据候选的 Advisor 和当前 bean 算出匹配的 Advisor
List&lt;Advisor&gt; eligibleAdvisors = findAdvisorsThatCanApply(candidateAdvisors, beanClass, beanName);
extendAdvisors(eligibleAdvisors);
if (!eligibleAdvisors.isEmpty()) {
//排序
eligibleAdvisors = sortAdvisors(eligibleAdvisors);
}
return eligibleAdvisors;
}
```
通过研读代码,最终 Advisors 的顺序是由两点决定:
1. candidateAdvisors 的顺序;
1. sortAdvisors 进行的排序。
这里我们可以重点看下对本案例起关键作用的 candidateAdvisors 排序。实际上,它的顺序是在 @Aspect 标记的 AopConfig Bean 构建时就决定了。具体而言,就是在初始化过程中会排序自己配置的 Advisors并把排序结果存入了缓存BeanFactoryAspectJAdvisorsBuilder#advisorsCache)。
后续 Bean 创建代理时,直接拿出这个排序好的候选 Advisors。候选 Advisors 排序发生在 Bean 构建这个结论时,我们也可以通过 AopConfig Bean 构建中的堆栈信息验证:
<img src="https://static001.geekbang.org/resource/image/61/d1/611f386b14b05c2d151340d31f34e3d1.png" alt="">
可以看到,排序是在 Bean 的构建中进行的,而最后排序执行的关键代码位于下面的方法中(参考 ReflectiveAspectJAdvisorFactory#getAdvisorMethods
```
private List&lt;Method&gt; getAdvisorMethods(Class&lt;?&gt; aspectClass) {
final List&lt;Method&gt; methods = new ArrayList&lt;&gt;();
ReflectionUtils.doWithMethods(aspectClass, method -&gt; {
// Exclude pointcuts
if (AnnotationUtils.getAnnotation(method, Pointcut.class) == null) {
methods.add(method);
}
}, ReflectionUtils.USER_DECLARED_METHODS);
// 排序
methods.sort(METHOD_COMPARATOR);
return methods;
}
```
上述代码的重点是第九行 methods.sort(METHOD_COMPARATOR)方法。
我们来查看 METHOD_COMPARATOR 的代码,会发现它是定义在 ReflectiveAspectJAdvisorFactory 类中的静态方法块,代码如下:
```
static {
Comparator&lt;Method&gt; adviceKindComparator = new ConvertingComparator&lt;&gt;(
new InstanceComparator&lt;&gt;(
Around.class, Before.class, After.class, AfterReturning.class, AfterThrowing.class),
(Converter&lt;Method, Annotation&gt;) method -&gt; {
AspectJAnnotation&lt;?&gt; annotation =
AbstractAspectJAdvisorFactory.findAspectJAnnotationOnMethod(method);
return (annotation != null ? annotation.getAnnotation() : null);
});
Comparator&lt;Method&gt; methodNameComparator = new ConvertingComparator&lt;&gt;(Method::getName);
//合并上面两者比较器
METHOD_COMPARATOR = adviceKindComparator.thenComparing(methodNameComparator);
}
```
METHOD_COMPARATOR 本质上是一个连续比较器,由 adviceKindComparator 和 methodNameComparator 这两个比较器通过 thenComparing()连接而成。
通过这个案例,我们重点了解 adviceKindComparator 这个比较器,此对象通过实例化 ConvertingComparator 类而来,而 ConvertingComparator 类是 Spring 中较为经典的一个实现。顾名思义,先转化再比较,它构造参数接受以下这两个参数:
- 第一个参数是基准比较器,即在 adviceKindComparator 中最终要调用的比较器,在构造函数中赋值于 this.comparator
- 第二个参数是一个 lambda 回调函数,用来将传递的参数转化为基准比较器需要的参数类型,在构造函数中赋值于 this.converter。
查看 ConvertingComparator 比较器核心方法 compare 如下:
```
public int compare(S o1, S o2) {
T c1 = this.converter.convert(o1);
T c2 = this.converter.convert(o2);
return this.comparator.compare(c1, c2);
}
```
可知,这里是先调用从构造函数中获取到的 lambda 回调函数 this.converter将需要比较的参数进行转化。我们可以从之前的代码中找出这个转化工作
```
(Converter&lt;Method, Annotation&gt;) method -&gt; {
AspectJAnnotation&lt;?&gt; annotation =
AbstractAspectJAdvisorFactory.findAspectJAnnotationOnMethod(method);
return (annotation != null ? annotation.getAnnotation() : null);
});
```
转化功能的代码逻辑较为简单就是返回传入方法method上标记的增强注解Pointcut,Around,Before,After,AfterReturning 以及 AfterThrowing
```
private static final Class&lt;?&gt;[] ASPECTJ_ANNOTATION_CLASSES = new Class&lt;?&gt;[] {
Pointcut.class, Around.class, Before.class, After.class, AfterReturning.class, AfterThrowing.class};
protected static AspectJAnnotation&lt;?&gt; findAspectJAnnotationOnMethod(Method method) {
for (Class&lt;?&gt; clazz : ASPECTJ_ANNOTATION_CLASSES) {
AspectJAnnotation&lt;?&gt; foundAnnotation = findAnnotation(method, (Class&lt;Annotation&gt;) clazz);
if (foundAnnotation != null) {
return foundAnnotation;
}
}
return null;
}
```
经过转化后,我们获取到的待比较的数据其实就是注解了。而它们的排序依赖于 ConvertingComparator 的第一个参数,即最终会调用的基准比较器,以下是它的关键实现代码:
```
new InstanceComparator&lt;&gt;(
Around.class, Before.class, After.class, AfterReturning.class, AfterThrowing.class)
```
最终我们要调用的基准比较器本质上就是一个 InstanceComparator 类,我们先重点注意下这几个增强注解的传递顺序。继续查看它的构造方法如下:
```
public InstanceComparator(Class&lt;?&gt;... instanceOrder) {
Assert.notNull(instanceOrder, &quot;'instanceOrder' array must not be null&quot;);
this.instanceOrder = instanceOrder;
}
```
构造方法也是较为简单的,只是将传递进来的 instanceOrder 赋予了类成员变量,继续查看 InstanceComparator 比较器核心方法 compare 如下,也就是最终要调用的比较方法:
```
public int compare(T o1, T o2) {
int i1 = getOrder(o1);
int i2 = getOrder(o2);
return (i1 &lt; i2 ? -1 : (i1 == i2 ? 0 : 1));
}
```
一个典型的 Comparator代码逻辑按照 i1、i2 的升序排列,即 getOrder() 返回的值越小,排序越靠前。
查看 getOrder() 的逻辑如下:
```
private int getOrder(@Nullable T object) {
if (object != null) {
for (int i = 0; i &lt; this.instanceOrder.length; i++) {
//instance 在 instanceOrder 中的“排号”
if (this.instanceOrder[i].isInstance(object)) {
return i;
}
}
}
return this.instanceOrder.length;
}
```
返回当前传递的增强注解在 this.instanceOrder 中的序列值,序列值越小,则越靠前。而结合之前构造参数传递的顺序,我们很快就能判断出:最终的排序结果依次是 Around.class, Before.class, After.class, AfterReturning.class, AfterThrowing.class。
到此为止答案也呼之欲出this.instanceOrder 的排序,即为不同类型增强的优先级,**排序越靠前,优先级越高**。
结合之前的讨论我们可以得出一个结论同一个切面中不同类型的增强方法被调用的顺序依次为Around.class, Before.class, After.class, AfterReturning.class, AfterThrowing.class。
### 问题修正
从上述案例解析中,我们知道 Around 类型的增强被调用的优先级高于 Before 类型的增强,所以上述案例中性能统计所花费的时间,包含权限验证的时间,也在情理之中。
知道了原理,修正起来也就简单了。假设不允许我们去拆分类,我们可以按照下面的思路来修改:
1. 将 ElectricService.charge() 的业务逻辑全部移动到 doCharge(),在 charge() 中调用 doCharge()
1. 性能统计只需要拦截 doCharge()
1. 权限统计增强保持不变,依然拦截 charge()。
ElectricService 类代码更改如下:
```
@Service
public class ElectricService {
public void charge() {
doCharge();
}
public void doCharge() {
System.out.println(&quot;Electric charging ...&quot;);
}
}
```
切面代码更改如下:
```
//省略 imports
@Aspect
@Service
public class AopConfig {
@Before(&quot;execution(* com.spring.puzzle.class6.example1.ElectricService.charge()) &quot;)
public void checkAuthority(JoinPoint pjp) throws Throwable {
System.out.println(&quot;validating user authority&quot;);
Thread.sleep(1000);
}
@Around(&quot;execution(* com.spring.puzzle.class6.example1.ElectricService.doCharge()) &quot;)
public void recordPerformance(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
pjp.proceed();
long end = System.currentTimeMillis();
System.out.println(&quot;charge method time cost: &quot; + (end - start));
}
}
```
## 案例 2错乱混合同类型增强
那学到这里,你可能还有疑问,如果同一个切面里的多个增强方法其增强都一样,那调用顺序又如何呢?我们继续看下一个案例。
这里业务逻辑类 ElectricService 没有任何变化,仅包含一个 charge()
```
import org.springframework.stereotype.Service;
@Service
public class ElectricService {
public void charge() {
System.out.println(&quot;Electric charging ...&quot;);
}
}
```
切面类 AspectService 包含两个方法,都是 Before 类型增强。
第一个方法 logBeforeMethod(),目的是在 run() 执行之前希望能输入日志,表示当前方法被调用一次,方便后期统计。另一个方法 validateAuthority(),目的是做权限验证,其作用是在调用此方法之前做权限验证,如果不符合权限限制要求,则直接抛出异常。这里为了方便演示,此方法将直接抛出异常:
```
//省略 imports
@Aspect
@Service
public class AopConfig {
@Before(&quot;execution(* com.spring.puzzle.class5.example2.ElectricService.charge())&quot;)
public void logBeforeMethod(JoinPoint pjp) throws Throwable {
System.out.println(&quot;step into -&gt;&quot;+pjp.getSignature());
}
@Before(&quot;execution(* com.spring.puzzle.class5.example2.ElectricService.charge()) &quot;)
public void validateAuthority(JoinPoint pjp) throws Throwable {
throw new RuntimeException(&quot;authority check failed&quot;);
}
}
```
我们对代码的执行预期为:当鉴权失败时,由于 ElectricService.charge() 没有被调用,那么 run() 的调用日志也不应该被输出,即 logBeforeMethod() 不应该被调用,但事实总是出乎意料,执行结果如下:
>
<p>step into -&gt;void com.spring.puzzle.class6.example2.Electric.charge()<br>
Exception in thread "main" java.lang.RuntimeException: authority check failed</p>
虽然鉴权失败,抛出了异常且 ElectricService.charge() 没有被调用,但是 logBeforeMethod() 的调用日志却被输出了,这将导致后期针对于 ElectricService.charge() 的调用数据统计严重失真。
这里我们就需要搞清楚一个问题:当同一个切面包含多个同一种类型的多个增强,且修饰的都是同一个方法时,这多个增强的执行顺序是怎样的?
### 案例解析
我们继续从源代码中寻找真相!你应该还记得上述代码中,定义 METHOD_COMPARATOR 的静态代码块吧。
METHOD_COMPARATOR 本质是一个连续比较器,而上个案例中我们仅仅只看了第一个比较器,细心的你肯定发现了这里还有第二个比较器 methodNameComparator任意两个比较器都可以通过其内置的 thenComparing() 连接形成一个连续比较器,从而可以让我们按照比较器的连接顺序依次比较:
```
static {
//第一个比较器,用来按照增强类型排序
Comparator&lt;Method&gt; adviceKindComparator = new ConvertingComparator&lt;&gt;(
new InstanceComparator&lt;&gt;(
Around.class, Before.class, After.class, AfterReturning.class, AfterThrowing.class),
(Converter&lt;Method, Annotation&gt;) method -&gt; {
AspectJAnnotation&lt;?&gt; annotation =
AbstractAspectJAdvisorFactory.findAspectJAnnotationOnMethod(method);
return (annotation != null ? annotation.getAnnotation() : null);
})
//第二个比较器,用来按照方法名排序
Comparator&lt;Method&gt; methodNameComparator = new ConvertingComparator&lt;&gt;(Method::getName);
METHOD_COMPARATOR = adviceKindComparator.thenComparing(methodNameComparator);
}
```
我们可以看到,在第 12 行代码中,第 2 个比较器 methodNameComparator 依然使用的是 ConvertingComparator传递了方法名作为参数。我们基本可以猜测出该比较器是按照方法名进行排序的这里可以进一步查看构造器方法及构造器调用的内部 comparable()
```
public ConvertingComparator(Converter&lt;S, T&gt; converter) {
this(Comparators.comparable(), converter);
}
// 省略非关键代码
public static &lt;T&gt; Comparator&lt;T&gt; comparable() {
return ComparableComparator.INSTANCE;
}
```
上述代码中的 ComparableComparator 实例其实极其简单,代码如下:
```
public class ComparableComparator&lt;T extends Comparable&lt;T&gt;&gt; implements Comparator&lt;T&gt; {
public static final ComparableComparator INSTANCE = new ComparableComparator();
@Override
public int compare(T o1, T o2) {
return o1.compareTo(o2);
}
}
```
答案和我们的猜测完全一致methodNameComparator 最终调用了 String 类自身的 compareTo(),代码如下:
```
public int compareTo(String anotherString) {
int len1 = value.length;
int len2 = anotherString.value.length;
int lim = Math.min(len1, len2);
char v1[] = value;
char v2[] = anotherString.value;
int k = 0;
while (k &lt; lim) {
char c1 = v1[k];
char c2 = v2[k];
if (c1 != c2) {
return c1 - c2;
}
k++;
}
return len1 - len2;
}
```
到这,答案揭晓:如果两个方法名长度相同,则依次比较每一个字母的 ASCII 码ASCII 码越小,排序越靠前;若长度不同,且短的方法名字符串是长的子集时,短的排序靠前。
### 问题修正
从上述分析我们得知,在同一个切面配置类中,针对同一个方法存在多个同类型增强时,其执行顺序仅和当前增强方法的名称有关,而不是由谁代码在先、谁代码在后来决定。了解了这点,我们就可以直接通过调整方法名的方式来修正程序:
```
//省略 imports
@Aspect
@Service
public class AopConfig {
@Before(&quot;execution(* com.spring.puzzle.class6.example2.ElectricService.charge())&quot;)
public void logBeforeMethod(JoinPoint pjp) throws Throwable {
System.out.println(&quot;step into -&gt;&quot;+pjp.getSignature());
}
@Before(&quot;execution(* com.spring.puzzle.class6.example2.ElectricService.charge()) &quot;)
public void checkAuthority(JoinPoint pjp) throws Throwable {
throw new RuntimeException(&quot;authority check failed&quot;);
}
}
```
我们可以将原来的 validateAuthority() 改为 checkAuthority(),这种情况下,**对增强Advisor的排序其实最后就是在比较字符 l 和 字符 c**。显然易见checkAuthority()的排序会靠前,从而被优先执行,最终问题得以解决。
## 重点回顾
通过学习这两个案例,相信你对 Spring AOP 增强方法的执行顺序已经有了较为深入的理解。这里我来总结下关键点:
- 在同一个切面配置中,如果存在多个不同类型的增强,那么其执行优先级是按照增强类型的特定顺序排列,依次的增强类型为 Around.class, Before.class, After.class, AfterReturning.class, AfterThrowing.class
- 在同一个切面配置中,如果存在多个相同类型的增强,那么其执行优先级是按照该增强的方法名排序,排序方式依次为比较方法名的每一个字母,直到发现第一个不相同且 ASCII 码较小的字母。
同时,这节课我们也拓展了一些比较器相关的知识:
- 任意两个比较器Comparator可以通过 thenComparing() 连接合成一个新的连续比较器;
- 比较器的比较规则有一个简单的方法可以帮助你理解,就是最终一定需要对象两两比较,而比较的过程一定是比较这两个对象的同种属性。你只要抓住这两点:比较了什么属性以及比较的结果是什么就可以了,若比较结果为正数,则按照该属性的升序排列;若为负数,则按属性降序排列。
## 思考题
实际上,审阅上面两个案例的修正方案,你会发现它们虽然改动很小,但是都还不够优美。那么有没有稍微优美点的替代方案呢?如果有,你知道背后的原理及关键源码吗?顺便你也可以想想,我为什么没有用更优美的方案呢?
期待在留言区看到你的思考,我们下节课再见!

View File

@@ -0,0 +1,556 @@
<audio id="audio" title="07Spring事件常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/83/298ec04b83a2e5d9d0306a7a0c224483.mp3"></audio>
你好我是傅健这节课我们聊聊Spring事件上的常见错误。
前面的几讲中我们介绍了Spring依赖注入、AOP等核心功能点上的常见错误。而作为Spring 的关键功能支撑Spring事件是一个相对独立的点。或许你从没有在自己的项目中使用过Spring事件但是你一定见过它的相关日志。而且在未来的编程实践中你会发现一旦你用上了Spring事件往往完成的都是一些有趣的、强大的功能例如动态配置。那么接下来我就来讲讲Spring事件上都有哪些常见的错误。
## 案例1试图处理并不会抛出的事件
Spring事件的设计比较简单。说白了就是监听器设计模式在Spring中的一种实现参考下图
<img src="https://static001.geekbang.org/resource/image/34/c6/349f79e396276ab3744c04b0a29eccc6.jpg" alt="">
从图中我们可以看出Spring事件包含以下三大组件。
1. 事件Event用来区分和定义不同的事件在Spring中常见的如ApplicationEvent和AutoConfigurationImportEvent它们都继承于java.util.EventObject。
1. 事件广播器Multicaster负责发布上述定义的事件。例如负责发布ApplicationEvent 的ApplicationEventMulticaster就是Spring中一种常见的广播器。
1. 事件监听器Listener负责监听和处理广播器发出的事件例如ApplicationListener就是用来处理ApplicationEventMulticaster发布的ApplicationEvent它继承于JDK的 EventListener我们可以看下它的定义来验证这个结论
>
<p>public interface ApplicationListener&lt;E extends ApplicationEvent&gt; extends EventListener {<br>
void onApplicationEvent(E event);<br>
}</p>
当然虽然在上述组件中任何一个都是缺一不可的但是功能模块命名不见得完全贴合上述提及的关键字例如发布AutoConfigurationImportEvent的广播器就不含有Multicaster字样。它的发布是由AutoConfigurationImportSelector来完成的。
对这些基本概念和实现有了一定的了解后我们就可以开始解析那些常见的错误。闲话少说我们先来看下面这段基于Spring Boot技术栈的代码
```
@Slf4j
@Component
public class MyContextStartedEventListener implements ApplicationListener&lt;ContextStartedEvent&gt; {
public void onApplicationEvent(final ContextStartedEvent event) {
log.info(&quot;{} received: {}&quot;, this.toString(), event);
}
}
```
很明显这段代码定义了一个监听器MyContextStartedEventListener试图拦截ContextStartedEvent。因为在很多Spring初级开发者眼中Spring运转的核心就是一个Context的维护那么启动Spring自然会启动Context于是他们是很期待出现类似下面的日志的
>
2021-03-07 07:08:21.197 INFO 2624 --- [nio-8080-exec-1] c.s.p.l.e.MyContextStartedEventListener : com.spring.puzzle.class7.example1.MyContextStartedEventListener@d33d5a **received**: org.springframework.context.event.**ContextStartedEvent**[source=org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@19b56c0, started on Sun Mar 07 07:07:57 CST 2021]
但是当我们启动Spring Boot后会发现并不会拦截到这个事件如何理解这个错误呢
### 案例解析
在Spring事件运用上这是一个常见的错误就是不假思索地认为一个框架只要定义了一个事件那么一定会抛出来。例如在本案例中ContextStartedEvent就是Spring内置定义的事件而Spring Boot本身会创建和运维Context表面看起来这个事件的抛出是必然的但是这个事件一定会在Spring Boot启动时抛出来么
答案明显是否定的我们首先看下要抛出这个事件需要调用的方法是什么在Spring Boot中这个事件的抛出只发生在一处即位于方法AbstractApplicationContext#start中
```
@Override
public void start() {
getLifecycleProcessor().start();
publishEvent(new ContextStartedEvent(this));
}
```
也就是说只有上述方法被调用才会抛出ContextStartedEvent但是这个方法在Spring Boot启动时会被调用么我们可以查看Spring启动方法中围绕Context的关键方法调用代码如下
```
public ConfigurableApplicationContext run(String... args) {
//省略非关键代码
context = createApplicationContext();
//省略非关键代码
prepareContext(context, environment, listeners, applicationArguments, printedBanner);
refreshContext(context);
//省略非关键代码
return context;
}
```
我们发现围绕Context、Spring Boot的启动只做了两个关键工作创建Context和Refresh Context。其中Refresh的关键代码如下
```
protected void refresh(ApplicationContext applicationContext) {
Assert.isInstanceOf(AbstractApplicationContext.class, applicationContext);
((AbstractApplicationContext) applicationContext).refresh();
}
```
很明显Spring启动最终调用的是AbstractApplicationContext#refresh,并不是 AbstractApplicationContext#start。在这样的残酷现实下ContextStartedEvent自然不会被抛出不抛出自然也不可能被捕获。所以这样的错误也就自然发生了。
### 问题修正
针对这个案例,有了源码的剖析,我们可以很快找到问题发生的原因,但是修正这个问题还要去追溯我们到底想要的是什么?我们可以分两种情况来考虑。
**1. 假设我们是误读了ContextStartedEvent。**
针对这种情况往往是因为我们确实想在Spring Boot启动时拦截一个启动事件但是我们粗略扫视相关事件后误以为ContextStartedEvent就是我们想要的。针对这种情况我们只需要把监听事件的类型修改成真正发生的事件即可例如在本案例中我们可以修正如下
```
@Component
public class MyContextRefreshedEventListener implements ApplicationListener&lt;ContextRefreshedEvent&gt; {
public void onApplicationEvent(final ContextRefreshedEvent event) {
log.info(&quot;{} received: {}&quot;, this.toString(), event);
}
}
```
我们监听ContextRefreshedEvent而非ContextStartedEvent。ContextRefreshedEvent的抛出可以参考方法AbstractApplicationContext#finishRefresh它本身正好是Refresh操作中的一步。
```
protected void finishRefresh() {
//省略非关键代码
initLifecycleProcessor();
// Propagate refresh to lifecycle processor first.
getLifecycleProcessor().onRefresh();
// Publish the final event.
publishEvent(new ContextRefreshedEvent(this));
//省略非关键代码
}
```
**2. 假设我们就是想要处理ContextStartedEvent。**
这种情况下我们真的需要去调用AbstractApplicationContext#start方法。例如,我们可以使用下面的代码来让这个事件抛出:
```
@RestController
public class HelloWorldController {
@Autowired
private AbstractApplicationContext applicationContext;
@RequestMapping(path = &quot;publishEvent&quot;, method = RequestMethod.GET)
public String notifyEvent(){
applicationContext.start();
return &quot;ok&quot;;
};
}
```
我们随便找一处来Autowired一个AbstractApplicationContext然后直接调用其start()就能让事件抛出来。
很明显这种抛出并不难但是作为题外话我们可以思考下为什么要去调用start()呢start()本身在Spring Boot中有何作用
如果我们去翻阅这个方法我们会发现start()是org.springframework.context.Lifecycle定义的方法而它在Spring Boot的默认实现中是去执行所有Lifecycle Bean的启动方法这点可以参考DefaultLifecycleProcessor#startBeans方法来验证
```
private void startBeans(boolean autoStartupOnly) {
Map&lt;String, Lifecycle&gt; lifecycleBeans = getLifecycleBeans();
Map&lt;Integer, LifecycleGroup&gt; phases = new HashMap&lt;&gt;();
lifecycleBeans.forEach((beanName, bean) -&gt; {
if (!autoStartupOnly || (bean instanceof SmartLifecycle &amp;&amp; ((SmartLifecycle) bean).isAutoStartup())) {
int phase = getPhase(bean);
LifecycleGroup group = phases.get(phase);
if (group == null) {
group = new LifecycleGroup(phase, this.timeoutPerShutdownPhase, lifecycleBeans, autoStartupOnly);
phases.put(phase, group);
}
group.add(beanName, bean);
}
});
if (!phases.isEmpty()) {
List&lt;Integer&gt; keys = new ArrayList&lt;&gt;(phases.keySet());
Collections.sort(keys);
for (Integer key : keys) {
phases.get(key).start();
}
}
}
```
说起来比较抽象我们可以去写一个Lifecycle Bean代码如下
```
@Component
@Slf4j
public class MyLifeCycle implements Lifecycle {
private volatile boolean running = false;
@Override
public void start() {
log.info(&quot;lifecycle start&quot;);
running = true;
}
@Override
public void stop() {
log.info(&quot;lifecycle stop&quot;);
running = false;
}
@Override
public boolean isRunning() {
return running;
}
}
```
当我们再次运行Spring Boot时只要执行了AbstractApplicationContext的start()就会输出上述代码定义的行为输出LifeCycle start日志。
通过这个Lifecycle Bean的使用AbstractApplicationContext的start要做的事我们就清楚多了。它和Refresh()不同Refresh()是初始化和加载所有需要管理的Bean而start只有在有Lifecycle Bean时才有被调用的价值。那么我们自定义Lifecycle Bean一般是用来做什么呢例如可以用它来实现运行中的启停。这里不再拓展你可以自己做更深入的探索。
通过这个案例,我们搞定了第一类错误。而从这个错误中,我们也得出了一个启示:**当一个事件拦截不了时,我们第一个要查的是拦截的事件类型对不对,执行的代码能不能抛出它。**把握好这点,也就事半功倍了。
## 案例2监听事件的体系不对
通过案例1的学习我们可以保证事件的抛出但是抛出的事件就一定能被我们监听到么我们再来看这样一个案例首先上代码
```
@Slf4j
@Component
public class MyApplicationEnvironmentPreparedEventListener implements ApplicationListener&lt;ApplicationEnvironmentPreparedEvent &gt; {
public void onApplicationEvent(final ApplicationEnvironmentPreparedEvent event) {
log.info(&quot;{} received: {}&quot;, this.toString(), event);
}
}
```
这里我们试图处理ApplicationEnvironmentPreparedEvent。期待出现拦截事件的日志如下
>
2021-03-07 09:12:08.886 INFO 27064 --- [ restartedMain] licationEnvironmentPreparedEventListener : com.spring.puzzle.class7.example2.MyApplicationEnvironmentPreparedEventListener@2b093d received: org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent[source=org.springframework.boot.SpringApplication@122b9e6]
有了案例1的经验首先我们就可以查看下这个事件的抛出会不会存在问题。这个事件在Spring中是由EventPublishingRunListener#environmentPrepared方法抛出,代码如下:
```
@Override
public void environmentPrepared(ConfigurableEnvironment environment) {
this.initialMulticaster
.multicastEvent(new ApplicationEnvironmentPreparedEvent(this.application, this.args, environment));
}
```
现在我们调试下代码你会发现这个方法在Spring启动时一定经由SpringApplication#prepareEnvironment方法调用,调试截图如下:
<img src="https://static001.geekbang.org/resource/image/f6/fe/f6e5b92bd2db8a3db93f53ff2a7944fe.png" alt="">
表面上看既然代码会被调用事件就会抛出那么我们在最开始定义的监听器就能处理但是我们真正去运行程序时会发现效果和案例1是一样的都是监听器的处理并不执行即拦截不了。这又是为何
### 案例解析
实际上这是在Spring事件处理上非常容易犯的一个错误即监听的体系不一致。通俗点说就是“驴头不对马嘴”。我们首先来看下关于ApplicationEnvironmentPreparedEvent的处理它相关的两大组件是什么
1. 广播器这个事件的广播器是EventPublishingRunListener的initialMulticaster代码参考如下
```
public class EventPublishingRunListener implements SpringApplicationRunListener, Ordered {
//省略非关键代码
private final SimpleApplicationEventMulticaster initialMulticaster;
public EventPublishingRunListener(SpringApplication application, String[] args) {
//省略非关键代码
this.initialMulticaster = new SimpleApplicationEventMulticaster();
for (ApplicationListener&lt;?&gt; listener : application.getListeners()) {
this.initialMulticaster.addApplicationListener(listener);
}
}
}
```
1. 监听器这个事件的监听器同样位于EventPublishingRunListener中获取方式参考关键代码行
>
this.initialMulticaster.addApplicationListener(listener);
如果继续查看代码我们会发现这个事件的监听器就存储在SpringApplication#Listeners中,调试下就可以找出所有的监听器,截图如下:
<img src="https://static001.geekbang.org/resource/image/14/6b/145f6d0a20a6f82fa8f6f08c7a08666b.png" alt="">
从中我们可以发现并不存在我们定义的MyApplicationEnvironmentPreparedEventListener这是为何
还是查看代码当Spring Boot被构建时会使用下面的方法去寻找上述监听器
>
setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));
而上述代码最终寻找Listeners的候选者参考代码 SpringFactoriesLoader#loadSpringFactories中的关键行
>
<p>//下面的FACTORIES_RESOURCE_LOCATION定义为 "META-INF/spring.factories"<br>
classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :</p>
我们可以寻找下这样的文件spring.factories确实可以发现类似的定义
```
org.springframework.context.ApplicationListener=\
org.springframework.boot.ClearCachesApplicationListener,\
org.springframework.boot.builder.ParentContextCloserApplicationListener,\
org.springframework.boot.cloud.CloudFoundryVcapEnvironmentPostProcessor,\
//省略其他监听器
```
说到这里相信你已经意识到本案例的问题所在。我们定义的监听器并没有被放置在META-INF/spring.factories中实际上我们的监听器监听的体系是另外一套其关键组件如下
1. 广播器即AbstractApplicationContext#applicationEventMulticaster
1. 监听器由上述提及的META-INF/spring.factories中加载的监听器以及扫描到的 ApplicationListener类型的Bean共同组成。
这样比较后,我们可以得出一个结论:**我们定义的监听器并不能监听到initialMulticaster广播出的ApplicationEnvironmentPreparedEvent。**
### 问题修正
现在就到了解决问题的时候了我们可以把自定义监听器注册到initialMulticaster广播体系中这里提供两种方法修正问题。
1. 在构建Spring Boot时添加MyApplicationEnvironmentPreparedEventListener
```
@SpringBootApplication
public class Application {
public static void main(String[] args) {
MyApplicationEnvironmentPreparedEventListener myApplicationEnvironmentPreparedEventListener = new MyApplicationEnvironmentPreparedEventListener();
SpringApplication springApplication = new SpringApplicationBuilder(Application.class).listeners(myApplicationEnvironmentPreparedEventListener).build();
springApplication.run(args);
}
}
```
1. 使用META-INF/spring.factories即在/src/main/resources下面新建目录META-INF然后新建一个对应的spring.factories文件
```
org.springframework.context.ApplicationListener=\
com.spring.puzzle.listener.example2.MyApplicationEnvironmentPreparedEventListener
```
通过上述两种修改方式即可完成事件的监听很明显第二种方式要优于第一种至少完全用原生的方式去解决而不是手工实例化一个MyApplicationEnvironmentPreparedEventListener。这点还是挺重要的。
反思这个案例的错误,结论就是**对于事件一定要注意“驴头”(监听器)对上“马嘴”(广播)**。
## 案例3部分事件监听器失效
通过前面案例的解析,我们可以确保事件在合适的时机被合适的监听器所捕获。但是理想总是与现实有差距,有些时候,我们可能还会发现部分事件监听器一直失效或偶尔失效。这里我们可以写一段代码来模拟偶尔失效的场景,首先我们完成一个自定义事件和两个监听器,代码如下:
```
public class MyEvent extends ApplicationEvent {
public MyEvent(Object source) {
super(source);
}
}
@Component
@Order(1)
public class MyFirstEventListener implements ApplicationListener&lt;MyEvent&gt; {
Random random = new Random();
@Override
public void onApplicationEvent(MyEvent event) {
log.info(&quot;{} received: {}&quot;, this.toString(), event);
//模拟部分失效
if(random.nextInt(10) % 2 == 1)
throw new RuntimeException(&quot;exception happen on first listener&quot;);
}
}
@Component
@Order(2)
public class MySecondEventListener implements ApplicationListener&lt;MyEvent&gt; {
@Override
public void onApplicationEvent(MyEvent event) {
log.info(&quot;{} received: {}&quot;, this.toString(), event);
}
}
```
这里监听器MyFirstEventListener的优先级稍高且执行过程中会有50%的概率抛出异常。然后我们再写一个Controller来触发事件的发送
```
@RestController
@Slf4j
public class HelloWorldController {
@Autowired
private AbstractApplicationContext applicationContext;
@RequestMapping(path = &quot;publishEvent&quot;, method = RequestMethod.GET)
public String notifyEvent(){
log.info(&quot;start to publish event&quot;);
applicationContext.publishEvent(new MyEvent(UUID.randomUUID()));
return &quot;ok&quot;;
};
}
```
完成这些代码后,我们就可以使用[http://localhost:8080/publishEvent](http://localhost:8080/publishEvent) 来测试监听器的接收和执行了。观察测试结果我们会发现监听器MySecondEventListener有一半的概率并没有接收到任何事件。可以说我们使用了最简化的代码模拟出了部分事件监听器偶尔失效的情况。当然在实际项目中抛出异常这个根本原因肯定不会如此明显但还是可以借机举一反三的。那么如何理解这个问题呢
### 案例解析
这个案例非常简易如果你稍微有些开发经验的话大概也能推断出原因处理器的执行是顺序执行的在执行过程中如果一个监听器执行抛出了异常则后续监听器就得不到被执行的机会了。这里我们可以通过Spring源码看下事件是如何被执行的
具体而言,当广播一个事件,执行的方法参考 SimpleApplicationEventMulticaster#multicastEvent(ApplicationEvent)
```
@Override
public void multicastEvent(final ApplicationEvent event, @Nullable ResolvableType eventType) {
ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event));
Executor executor = getTaskExecutor();
for (ApplicationListener&lt;?&gt; listener : getApplicationListeners(event, type)) {
if (executor != null) {
executor.execute(() -&gt; invokeListener(listener, event));
}
else {
invokeListener(listener, event);
}
}
}
```
上述方法通过Event类型等信息调用getApplicationListeners获取了具有执行资格的所有监听器在本案例中即为MyFirstEventListener和MySecondEventListener然后按顺序去执行。最终每个监听器的执行是通过invokeListener()来触发的,调用的是接口方法 ApplicationListener#onApplicationEvent。执行逻辑可参考如下代码:
```
protected void invokeListener(ApplicationListener&lt;?&gt; listener, ApplicationEvent event) {
ErrorHandler errorHandler = getErrorHandler();
if (errorHandler != null) {
try {
doInvokeListener(listener, event);
}
catch (Throwable err) {
errorHandler.handleError(err);
}
}
else {
doInvokeListener(listener, event);
}
}
private void doInvokeListener(ApplicationListener listener, ApplicationEvent event) {
try {
listener.onApplicationEvent(event);
}
catch (ClassCastException ex) {
//省略非关键代码
}
else {
throw ex;
}
}
}
```
这里我们并没有去设置什么org.springframework.util.ErrorHandler也没有绑定什么Executor 来执行任务,所以针对本案例的情况,我们可以看出:**最终事件的执行是由同一个线程按顺序来完成的,任何一个报错,都会导致后续的监听器执行不了。**
### 问题修正
怎么解决呢?好办,我提供两种方案给你。
**1. 确保监听器的执行不会抛出异常。**
既然我们使用多个监听器,我们肯定是希望它们都能执行的,所以我们一定要保证每个监听器的执行不会被其他监听器影响。基于这个思路,我们修改案例代码如下:
```
@Component
@Order(1)
public class MyFirstEventListener implements ApplicationListener&lt;MyEvent&gt; {
@Override
public void onApplicationEvent(MyEvent event) {
try {
// 省略事件处理相关代码
}catch(Throwable throwable){
//write error/metric to alert
}
}
}
```
**2. 使用org.springframework.util.ErrorHandler。**
通过上面的案例解析我们发现假设我们设置了一个ErrorHandler那么就可以用这个ErrorHandler去处理掉异常从而保证后续事件监听器处理不受影响。我们可以使用下面的代码来修正问题
```
SimpleApplicationEventMulticaster simpleApplicationEventMulticaster = applicationContext.getBean(APPLICATION_EVENT_MULTICASTER_BEAN_NAME, SimpleApplicationEventMulticaster.class);
simpleApplicationEventMulticaster.setErrorHandler(TaskUtils.LOG_AND_SUPPRESS_ERROR_HANDLER);
```
其中LOG_AND_SUPPRESS_ERROR_HANDLER的实现如下
```
public static final ErrorHandler LOG_AND_SUPPRESS_ERROR_HANDLER = new LoggingErrorHandler();
private static class LoggingErrorHandler implements ErrorHandler {
private final Log logger = LogFactory.getLog(LoggingErrorHandler.class);
@Override
public void handleError(Throwable t) {
logger.error(&quot;Unexpected error occurred in scheduled task&quot;, t);
}
}
```
对比下方案1使用ErrorHandler有一个很大的优势就是我们不需要在某个监听器中都重复类似下面的代码了
```
try {
//省略事件处理过程
}catch(Throwable throwable){
//write error/metric to alert
}
```
这么看的话其实Spring的设计还是很全面的它考虑了各种各样的情况。但是Spring使用者往往都不会去了解其内部实现这样就会遇到各种各样的问题。相反如果你对其实现有所了解的话也对常见错误有一个感知则大概率是可以快速避坑的项目也可以运行得更加平稳顺畅。
## 重点回顾
今天我们粗略地了解了Spring事件处理的基本流程。其实抛开Spring框架我们去设计一个通用的事件处理框架常常也会犯这三种错误
1. 误读事件本身含义;
1. 监听错了事件的传播系统;
1. 事件处理之间互相影响,导致部分事件处理无法完成。
这三种错误正好对应了我们这节课讲解的三个案例。
此外在Spring事件处理过程中我们也学习到了监听器加载的特殊方式即使用SPI的方式直接从配置文件META-INF/spring.factories中加载。这种方式或者说思想非常值得你去学习因为它在许多Java应用框架中都有所使用例如Dubbo就是使用增强版的SPI来配置编解码器的。
## 思考题
在案例3中我们提到默认的事件执行是在同一个线程中执行的即事件发布者使用的线程。参考如下日志佐证这个结论
>
<p>2021-03-09 09:10:33.052 INFO 18104 --- [nio-8080-exec-1] c.s.p.listener.HelloWorldController : start to publish event<br>
2021-03-09 09:10:33.055 INFO 18104 --- [nio-8080-exec-1] c.s.p.l.example3.MyFirstEventListener : com.spring.puzzle.class7.example3.MyFirstEventListener@18faf0 received: com.spring.puzzle.class7.example3.MyEvent[source=df42b08f-8ee2-44df-a957-d8464ff50c88]</p>
通过日志可以看出事件的发布和执行使用的都是nio-8080-exec-1线程但是在事件比较多时我们往往希望事件执行得更快些或者希望事件的执行可以异步化不影响主线程。此时应该怎么做呢
期待在留言区看到你的回复,我们下节课见!

View File

@@ -0,0 +1,455 @@
<audio id="audio" title="08答疑现场Spring Core 篇思考题合集" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/15/f285d5312613a58052cf4993bd7a7615.mp3"></audio>
你好,我是傅健。
如果你看到这篇文章,那么我真的非常开心,这说明第一章节的内容你都跟下来了,并且对于课后的思考题也有研究,在这我要手动给你点个赞。繁忙的工作中,还能为自己持续充电,保持终身学习的心态,我想我们一定是同路人。
那么到今天为止,我们已经学习了 17 个案例,解决的问题也不算少了,不知道你的感受如何?收获如何呢?
我还记得[开篇词](https://time.geekbang.org/column/article/364661)的留言区中有位很有趣的同学,他说:“作为一线 bug 制造者,希望能少写点 bug。” 感同身受,和 Spring 斗智斗勇的这些年,我也经常为一些问题而抓狂过,因不能及时解决而焦虑过,但最终还是觉得蛮有趣的,这个专栏也算是沉淀之作,希望能给你带来一些实际的帮助。
最初,我其实是想每节课都和你交流下上节课的思考题,但又担心大家的学习进度不一样,所以就有了这次的集中答疑,我把我的答案给到大家,你也可以对照着去看一看,也许有更好的方法,欢迎你来贡献“选项”,我们一起交流。希望大家都能在问题的解决中获得一些正向反馈,完成学习闭环。
## **[第1课](https://time.geekbang.org/column/article/364761)**
在案例 2 中,显示定义构造器,这会发生根据构造器参数寻找对应 Bean 的行为。这里请你思考一个问题,假设寻找不到对应的 Bean一定会如案例 2 那样直接报错么?
实际上,答案是否定的。这里我们不妨修改下案例 2 的代码,修改后如下:
```
@Service
public class ServiceImpl {
private List&lt;String&gt; serviceNames;
public ServiceImpl(List&lt;String&gt; serviceNames){
this.serviceNames = serviceNames;
System.out.println(this.serviceNames);
}
}
```
参考上述代码我们的构造器参数由普通的String改成了一个List<string>,最终运行程序会发现这并不会报错,而是输出 []。</string>
要了解这个现象,我们可以直接定位构建构造器调用参数的代码所在地(即 ConstructorResolver#resolveAutowiredArgument
```
@Nullable
protected Object resolveAutowiredArgument(MethodParameter param, String beanName,
@Nullable Set&lt;String&gt; autowiredBeanNames, TypeConverter typeConverter, boolean fallback) {
//省略非关键代码
try {
//根据构造器参数寻找 bean
return this.beanFactory.resolveDependency(
new DependencyDescriptor(param, true), beanName, autowiredBeanNames, typeConverter);
}
catch (NoUniqueBeanDefinitionException ex) {
throw ex;
}
catch (NoSuchBeanDefinitionException ex) {
//找不到 “bean” 进行fallback
if (fallback) {
// Single constructor or factory method -&gt; let's return an empty array/collection
// for e.g. a vararg or a non-null List/Set/Map parameter.
if (paramType.isArray()) {
return Array.newInstance(paramType.getComponentType(), 0);
}
else if (CollectionFactory.isApproximableCollectionType(paramType)) {
return CollectionFactory.createCollection(paramType, 0);
}
else if (CollectionFactory.isApproximableMapType(paramType)) {
return CollectionFactory.createMap(paramType, 0);
}
}
throw ex;
}
}
```
当构建集合类型的参数实例寻找不到合适的 Bean 时并不是不管不顾地直接报错而是会尝试进行fallback。对于本案例而言会使用下面的语句来创建一个空的集合作为构造器参数传递进去
>
CollectionFactory.createCollection(paramType, 0);
上述代码最终调用代码如下:
>
return new ArrayList&lt;&gt;(capacity);
所以很明显,最终修改后的案例并不会报错,而是把 serviceNames 设置为一个空的 List。从这一点也可知**自动装配远比想象的要复杂**。
## **[第2课](https://time.geekbang.org/column/article/366170)**
我们知道了通过@Qualifier可以引用想匹配的Bean也可以直接命名属性的名称为Bean的名称来引用这两种方式如下
```
//方式1属性命名为要装配的bean名称
@Autowired
DataService oracleDataService;
//方式2使用@Qualifier直接引用
@Autowired
@Qualifier(&quot;oracleDataService&quot;)
DataService dataService;
```
那么对于案例3的内部类引用你觉得可以使用第1种方式做到么例如使用如下代码
>
<p>@Autowired<br>
DataService studentController.InnerClassDataService;</p>
实际上,如果你动动手或者我们稍微敏锐点就会发现,代码本身就不能编译,因为中间含有“.”。那么还有办法能通过这种方式引用到内部类么?
查看决策谁优先的源码最终使用属性名来匹配的执行情况可参考DefaultListableBeanFactory#matchesBeanName方法的调试视图
<img src="https://static001.geekbang.org/resource/image/86/37/8658173a310332b1ca532997c4cd5337.png" alt="">
我们可以看到实现的关键其实是下面这行语句:
>
candidateName.equals(beanName) || ObjectUtils.containsElement(getAliases(beanName), candidateName))
很明显我们的Bean没有被赋予别名而鉴于属性名不可能含有“.”,所以它不可能匹配上带“.”的Bean名即studentController.InnerClassDataService
综上如果一个内部类没有显式指定名称或者别名试图使用属性名和Bean名称一致来引用到对应的Bean是行不通的。
## **[第3课](https://time.geekbang.org/column/article/366930)**
在案例2中我们初次运行程序获取的结果如下
>
[Student(id=1, name=xie), Student(id=2, name=fang)]
那么如何做到让学生2优先输出呢
实际上在案例2中我们收集的目标类型是List而List是可排序的那么到底是如何排序的在案例2的解析中我们给出了DefaultListableBeanFactory#resolveMultipleBeans方法的代码,不过省略了一些非关键的代码,这其中就包括了排序工作,代码如下:
```
if (result instanceof List) {
Comparator&lt;Object&gt; comparator = adaptDependencyComparator(matchingBeans);
if (comparator != null) {
((List&lt;?&gt;) result).sort(comparator);
}
}
```
而针对本案例最终排序执行的是OrderComparator#doCompare方法,关键代码如下:
```
private int doCompare(@Nullable Object o1, @Nullable Object o2, @Nullable OrderSourceProvider sourceProvider) {
boolean p1 = (o1 instanceof PriorityOrdered);
boolean p2 = (o2 instanceof PriorityOrdered);
if (p1 &amp;&amp; !p2) {
return -1;
}
else if (p2 &amp;&amp; !p1) {
return 1;
}
int i1 = getOrder(o1, sourceProvider);
int i2 = getOrder(o2, sourceProvider);
return Integer.compare(i1, i2);
}
```
其中getOrder的执行获取到的order值相当于优先级是通过AnnotationAwareOrderComparator#findOrder来获取的
```
protected Integer findOrder(Object obj) {
Integer order = super.findOrder(obj);
if (order != null) {
return order;
}
return findOrderFromAnnotation(obj);
}
```
不难看出获取order值包含了2种方式
1.@Order获取值参考AnnotationAwareOrderComparator#findOrderFromAnnotation
```
@Nullable
private Integer findOrderFromAnnotation(Object obj) {
AnnotatedElement element = (obj instanceof AnnotatedElement ? (AnnotatedElement) obj : obj.getClass());
MergedAnnotations annotations = MergedAnnotations.from(element, SearchStrategy.TYPE_HIERARCHY);
Integer order = OrderUtils.getOrderFromAnnotations(element, annotations);
if (order == null &amp;&amp; obj instanceof DecoratingProxy) {
return findOrderFromAnnotation(((DecoratingProxy) obj).getDecoratedClass());
}
return order;
}
```
1. 从Ordered 接口实现方法获取值参考OrderComparator#findOrder
```
protected Integer findOrder(Object obj) {
return (obj instanceof Ordered ? ((Ordered) obj).getOrder() : null);
}
```
通过上面的分析如果我们不能改变类继承关系例如让Student实现Ordered接口则可以通过使用@Order来调整顺序,具体修改代码如下:
```
@Bean
@Order(2)
public Student student1(){
return createStudent(1, &quot;xie&quot;);
}
@Bean
@Order(1)
public Student student2(){
return createStudent(2, &quot;fang&quot;);
}
```
现在我们就可以把原先的Bean输出顺序颠倒过来了示例如下
>
Student(id=2, name=fang)],[Student(id=1, name=xie)
## **[第4课](https://time.geekbang.org/column/article/367876)**
案例 2 中的类 LightService当我们不在 Configuration 注解类中使用 Bean 方法将其注入 Spring 容器,而是坚持使用 @Service 将其自动注入到容器,同时实现 Closeable 接口,代码如下:
```
import org.springframework.stereotype.Component;
import java.io.Closeable;
@Service
public class LightService implements Closeable {
public void close() {
System.out.println(&quot;turn off all lights);
}
//省略非关键代码
}
```
接口方法 close() 也会在 Spring 容器被销毁的时候自动执行么?
答案是肯定的,通过案例 2 的分析,你可以知道,当 LightService 是一个实现了 Closable 接口的单例 Bean 时,会有一个 DisposableBeanAdapter 被添加进去。
而具体到执行哪一种方法shutdown()close()? 在代码中你能够找到答案,在 DisposableBeanAdapter 类的 inferDestroyMethodIfNecessary 中,我们可以看到有两种情况会获取到当前 Bean 类中的 close()。
第一种情况,就是我们这节课提到的当使用@Bean且使用默认的 destroyMethod 属性INFER_METHOD第二种情况是判断当前类是否实现了 AutoCloseable 接口,如果实现了,那么一定会获取此类的 close()。
```
private String inferDestroyMethodIfNecessary(Object bean, RootBeanDefinition beanDefinition) {
String destroyMethodName = beanDefinition.getDestroyMethodName();
if (AbstractBeanDefinition.INFER_METHOD.equals(destroyMethodName) ||(destroyMethodName == null &amp;&amp; bean instanceof AutoCloseable)) {
if (!(bean instanceof DisposableBean)) {
try {
return bean.getClass().getMethod(CLOSE_METHOD_NAME).getName();
}
catch (NoSuchMethodException ex) {
try {
return bean.getClass().getMethod(SHUTDOWN_METHOD_NAME).getName();
}
catch (NoSuchMethodException ex2) {
// no candidate destroy method found
}
}
}
return null;
}
return (StringUtils.hasLength(destroyMethodName) ? destroyMethodName : null);
}
```
到这,相信你应该可以结合 Closable 接口和@Service(或其他@Component)让关闭方法得到执行了。
## **[第5课](https://time.geekbang.org/column/article/369251)**
案例2中我们提到了通过反射来实例化类的三种方式
- java.lang.Class.newInsance()
- java.lang.reflect.Constructor.newInstance()
- sun.reflect.ReflectionFactory.newConstructorForSerialization().newInstance()
其中第三种方式不会初始化类属性,你能够写一个例子来证明这一点吗?
能证明的例子,代码示例如下:
```
import sun.reflect.ReflectionFactory;
import java.lang.reflect.Constructor;
public class TestNewInstanceStyle {
public static class TestObject{
public String name = &quot;fujian&quot;;
}
public static void main(String[] args) throws Exception {
//ReflectionFactory.newConstructorForSerialization()方式
ReflectionFactory reflectionFactory = ReflectionFactory.getReflectionFactory();
Constructor constructor = reflectionFactory.newConstructorForSerialization(TestObject.class, Object.class.getDeclaredConstructor());
constructor.setAccessible(true);
TestObject testObject1 = (TestObject) constructor.newInstance();
System.out.println(testObject1.name);
//普通方式
TestObject testObject2 = new TestObject();
System.out.println(testObject2.name);
}
}
```
运行结果如下:
>
<p>null<br>
fujian</p>
## **[第6课](https://time.geekbang.org/column/article/369989)**
实际上,审阅这节课两个案例的修正方案,你会发现它们虽然改动很小,但是都还不够优美。那么有没有稍微优美点的替代方案呢?如果有,你知道背后的原理及关键源码吗?顺便你也可以想想,我为什么没有用更优美的方案呢?
我们可以将“未达到执行顺序预期”的增强方法移动到一个独立的切面类,而不同的切面类可以使用 @Order 进行修饰。@Order 的 value 值越低,则执行优先级越高。以案例 2 为例,可以修改如下:
```
@Aspect
@Service
@Order(1)
public class AopConfig1 {
@Before(&quot;execution(* com.spring.puzzle.class6.example2.ElectricService.charge()) &quot;)
public void validateAuthority(JoinPoint pjp) throws Throwable {
throw new RuntimeException(&quot;authority check failed&quot;);
}
}
@Aspect
@Service
@Order(2)
public class AopConfig2 {
@Before(&quot;execution(* com.spring.puzzle.class6.example2.ElectricService.charge())&quot;)
public void logBeforeMethod(JoinPoint pjp) throws Throwable {
System.out.println(&quot;step into -&gt;&quot;+pjp.getSignature());
}
}
```
上述修改的核心就是将原来的 AOP 配置,切成两个类进行,并分别使用@Order标记下优先级。这样修改后当授权失败了则不会打印“step into -&gt;”相关日志。
为什么这样是可行的呢这还得回溯到案例1当时我们提出这样一个结论AbstractAdvisorAutoProxyCreator 执行 findEligibleAdvisors代码如下寻找匹配的 Advisors 时,最终返回的 Advisors 顺序是由两点来决定的candidateAdvisors 的顺序和 sortAdvisors 执行的排序。
```
protected List&lt;Advisor&gt; findEligibleAdvisors(Class&lt;?&gt; beanClass, String beanName) {
List&lt;Advisor&gt; candidateAdvisors = findCandidateAdvisors();
List&lt;Advisor&gt; eligibleAdvisors = findAdvisorsThatCanApply(candidateAdvisors, beanClass, beanName);
extendAdvisors(eligibleAdvisors);
if (!eligibleAdvisors.isEmpty()) {
eligibleAdvisors = sortAdvisors(eligibleAdvisors);
}
return eligibleAdvisors;
}
```
当时影响我们案例出错的关键点都是在 candidateAdvisors 的顺序上,所以我们重点介绍了它。而对于 sortAdvisors 执行的排序并没有多少涉及,这里我可以再重点介绍下。
在实现上sortAdvisors 的执行最终调用的是比较器 AnnotationAwareOrderComparator 类的 compare(),它调用了 getOrder() 的返回值作为排序依据:
```
public int compare(@Nullable Object o1, @Nullable Object o2) {
return doCompare(o1, o2, null);
}
private int doCompare(@Nullable Object o1, @Nullable Object o2, @Nullable OrderSourceProvider sourceProvider) {
boolean p1 = (o1 instanceof PriorityOrdered);
boolean p2 = (o2 instanceof PriorityOrdered);
if (p1 &amp;&amp; !p2) {
return -1;
}
else if (p2 &amp;&amp; !p1) {
return 1;
}
int i1 = getOrder(o1, sourceProvider);
int i2 = getOrder(o2, sourceProvider);
return Integer.compare(i1, i2);
}
```
继续跟踪 getOrder() 的执行细节,我们会发现对于我们的案例,这个方法会找出配置切面的 Bean 的 Order值。这里可以参考 BeanFactoryAspectInstanceFactory#getOrder 的调试视图验证这个结论:
<img src="https://static001.geekbang.org/resource/image/21/8e/211b5c15657881e5d0cc3cc86229a28e.png" alt="">
上述截图中aopConfig2 即是我们配置切面的 Bean 的名称。这里再顺带提供出调用栈的截图,以便你做进一步研究:
<img src="https://static001.geekbang.org/resource/image/60/a9/600ac1d34422c57276d83c8ee03a36a9.png" alt="">
现在我们就知道了,将不同的增强方法放置到不同的切面配置类中,使用不同的 Order 值来修饰是可以影响顺序的。相反,如果都是在一个配置类中,自然不会影响顺序,所以这也是当初我的方案中没有重点介绍 sortAdvisors 方法的原因,毕竟当时我们给出的案例都只有一个 AOP 配置类。
## **[第7课](https://time.geekbang.org/column/article/370741)**
在案例 3 中,我们提到默认的事件执行是在同一个线程中执行的,即事件发布者使用的线程。参考如下日志佐证这个结论:
>
<p>2021-03-09 09:10:33.052 INFO 18104 --- [nio-8080-exec-1] c.s.p.listener.HelloWorldController : start to publish event<br>
2021-03-09 09:10:33.055 INFO 18104 --- [nio-8080-exec-1] c.s.p.l.example3.MyFirstEventListener : com.spring.puzzle.class7.example3.MyFirstEventListener@18faf0 received: com.spring.puzzle.class7.example3.MyEvent[source=df42b08f-8ee2-44df-a957-d8464ff50c88]</p>
通过日志可以看出事件的发布和执行使用的都是nio-8080-exec-1线程但是在事件比较多时我们往往希望事件执行得更快些或者希望事件的执行可以异步化以不影响主线程。此时应该如何做呢
针对上述问题中的需求,我们只需要对于事件的执行引入线程池即可。我们先来看下 Spring 对这点的支持。实际上,在案例 3 的解析中,我们已贴出了以下代码片段(位于 SimpleApplicationEventMulticaster#multicastEvent 方法中):
```
//省略其他非关键代码
//获取 executor
Executor executor = getTaskExecutor();
for (ApplicationListener&lt;?&gt; listener : getApplicationListeners(event, type)) {
//如果存在 executor则提交到 executor 中去执行
if (executor != null) {
executor.execute(() -&gt; invokeListener(listener, event));
}
//省略其他非关键代码
```
对于事件的处理,可以绑定一个 Executor 去执行,那么如何绑定?其实与这节课讲过的绑定 ErrorHandler 的方法是类似的。绑定代码示例如下:
```
//注意下面的语句只能执行一次,以避免重复创建线程池
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
//省略非关键代码
SimpleApplicationEventMulticaster simpleApplicationEventMulticaster = applicationContext.getBean(APPLICATION_EVENT_MULTICASTER_BEAN_NAME, SimpleApplicationEventMulticaster.class);
simpleApplicationEventMulticaster.setTaskExecutor(newCachedThreadPool );
```
取出SimpleApplicationEventMulticaster然后直接调用相关 set() 设置线程池就可以了。按这种方式修改后的程序,事件处理的日志如下:
>
<p>2021-03-09 09:25:09.917 INFO 16548 --- [nio-8080-exec-1] c.s.p.c.HelloWorldController : start to publish event<br>
2021-03-09 09:25:09.920 INFO 16548 --- [pool-1-thread-3] c.s.p.l.example3.MyFirstEventListener : com.spring.puzzle.class7.example3.MyFirstEventListener@511056 received: com.spring.puzzle.class7.example3.MyEvent[source=cbb97bcc-b834-485c-980e-2e20de56c7e0]</p>
可以看出,事件的发布和处理分属不同的线程了,分别为 nio-8080-exec-1 和 pool-1-thread-3满足了我们的需求。
以上就是这次答疑的全部内容,我们下一章节再见!

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="导读5分钟轻松了解Spring基础知识" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/a6/24f295653369a0d57b737574111dcea6.mp3"></audio>
你好,我是傅健。
在开始我们第一章的学习之前我想为你总结下有关Spring最基础的知识这可以帮助我们后面的学习进展更加顺利一些。
就第一章来说我们关注的是Spring核心功能使用中的各类错误案例。针对问题的讲解我们大多都是直奔主题这也是这个专栏的内容定位。所以对于**很多基础的知识和流程**,我们不会在解析过程中反复介绍,但它们依然是重要的,是我们解决问题的前提。借助这篇导读,我带你梳理下。
回顾Spring本身什么是Spring最基础的知识呢
其实就是那些**Spring最本质的实现和思想**。当你最开始学习的时候你可能困惑于为什么要用Spring而随着对Spring原理的深入探究和应用你慢慢会发现最大的收获其实还是对于这个困惑的理解。接下来我就给你讲讲。
在进行“传统的”Java编程时对象与对象之间的关系都是紧密耦合的例如服务类 Service 使用组件 ComponentA则可能写出这样的代码
```
public class Service {
private ComponentA component = new ComponentA(&quot;first component&quot;);
}
```
在没有Spring之前你应该会觉得这段代码并没有多大问题毕竟大家都这么写而且也没有什么更好的方式。就像只有一条大路可走时大家都朝一个方向走你大概率不会反思是不是有捷径。
而随着项目的开发推进,你会发现检验一个方式好不好的硬性标准之一,就是看它**有没有拥抱变化的能力**。假设有一天我们的ComponentA类的构造器需要更多的参数了你会发现上述代码到处充斥着这行需要改进的代码
>
private ComponentA component = new ComponentA("first component");
此时你可能会想了那我用下面这种方式来构造Service就可以了吧
```
public class Service {
private ComponentA component
public Service(ComponentA component){
this.component = component;
}
}
```
当然不行你忽略了一点你在构建Service对象的时候不还得使用new关键字来构建Component需要修改的调用处并不少
很明显这是一个噩梦。那么除了这点还有没有别的不好的地方呢上面说的是非单例的情况如果ComponentA本身是一个单例会不会好些毕竟我们可能找一个地方new一次ComponentA实例就足够了但是你可能会发现另外一些问题。
下面是一段用“双重检验锁”实现的CompoentA类
```
public class ComponentA{
private volatile static ComponentA INSTANCE;
private ComponentA() {}
public static ComponentA getInstance(){
if (INSTANCE== null) {
synchronized (ComponentA.class) {
if (INSTANCE== null) {
INSTANCE= new ComponentA();
}
}
}
return INSTANCE;
}
}
```
其实写了这么多代码最终我们只是要一个单例而已。而且假设我们有ComponentB、ComponentC、ComponentD等那上面的重复性代码不都得写一遍也是烦的不行不是么
除了上述两个典型问题还有不易于测试、不易扩展功能例如支持AOP等缺点。说白了所有问题的根源之一就是**对象与对象之间耦合性太强了**。
所以Spring的引入解决了上面这些零零种种的问题。那么它是怎么解决的呢
这里套用一个租房的场景。我们为什么喜欢通过中介来租房子呢?因为省事呀,只要花点小钱就不用与房东产生直接的“纠缠”了。
Spring就是这个思路它就像一个“中介”公司。当你需要一个依赖的对象房子你直接把你的需求告诉Spring中介就好了它会帮你搞定这些依赖对象按需创建它们而无需你的任何额外操作。
不过在Spring中房东和租房者都是对象实例只不过换了一个名字叫 Bean 而已。
可以说通过一套稳定的生产流程作为“中介”的Spring完成了生产和预装牵线搭桥这些Bean的任务。此时你可能想了解更多。例如如果一个Bean租房者需要用到另外一个Bean房子具体是怎么操作呢
本质上只能从Spring“中介”里去找有时候我们直接根据名称小区名去找有时候则根据类型户型各种方式不尽相同。你就把**Spring理解成一个Map型的公司**即可,实现如下:
```
public class BeanFactory {
private Map&lt;String, Bean&gt; beanMap = new HashMap&lt;&gt;();
public Bean getBean(String key){
return beanMap.get(key) ;
}
}
```
如上述代码所示Bean所属公司提供了对于Map的操作来完成查找找到Bean后装配给其它对象这就是依赖查找、自动注入的过程。
那么回过头看这些Bean又是怎么被创建的呢
对于一个项目而言不可避免会出现两种情况一些对象是需要Spring来管理的另外一些例如项目中其它的类和依赖的Jar中的类又不需要。所以我们得有一个办法去标识哪些是需要成为Spring Bean因此各式各样的注解才应运而生例如Component注解等。
那有了这些注解后,谁又来做“发现”它们的工作呢?直接配置指定自然不成问题,但是很明显“自动发现”更让人省心。此时,我们往往需要一个扫描器,可以模拟写下这样一个扫描器:
```
public class AnnotationScan {
//通过扫描包名来找到Bean
void scan(String packages) {
//
}
}
```
有了扫描器我们就知道哪些类是需要成为Bean。
那怎么实例化为Bean也就是一个对象实例而已很明显只能通过**反射**来做了。不过这里面的方式可能有多种:
- java.lang.Class.newInsance()
- java.lang.reflect.Constructor.newInstance()
- ReflectionFactory.newConstructorForSerialization()
**有了创建有了装配一个Bean才能成为自己想要的样子。**
而需求总是源源不断的我们有时候想记录一个方法调用的性能有时候我们又想在方法调用时输出统一的调用日志。诸如此类我们肯定不想频繁再来个散弹式的修改。所以我们有了AOP帮忙拦截方法调用进行功能扩展。拦截谁呢在Spring中自然就是Bean了。
其实AOP并不神奇结合刚才的Bean中介公司来讲假设我们判断出一个Bean需要“增强”了我们直接让它从公司返回的时候就使用一个代理对象作为返回不就可以了么示例如下
```
public class BeanFactory {
private Map&lt;String, Bean&gt; beanMap = new HashMap&lt;&gt;();
public Bean getBean(String key){
//查找是否创建过
Bean bean = beanMap.get(key);
if(bean != null){
return bean;
}
//创建一个Bean
Bean bean = createBean();
//判断要不要AOP
boolean needAop = judgeIfNeedAop(bean);
try{
if(needAop)
//创建代理对象
bean = createProxyObject(bean);
return bean;
else:
return bean
}finally{
beanMap.put(key, bean);
}
}
}
```
那么怎么知道一个对象要不要AOP既然一个对象要AOP它肯定被标记了一些“规则”例如拦截某个类的某某方法示例如下
```
@Aspect
@Service
public class AopConfig {
@Around(&quot;execution(* com.spring.puzzle.ComponentA.execute()) &quot;)
public void recordPayPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
//
}
}
```
这个时候很明显了假设你的Bean名字是ComponentA那么就应该返回ComponentA类型的代理对象了。至于这些规则是怎么建立起来的呢你看到它上面使用的各种注解大概就能明白其中的规则了无非就是**扫描注解,根据注解创建规则**。
以上即为Spring的一些核心思想包括**Bean的构建、自动注入和AOP**,这中间还会掺杂无数的细节,不过这不重要,抓住这个核心思想对你接下来理解各种类型的错误案例才是大有裨益的!
你好,我是傅健,这节课我们来聊一聊 Spring Bean 的初始化过程及销毁过程中的一些问题。
虽然说 Spring 容器上手简单,可以仅仅通过学习一些有限的注解,即可达到快速使用的目的。但在工程实践中,我们依然会从中发现一些常见的错误。尤其当你对 Spring 的生命周期还没有深入了解时,类初始化及销毁过程中潜在的约定就不会很清楚。

View File

@@ -0,0 +1,645 @@
<audio id="audio" title="09Spring Web URL 解析常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/60/f6d5eb2743c77f06fb192f97ec0d9460.mp3"></audio>
你好,我是傅健。
上一章节我们讲解了各式各样的错误案例,这些案例都是围绕 Spring 的核心功能展开的例如依赖注入、AOP 等诸多方面。然而,从现实情况来看,在使用上,我们更多地是使用 Spring 来构建一个 Web 服务,所以从这节课开始,我们会重点解析在 Spring Web 开发中经常遇到的一些错误,帮助你规避这些问题。
不言而喻,这里说的 Web 服务就是指使用 HTTP 协议的服务。而对于 HTTP 请求,首先要处理的就是 URL所以今天我们就先来介绍下在 URL 的处理上Spring 都有哪些经典的案例。闲话少叙,下面我们直接开始演示吧。
## 案例 1当@PathVariable 遇到 /
在解析一个 URL 时,我们经常会使用 @PathVariable 这个注解。例如我们会经常见到如下风格的代码:
```
@RestController
@Slf4j
public class HelloWorldController {
@RequestMapping(path = &quot;/hi1/{name}&quot;, method = RequestMethod.GET)
public String hello1(@PathVariable(&quot;name&quot;) String name){
return name;
};
}
```
当我们使用 [http://localhost:8080/hi1/xiaoming](http://localhost:8080/hi1/xiaoming) 访问这个服务时,会返回"xiaoming",即 Spring 会把 name 设置为 URL 中对应的值。
看起来顺风顺水,但是假设这个 name 中含有特殊字符/时(例如[http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) ),会如何?如果我们不假思索,或许答案是"xiao/ming"?然而稍微敏锐点的程序员都会判定这个访问是会报错的,具体错误参考:
<img src="https://static001.geekbang.org/resource/image/92/64/92a3c8894b88eec937139f3c858bf664.png" alt="">
如图所示,当 name 中含有/,这个接口不会为 name 获取任何值而是直接报Not Found错误。当然这里的“找不到”并不是指name找不到而是指服务于这个特殊请求的接口。
实际上,这里还存在另外一种错误,即当 name 的字符串以/结尾时,/会被自动去掉。例如我们访问 [http://localhost:8080/hi1/xiaoming/](http://localhost:8080/hi1/xiaoming/)Spring 并不会报错而是返回xiaoming。
针对这两种类型的错误,应该如何理解并修正呢?
### 案例解析
实际上,这两种错误都是 URL 匹配执行方法的相关问题,所以我们有必要先了解下 URL 匹配执行方法的大致过程。参考 AbstractHandlerMethodMapping#lookupHandlerMethod
```
@Nullable
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
List&lt;Match&gt; matches = new ArrayList&lt;&gt;();
//尝试按照 URL 进行精准匹配
List&lt;T&gt; 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&lt;Match&gt; comparator = new MatchComparator(getMappingComparator(request));
matches.sort(comparator);
Match bestMatch = matches.get(0);
if (matches.size() &gt; 1) {
//处理多个匹配的情况
}
//省略其他非关键代码
return bestMatch.handlerMethod;
}
else {
//匹配不上,直接报错
return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request);
}
```
大体分为这样几个基本步骤。
**1. 根据 Path 进行精确匹配**
这个步骤执行的代码语句是"this.mappingRegistry.getMappingsByUrl(lookupPath)",实际上,它是查询 MappingRegistry#urlLookup,它的值可以用调试视图查看,如下图所示:
<img src="https://static001.geekbang.org/resource/image/d5/80/d579a4557a06ef8a0ba960ed05184b80.png" alt="">
查询 urlLookup 是一个精确匹配 Path 的过程。很明显,[http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) 的 lookupPath 是"/hi1/xiao/ming",并不能得到任何精确匹配。这里需要补充的是,"/hi1/{name}"这种定义本身也没有出现在 urlLookup 中。
**2. 假设 Path 没有精确匹配上,则执行模糊匹配**
在步骤 1 匹配失败时,会根据请求来尝试模糊匹配,待匹配的匹配方法可参考下图:
<img src="https://static001.geekbang.org/resource/image/1d/2b/1da52225336ec68451471ac4de36db2b.png" alt="">
显然,"/hi1/{name}"这个匹配方法已经出现在待匹配候选中了。具体匹配过程可以参考方法 RequestMappingInfo#getMatchingCondition
```
public RequestMappingInfo getMatchingCondition(HttpServletRequest request) {
RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(request);
if (methods == null) {
return null;
}
ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(request);
if (params == null) {
return null;
}
//省略其他匹配条件
PatternsRequestCondition patterns = this.patternsCondition.getMatchingCondition(request);
if (patterns == null) {
return null;
}
//省略其他匹配条件
return new RequestMappingInfo(this.name, patterns,
methods, params, headers, consumes, produces, custom.getCondition());
}
```
现在我们知道**匹配会查询所有的信息**,例如 Header、Body 类型以及URL 等。如果有一项不符合条件,则不匹配。
在我们的案例中,当使用 [http://localhost:8080/hi1/xiaoming](http://localhost:8080/hi1/xiaoming) 访问时,其中 patternsCondition 是可以匹配上的。实际的匹配方法执行是通过 AntPathMatcher#match 来执行,判断的相关参数可参考以下调试视图:
<img src="https://static001.geekbang.org/resource/image/f2/c6/f224047fd2d4ee0751229415a9ac87c6.png" alt="">
但是当我们使用 [http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) 来访问时AntPathMatcher 执行的结果是"/hi1/xiao/ming"匹配不上"/hi1/{name}"。
**3. 根据匹配情况返回结果**
如果找到匹配的方法,则返回方法;如果没有,则返回 null。
在本案例中,[http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) 因为找不到匹配方法最终报 404 错误。追根溯源就是 AntPathMatcher 匹配不了"/hi1/xiao/ming"和"/hi1/{name}"。
另外,我们再回头思考 [http://localhost:8080/hi1/xiaoming/](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(&quot;/&quot;) &amp;&amp; this.pathMatcher.match(pattern + &quot;/&quot;, lookupPath)) {
return pattern + &quot;/&quot;;
}
}
return null;
}
```
在这段代码中AntPathMatcher 匹配不了"/hi1/xiaoming/"和"/hi1/{name}",所以不会直接返回。进而,在 useTrailingSlashMatch 这个参数启用时(默认启用),会把 Pattern 结尾加上/再尝试匹配一次。如果能匹配上,在最终返回 Pattern 时就隐式自动加/。
很明显,我们的案例符合这种情况,等于说我们最终是用了"/hi1/{name}/"这个 Pattern而不再是"/hi1/{name}"。所以自然 URL 解析 name 结果是去掉/的。
### 问题修正
针对这个案例,有了源码的剖析,我们可能会想到可以先用"**"匹配上路径,等进入方法后再尝试去解析,这样就可以万无一失吧。具体修改代码如下:
```
@RequestMapping(path = &quot;/hi1/**&quot;, method = RequestMethod.GET)
public String hi1(HttpServletRequest request){
String requestURI = request.getRequestURI();
return requestURI.split(&quot;/hi1/&quot;)[1];
};
```
但是这种修改方法还是存在漏洞,假设我们路径的 name 中刚好又含有"/hi1/",则 split 后返回的值就并不是我们想要的。实际上,更合适的修订代码示例如下:
```
private AntPathMatcher antPathMatcher = new AntPathMatcher();
@RequestMapping(path = &quot;/hi1/**&quot;, method = RequestMethod.GET)
public String hi1(HttpServletRequest request){
String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
//matchPattern 即为&quot;/hi1/**&quot;
String matchPattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
return antPathMatcher.extractPathWithinPattern(matchPattern, path);
};
```
经过修改,两个错误都得以解决了。当然也存在一些其他的方案,例如对传递的参数进行 URL 编码以避免出现/或者干脆直接把这个变量作为请求参数、Header 等,而不是作为 URL 的一部分。你完全可以根据具体情况来选择合适的方案。
## 案例 2错误使用@RequestParam、@PathVarible 等注解
我们常常使用@RequestParam@PathVarible 来获取请求参数request parameters以及 path 中的部分。但是在频繁使用这些参数时,不知道你有没有觉得它们的使用方式并不友好,例如我们去获取一个请求参数 name我们会定义如下
>
@RequestParam("name") String name
此时,我们会发现变量名称大概率会被定义成 RequestParam值。所以我们是不是可以用下面这种方式来定义
>
@RequestParam String name
这种方式确实是可以的,本地测试也能通过。这里我还给出了完整的代码,你可以感受下这两者的区别。
```
@RequestMapping(path = &quot;/hi1&quot;, method = RequestMethod.GET)
public String hi1(@RequestParam(&quot;name&quot;) String name){
return name;
};
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestParam String name){
return name;
};
```
很明显,对于喜欢追究极致简洁的同学来说,这个酷炫的功能是一个福音。但当我们换一个项目时,有可能上线后就失效了,然后报错 500提示匹配不上。
<img src="https://static001.geekbang.org/resource/image/f3/7f/f377e98e0293e480c4ea249596ec4d7f.png" alt="">
### 案例解析
要理解这个问题出现的原因,首先我们需要把这个问题复现出来。例如我们可以修改下 pom.xml 来关掉两个选项:
```
&lt;plugin&gt;
&lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;
&lt;artifactId&gt;maven-compiler-plugin&lt;/artifactId&gt;
&lt;configuration&gt;
&lt;debug&gt;false&lt;/debug&gt;
&lt;parameters&gt;false&lt;/parameters&gt;
&lt;/configuration&gt;
&lt;/plugin&gt;
```
上述配置显示关闭了 parameters 和 debug这 2 个参数的作用你可以参考下面的表格:
<img src="https://static001.geekbang.org/resource/image/c6/a0/c60cabd6a71f02db8663eae8224ddaa0.jpg" alt="">
通过上述描述,我们可以看出这 2 个参数控制了一些 debug 信息是否加进 class 文件中。我们可以开启这两个参数来编译,然后使用下面的命令来查看信息:
>
javap -verbose HelloWorldController.class
执行完命令后,我们会看到以下 class 信息:
<img src="https://static001.geekbang.org/resource/image/1e/e4/1e7fee355e63528c97bbf47e8bdaa6e4.png" alt="">
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(
&quot;Name for argument type [&quot; + parameter.getNestedParameterType().getName() +
&quot;] not available, and parameter name information not found in class file either.&quot;);
}
}
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 文件中找到参数名了,这点可以从下面的调试试图中得到验证:
<img src="https://static001.geekbang.org/resource/image/8d/37/8dc41bf12f0075573bf6b6d13b2a2537.png" alt="">
当参数名不存在,@RequestParam 也没有指明,自然就无法决定到底要用什么名称去获取请求参数,所以就会报本案例的错误。
### 问题修正
模拟出了问题是如何发生的,我们自然可以通过开启这两个参数让其工作起来。但是思考这两个参数的作用,很明显,它可以让我们的程序体积更小,所以很多项目都会青睐去关闭这两个参数。
为了以不变应万变,正确的修正方式是**必须显式在@RequestParam 中指定请求参数名**。具体修改如下:
>
@RequestParam("name") String name
通过这个案例我们可以看出很多功能貌似可以永远工作但是实际上只是在特定的条件下而已。另外这里再拓展下IDE 都喜欢开启相关 debug 参数,所以 IDE 里运行的程序不见得对产线适应,例如针对 parameters 这个参数IDEA 默认就开启了。
另外,本案例围绕的都是 @RequestParam,其实 @PathVarible 也有一样的问题。这里你要注意。
那么说到这里,我顺带提一个可能出现的小困惑:我们这里讨论的参数,和 @QueryParam@PathParam 有什么区别?实际上,后者都是 JAX-RS 自身的注解,不需要额外导包。而 @RequestParam@PathVariable 是 Spring 框架中的注解,需要额外导入依赖包。另外不同注解的参数也不完全一致。
## 案例 3未考虑参数是否可选
在上面的案例中,我们提到了 @RequestParam 的使用。而对于它的使用,我们常常会遇到另外一个问题。当需要特别多的请求参数时,我们往往会忽略其中一些参数是否可选。例如存在类似这样的代码:
```
@RequestMapping(path = &quot;/hi4&quot;, method = RequestMethod.GET)
public String hi4(@RequestParam(&quot;name&quot;) String name, @RequestParam(&quot;address&quot;) String address){
return name + &quot;:&quot; + address;
};
```
在访问 [http://localhost:8080/hi4?name=xiaoming&amp;address=beijing](http://localhost:8080/hi2?name=xiaoming&amp;address=beijing) 时并不会出问题,但是一旦用户仅仅使用 name 做请求(即 [http://localhost:8080/hi4?name=xiaoming](http://localhost:8080/hi4?name=xiaoming) )时,则会直接报错如下:
<img src="https://static001.geekbang.org/resource/image/92/09/9289ddbf7e1b39131662ab3fc1807709.png" alt="">
此时,返回错误码 400提示请求格式错误此处缺少 address 参数。
实际上,部分初学者即使面对这个错误,也会觉得惊讶,既然不存在 addressaddress 应该设置为 null而不应该是直接报错不是么接下来我们就分析下。
### 案例解析
要了解这个错误出现的根本原因,你就需要了解请求参数的发生位置。
实际上,这里我们也能按注解名(@RequestParam)来确定解析发生的位置是在 RequestParamMethodArgumentResolver 中。为什么是它?
追根溯源,针对当前案例,当根据 URL 匹配上要执行的方法是 hi4 后,要反射调用它,必须解析出方法参数 name 和 address 才可以。而它们被 @RequestParam 注解修饰,所以解析器借助 RequestParamMethodArgumentResolver 就成了很自然的事情。
接下来我们看下 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 &amp;&amp; !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());
}
```
实际上就是 @RequestParam 的相关信息,我们调试下,就可以验证这个结论,具体如下图所示:
<img src="https://static001.geekbang.org/resource/image/f5/5e/f56f4498bcd078c20e4320yy2353af5e.png" alt="">
**2. 在 @RequestParam 没有指明默认值时,会查看这个参数是否必须,如果必须,则按错误处理**
判断参数是否必须的代码即为下述关键代码行:
>
namedValueInfo.required &amp;&amp; !nestedParameter.isOptional()
很明显,若要判定一个参数是否是必须的,需要同时满足两个条件:条件 1 是@RequestParam 指明了必须(即属性 required 为 true实际上它也是默认值条件 2 是要求 @RequestParam 标记的参数本身不是可选的。
我们可以通过 MethodParameter#isOptional 方法看下可选的具体含义:
```
public boolean isOptional() {
return (getParameterType() == Optional.class || hasNullableAnnotation() ||
(KotlinDetector.isKotlinReflectPresent() &amp;&amp;
KotlinDetector.isKotlinType(getContainingClass()) &amp;&amp;
KotlinDelegate.isOptional(this)));
}
```
在不使用 Kotlin 的情况下,所谓可选,就是参数的类型为 Optional或者任何标记了注解名为 Nullable 且 RetentionPolicy 为 RUNTIM 的注解。
**3. 如果不是必须,则按 null 去做具体处理**
如果接受类型是 boolean返回 false如果是基本类型则直接报错这里不做展开。
结合我们的案例,我们的参数符合步骤 2 中判定为必选的条件,所以最终会执行方法 AbstractNamedValueMethodArgumentResolver#handleMissingValue
```
protected void handleMissingValue(String name, MethodParameter parameter) throws ServletException {
throw new ServletRequestBindingException(&quot;Missing argument '&quot; + name +
&quot;' for method parameter of type &quot; + parameter.getNestedParameterType().getSimpleName());
}
```
### 问题修正
通过案例解析,我们很容易就能修正这个问题,就是让参数有默认值或为非可选即可,具体方法包含以下几种。
**1. 设置 @RequestParam 的默认值**
修改代码如下:
>
@RequestParam(value = "address", defaultValue = "no address") String address
**2. 设置 @RequestParam 的 required 值**
修改代码如下:
>
@RequestParam(value = "address", required = false) String address)
**3. 标记任何名为 Nullable 且 RetentionPolicy 为 RUNTIME 的注解**
修改代码如下:
>
<p>[//org.springframework.lang.Nullable](//org.springframework.lang.Nullable) 可以<br>
[//edu.umd.cs.findbugs.annotations.Nullable](//edu.umd.cs.findbugs.annotations.Nullable) 可以<br>
@RequestParam(value = "address") @Nullable String address</p>
**4. 修改参数类型为 Optional**
修改代码如下:
>
@RequestParam(value = "address") Optional<string> address</string>
从这些修正方法不难看出:假设你不学习源码,解决方法就可能只局限于一两种,但是深入源码后,解决方法就变得格外多了。这里要特别强调的是:**在Spring Web 中,默认情况下,请求参数是必选项。**
## 案例 4请求参数格式错误
当我们使用 Spring URL 相关的注解,会发现 Spring 是能够完成自动转化的。例如在下面的代码中age 可以被直接定义为 int 这种基本类型Integer 也可以),而不是必须是 String 类型。
```
@RequestMapping(path = &quot;/hi5&quot;, method = RequestMethod.GET)
public String hi5(@RequestParam(&quot;name&quot;) String name, @RequestParam(&quot;age&quot;) int age){
return name + &quot; is &quot; + age + &quot; years old&quot;;
};
```
鉴于 Spring 的强大转化功能,我们断定 Spring 也支持日期类型的转化(也确实如此),于是我们可能会写出类似下面这样的代码:
```
@RequestMapping(path = &quot;/hi6&quot;, method = RequestMethod.GET)
public String hi6(@RequestParam(&quot;Date&quot;) Date date){
return &quot;date is &quot; + date ;
};
```
然后,我们使用一些看似明显符合日期格式的 URL 来访问,例如 [http://localhost:8080/hi6?date=2021-5-1 20:26:53](http://localhost:8080/hi6?date=2021-5-1%2020:26:53),我们会发现 Spring 并不能完成转化,而是报错如下:
<img src="https://static001.geekbang.org/resource/image/08/78/085931e6c4c8a01ae5f4f443c0393778.png" alt="">
此时,返回错误码 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&lt;?&gt; sourceClass = sourceType.getType();
Class&lt;?&gt; targetClass = targetType.getType();
//根据源类型去获取构建出目标类型的方法:可以是工厂方法(例如 valueOf、from 方法)也可以是构造器
Member member = getValidatedMember(targetClass, sourceClass);
try {
if (member instanceof Method) {
//如果是工厂方法,通过反射创建目标实例
}
else if (member instanceof Constructor) {
//如果是构造器,通过反射创建实例
Constructor&lt;?&gt; ctor = (Constructor&lt;?&gt;) 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](http://localhost:8080/hi6?date=2021-5-1%2020:26:53) 虽然确实是一种日期格式,但用来作为 Date 构造器参数是不支持的,最终报错,并被上层捕获,转化为 ConversionFailedException 异常。这就是这个案例背后的故事了。
### 问题修正
那么怎么解决呢?提供两种方法。
**1. 使用 Date 支持的格式**
例如下面的测试 URL 就可以工作起来:
>
[http://localhost:8080/hi6?date=Sat](http://localhost:8080/hi6?date=Sat), 12 Aug 1995 13:30:00 GMT
**2. 使用好内置格式转化器**
实际上在Spring中要完成 String 对于 Date 的转化ObjectToObjectConverter 并不是最好的转化器。我们可以使用更强大的AnnotationParserConverter。**在Spring 初始化时,会构建一些针对日期型的转化器,即相应的一些 AnnotationParserConverter 的实例。**但是为什么有时候用不上呢?
这是因为 AnnotationParserConverter 有目标类型的要求,这点我们可以通过调试角度来看下,参考 FormattingConversionService#addFormatterForFieldAnnotation 方法的调试试图:
<img src="https://static001.geekbang.org/resource/image/0c/34/0c8bd3fc14081710cc411091c8bd4f34.png" alt="">
这是适应于 String 到 Date 类型的转化器 AnnotationParserConverter 实例的构造过程,其需要的 annototationType 参数为 DateTimeFormat。
annototationType 的作用正是为了帮助判断是否能用这个转化器,这一点可以参考代码 AnnotationParserConverter#matches
```
@Override
public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
return targetType.hasAnnotation(this.annotationType);
}
```
最终构建出来的转化器相关信息可以参考下图:
<img src="https://static001.geekbang.org/resource/image/f0/b1/f068b39c4a3f81b8ccebbfd962e966b1.png" alt="">
图中构造出的转化器是可以用来转化 String 到 Date但是它要求我们标记 @DateTimeFormat。很明显,我们的参数 Date 并没有标记这个注解,所以这里为了使用这个转化器,我们可以使用上它并提供合适的格式。这样就可以让原来不工作的 URL 工作起来,具体修改代码如下:
```
@DateTimeFormat(pattern=&quot;yyyy-MM-dd HH:mm:ss&quot;) Date date
```
以上即为本案例的解决方案。除此之外,我们完全可以制定一个转化器来帮助我们完成转化,这里不再赘述。另外,通过这个案例,我们可以看出:尽管 Spring 给我们提供了很多内置的转化功能,但是我们一定要注意,格式是否符合对应的要求,否则代码就可能会失效。
## 重点回顾
通过这一讲的学习我们了解到了在Spring解析URL中的一些常见错误及其背后的深层原因。这里再次回顾下重点
1. 当我们使用@PathVariable时,一定要注意传递的值是不是含有 / ;
1. 当我们使用@RequestParam@PathVarible等注解时,一定要意识到一个问题,虽然下面这两种方式(以@RequestParam使用示例)都可以,但是后者在一些项目中并不能正常工作,因为很多产线的编译配置会去掉不是必须的调试信息。
```
@RequestMapping(path = &quot;/hi1&quot;, method = RequestMethod.GET)
public String hi1(@RequestParam(&quot;name&quot;) String name){
return name;
};
//方式2没有显式指定RequestParam的“name”这种方式有时候会不行
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestParam String name){
return name;
};
```
1. 任何一个参数我们都需要考虑它是可选的还是必须的。同时你一定要想到参数类型的定义到底能不能从请求中自动转化而来。Spring本身给我们内置了很多转化器但是我们要以合适的方式使用上它。另外Spring对很多类型的转化设计都很贴心例如使用下面的注解就能解决自定义日期格式参数转化问题。
```
@DateTimeFormat(pattern=&quot;yyyy-MM-dd HH:mm:ss&quot;) Date date
```
希望这些核心知识点能帮助你高效解析URL。
## 思考题
关于 URL 解析,其实还有许多让我们惊讶的地方,例如案例 2 的部分代码:
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestParam(&quot;name&quot;) String name){
return name;
};
```
在上述代码的应用中,我们可以使用 [http://localhost:8080/hi2?name=xiaoming&amp;name=hanmeimei](http://localhost:8080/hi2?name=xiaoming&amp;name=hanmeimei) 来测试下,结果会返回什么呢?你猜会是[xiaoming&amp;name=hanmeimei](http://localhost:8080/hi2?name=xiaoming&amp;name=hanmeimei) 么?
我们留言区见!

View File

@@ -0,0 +1,588 @@
<audio id="audio" title="10 | Spring Web Header 解析常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/17/a4/176b58756c8d9efdb2ea7f5043176da4.mp3"></audio>
你好,我是傅健,这节课我们来聊聊 Spring Web 开发中 Header 相关的常见错误案例。
在上节课,我们梳理了 URL 相关错误。实际上,对于一个 HTTP 请求而言URL 固然重要但是为了便于用户使用URL 的长度有限,所能携带的信息也因此受到了制约。
如果想提供更多的信息Header 往往是不二之举。不言而喻Header 是介于 URL 和 Body 之外的第二大重要组成它提供了更多的信息以及围绕这些信息的相关能力例如Content-Type指定了我们的请求或者响应的内容类型便于我们去做解码。虽然 Spring 对于 Header 的解析,大体流程和 URL 相同,但是 Header 本身具有自己的特点。例如Header 不像 URL 只能出现在请求中。所以Header 处理相关的错误和 URL 又不尽相同。接下来我们看看具体的案例。
## 案例 1接受 Header 使用错 Map 类型
在 Spring 中解析 Header 时我们在多数场合中是直接按需解析的。例如我们想使用一个名为myHeaderName的 Header我们会书写代码如下
```
@RequestMapping(path = &quot;/hi&quot;, method = RequestMethod.GET)
public String hi(@RequestHeader(&quot;myHeaderName&quot;) String name){
//省略 body 处理
};
```
定义一个参数,标记上@RequestHeader,指定要解析的 Header 名即可。但是假设我们需要解析的 Header 很多时,按照上面的方式很明显会使得参数越来越多。在这种情况下,我们一般都会使用 Map 去把所有的 Header 都接收到,然后直接对 Map 进行处理。于是我们可能会写出下面的代码:
```
@RequestMapping(path = &quot;/hi1&quot;, method = RequestMethod.GET)
public String hi1(@RequestHeader() Map map){
return map.toString();
};
```
粗略测试程序,你会发现一切都很好。而且上面的代码也符合针对接口编程的范式,即使用了 Map 这个接口类型。但是上面的接口定义在遇到下面的请求时,就会超出预期。请求如下:
>
<p>GET [http://localhost:8080/hi1](http://localhost:8080/hi1)<br>
myheader: h1<br>
myheader: h2</p>
这里存在一个 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}
```
如何理解这个常见错误及背后原理?接下来我们就具体解析下。
### 案例解析
实际上,当我们看到这个测试结果,大多数同学已经能反应过来了。对于一个多值的 Header在实践中通常有两种方式来实现一种是采用下面的方式
>
Key: value1,value2
而另外一种方式就是我们测试请求中的格式:
>
<p>Key:value1<br>
Key:value2</p>
对于方式 1我们使用 Map 接口自然不成问题。但是如果使用的是方式 2我们就不能拿到所有的值。这里我们可以翻阅代码查下 Map 是如何接收到所有请求的。
对于一个 Header 的解析,主要有两种方式,分别实现在 RequestHeaderMethodArgumentResolver 和 RequestHeaderMapMethodArgumentResolver 中,它们都继承于 AbstractNamedValueMethodArgumentResolver但是应用的场景不同我们可以对比下它们的 supportsParameter(),来对比它们适合的场景:
<img src="https://static001.geekbang.org/resource/image/f7/e6/f7f804ec3e447c95eafde8cc5255bee6.png" alt="">
在上图中,左边是 RequestHeaderMapMethodArgumentResolver 的方法。通过比较可以发现,对于一个标记了 @RequestHeader 的参数,如果它的类型是 Map则使用 RequestHeaderMapMethodArgumentResolver否则一般使用的是 RequestHeaderMethodArgumentResolver。
在我们的案例中,很明显,参数类型定义为 Map所以使用的自然是 RequestHeaderMapMethodArgumentResolver。接下来我们继续查看它是如何解析 Header 的,关键代码参考 resolveArgument()
```
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
Class&lt;?&gt; paramType = parameter.getParameterType();
if (MultiValueMap.class.isAssignableFrom(paramType)) {
MultiValueMap&lt;String, String&gt; result;
if (HttpHeaders.class.isAssignableFrom(paramType)) {
result = new HttpHeaders();
}
else {
result = new LinkedMultiValueMap&lt;&gt;();
}
for (Iterator&lt;String&gt; 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&lt;String, String&gt; result = new LinkedHashMap&lt;&gt;();
for (Iterator&lt;String&gt; 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 &lt; count; i++) {
if (headers[i].getName().equalsIgnoreCase(name)) {
return headers[i].getValue();
}
}
return null;
}
```
当一个请求出现多个同名 Header 时,我们只要匹配上任何一个即立马返回。所以在本案例中,只返回了一个 Header 的值。
其实换一个角度思考这个问题,毕竟前面已经定义的接收类型是 LinkedHashMap它的 Value 的泛型类型是 String也不适合去组织多个值的情况。综上不管是结合代码还是常识本案例的代码都不能获取到myHeader的所有值。
### 问题修正
现在我们要修正这个问题。在案例解析部分,其实我已经给出了答案。
在 RequestHeaderMapMethodArgumentResolver 的 resolveArgument() 中,假设我们的参数类型是 MultiValueMap我们一般会创建一个 LinkedMultiValueMap然后使用下面的语句来获取 Header 的值并添加到 Map 中去:
>
String[] headerValues = webRequest.getHeaderValues(headerName)
参考上面的语句,不用细究,我们也能看出,我们是可以获取多个 Header 值的。另外假设我们定义的是 HttpHeaders也是一种 MultiValueMap我们会直接创建一个 HttpHeaders 来存储所有的 Header。
有了上面的解析,我们可以得出这样一个结论:**要完整接收到所有的Header不能直接使用Map而应该使用MultiValueMap。**我们可以采用以下两种方式来修正这个问题:
```
//方式 1
@RequestHeader() MultiValueMap map
//方式 2
@RequestHeader() HttpHeaders map
```
重新运行测试,你会发现结果符合预期:
>
[myheader:"h1", "h2", host:"localhost:8080", connection:"Keep-Alive", user-agent:"Apache-HttpClient/4.5.12 (Java/11.0.6)", accept-encoding:"gzip,deflate"]
对比来说,方式 2 更值得推荐,因为它使用了大多数人常用的 Header 获取方法,例如获取 Content-Type 直接调用它的 getContentType() 即可,诸如此类,非常好用。
反思这个案例,我们为什么会犯这种错误呢?追根溯源,还是在于我们很少看到一个 Header 有多个值的情况,从而让我们疏忽地用错了接收类型。
## 案例 2错认为 Header 名称首字母可以一直忽略大小写
在 HTTP 协议中Header 的名称是无所谓大小写的。在使用各种框架构建 Web 时,我们都会把这个事实铭记于心。我们可以验证下这个想法。例如,我们有一个 Web 服务接口如下:
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestHeader(&quot;MyHeader&quot;) String myHeader){
return myHeader;
};
```
然后,我们使用下面的请求来测试这个接口是可以获取到对应的值的:
>
<p>GET [http://localhost:8080/hi2](http://localhost:8080/hi2)<br>
myheader: myheadervalue</p>
另外结合案例1我们知道可以使用 Map 来接收所有的 Header那么这种方式下是否也可以忽略大小写呢这里我们不妨使用下面的代码来比较下
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestHeader(&quot;MyHeader&quot;) String myHeader, @RequestHeader MultiValueMap map){
return myHeader + &quot; compare with : &quot; + map.get(&quot;MyHeader&quot;);
};
```
再次运行之前的测试请求,我们得出下面的结果:
>
myheadervalue compare with : null
综合来看,直接获取 Header 是可以忽略大小写的,但是如果从接收过来的 Map 中获取 Header 是不能忽略大小写的。稍微不注意,我们就很容易认为 Header 在任何情况下,都可以不区分大小写来获取值。
那么针对这个案例,如何去理解?
### 案例解析
我们知道,对于"@RequestHeader("MyHeader") String myHeader"的定义Spring 使用的是 RequestHeaderMethodArgumentResolver 来做解析。解析的方法参考 RequestHeaderMethodArgumentResolver#resolveName
```
protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
String[] headerValues = request.getHeaderValues(name);
if (headerValues != null) {
return (headerValues.length == 1 ? headerValues[0] : headerValues);
}
else {
return null;
}
}
```
从上述方法的关键调用"request.getHeaderValues(name)"去按图索骥,我们可以找到查找 Header 的最根本方法,即 org.apache.tomcat.util.http.ValuesEnumerator#findNext
```
private void findNext() {
next=null;
for(; pos&lt; size; pos++ ) {
MessageBytes n1=headers.getName( pos );
if( n1.equalsIgnoreCase( name )) {
next=headers.getValue( pos );
break;
}
}
pos++;
}
```
在上述方法中name 即为查询的 Header 名称,可以看出这里是忽略大小写的。
而如果我们用 Map 来接收所有的 Header我们来看下这个 Map 最后存取的 Header 和获取的方法有没有忽略大小写。
有了案例 1 的解析,针对当前的类似案例,结合具体的代码,我们很容易得出下面两个结论。
**1. 存取 Map 的 Header 是没有忽略大小写的**
参考案例 1 解析部分贴出的代码,可以看出,在存取 Header 时,需要的 key 是遍历 webRequest.getHeaderNames() 的返回结果。而这个方法的执行过程参考 org.apache.tomcat.util.http.NamesEnumerator#findNext
```
private void findNext() {
next=null;
for(; pos&lt; size; pos++ ) {
next=headers.getName( pos ).toString();
for( int j=0; j&lt;pos ; j++ ) {
if( headers.getName( j ).equalsIgnoreCase( next )) {
// duplicate.
next=null;
break;
}
}
if( next!=null ) {
// it's not a duplicate
break;
}
}
// next time findNext is called it will try the
// next element
pos++;
}
```
这里,返回结果并没有针对 Header 的名称做任何大小写忽略或转化工作。
**2. 从 Map 中获取的 Header 也没有忽略大小写**
这点可以从返回是 LinkedHashMap 类型看出LinkedHashMap 的 get() 未忽略大小写。
接下来我们看下怎么解决。
### 问题修正
就从接收类型 Map 中获取 Header 时注意下大小写就可以了,修正代码如下:
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestHeader(&quot;MyHeader&quot;) String myHeader, @RequestHeader MultiValueMap map){
return myHeader + &quot; compare with : &quot; + map.get(&quot;myHeader&quot;);
};
```
另外,你可以思考一个问题,如果我们使用 HTTP Headers 来接收请求,那么从它里面获取 Header 是否可以忽略大小写呢?
这点你可以通过它的构造器推测出来,其构造器代码如下:
```
public HttpHeaders() {
this(CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap&lt;&gt;(8, Locale.ENGLISH)));
}
```
可以看出,它使用的是 LinkedCaseInsensitiveMap而不是普通的 LinkedHashMap。所以这里是可以忽略大小写的我们不妨这样修正
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestHeader(&quot;MyHeader&quot;) String myHeader, @RequestHeader HttpHeaders map){
return myHeader + &quot; compare with : &quot; + map.get(&quot;MyHeader&quot;);
};
```
再运行下程序,结果已经符合我们的预期了:
>
myheadervalue compare with : [myheadervalue]
通过这个案例,我们可以看出:**在实际使用时,虽然 HTTP 协议规范可以忽略大小写,但是不是所有框架提供的接口方法都是可以忽略大小写的。**这点你一定要注意!
## 案例 3试图在 Controller 中随意自定义 CONTENT_TYPE 等
和开头我们提到的 Header 和 URL 不同Header 可以出现在返回中。正因为如此,一些应用会试图去定制一些 Header 去处理。例如使用 Spring Boot 基于 Tomcat 内置容器的开发中,存在下面这样一段代码去设置两个 Header其中一个是常用的 CONTENT_TYPE另外一个是自定义的命名为 myHeader。
```
@RequestMapping(path = &quot;/hi3&quot;, method = RequestMethod.GET)
public String hi3(HttpServletResponse httpServletResponse){
httpServletResponse.addHeader(&quot;myheader&quot;, &quot;myheadervalue&quot;);
httpServletResponse.addHeader(HttpHeaders.CONTENT_TYPE, &quot;application/json&quot;);
return &quot;ok&quot;;
};
```
运行程序测试下(访问 GET [http://localhost:8080/hi3](http://localhost:8080/hi3) ),我们会得到如下结果:
>
<p>GET [http://localhost:8080/hi3](http://localhost:8080/hi3)<br>
&nbsp;<br>
HTTP/1.1 200<br>
myheader: myheadervalue<br>
Content-Type: text/plain;charset=UTF-8<br>
Content-Length: 2<br>
Date: Wed, 17 Mar 2021 08:59:56 GMT<br>
Keep-Alive: timeout=60<br>
Connection: keep-alive</p>
可以看到 myHeader 设置成功了,但是 Content-Type 并没有设置成我们想要的"application/json",而是"text/plain;charset=UTF-8"。为什么会出现这种错误?
### 案例解析
首先我们来看下在 Spring Boot 使用内嵌 Tomcat 容器时,尝试添加 Header 会执行哪些关键步骤。
第一步我们可以查看 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如果是不要把这个 Header 作为 header 添加到 org.apache.coyote.Response
if (checkSpecialHeader(name, value))
return;
}
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(&quot;Content-Type&quot;)) {
setContentType(value);
return true;
}
return false;
}
```
最终我们获取到的 Response 如下:
<img src="https://static001.geekbang.org/resource/image/5f/5e/5f6e7b91eedcbdc98c124cac6f00f85e.png" alt="">
从上图可以看出Headers 里并没有 Content-Type而我们设置的 Content-Type 已经作为 coyoteResponse 成员的值了。当然也不意味着后面一定不会返回,我们可以继续跟踪后续执行。
在案例代码返回ok后我们需要对返回结果进行处理执行方法为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 &amp;&amp; contentType.isConcrete();
//如果 header 中有 contentType则用其作为选择的 selectedMediaType。
if (isContentTypePreset) {
selectedMediaType = contentType;
}
//没有则根据“Accept”头、返回值等核算用哪一种
else {
HttpServletRequest request = inputMessage.getServletRequest();
List&lt;MediaType&gt; acceptableTypes = getAcceptableMediaTypes(request);
List&lt;MediaType&gt; producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
//省略其他非关键代码
List&lt;MediaType&gt; mediaTypesToUse = new ArrayList&lt;&gt;();
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&lt;?&gt; converter : this.messageConverters) {
GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
(GenericHttpMessageConverter&lt;?&gt;) 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);
}
}
//省略其他非关键代码
}
}
```
如代码所示,即结合 targetTypeString、valueTypeString、selectedMediaTypeMediaType#TEXT_PLAIN)三个信息来决策可以使用哪种消息 Converter。常见候选 Converter 可以参考下图:
<img src="https://static001.geekbang.org/resource/image/a3/e6/a33b9282baac597d1f3acf74a6874ce6.png" alt="">
最终,本案例选择的是 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"。
通过案例分析可以总结出,虽然我们在 Controller 设置了 Content-Type但是它是一种特殊的 Header所以**在 Spring Boot 基于内嵌 Tomcat 开发时并不一定能设置成功,最终返回的 Content-Type 是根据实际的返回值及类型等多个因素来决定的。**
### 问题修正
针对这个问题,如果想设置成功,我们就必须让其真正的返回就是 JSON 类型,这样才能刚好生效。而且从上面的分析也可以看出,返回符合预期也并非是在 Controller 设置的功劳。不过围绕目标,我们也可以这样去修改下:
**1. 修改请求中的 Accept 头,约束返回类型**
参考代码如下:
```
GET http://localhost:8080/hi3
Accept:application/json
```
即带上 Accept 头,这样服务器在最终决定 MediaType 时,会选择 Accept 的值。具体执行可参考方法 AbstractMessageConverterMethodProcessor#getAcceptableMediaTypes
**2. 标记返回类型**
主动显式指明类型,修改方法如下:
```
@RequestMapping(path = &quot;/hi3&quot;, method = RequestMethod.GET, produces = {&quot;application/json&quot;})
```
即使用 produces 属性来指明即可。这样的方式影响的是可以返回的 Media 类型,一旦设置,下面的方法就可以只返回一个指明的类型了。参考 AbstractMessageConverterMethodProcessor#getProducibleMediaTypes
```
protected List&lt;MediaType&gt; getProducibleMediaTypes(
HttpServletRequest request, Class&lt;?&gt; valueClass, @Nullable Type targetType) {
Set&lt;MediaType&gt; mediaTypes =
(Set&lt;MediaType&gt;) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList&lt;&gt;(mediaTypes);
}
//省略其他非关键代码
}
```
上述两种方式,一个修改了 getAcceptableMediaTypes 返回值,一个修改了 getProducibleMediaTypes这样就可以控制最终协商的结果为 JSON 了。从而影响后续的执行结果。
不过这里需要额外注意的是,虽然我们最终结果返回的 Content-Type 头是 JSON 了,但是对于内容的加工,仍然采用的是 StringHttpMessageConverter感兴趣的话你可以自己去研究下原因。
## 重点回顾
通过这节课的学习,我们了解到了在 Spring 解析Header中的一些常见错误及其背后的深层原因。这里带你回顾下重点
1. 要完整接收到所有的 Header不能直接使用Map而应该使用MultiValueMap。常见的两种方式如下
```
//方式 1
@RequestHeader() MultiValueMap map
//方式 2专用于Header的MultiValueMap子类型
@RequestHeader() HttpHeaders map
```
深究原因Spring在底层解析Header时如果接收参数是Map则当请求的Header是多Value时只存下了其中一个Value。
<li>
在 HTTP 协议规定中Header 的名称是无所谓大小写的。但是这并不意味着所有能获取到Header的途径最终得到的Header名称都是统一大小写的。
</li>
<li>
不是所有的Header在响应中都能随意指定虽然表面看起来能生效但是最后返回给客户端的仍然不是你指定的值。例如在Tomcat下CONTENT_TYPE这个Header就是这种情况。
</li>
以上即为这一讲的核心知识点希望你以后在解析Header时会更有信心。
## 思考题
在案例 3 中,我们以 Content-Type 为例,提到在 Controller 层中随意自定义常用头有时候会失效。那么这个结论是不是普适呢?即在使用其他内置容器或者在其他开发框架下,是不是也会存在一样的问题?
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,597 @@
<audio id="audio" title="11 | Spring Web Body 转化常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/4e/be86c1f408267b28909240f67471a84e.mp3"></audio>
你好,我是傅健。前面几节课我们学习了 Spring Web 开发中绕不开的 URL 和 Header 处理。这一节课,我们接着讲 Body 的处理。
实际上,在 Spring 中,对于 Body 的处理很多是借助第三方编解码器来完成的。例如常见的 JSON 解析Spring 都是借助于 Jackson、Gson 等常见工具来完成。所以在 Body 处理中,我们遇到的很多错误都是第三方工具使用中的一些问题。
真正对于 Spring 而言,错误并不多,特别是 Spring Boot 的自动包装以及对常见问题的不断完善,让我们能犯的错误已经很少了。不过,毕竟不是每个项目都是直接基于 Spring Boot 的,所以还是会存在一些问题,接下来我们就一起梳理下。
## 案例 1No 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(&quot;/hi1&quot;)
public Student hi1() {
return new Student(&quot;xiaoming&quot;, Integer.valueOf(12));
}
}
```
然后,我们的 pom.xml 文件也都是最基本的必备项,关键配置如下:
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework&lt;/groupId&gt;
&lt;artifactId&gt;spring-webmvc&lt;/artifactId&gt;
&lt;version&gt;5.2.3.RELEASE&lt;/version&gt;
&lt;/dependency&gt;
```
但是当我们运行起程序,执行测试代码,就会报错如下:
<img src="https://static001.geekbang.org/resource/image/42/b8/42b44dd7673c9db6828e57566a5af1b8.png" alt="">
从上述代码及配置来看,并没有什么明显的错误,可为什么会报错呢?难道框架不支持?
### 案例解析
要了解这个案例出现的原因,需要我们对如何处理响应有一个初步的认识。
当我们的请求到达 Controller 层后,我们获取到了一个对象,即案例中的 new Student("xiaoming", Integer.valueOf(12)),那么这个对象应该怎么返回给客户端呢?
用 JSON 还是用 XML还是其他类型编码此时就需要一个决策我们可以先找到这个决策的关键代码所在参考方法 AbstractMessageConverterMethodProcessor#writeWithMessageConverters
```
HttpServletRequest request = inputMessage.getServletRequest();
List&lt;MediaType&gt; acceptableTypes = getAcceptableMediaTypes(request);
List&lt;MediaType&gt; producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
if (body != null &amp;&amp; producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
&quot;No converter found for return value of type: &quot; + valueType);
}
List&lt;MediaType&gt; mediaTypesToUse = new ArrayList&lt;&gt;();
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
}
```
实际上节课我们就贴出过相关代码并分析过,所以这里只是带着你简要分析下上述代码的基本逻辑:
1. 查看请求的头中是否有 ACCET 头,如果没有则可以使用任何类型;
1. 查看当前针对返回类型(即 Student 实例)可以采用的编码类型;
1. 取上面两步获取结果的交集来决定用什么方式返回。
比较代码我们可以看出假设第2步中就没有找到合适的编码方式则直接报案例中的错误具体的关键代码行如下
```
if (body != null &amp;&amp; producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
&quot;No converter found for return value of type: &quot; + valueType);
}
```
那么当前可采用的编码类型是怎么决策出来的呢?我们可以进一步查看方法 AbstractMessageConverterMethodProcessor#getProducibleMediaTypes
```
protected List&lt;MediaType&gt; getProducibleMediaTypes(
HttpServletRequest request, Class&lt;?&gt; valueClass, @Nullable Type targetType) {
Set&lt;MediaType&gt; mediaTypes =
(Set&lt;MediaType&gt;) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList&lt;&gt;(mediaTypes);
}
else if (!this.allSupportedMediaTypes.isEmpty()) {
List&lt;MediaType&gt; result = new ArrayList&lt;&gt;();
for (HttpMessageConverter&lt;?&gt; converter : this.messageConverters) {
if (converter instanceof GenericHttpMessageConverter &amp;&amp; targetType != null) {
if (((GenericHttpMessageConverter&lt;?&gt;) 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 来负责路由和处理请求。
具体而言,当我们使用 &lt;mvc:annotation-driven/&gt; 时,我们会通过 AnnotationDrivenBeanDefinitionParser 来构建这个 Bean。而在它的构建过程中会决策出以后要使用哪些 HttpMessageConverter相关代码参考 AnnotationDrivenBeanDefinitionParser#getMessageConverters
```
messageConverters.add(createConverterDefinition(ByteArrayHttpMessageConverter.class, source));
RootBeanDefinition stringConverterDef = createConverterDefinition(StringHttpMessageConverter.class, source);
stringConverterDef.getPropertyValues().add(&quot;writeAcceptCharset&quot;, false);
messageConverters.add(stringConverterDef);
messageConverters.add(createConverterDefinition(ResourceHttpMessageConverter.class, source));
//省略其他非关键代码
if (jackson2Present) {
Class&lt;?&gt; 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 相关的转化器。最终候选列表示例如下:
<img src="https://static001.geekbang.org/resource/image/d8/d4/d84e2d85c91fd7dd6825e14984b071d4.png" alt="">
由此可见,并没有任何 JSON 相关的编解码器。而针对 Student 类型的返回对象,上面的这些编解码器又不符合要求,所以最终走入了下面的代码行:
```
if (body != null &amp;&amp; producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
&quot;No converter found for return value of type: &quot; + valueType);
}
```
抛出了 "No converter found for return value of type" 这种错误,结果符合案例中的实际测试情况。
### 问题修正
针对这个案例,有了源码的剖析,可以看出,**不是每种类型的编码器都会与生俱来,而是根据当前项目的依赖情况决定是否支持。**要解析 JSON我们就要依赖相关的包所以这里我们可以以 Gson 为例修正下这个问题:
```
&lt;dependency&gt;
&lt;groupId&gt;com.google.code.gson&lt;/groupId&gt;
&lt;artifactId&gt;gson&lt;/artifactId&gt;
&lt;version&gt;2.8.6&lt;/version&gt;
&lt;/dependency&gt;
```
我们添加了 Gson 的依赖到 pom.xml。重新运行程序和测试案例你会发现不再报错了。
另外,这里我们还可以查看下 GsonHttpMessageConverter 这种编码器是如何支持上 Student 这个对象的解析的。
通过这个案例我们可以知道Spring 给我们提供了很多好用的功能,但是这些功能交织到一起后,我们就很可能入坑,只有深入了解它的运行方式,才能迅速定位问题并解决问题。
## 案例 2变动地返回 Body
案例1让我们解决了解析问题那随着不断实践我们可能还会发现在代码并未改动的情况下返回结果不再和之前相同了。例如我们看下这段代码
```
@RestController
public class HelloController {
@PostMapping(&quot;/hi2&quot;)
public Student hi2(@RequestBody Student student) {
return student;
}
}
```
上述代码接受了一个 Student 对象,然后原样返回。我们使用下面的测试请求进行测试:
>
<p>POST [http://localhost:8080/springmvc3_war/app/hi2](http://localhost:8080/springmvc3_war/app/hi2)<br>
Content-Type: application/json<br>
{<br>
"name": "xiaoming"<br>
}</p>
经过测试,我们会得到以下结果:
>
<p>{<br>
"name": "xiaoming"<br>
}</p>
但是随着项目的推进,在代码并未改变时,我们可能会返回以下结果:
>
<p>&nbsp;<br>
{<br>
"name": "xiaoming",<br>
"age": null<br>
}</p>
即当 age 取不到值,开始并没有序列化它作为响应 Body 的一部分,后来又序列化成 null 作为 Body 返回了。
在什么情况下会如此?如何规避这个问题,保证我们的返回始终如一。
### 案例解析
如果我们发现上述问题,那么很有可能是这样一种情况造成的。即在后续的代码开发中,我们直接依赖或者间接依赖了新的 JSON 解析器例如下面这种方式就依赖了Jackson
```
&lt;dependency&gt;
&lt;groupId&gt;com.fasterxml.jackson.core&lt;/groupId&gt;
&lt;artifactId&gt;jackson-databind&lt;/artifactId&gt;
&lt;version&gt;2.9.6&lt;/version&gt;
&lt;/dependency&gt;
```
当存在多个 Jackson 解析器时,我们的 Spring MVC 会使用哪一种呢?这个决定可以参考
```
if (jackson2Present) {
Class&lt;?&gt; 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.&lt;Type, InstanceCreator&lt;?&gt;&gt;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.&lt;TypeAdapterFactory&gt;emptyList(), Collections.&lt;TypeAdapterFactory&gt;emptyList(),
Collections.&lt;TypeAdapterFactory&gt;emptyList());
}
```
从DEFAULT_SERIALIZE_NULLS可以看出它是默认不序列化 null 的。
**2. 对于 Jackson 而言:**
MappingJackson2HttpMessageConverter 使用"Jackson2ObjectMapperBuilder.json().build()"来构建 ObjectMapper它默认只显式指定了下面两个配置
>
<p>MapperFeature.DEFAULT_VIEW_INCLUSION<br>
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES</p>
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 的处理变成一致。
上述修改方案虽然看起来简单,但是假设有很多对象如此,万一遗漏了怎么办呢?所以可以从全局角度来修改,修改的关键代码如下:
>
<p>//ObjectMapper mapper = new ObjectMapper();<br>
mapper.setSerializationInclusion(Include.NON_NULL);</p>
但是如何修改 ObjectMapper 呢?这个对象是由 MappingJackson2HttpMessageConverter 构建的,看似无法插足去修改。实际上,我们在非 Spring Boot 程序中,可以按照下面这种方式来修改:
```
@RestController
public class HelloController {
public HelloController(RequestMappingHandlerAdapter requestMappingHandlerAdapter){
List&lt;HttpMessageConverter&lt;?&gt;&gt; messageConverters =
requestMappingHandlerAdapter.getMessageConverters();
for (HttpMessageConverter&lt;?&gt; messageConverter : messageConverters) {
if(messageConverter instanceof MappingJackson2HttpMessageConverter ){
(((MappingJackson2HttpMessageConverter)messageConverter).getObjectMapper()).setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
}
}
//省略其他非关键代码
}
```
我们用自动注入的方式获取到 RequestMappingHandlerAdapter然后找到 Jackson 解析器,进行配置即可。
通过上述两种修改方案,我们就能做到忽略 null 的 age 字段了。
## 案例 3Required request body is missing
通过案例 1我们已经能够解析 Body 了,但是有时候,我们会有一些很好的想法。例如为了查询问题方便,在请求过来时,自定义一个 Filter 来统一输出具体的请求内容,关键代码如下:
```
public class ReadBodyFilter implements Filter {
//省略其他非关键代码
@Override
public void doFilter(ServletRequest request,
ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String requestBody = IOUtils.toString(request.getInputStream(), &quot;utf-8&quot;);
System.out.println(&quot;print request body in filter:&quot; + requestBody);
chain.doFilter(request, response);
}
}
```
然后,我们可以把这个 Filter 添加到 web.xml 并配置如下:
```
&lt;filter&gt;
&lt;filter-name&gt;myFilter&lt;/filter-name&gt;
&lt;filter-class&gt;com.puzzles.ReadBodyFilter&lt;/filter-class&gt;
&lt;/filter&gt;
&lt;filter-mapping&gt;
&lt;filter-name&gt;myFilter&lt;/filter-name&gt;
&lt;url-pattern&gt;/app/*&lt;/url-pattern&gt;
&lt;/filter-mapping&gt;
```
再测试下 Controller 层中定义的接口:
```
@PostMapping(&quot;/hi3&quot;)
public Student hi3(@RequestBody Student student) {
return student;
}
```
运行测试,我们会发现下面的日志:
>
<p>print request body in filter:{<br>
"name": "xiaoming",<br>
"age": 10<br>
}<br>
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)]</p>
可以看到,请求的 Body 确实在请求中输出了但是后续的操作直接报错了错误提示Required request body is missing。
### 案例解析
要了解这个错误的根本原因,你得知道这个错误抛出的源头。查阅请求 Body 转化的相关代码,有这样一段关键逻辑(参考 RequestResponseBodyMethodProcessor#readWithMessageConverters
```
protected &lt;T&gt; 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 &amp;&amp; checkRequired(parameter)) {
throw new HttpMessageNotReadableException(&quot;Required request body is missing: &quot; +
parameter.getExecutable().toGenericString(), inputMessage);
}
return arg;
}
protected boolean checkRequired(MethodParameter parameter) {
RequestBody requestBody = parameter.getParameterAnnotation(RequestBody.class);
return (requestBody != null &amp;&amp; requestBody.required() &amp;&amp; !parameter.isOptional());
}
```
当使用了 @RequestBody 且是必须时,如果解析出的 Body 为 null则报错提示 Required request body is missing。
所以我们要继续追踪代码,来查询什么情况下会返回 body 为 null。关键代码参考 AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters
```
protected &lt;T&gt; Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType){
//省略非关键代码
Object body = NO_VALUE;
EmptyBodyCheckingHttpInputMessage message;
try {
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter&lt;?&gt; converter : this.messageConverters) {
Class&lt;HttpMessageConverter&lt;?&gt;&gt; converterType = (Class&lt;HttpMessageConverter&lt;?&gt;&gt;) converter.getClass();
GenericHttpMessageConverter&lt;?&gt; genericConverter =
(converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter&lt;?&gt;) converter : null);
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null &amp;&amp; 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(&quot;I/O error while reading input message&quot;, 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 来判断出的,即没有数据可以读取。
看到这里你可能会有疑问假设有Bodyread()的执行不就把数据读取走了一点么?确实如此,所以这里我使用了 pushbackInputStream.unread(b) 调用来把读取出来的数据归还回去,这样就完成了是否有 Body 的判断,又保证了 Body 的完整性。
分析到这里,再结合前面的案例,你应该能想到造成 Body 缺失的原因了吧?
1. 本身就没有 Body
1. 有Body但是 Body 本身代表的流已经被前面读取过了。
很明显我们的案例属于第2种情况即在过滤器中我们就已经将 Body 读取完了,关键代码如下:
>
<p>//request 是 ServletRequest<br>
String requestBody = IOUtils.toString(request.getInputStream(), "utf-8");</p>
在这种情况下,作为一个普通的流,已经没有数据可以供给后面的转化器来读取了。
### 问题修正
所以我们可以直接在过滤器中去掉 Body 读取的代码,这样后续操作就又能读到数据了。但是这样又不满足我们的需求,如果我们坚持如此怎么办呢?这里我先直接给出答案,即定义一个 RequestBodyAdviceAdapter 的 Bean
```
@ControllerAdvice
public class PrintRequestBodyAdviceAdapter extends RequestBodyAdviceAdapter {
@Override
public boolean supports(MethodParameter methodParameter, Type type, Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; aClass) {
return true;
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage,MethodParameter parameter, Type targetType,
Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; converterType) {
System.out.println(&quot;print request body in advice:&quot; + body);
return super.afterBodyRead(body, inputMessage, parameter, targetType, converterType);
}
}
```
我们可以看到方法 afterBodyRead 的命名,很明显,这里的 Body 已经是从数据流中转化过的。
那么它是如何工作起来的呢?我们可以查看下面的代码(参考 AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters
```
protected &lt;T&gt; 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&lt;T&gt;)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 解析的常见错误已经有所了解了,这里我们再次回顾下关键知识点:
1. 不同的 Body 需要不同的编解码器,而使用哪一种是协商出来的,协商过程大体如下:
- 查看请求头中是否有 ACCET 头,如果没有则可以使用任何类型;
- 查看当前针对返回类型(即 Student 实例)可以采用的编码类型;
- 取上面两步获取的结果的交集来决定用什么方式返回。
<li>
在非 Spring Boot 程序中JSON 等编解码器不见得是内置好的,需要添加相关的 JAR 才能自动依赖上,而自动依赖的实现是通过检查 Class 是否存在来实现的:当依赖上相关的 JAR 后,关键的 Class 就存在了,响应的编解码器功能也就提供上了。
</li>
<li>
不同的编解码器的实现(例如 JSON 工具 Jaskson 和 Gson可能有一些细节上的不同所以你一定要注意当依赖一个新的 JAR 时,是否会引起默认编解码器的改变,从而影响到一些局部行为的改变。
</li>
<li>
在尝试读取 HTTP Body 时,你要注意到 Body 本身是一个流对象,不能被多次读取。
</li>
以上即为这节课的主要内容,希望能对你有所帮助。
## 思考题
通过案例 1 的学习,我们知道直接基于 Spring MVC 而非 Spring Boot 时,是需要我们手工添加 JSON 依赖,才能解析出 JSON 的请求或者编码 JSON 响应,那么为什么基于 Spring Boot 就不需要这样做了呢?
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,421 @@
<audio id="audio" title="12Spring Web 参数验证常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9c/37/9c2f87183eeb7733d371ee1176f73a37.mp3"></audio>
你好,我是傅健,这节课我们来聊聊 Spring Web 开发中的参数检验Validation
参数检验是我们在Web编程时经常使用的技术之一它帮助我们完成请求的合法性校验可以有效拦截无效请求从而达到节省系统资源、保护系统的目的。
相比较其他 Spring 技术Spring提供的参数检验功能具有独立性强、使用难度不高的特点。但是在实践中我们仍然会犯一些常见的错误这些错误虽然不会导致致命的后果但是会影响我们的使用体验例如非法操作要在业务处理时才被拒绝且返回的响应码不够清晰友好。而且这些错误不经测试很难发现接下来我们就具体分析下这些常见错误案例及背后的原理。
## 案例1对象参数校验失效
在构建Web服务时我们一般都会对一个HTTP请求的 Body 内容进行校验,例如我们来看这样一个案例及对应代码。
当开发一个学籍管理系统时,我们会提供了一个 API 接口去添加学生的相关信息,其对象定义参考下面的代码:
```
import lombok.Data;
import javax.validation.constraints.Size;
@Data
public class Student {
@Size(max = 10)
private String name;
private short age;
}
```
这里我们使用了@Size(max = 10)给学生的姓名做了约束(最大为 10 字节),以拦截姓名过长、不符合“常情”的学生信息的添加。
定义完对象后,我们再定义一个 Controller 去使用它,使用方法如下:
```
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 = &quot;students&quot;, method = RequestMethod.POST)
public void addStudent(@RequestBody Student student){
log.info(&quot;add new student: {}&quot;, student.toString());
//省略业务代码
};
}
```
我们提供了一个支持学生信息添加的接口。启动服务后,使用 IDEA 自带的 HTTP Client 工具来发送下面的请求以添加一个学生当然这个学生的姓名会远超想象即this_is_my_name_which_is_too_long
```
POST http://localhost:8080/students
Content-Type: application/json
{
&quot;name&quot;: &quot;this_is_my_name_which_is_too_long&quot;,
&quot;age&quot;: 10
}
```
很明显发送这样的请求name 超长)是期待 Spring Validation 能拦截它的,我们的预期响应如下(省略部分响应字段):
```
HTTP/1.1 400
Content-Type: application/json
{
&quot;timestamp&quot;: &quot;2021-01-03T00:47:23.994+0000&quot;,
&quot;status&quot;: 400,
&quot;error&quot;: &quot;Bad Request&quot;,
&quot;errors&quot;: [
&quot;defaultMessage&quot;: &quot;个数必须在 0 和 10 之间&quot;,
&quot;objectName&quot;: &quot;student&quot;,
&quot;field&quot;: &quot;name&quot;,
&quot;rejectedValue&quot;: &quot;this_is_my_name_which_is_too_long&quot;,
&quot;bindingFailure&quot;: false,
&quot;code&quot;: &quot;Size&quot;
}
],
&quot;message&quot;: &quot;Validation failed for object='student'. Error count: 1&quot;,
&quot;path&quot;: &quot;/students&quot;
}
```
但是理想与现实往往有差距。实际测试会发现使用上述代码构建的Web服务并没有做任何拦截。
### 案例解析
要找到这个问题的根源,我们就需要对 Spring Validation 有一定的了解。首先,我们来看下 RequestBody 接受对象校验发生的位置和条件。
假设我们构建Web服务使用的是Spring Boot技术我们可以参考下面的时序图了解它的核心执行步骤
<img src="https://static001.geekbang.org/resource/image/5f/09/5fbea419f5ced363b27c2b71ac35e009.png" alt="">
如上图所示,当一个请求来临时,都会进入 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(&quot;Arguments: &quot; + Arrays.toString(args));
}
//携带方法参数实例去“反射”调用方法
return doInvoke(args);
}
```
要使用 Java 反射去执行一个方法需要先获取调用的参数上述代码正好验证了这一点getMethodArgumentValues() 负责获取方法执行参数doInvoke() 负责使用这些获取到的参数去执行。
而具体到getMethodArgumentValues() 如何获取方法调用参数,可以参考 addStudent 的方法定义我们需要从当前的请求NativeWebRequest )中构建出 Student 这个方法参数的实例。
>
public void addStudent(@RequestBody Student student)
那么如何构建出这个方法参数实例Spring 内置了相当多的 HandlerMethodArgumentResolver参考下图
<img src="https://static001.geekbang.org/resource/image/5c/8c/5c69fc306e942872dc0a4fba3047668c.png" alt="">
当试图构建出一个方法参数时会遍历所有支持的解析器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(&quot;Valid&quot;)) {
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)方法),必须匹配下面两个条件的其中之一:
1. 标记了 org.springframework.validation.annotation.Validated 注解;
1. 标记了其他类型的注解且注解名称以Valid关键字开头。
因此结合案例程序我们知道student 方法参数并不符合这两个条件,所以即使它的内部成员添加了校验(即@Size(max = 10)),也不能生效。
### 问题修正
针对这个案例,有了源码的剖析,我们就可以很快地找到解决方案。即对于 RequestBody 接受的对象参数而言,要启动 Validation必须将对象参数标记上 @Validated 或者其他以@Valid关键字开头的注解,因此,我们可以采用对应的策略去修正问题。
1. 标记 @Validated
修正后关键代码行如下:
>
public void addStudent(**@Validated** @RequestBody Student student)
1. 标记@Valid关键字开头的注解
这里我们可以直接使用熟识的 javax.validation.Valid 注解,它就是一种以@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 时就会丢失了。所以在运行时,依据这个注解来判断是否校验,肯定会失效。
## 案例2嵌套校验失效
前面这个案例虽然比较经典,但是,它只是初学者容易犯的错误。实际上,关于 Validation 最容易忽略的是对嵌套对象的校验,我们沿用上面的案例举这样一个例子。
学生可能还需要一个联系电话信息,所以我们可以定义一个 Phone 对象,然后关联上学生对象,代码如下:
```
public class Student {
@Size(max = 10)
private String name;
private short age;
private Phone phone;
}
@Data
class Phone {
@Size(max = 10)
private String number;
}
```
这里我们也给 Phone 对象做了合法性要求(@Size(max = 10)),当我们使用下面的请求(请求 body 携带一个联系电话信息超过 10 位),测试校验会发现这个约束并不生效。
```
POST http://localhost:8080/students
Content-Type: application/json
{
&quot;name&quot;: &quot;xiaoming&quot;,
&quot;age&quot;: 10,
&quot;phone&quot;: {&quot;number&quot;:&quot;12306123061230612306&quot;}
}
```
为什么会不生效?
### 案例解析
在解析案例 1 时,我们提及只要给对象参数 student 加上@Valid(或@Validated 等注解)就可以开启这个对象的校验。但实际上,关于 student 本身的 Phone 类型成员是否校验是在校验过程中即案例1中的代码行binder.validate(validationHints))决定的。
在校验执行时,首先会根据 Student 的类型定义找出所有的校验点,然后对 Student 对象实例执行校验,这个逻辑过程可以参考代码 ValidatorImpl#validate
```
@Override
public final &lt;T&gt; Set&lt;ConstraintViolation&lt;T&gt;&gt; validate(T object, Class&lt;?&gt;... groups) {
//省略部分非关键代码
Class&lt;T&gt; rootBeanClass = (Class&lt;T&gt;) object.getClass();
//获取校验对象类型的“信息”(包含“约束”)
BeanMetaData&lt;T&gt; rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );
if ( !rootBeanMetaData.hasConstraints() ) {
return Collections.emptySet();
}
//省略部分非关键代码
//执行校验
return validateInContext( validationContext, valueContext, validationOrder );
}
```
这里语句"beanMetaDataManager.getBeanMetaData( rootBeanClass )"根据 Student 类型组装出 BeanMetaDataBeanMetaData 即包含了需要做的校验(即 Constraint
在组装 BeanMetaData 过程中,会根据成员字段是否标记了@Valid 来决定(记录)这个字段以后是否做级联校验,参考代码 AnnotationMetaDataProvider#getCascadingMetaData
```
private CascadingMetaDataBuilder getCascadingMetaData(Type type, AnnotatedElement annotatedElement,
Map&lt;TypeVariable&lt;?&gt;, CascadingMetaDataBuilder&gt; 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时并不会级联校验它。
### 问题修正
从源码级别了解了嵌套 Validation 失败的原因后,我们会发现,要让嵌套校验生效,解决的方法只有一种,就是加上@Valid,修正代码如下:
>
<p>@Valid<br>
private Phone phone;</p>
当修正完问题后,我们会发现校验生效了。而如果此时去调试修正后的案例代码,会看到 phone 字段 MetaData 信息中的 cascading 确实为 true 了,参考下图:
<img src="https://static001.geekbang.org/resource/image/46/56/4637447e4534c8f28d5541da0f8f0d56.png" alt="">
另外,假设我们不去解读源码,我们很可能会按照案例 1 所述的其他修正方法去修正这个问题。例如,使用 @Validated 来修正这个问题,但是此时你会发现,不考虑源码是否支持,代码本身也编译不过,这主要在于 @Validated 的定义是不允许修饰一个 Field 的:
```
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {
```
通过上述方法修正问题,最终我们让嵌套验证生效了。但是你可能还是会觉得这个错误看起来不容易犯,那么可以试想一下,我们的案例仅仅是嵌套一层,而产品代码往往都是嵌套 n 层,此时我们是否能保证每一级都不会疏忽漏加@Valid呢?所以这仍然是一个典型的错误,需要你格外注意。
## 案例3误解校验执行
通过前面两个案例的填坑,我们一般都能让参数校验生效起来,但是校验本身有时候是一个无止境的完善过程,校验本身已经生效,但是否完美匹配我们所有苛刻的要求是另外一个容易疏忽的地方。例如,我们可能在实践中误解一些校验的使用。这里我们可以继续沿用前面的案例,变形一下。
之前我们定义的学生对象的姓名要求是小于 10 字节的(即@Size(max = 10))。此时我们可能想完善校验,例如,我们希望姓名不能是空,此时你可能很容易想到去修改关键行代码如下:
```
@Size(min = 1, max = 10)
private String name;
```
然后,我们以下面的 JSON Body 做测试:
```
{
&quot;name&quot;: &quot;&quot;,
&quot;age&quot;: 10,
&quot;phone&quot;: {&quot;number&quot;:&quot;12306&quot;}
}
```
测试结果符合我们的预期,但是假设更进一步,用下面的 JSON Body去除 name 字段)做测试呢?
```
{
&quot;age&quot;: 10,
&quot;phone&quot;: {&quot;number&quot;:&quot;12306&quot;}
}
```
我们会发现校验失败了。这结果难免让我们有一些惊讶,也倍感困惑:@Size(min = 1, max = 10) 都已经要求最小字节为 1 了,难道还只能约束空字符串(即“”),不能约束 null?
### 案例解析
如果我们稍微留心点的话,就会发现其实 @Size 的 Javadoc 已经明确了这种情况,参考下图:
<img src="https://static001.geekbang.org/resource/image/12/1f/12c9df125a788yyea25d191b9250d11f.png" alt="">
如图所示,"null elements are considered valid" 很好地解释了约束不住null的原因。当然纸上得来终觉浅我们还需要从源码级别解读下@Size 的校验过程。
这里我们找到了完成@Size 约束的执行方法,参考 SizeValidatorForCharSequence#isValid 方法:
```
public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
if ( charSequence == null ) {
return true;
}
int length = charSequence.length();
return length &gt;= min &amp;&amp; length &lt;= max;
}
```
如代码所示,当字符串为 null 时,直接通过了校验,而不会做任何进一步的约束检查。
### 问题修正
关于这个问题的修正,其实很简单,我们可以使用其他的注解(@NotNull@NotEmpty)来加强约束,修正代码如下:
```
@NotEmpty
@Size(min = 1, max = 10)
private String name;
```
完成代码修改后,重新测试,你就会发现约束已经完全满足我们的需求了。
## 重点回顾
看完上面的一些案例,我们会发现,这些错误的直接结果都是校验完全失败或者部分失败,并不会造成严重的后果,但是就像本讲开头所讲的那样,这些错误会影响我们的使用体验,所以我们还是需要去规避这些错误,把校验做强最好!
另外,关于@Valid@Validation 是我们经常犯迷糊的地方,不知道到底有什么区别。同时我们也经常产生一些困惑,例如能用其中一种时,能不能用另外一种呢?
通过解析,我们会发现,在很多场景下,我们不一定要寄希望于搜索引擎去区别,只需要稍微研读下代码,反而更容易理解。例如,对于案例 1研读完代码后我们发现它们不仅可以互换而且完全可以自定义一个以@Valid开头的注解来使用;而对于案例 2只能用@Valid 去开启级联校验。
## 思考题
在上面的学籍管理系统中,我们还存在一个接口,负责根据学生的学号删除他的信息,代码如下:
```
@RequestMapping(path = &quot;students/{id}&quot;, method = RequestMethod.DELETE)
public void deleteStudent(@PathVariable(&quot;id&quot;) @Range(min = 1,max = 10000) String id){
log.info(&quot;delete student: {}&quot;,id);
//省略业务代码
};
```
这个学生的编号是从请求的Path中获取的而且它做了范围约束必须在1到10000之间。那么你能找出负责解出 ID 的解析器HandlerMethodArgumentResolver是哪一种吗校验又是如何触发的
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,522 @@
<audio id="audio" title="13 | Spring Web 过滤器使用常见错误(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/f1/3dfbdf66b16a09037887f16f76a52bf1.mp3"></audio>
你好,我是傅健。
我们都知道,过滤器是 Servlet 的重要标准之一,其在请求和响应的统一处理、访问日志记录、请求权限审核等方面都有着不可替代的作用。在 Spring 编程中,我们主要就是配合使用 @ServletComponentScan@WebFilter 这两个注解来构建过滤器。
说起来比较简单,好像只是标记下这两个注解就一劳永逸了。但是我们还是会遇到各式各样的问题,例如工作不起来、顺序不对、执行多次等等都是常见的问题。这些问题的出现大多都是使用简单致使我们掉以轻心,只要你加强意识,大概率就可以规避了。
那么接下来我们就来学习两个典型的案例,并通过分析,带你进一步理解过滤器执行的流程和原理。
## 案例 1@WebFilter 过滤器无法被自动注入
假设我们要基于 Spring Boot 去开发一个学籍管理系统。为了统计接口耗时,可以实现一个过滤器如下:
```
@WebFilter
@Slf4j
public class TimeCostFilter implements Filter {
public TimeCostFilter(){
System.out.println(&quot;construct&quot;);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info(&quot;开始计算接口耗时&quot;);
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println(&quot;执行时间(ms)&quot; + time);
}
}
```
这个过滤器标记了@WebFilter。所以在启动程序中,我们需要加上扫描注解(即@ServletComponentScan)让其生效,启动程序如下:
```
@SpringBootApplication
@ServletComponentScan
@Slf4j
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
log.info(&quot;启动成功&quot;);
}
}
```
然后,我们提供了一个 StudentController 接口来供学生注册:
```
@Controller
@Slf4j
public class StudentController {
@PostMapping(&quot;/regStudent/{name}&quot;)
@ResponseBody
public String saveUser(String name) throws Exception {
System.out.println(&quot;用户注册成功&quot;);
return &quot;success&quot;;
}
}
```
上述程序完成后,你会发现一切按预期执行。但是假设有一天,我们可能需要把 TimeCostFilter 记录的统计数据输出到专业的度量系统ElasticeSearch/InfluxDB 等)里面去,我们可能会添加这样一个 Service 类:
```
@Service
public class MetricsService {
@Autowired
public TimeCostFilter timeCostFilter;
//省略其他非关键代码
}
```
完成后你会发现Spring Boot 都无法启动了:
>
<p>***************************<br>
APPLICATION FAILED TO START<br>
***************************<br>
&nbsp;<br>
Description:<br>
&nbsp;<br>
Field timeCostFilter in com.spring.puzzle.web.filter.example1.MetricsService required a bean of type 'com.spring.puzzle.web.filter.example1.TimeCostFilter' that could not be found.</p>
为什么会出现这样的问题?既然 TimeCostFilter 生效了,看起来也像一个普通的 Bean为什么不能被自动注入
### 案例解析
这次我们换个方式,我先告诉你结论,你可以暂停几分钟想想关键点。
本质上,过滤器被 @WebFilter 修饰后TimeCostFilter 只会被包装为 FilterRegistrationBean而 TimeCostFilter 自身,只会作为一个 InnerBean 被实例化,这意味着 **TimeCostFilter 实例并不会作为 Bean 注册到 Spring 容器**
<img src="https://static001.geekbang.org/resource/image/61/6b/615a6049ec924b596cfb6b0abd795c6b.png" alt="">
所以当我们想自动注入 TimeCostFilter 时,就会失败了。知道这个结论后,我们可以带着两个问题去理清一些关键的逻辑:
1. FilterRegistrationBean 是什么?它是如何被定义的?
1. TimeCostFilter 是怎么实例化,并和 FilterRegistrationBean 关联起来的?
我们先来看第一个问题FilterRegistrationBean 是什么?它是如何定义的?
实际上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。
那么这个 FilterRegistrationBean 最早是如何获取的呢?这还得追溯到 @WebFilter 这个注解是如何被处理的。在具体解析之前,我们先看下 @WebFilter 是如何工作起来的。使用 @WebFilterFilter 被加载有两个条件:
- 声明了 @WebFilter
- 在能被 @ServletComponentScan 扫到的路径之下。
这里我们直接检索对 @WebFilter 的使用,可以发现 WebFilterHandler 类使用了它,直接在 doHandle() 中加入断点,开始调试,执行调用栈如下:
<img src="https://static001.geekbang.org/resource/image/67/35/67ff2f20993e9de7cdfc5c73acbe1035.png" alt="">
从堆栈上,我们可以看出对@WebFilter 的处理是在 Spring Boot 启动时,而处理的触发点是 ServletComponentRegisteringPostProcessor 这个类。它继承了 BeanFactoryPostProcessor 接口,实现对 @WebFilter@WebListener@WebServlet 的扫描和处理,其中对于@WebFilter 的处理使用的就是上文中提到的 WebFilterHandler。这个逻辑可以参考下面的关键代码
```
class ServletComponentRegisteringPostProcessor implements BeanFactoryPostProcessor, ApplicationContextAware {
private static final List&lt;ServletComponentHandler&gt; HANDLERS;
static {
List&lt;ServletComponentHandler&gt; servletComponentHandlers = new ArrayList&lt;&gt;();
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&lt;String, Object&gt; attributes, AnnotatedBeanDefinition beanDefinition,
BeanDefinitionRegistry registry) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(FilterRegistrationBean.class);
builder.addPropertyValue(&quot;asyncSupported&quot;, attributes.get(&quot;asyncSupported&quot;));
builder.addPropertyValue(&quot;dispatcherTypes&quot;, extractDispatcherTypes(attributes));
builder.addPropertyValue(&quot;filter&quot;, beanDefinition);
//省略其他非关键代码
builder.addPropertyValue(&quot;urlPatterns&quot;, extractUrlPatterns(attributes));
registry.registerBeanDefinition(name, builder.getBeanDefinition());
}
```
从这里,我们第一次看到了 FilterRegistrationBean。通过调试上述代码的最后一行可以看到最终我们注册的 FilterRegistrationBean其名字就是我们定义的 WebFilter 的名字:
<img src="https://static001.geekbang.org/resource/image/00/1d/009c909439cdb317063ff49b2460e41d.png" alt="">
后续这个 Bean 的具体创建过程,这里不再赘述,感兴趣的话你可以继续深入研究。
现在我们接着看第二个问题TimeCostFilter 何时被实例化?
此时,我们想要的 Bean 被“张冠李戴”成 FilterRegistrationBean但是 TimeCostFilter 是何时实例化的呢?为什么它没有成为一个普通的 Bean?
关于这点,我们可以在 TimeCostFilter 的构造器中加个断点,然后使用调试的方式快速定位到它的初始化时机,这里我直接给出了调试截图:
<img src="https://static001.geekbang.org/resource/image/1c/b0/1cb7346dda1e06e68f092ab789e230b0.png" alt="">
在上述的关键调用栈中,结合源码,你可以找出一些关键信息:
1. Tomcat 等容器启动时,才会创建 FilterRegistrationBean
1. FilterRegistrationBean 在被创建时createBean会创建 TimeCostFilter 来装配自身TimeCostFilter 是通过 ResolveInnerBean 来创建的;
1. TimeCostFilter 实例最终是一种 InnerBean我们可以通过下面的调试视图看到它的一些关键信息
<img src="https://static001.geekbang.org/resource/image/d1/41/d1637b371db3b4fd1c192e2dcaa6b541.png" alt="">
通过上述分析,你可以看出**最终 TimeCostFilter 实例是一种 InnerBean**,所以自动注入不到也就非常合理了。
### 问题修正
找到了问题的根源,解决就变得简单了。
从上述的解析中,我们可以了解到,当使用 @WebFilter 修饰过滤器时TimeCostFilter 类型的 Bean 并没有注册到 Spring 容器中,真正注册的是 FilterRegistrationBean。这里考虑到可能存在多个 Filter所以我们可以这样修改下案例代码
```
@Controller
@Slf4j
public class StudentController {
@Autowired
@Qualifier(&quot;com.spring.puzzle.filter.TimeCostFilter&quot;)
FilterRegistrationBean timeCostFilter;
}
```
这里的关键点在于:
- 注入的类型是 FilterRegistrationBean 类型,而不是 TimeCostFilter 类型;
- 注入的名称是包含包名的长名称,即 com.spring.puzzle.filter.TimeCostFilter不能用 TimeCostFilter以便于存在多个过滤器时进行精确匹配。
经过上述修改后,代码成功运行无任何报错,符合我们的预期。
## 案例 2Filter 中不小心多次执行 doFilter()
在之前的案例中,我们主要都讨论了使用@ServletComponentScan + @WebFilter 构建过滤器过程中的一些常见问题。
而在实际生产过程中,如果我们需要构建的过滤器是针对全局路径有效,且没有任何特殊需求(主要是指对 Servlet 3.0 的一些异步特性支持),那么你完全可以直接使用 Filter 接口(或者继承 Spring 对 Filter 接口的包装类 OncePerRequestFilter并使用@Component 将其包装为 Spring 中的普通 Bean也是可以达到预期的需求。
不过不管你使用哪一种方式,你都可能会遇到一个共同的问题:**业务代码重复执行多次**。
考虑到上一个案例用的是@ServletComponentScan + @WebFilter,这里我们不妨再以@Component + Filter 接口的实现方式来呈现下我们的案例,也好让你对 Filter 的使用能了解到更多。
首先,还是需要通过 Spring Boot 创建一个 Web 项目,不过已经不需要 @ServletComponentScan
```
@SpringBootApplication()
public class LearningApplication {
public static void main(String[] args) {
SpringApplication.run(LearningApplication.class, args);
System.out.println(&quot;启动成功&quot;);
}
}
```
StudentController 保持功能不变,所以你可以直接参考之前的代码。另外我们定义一个 DemoFilter 用来模拟问题,这个 Filter 标记了 @Component 且实现了 Filter 接口,已经不同于我们上一个案例的方式:
```
@Component
public class DemoFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
//模拟异常
System.out.println(&quot;Filter 处理中时发生异常&quot;);
throw new RuntimeException();
} catch (Exception e) {
chain.doFilter(request, response);
}
chain.doFilter(request, response);
}
}
```
全部代码实现完毕,执行后结果如下:
```
Filter 处理中时发生异常
......用户注册成功
......用户注册成功
```
这里我们可以看出,业务代码被执行了两次,这并不符合我们的预期。
我们本来的设计目标是希望 Filter 的业务执行不会影响到核心业务的执行所以当抛出异常时我们还是会调用chain.doFilter。不过往往有时候我们会忘记及时返回而误入其他的chain.doFilter最终导致我们的 Filter 执行多次。
而检查代码时,我们往往不能立马看出问题。所以说,这是一个典型的错误,虽然原因很简单吧。不过借着这个案例,我们可以分析下为什么会执行两次,以深入了解 Filter 的执行。
### 案例解析
在解析之前,我先给你讲下 Filter 背后的机制,即责任链模式。
以 Tomcat 为例,我们先来看下它的 Filter 实现中最重要的类 ApplicationFilterChain。它采用的是责任职责链设计模式在形式上很像一种递归调用。
但区别在于递归调用是同一个对象把子任务交给同一个方法本身去完成,而**职责链则是一个对象把子任务交给其他对象的同名方法去完成**。其核心在于上下文 FilterChain 在不同对象 Filter 间的传递与状态的改变,通过这种链式串联,我们就可以对同一种对象资源实现不同业务场景的处理,达到业务解耦。整个 FilterChain 的结构就像这张图一样:
<img src="https://static001.geekbang.org/resource/image/77/de/7791d08ec8212d86ba4b4bc217bf35de.png" alt="">
这里我们不妨还是带着两个问题去理解 FilterChain
1. FilterChain 在何处被创建,又是在何处进行初始化调用,从而激活责任链开始链式调用?
1. 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) &amp;&amp; (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 &lt; 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 &lt; 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 &lt; n 意味着执行完所有的过滤器才能通过servlet.service(request, response) 去执行真正的业务。
执行完所有的过滤器后,代码调用了 servlet.service(request, response) 方法。从下面这张调用栈的截图中,可以看到,经历了一个很长的看似循环的调用栈,我们终于从 internalDoFilter() 执行到了Controller层的saveUser()。这个过程就不再一一细讲了。
<img src="https://static001.geekbang.org/resource/image/9c/f3/9ce67a5bec2dd4686b428ec88126fbf3.png" alt="">
分析了这么多,最后我们再来思考一下这个问题案例。
DemoFilter 代码中的 doFilter() 在捕获异常的部分执行了一次,随后在 try 外面又执行了一次因而当抛出异常的时候doFilter() 明显会被执行两次,相对应的 servlet.service(request, response) 方法以及对应的 Controller 处理方法也被执行了两次。
你不妨回过头再次查看上文中的过滤器执行流程图,相信你会有更多的收获。
### 问题修正
现在就剩下解决这个问题了。其实只需要删掉重复的 filterChain.doFilter(request, response) 就可以了,于是代码就变成了这样:
```
@Component
public class DemoFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
//模拟异常
System.out.println(&quot;Filter 处理中时发生异常&quot;);
throw new RuntimeException();
} catch (Exception e) {
//去掉下面这行调用
//chain.doFilter(request, response);
}
chain.doFilter(request, response);
}
}
```
重新运行程序和测试,结果符合预期,业务只执行了一次。回顾这个问题,我想你应该有所警示:在使用过滤器的时候,一定要注意,**不管怎么调用,不能多次调用 FilterChain#doFilter()**。
## 重点回顾
通过这节课的学习,相信你对过滤器已经有了一个较为深入的了解,这里我们不妨再次梳理下关键知识点:
1. @WebFilter 这种方式构建的 Filter 是无法直接根据过滤器定义类型来自动注入的因为这种Filter本身是以内部Bean来呈现的它最终是通过FilterRegistrationBean来呈现给Spring的。所以我们可以通过自动注入FilterRegistrationBean类型来完成装配工作示例如下
```
@Autowired
@Qualifier(&quot;com.spring.puzzle.filter.TimeCostFilter&quot;)
FilterRegistrationBean timeCostFilter;
```
1. 我们在过滤器的执行中一定要注意避免不要多次调用doFilter(),否则可能会出现业务代码执行多次的问题。这个问题出现的根源往往在于“不小心”,但是要理解这个问题呈现的现象,就必须对过滤器的流程有所了解。可以看过滤器执行的核心流程图:
<img src="https://static001.geekbang.org/resource/image/77/de/7791d08ec8212d86ba4b4bc217bf35de.png" alt="">
结合这个流程图,我们还可以进一步细化出以下关键步骤:
- 当一个请求来临时,会执行到 StandardWrapperValve的invoke(),这个方法会创建 ApplicationFilterChain并通过ApplicationFilterChain#doFilter() 触发过滤器执行;
- ApplicationFilterChain 的 doFilter() 会执行其私有方法 internalDoFilter
- 在 internalDoFilter 方法中获取下一个Filter并使用 request、response、this当前ApplicationFilterChain 实例)作为参数来调用 doFilter()
>
<p>public void doFilter(ServletRequest request, ServletResponse response,<br>
FilterChain chain) throws IOException, ServletException;</p>
- 在 Filter 类的 doFilter() 中执行Filter定义的动作并继续传递获取第三个参数 ApplicationFilterChain并执行其 doFilter()
- 此时会循环执行进入第 2 步、第 3 步、第 4 步直到第3步中所有的 Filter 类都被执行完毕为止;
- 所有的Filter过滤器都被执行完毕后会执行 servlet.service(request, response) 方法,最终调用对应的 Controller 层方法 。
以上即为过滤器执行的关键流程,希望你能牢牢记住。
## 思考题
在案例2中我们提到一定要避免在过滤器中调用多次FilterChain#doFilter()。那么假设一个过滤器因为疏忽,在某种情况下,这个方法一次也没有调用,会出现什么情况呢?
这样的过滤器可参考改造后的DemoFilter
```
@Component
public class DemoFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println(&quot;do some logic&quot;);
}
}
```
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,598 @@
<audio id="audio" title="14 | Spring Web 过滤器使用常见错误(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/6f/666f5bfcd124f4c58fa619148679176f.mp3"></audio>
你好,我是傅健。
通过上节课的两个案例,我们了解了容器运行时过滤器的工作原理,那么这节课我们还是通过两个错误案例,来学习下容器启动时过滤器初始化以及排序注册等相关逻辑。了解了它们,你会对如何使用好过滤器更有信心。下面,我们具体来看一下。
## 案例1@WebFilter过滤器使用@Order无效
假设我们还是基于Spring Boot去开发上节课的学籍管理系统这里我们简单复习下上节课用到的代码。
首先,创建启动程序的代码如下:
```
@SpringBootApplication
@ServletComponentScan
@Slf4j
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
log.info(&quot;启动成功&quot;);
}
}
```
实现的Controller代码如下
```
@Controller
@Slf4j
public class StudentController {
@PostMapping(&quot;/regStudent/{name)}&quot;)
@ResponseBody
public String saveUser(String name) throws Exception {
System.out.println(&quot;......用户注册成功&quot;);
return &quot;success&quot;;
}
}
```
上述代码提供了一个 Restful 接口 "/regStudent"。该接口只有一个参数 name注册成功会返回"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(&quot;通过授权&quot;);
chain.doFilter(request, response);
}else{
System.out.println(&quot;未通过授权&quot;);
((HttpServletResponse)response).sendError(401);
}
}
private boolean isPassAuth() throws InterruptedException {
System.out.println(&quot;执行检查权限&quot;);
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(&quot;#开始计算接口耗时&quot;);
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println(&quot;#执行时间(ms)&quot; + time);
}
}
```
在上述代码中,我们使用了@Order期望TimeCostFilter先被执行因为TimeCostFilter设计的初衷是统计这个接口的性能所以是需要统计AuthFilter执行的授权过程的。
全部代码实现完毕,执行结果如下:
```
执行检查权限
通过授权
#开始计算接口耗时
......用户注册成功
#执行时间(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 &lt; 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 &lt; 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(&quot;addFilterMap&quot;, 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&lt;ServletContextInitializer&gt; getServletContextInitializerBeans() {
return new ServletContextInitializerBeans(getBeanFactory());
}
```
上述方法的返回值是个Collection可见ServletContextInitializerBeans类是一个集合类它继承了AbstractCollection抽象类。也因为如此上述selfInitialize()才可以遍历 ServletContextInitializerBeans的实例对象。
既然ServletContextInitializerBeans是集合类那么我们就可以先查看其iterator(),看看它遍历的是什么。
```
@Override
public Iterator&lt;ServletContextInitializer&gt; iterator() {
return this.sortedList.iterator();
}
```
此集合类对外暴露的集合遍历元素为sortedList成员变量也就是说上述selfInitialize()最终遍历的即为sortedList成员变量。
到这我们可以进一步确定下结论selfInitialize()中是通过getServletContextInitializerBeans()获取到的ServletContextInitializer类型的Beans集合即为ServletContextInitializerBeans的类型成员变量sortedList。反过来说**sortedList中的过滤器Bean元素顺序决定了最终过滤器的执行顺序**。
现在我们继续查看ServletContextInitializerBeans的构造方法如下
```
public ServletContextInitializerBeans(ListableBeanFactory beanFactory,
Class&lt;? extends ServletContextInitializer&gt;... initializerTypes) {
this.initializers = new LinkedMultiValueMap&lt;&gt;();
this.initializerTypes = (initializerTypes.length != 0) ? Arrays.asList(initializerTypes)
: Collections.singletonList(ServletContextInitializer.class);
addServletContextInitializerBeans(beanFactory);
addAdaptableBeans(beanFactory);
List&lt;ServletContextInitializer&gt; sortedInitializers = this.initializers.values().stream()
.flatMap((value) -&gt; 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&lt;? extends ServletContextInitializer&gt; initializerType : this.initializerTypes) {
for (Entry&lt;String, ? extends ServletContextInitializer&gt; 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&lt;?&gt;) initializer).getServlet();
addServletContextInitializerBean(Servlet.class, beanName, initializer, beanFactory, source);
}
else if (initializer instanceof FilterRegistrationBean) {
Filter source = ((FilterRegistrationBean&lt;?&gt;) 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&lt;?&gt;) initializer).getListener();
addServletContextInitializerBean(EventListener.class, beanName, initializer, beanFactory, source);
}
else {
addServletContextInitializerBean(ServletContextInitializer.class, beanName, initializer, beanFactory,
initializer);
}
}
```
最终添加到this.initializers成员变量中
```
private void addServletContextInitializerBean(Class&lt;?&gt; 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&lt;String, Object&gt; attributes, AnnotatedBeanDefinition beanDefinition,
BeanDefinitionRegistry registry) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(FilterRegistrationBean.class);
builder.addPropertyValue(&quot;asyncSupported&quot;, attributes.get(&quot;asyncSupported&quot;));
builder.addPropertyValue(&quot;dispatcherTypes&quot;, extractDispatcherTypes(attributes));
builder.addPropertyValue(&quot;filter&quot;, beanDefinition);
builder.addPropertyValue(&quot;initParameters&quot;, extractInitParameters(attributes));
String name = determineName(attributes, beanDefinition);
builder.addPropertyValue(&quot;name&quot;, name);
builder.addPropertyValue(&quot;servletNames&quot;, attributes.get(&quot;servletNames&quot;));
builder.addPropertyValue(&quot;urlPatterns&quot;, extractUrlPatterns(attributes));
registry.registerBeanDefinition(name, builder.getBeanDefinition());
}
// 省略非关键代码
```
这里我再次贴出了WebFilterHandler中doHandle()的逻辑(即通过 BeanDefinitionBuilder动态构建了FilterRegistrationBean类型的BeanDefinition。然而遗憾的是**此处并没有设置order的值更没有根据@Order指定的值去设置。**
到这里我们终于看清楚了问题的本质,所有被@WebFilter注解的类最终都会在此处被包装为FilterRegistrationBean类的BeanDefinition。虽然FilterRegistrationBean也拥有Ordered接口但此处却并没有填充值因为这里所有的属性都是从@WebFilter对应的属性获取的,而@WebFilter本身没有指定可以辅助排序的属性
现在我们来总结下,过滤器的执行顺序是由下面这个串联决定的:
>
<p>RegistrationBean中order属性的值-&gt;<br>
ServletContextInitializerBeans类成员变量sortedList中元素的顺序-&gt;<br>
ServletWebServerApplicationContext 中selfInitialize()遍历FilterRegistrationBean的顺序-&gt;<br>
addFilterMapBefore()调用的顺序-&gt;<br>
filterMaps内元素的顺序-&gt;<br>
过滤器的执行顺序</p>
可见RegistrationBean中order属性的值最终可以决定过滤器的执行顺序。但是可惜的是当使用@WebFilter时构建的FilterRegistrationBean并没有依据@Order的值去设置order属性,所以@Order失效了
### 问题修正
现在我们理清了Spring启动Web服务之前的一些必要类的初始化流程同时也弄清楚了@Order和@WebFilter同时使用失效的原因,但这个问题想要解决却并非那么简单。
这里我先提供给你一个常见的做法即实现自己的FilterRegistrationBean来配置添加过滤器不再使用@WebFilter。具体代码如下:
```
@Configuration
public class FilterConfiguration {
@Bean
public FilterRegistrationBean authFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new AuthFilter());
registration.addUrlPatterns(&quot;/*&quot;);
registration.setOrder(2);
return registration;
}
@Bean
public FilterRegistrationBean timeCostFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new TimeCostFilter());
registration.addUrlPatterns(&quot;/*&quot;);
registration.setOrder(1);
return registration;
}
}
```
按照我们查看的源码中的逻辑虽然WebFilterHandler中doHandle()构建了FilterRegistrationBean类型的BeanDefinition但**没有设置order的值**。
所以在这里我们直接手工实例化了FilterRegistrationBean实例而且设置了其setOrder()。同时不要忘记去掉AuthFilter和TimeCostFilter类中的@WebFilter,这样问题就得以解决了。
## 案例2过滤器被多次执行
我们继续沿用上面的案例代码,要解决排序问题,可能有人就想了是不是有其他的解决方案呢?比如我们能否在两个过滤器中增加@Component,从而让@Order生效呢?代码如下。
AuthFilter
```
@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(&quot;通过授权&quot;);
chain.doFilter(request, response);
}else{
System.out.println(&quot;未通过授权&quot;);
((HttpServletResponse)response).sendError(401);
}
}
private boolean isPassAuth() throws InterruptedException {
System.out.println(&quot;执行检查权限&quot;);
Thread.sleep(1000);
return true;
}
}
```
TimeCostFilter类如下
```
@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(&quot;#开始计算接口耗时&quot;);
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println(&quot;#执行时间(ms)&quot; + 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&lt;? extends ServletContextInitializer&gt;... initializerTypes) {
this.initializers = new LinkedMultiValueMap&lt;&gt;();
this.initializerTypes = (initializerTypes.length != 0) ? Arrays.asList(initializerTypes)
: Collections.singletonList(ServletContextInitializer.class);
addServletContextInitializerBeans(beanFactory);
addAdaptableBeans(beanFactory);
List&lt;ServletContextInitializer&gt; sortedInitializers = this.initializers.values().stream()
.flatMap((value) -&gt; 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 &lt;T, B extends T&gt; void addAsRegistrationBean(ListableBeanFactory beanFactory, Class&lt;T&gt; type,
Class&lt;B&gt; beanType, RegistrationBeanAdapter&lt;T&gt; adapter) {
List&lt;Map.Entry&lt;String, B&gt;&gt; entries = getOrderedBeansOfType(beanFactory, beanType, this.seen);
for (Entry&lt;String, B&gt; 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(&quot;Created &quot; + type.getSimpleName() + &quot; initializer for bean '&quot; + beanName + &quot;'; order=&quot;
+ order + &quot;, resource=&quot; + 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类型的过滤器。
### 问题修正
解决这个问题提及的顺序问题自然可以继续参考案例1的问题修正部分。另外我们也可以去掉@WebFilter保留@Component的方式进行修改修改后的Filter示例如下
```
//@WebFilter
@Slf4j
@Order(1)
@Component
public class TimeCostFilter implements Filter {
//省略非关键代码
}
```
## 重点回顾
这节课我们分析了过滤器在Spring框架中注册、包装以及实例化的整个流程最后我们再次回顾下重点。
@WebFilter和@Component的相同点是
- 它们最终都被包装并实例化成为了FilterRegistrationBean
- 它们最终都是在 ServletContextInitializerBeans的构造器中开始被实例化。
@WebFilter和@Component的不同点是
-@WebFilter修饰的过滤器会被提前在BeanFactoryPostProcessors扩展点包装成FilterRegistrationBean类型的BeanDefinition然后在ServletContextInitializerBeans.addServletContextInitializerBeans() 进行实例化;而使用@Component修饰的过滤器类是在ServletContextInitializerBeans.addAdaptableBeans() 中被实例化成Filter类型后再包装为RegistrationBean类型。
-@WebFilter修饰的过滤器不会注入Order属性,但被@Component修饰的过滤器会在ServletContextInitializerBeans.addAdaptableBeans() 中注入Order属性。
## 思考题
这节课的两个案例它们都是在Tomcat容器启动时发生的但你了解Spring是如何整合Tomcat使其在启动时注册这些过滤器吗
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,276 @@
<audio id="audio" title="导读5分钟轻松了解一个HTTP请求的处理过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c9/fa/c989d3797f4ff57a89611196c6a6fffa.mp3"></audio>
你好,我是傅健。
上一章节我们学习了自动注入、AOP 等 Spring 核心知识运用上的常见错误案例。然而,我们**使用 Spring 大多还是为了开发一个 Web 应用程序**所以从这节课开始我们将学习Spring Web 的常见错误案例。
在这之前,我想有必要先给你简单介绍一下 Spring Web 最核心的流程,这可以让我们后面的学习进展更加顺利一些。
那什么是 Spring Web 最核心的流程呢?无非就是一个 HTTP 请求的处理过程。这里我以 Spring Boot 的使用为例,以尽量简单的方式带你梳理下。
首先,回顾下我们是怎么添加一个 HTTP 接口的,示例如下:
```
@RestController
public class HelloWorldController {
@RequestMapping(path = &quot;hi&quot;, method = RequestMethod.GET)
public String hi(){
return &quot;helloworld&quot;;
};
}
```
这是我们最喜闻乐见的一个程序,但是对于很多程序员而言,其实完全不知道为什么这样就工作起来了。毕竟,不知道原理,它也能工作起来。
但是,假设你是一个严谨且有追求的人,你大概率是有好奇心去了解它的。而且相信我,这个问题面试也可能会问到。我们一起来看看它背后的故事。
其实仔细看这段程序,你会发现一些**关键的“元素”**
1. 请求的 Path: hi
1. 请求的方法Get
1. 对应方法的执行hi()
那么,假设让你自己去实现 HTTP 的请求处理,你可能会写出这样一段伪代码:
```
public class HttpRequestHandler{
Map&lt;RequestKey, Method&gt; mapper = new HashMap&lt;&gt;();
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);
};
}
```
那么现在需要哪些组件来完成一个请求的对应和执行呢?
1. 需要有一个地方(例如 Map去维护从 HTTP path/method 到具体执行方法的映射;
1. 当一个请求来临时,根据请求的关键信息来获取对应的需要执行的方法;
1. 根据方法定义解析出调用方法的参数值,然后通过反射调用方法,获取返回结果。
除此之外,你还需要一个东西,就是利用底层通信层来解析出你的 HTTP 请求。只有解析出请求了,才能知道 path/method 等信息,才有后续的执行,否则也是“巧妇难为无米之炊”了。
所以综合来看,你大体上需要这些过程才能完成一个请求的解析和处理。那么接下来我们就按照处理顺序分别看下 Spring Boot 是如何实现的,对应的一些关键实现又长什么样。
首先,解析 HTTP 请求。对于 Spring 而言它本身并不提供通信层的支持它是依赖于Tomcat、Jetty等容器来完成通信层的支持例如当我们引入Spring Boot时我们就间接依赖了Tomcat。依赖关系图如下
<img src="https://static001.geekbang.org/resource/image/bf/71/bf28efcd2d8dc920dddbe4dabaeefb71.png" alt="">
另外正是这种自由组合的关系让我们可以做到直接置换容器而不影响功能。例如我们可以通过下面的配置从默认的Tomcat切换到Jetty
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-web&lt;/artifactId&gt;
&lt;exclusions&gt;
&lt;exclusion&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-tomcat&lt;/artifactId&gt;
&lt;/exclusion&gt;
&lt;/exclusions&gt;-
&lt;/dependency&gt;
&lt;!-- Use Jetty instead --&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-jetty&lt;/artifactId&gt;
&lt;/dependency&gt;
```
依赖了Tomcat后Spring Boot在启动的时候就会把Tomcat启动起来做好接收连接的准备。
关于Tomcat如何被启动你可以通过下面的调用栈来大致了解下它的过程
<img src="https://static001.geekbang.org/resource/image/45/44/456dc47793b0f99c9c2d193027f0ed44.png" alt="">
说白了就是调用下述代码行就会启动Tomcat
```
SpringApplication.run(Application.class, args);
```
那为什么使用的是Tomcat你可以看下面这个类或许就明白了
```
//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&lt;JettyServerCustomizer&gt; 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) &gt; 0) {
keyCount = selector.selectNow();
} else {
keyCount = selector.select(selectorTimeout);
//省略其他非关键代码
Iterator&lt;SelectionKey&gt; iterator =
keyCount &gt; 0 ? selector.selectedKeys().iterator() : null;
while (iterator != null &amp;&amp; iterator.hasNext()) {
SelectionKey sk = iterator.next();
NioSocketWrapper socketWrapper = (NioSocketWrapper)
//处理事件
processKey(sk, socketWrapper);
//省略其他非关键代码
}
//省略其他非关键代码
}
}
```
上述代码会完成请求事件的监听和处理最终在processKey中把请求事件丢入线程池去处理。请求事件的接收具体调用栈如下
<img src="https://static001.geekbang.org/resource/image/f4/e3/f4b3febfced888415038f4b7cccb2fe3.png" alt="">
线程池对这个请求的处理的调用栈如下:
<img src="https://static001.geekbang.org/resource/image/99/e0/99021847afb18bf522860cf2a42aa3e0.png" alt="">
在上述调用中最终会进入Spring Boot的处理核心即DispatcherServlet上述调用栈没有继续截取完整调用所以未显示。可以说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查找来得复杂但是无非还是一个根据请求寻找候选执行方法的过程这里我们可以通过一个调试视图感受下这种对应关系
<img src="https://static001.geekbang.org/resource/image/58/dc/58f9b4c2ac68e8648f441381f1ff88dc.png" alt="">
这里的关键映射Map其实就是上述调试视图中的RequestMappingHandlerMapping。
**2. 执行,反射执行寻找到的执行方法**
这点可以参考下面的调试视图来验证这个结论参考代码org.springframework.web.method.support.InvocableHandlerMethod#doInvoke
<img src="https://static001.geekbang.org/resource/image/6d/94/6d83528c381441a11bfc111f0f645794.png" alt="">
最终我们是通过反射来调用执行方法的。
通过上面的梳理你应该基本了解了一个HTTP请求是如何执行的。但是你可能会产生这样一个疑惑Handler的映射是如何构建出来的呢
说白了核心关键就是RequestMappingHandlerMapping这个Bean的构建过程。
它的构建完成后会调用afterPropertiesSet来做一些额外的事这里我们可以先看下它的调用栈
<img src="https://static001.geekbang.org/resource/image/f1/16/f106c25aed5f62fce28d589390891b16.png" alt="">
其中关键的操作是AbstractHandlerMethodMapping#processCandidateBean方法
```
protected void processCandidateBean(String beanName) {
//省略非关键代码
if (beanType != null &amp;&amp; isHandler(beanType)) {
detectHandlerMethods(beanName);
}
}
```
isHandler(beanType)的实现参考以下关键代码:
```
@Override
protected boolean isHandler(Class&lt;?&gt; beanType) {
return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) ||
AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class));
}
```
这里你会发现判断的关键条件是是否标记了合适的注解Controller或者RequestMapping。只有标记了才能添加到Map信息。换言之Spring在构建RequestMappingHandlerMapping时会处理所有标记Controller和RequestMapping的注解然后解析它们构建出请求到处理的映射关系。
以上即为Spring Boot处理一个HTTP请求的核心过程无非就是绑定一个内嵌容器Tomcat/Jetty/其他来接收请求然后为请求寻找一个合适的方法最后反射执行它。当然这中间还会掺杂无数的细节不过这不重要抓住这个核心思想对你接下来理解Spring Web中各种类型的错误案例才是大有裨益的

View File

@@ -0,0 +1,145 @@
<audio id="audio" title="开篇词贴心“保姆”Spring罢工了怎么办" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a0/99/a0c97c23970084d99542c577d77aa499.mp3"></audio>
你好,我是傅健,很开心能在这里遇见你。
先做个自我介绍吧!你可能认识我,没错,我之前在极客时间开过一门视频课[《Netty源码剖析与实战》](https://time.geekbang.org/course/intro/100036701)。出于对开源的热爱我本身是一名Netty源码贡献者同时也是Jedis、Spring Data Redis、influxdbjava、Jenkins等众多开源项目的Contributor如果我们曾在开源社区相识也算很有缘分了。
本职工作的话,我是一名软件工程师,在思科中国研发中心工作,从业已经有十多年了,和同事一起合作写过一本书叫《度量驱动开发》。期间,我也做过很多项目,类型很丰富,从移动端应用到文档存储系统,消息系统到电话接入系统等等。实际上,不管这些项目冠以什么名称、历经什么级别流量的洗礼,你都不会质疑一点:**我们在项目中大量使用和依赖Spring。**
## Spring的变革
细数经历我和团队开始使用Spring可以追溯到10多年前正是我刚参加工作的时候。那时候我们了解Spring都是从SSH框架开始的。到了今天Spring已经随着技术的发展悄然换了一副面貌。
在Spring还没有像今天这样被广泛应用时我们开发一个Java Web程序还属于茹毛饮血的时代我们会编写一堆看似重复的代码或者配置然后战战兢兢地期待一次就能运行成功。然而即使这些工作都是重复的仍然会有各种各样的错误产生。
到了2014年之后便捷、强大的Spring Boot的引入让Spring的应用变得更加广泛起来。它给我们这些Java程序员带来了福音我第一次见到Spring编写的Hello World Web应用程序时示例如下那种惊叹的感觉至今记忆犹新。
```
@SpringBootApplication
@RestController
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@RequestMapping(path = &quot;/hi&quot;)
public String hi(){
return &quot;hi, spring&quot;;
};
}
```
但利好往往就像一把双刃剑。后来有很多人说Spring降低了程序员的技术门槛确实以往那些错综复杂的开发工作已经变得非常简单了。可也有很多人掉进了一个误区因为简单所以穿“格子衫”“会码字”就能搞Java开发了吗现实残酷啊
## Spring踩坑之旅
不管你是新手程序员还是资深程序员只要你使用过Spring应该都有过**类似这样的感受**。
虽然完成了工作,但是总觉得心里没底。例如,我们在给一个接口类添加@RestController注解时,有时候难免会想,换成@Controller可以么?到底用哪个更好?
当我们遇到一个过滤器Filter不按我们想要的顺序执行时通常都是立马想到去加@Order,但是@Order不见得能搞定所有的情景呀。此时,我们又会抓狂地胡乱操作,各种注解来一遍,最终顺序可能保证了,但是每个过滤器都执行了多次。当然也可能真的搞定了问题,但解决得糊里糊涂。
还有为什么我们只是稍微动了下就出故障了呢例如新手常遇到的一个错误在Spring Boot中将Controller层的类移动到Application的包之外此时Controller层提供的接口就直接失效了。
而当我们遇到问题时,又该从何查起?例如,下面这段代码在一些项目中是可以运行的,但是换成另外一个项目又不可以了,这是什么情况呢?
```
@RequestMapping(path = &quot;/hi&quot;, method = RequestMethod.GET)
public String hi(@RequestParam String name){
return name;
};
```
甚至有时候,我们都不是换一个项目,而是添加一些新的功能,都会导致旧的功能出问题。例如,我们对下面这个 Bean 增加 AOP 切面配置来拦截它的 login 方法后:
```
@Service
public class AdminUserService {
public final User adminUser = new User(&quot;fujian&quot;);
public User getAdminUser(){
return adminUser;
}
public void login(){
//
}
}
```
你可能会蒙圈地发现:下面这行本来在别处工作正常的代码,忽然就报空指针错误了,这又是为何?
此时相信你的内心是迷惘、纠结的心里可能还会暗骂去它的Spring搞啥呢
>
String adminUserName = adminUserService.adminUser.getUserName();
为什么会有这些感受呢?追根溯源,还是在于 **Spring实在太“贴心”了**。它就像一个“保姆”,把我们所有常见的工作都完成了,如果你幸运的话,可能很久都不会遇到问题。
但是,这份贴心毕竟是建立在很多**约定俗成的规则**之上。就像我们雇佣的保姆,她可能一直假定你是吃中餐的,所以每次你下班回家,中餐就已经做好了。但是假设有一天,你忽然临时兴起想吃西餐,你可能才会发现这个贴心的保姆她只会做中餐,你想不吃都不行。
Spring就是这样它有很多隐性的约定而这些约定并不一定是你所熟悉的。所以当你遇到问题时很有可能就抓狂了。一方面我们得益于它所带来的轻松因为不需要了解太多我们也能工作另一方面也会崩溃于问题来临之时无法快速解决因为我们平时根本不需要甚至不觉得要了解更多。
这个时候就有很多人跳出来跟你说“你一定要提前把Spring吃透啊
可当你翻阅Spring源码时你肯定会望而生畏真的太多了不带着问题去学习无异于大海捞针。即使你去通读市场上大多数畅销的Spring教程你可能仍然会感觉到茫然不知道自己到底掌握得如何。毕竟读完之后你不一定能预见到未来可能遇到哪些问题而**这些问题的规避和处理往往才是检验你学习成果的标准。**
## 我如何讲这门课?
厌倦了遇到问题时的疲于奔命自然就要寻找高效便捷的学习法门了所以这几年我一直在整理Spring开发中所遇到的各种各样的问题然后按类划分。
项目忙的时候,就简单记录一下,忙过去了就深入研究。现在我的 ToDoList 已经非常详实了,对我的团队帮助也非常大。对于新人来说,这是份**全面的避坑指南**;对于老人来说,这又是个很好的**问题备忘录**。
这就是我做这门课的初衷,这里也真心分享给你。
在内容设计上,整个专栏都是以问题驱动的方式来组织知识点的,大概是这样的一个思路:
<img src="https://static001.geekbang.org/resource/image/45/de/45d103389eab48e4d911a7a6f7d4c0de.png" alt="">
1. 给出50+错误案例;
1. 从源码级别探究问题出现的原因;
1. 给出问题的解决方案并总结关键点。
另外专栏中的大多数问题并没有太大关联这是为了避免你的学习负担过重我想尽可能地让你在碎片化时间里去吃透一个问题及其背后原理。最终通过这些无数的问题点帮助你形成对Spring的整体认知做到独当一面。
而在问题的选型上我一共筛选出了50多个常见问题这些问题主要来自我和同事在生产环境中经常遇到问题Stack Overflow网站上的一些高频问题以及常用搜索引擎检索到的一些高频问题。
这些问题的选择都遵循这样几个原则:
1. 不难,但是常见,基本每个人都会遇到;
1. 不太常见,但是一旦碰见,很容易入坑;
1. 在某些场景下可以工作,换一种情况就失效。
## 课程设计
有了关于具体内容的详细说明,我相信你对专栏所能解决的问题已经有了大概的感知。接下来,我再跟你说说整体的课程设计,帮助你进一步了解。
本专栏共分为以下三个部分,你可以对照着下面这张图去理解我的设计思路:
<img src="https://static001.geekbang.org/resource/image/83/fc/834c92d778378859acf4e0e02ee778fc.png" alt="">
**Spring Core篇**Spring Core包括Bean定义、注入、AOP等核心功能可以说它们是Spring的基石。不管未来你是做Spring Web开发还是使用Spring Cloud技术栈你都绕不开这些功能。所以这里我会重点介绍在这些功能使用上的常见问题。
**Spring Web篇**大多项目使用Spring还是为了进行Web开发所以我也梳理了从请求URL解析、Header解析、Body转化到授权等Web开发中绕不开的问题。不难发现它们正好涵盖了从一个请求到来到响应回去这一完整流程。
**Spring 补充篇:**作为补充这部分我会重点介绍Spring测试、Spring事务、Spring Data相关问题。最后我还会为你系统总结下Spring使用中发生问题的根本原因。
通过学习这50多个常见、典型的问题我相信对于Spring的本质你会有更加深刻的认识而对于产生问题的原因也能做到洞若观火。最终掌握这些问题的最佳解决方式触类旁通。
## Tips
不过,有几点我还是要提醒你一下。这门课程**需要一定的基础**你要知道最基本的Spring使用知识比如如何自动注入一个Bean如何使用AOP等同时你也需要有一定的耐心因为涉及源码理解。
另外这门课程重在实践与查漏补缺所以在每个问题的讲解上我不可能追根溯源地把所有的背景知识、前后调用关系都完整呈现出来否则你看到的无疑是一门包含大量重复内容的Spring教程而已这也违背了这门课的初衷。
我希望当你学到某个问题,但感觉基础有所欠缺时,你能**及时去补习相关的内容**。当然了,你也可以直接在留言区中问我,我会尽我所能为你提供帮助。
还有就是,课程中会有**很多的案例和示例代码**,还有一些关键实现,我希望你能跟着我的节奏去验证一下,只有真正自己动手了印象才会深刻。
最后,我想说,这个专栏是一个**问题库**也是一本工具书好好利用当你再次遇到各种各样的Spring问题时它会给你底气如果你现在已经遇到了一些难题也欢迎在留言区中与我交流对于专栏中未涉及却十分有价值的问题我后期会考虑以加餐的形式交付给你。
感谢信任,我们下节课见!