网易乐得技术团队

Spring-SpringMVC父子容器&AOP使用总结

Spring&SpringMVC

Spring&SpringMVC作为bean管理容器和MVC默认框架,是大多数web应用都会选择的方案。在其使用过程中,尽管基于xml的配置bean管理的方式依然存在,但在很多情况下已经采用的强大的注解功能将其替代。实际项目中,Spring和SpringMVC同时配置,以及xml配置bean和注解的混合使用,会造成诸如bean重复加载、多次实例化、无法自动注入、配置不生效等奇怪的异常现象。其实,以上问题的大多数原因还是出在Spring容器的理解与使用上。

关键知识回顾

正式开始前,先回顾几个关键知识点:

  • bean是Spring管理的基本单位。
  • 无论是何种容器类型,最根本都是BeanFactory。
  • Spring容器实现不唯一,可归为两类:
    • bean工厂(BeanFactory):最基本容器,提供基本DI支持。
    • 应用上下文(ApplicationContext):基于BeanFactory构建,提供应用框架级服务,拥有多种实现。
  • SpringMVC会创建单独的应用上下文作为容器。

所有的bean对象均生存于Spring容器内,容器负责创建他们、装配他们(PostConstruct)、(预)销毁他们(PreDestory),由生(new)到死(finalize)。

容器

Spring整体框架核心概念中,容器是核心思想,但在一个项目,容器不一定只有一个,Spring中可以包括多个容器,且容器间存在上下层框架。最常见的使用场景就是同时使用Spring和SpringMVC两个框架,Srping为父(根)容器,SpringMVC作为子容器。通常的使用过程中,Spring父容器对SpringMVC子容器中的bean是不可见的,而子容器对父容器的中bean却是可见的,这是这两种容器的默认规则。但是:

  • 子容器对父容器中内容可见,不是默认规则

Spring&SpringMVC容器加载过程

web容器

对于一个web应用,需要将其部署在web容器中,web容器为其提供一个全局的ServletContext,并作为之后Spring容器提供宿主环境。

Spring根容器

1
2
3
4
5
6
7
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:applicationContext.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
  1. web.xml中的contextLoaderListener在web容器启动时,会监听到web容器的初始化事件,其contextInitialized方法会被调用,在这个方法中,spring会初始化一个启动上下文作为根上下文,即WebApplicationContext。WebApplicationContext只是接口类,其实际的实现类是XmlWebApplicationContext。

  2. 此上下文即作为Spring根容器,其对应的bean定义的配置由web.xml中的context-param中的contextConfigLocation指定。根容器初始化完毕后,Spring以WebApplicationContext.ROOTWEBAPPLICATIONCONTEXTATTRIBUTE为属性Key,将其存储到ServletContext中,便于获取。

SpringMVC子容器

1
2
3
4
5
6
7
8
9
<servlet>
<servlet-name>XXXX</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-mvc.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
  1. contextLoaderListener监听器初始化完毕后,开始初始化web.xml中配置的Servlet,servlet可以配置多个,加载顺序按照load-on-startup来执行。以最常见的DispatcherServlet为例,该servlet实际上是一个标准的前端控制器,用以转发、匹配、处理每个servlet请求。DispatcherServlet上下文在初始化的时候会建立自己的上下文,用以持有SpringMVC相关的bean。

  2. 之后先通过WebApplicationContext.ROOTWEBAPPLICATIONCONTEXTATTRIBUTE先从ServletContext中获取之前的根上下文(即WebApplicationContext)作为自己上下文的父上下文,然后建立DispatcherServlet自己的上下文。这个DispatcherServlet初始化自己上下文的工作在其initStrategies方法中可以看到,内容包括初始化处理器映射、视图解析等。该servlet自己持有的上下文默认实现类也是WebApplicationContext。

  3. 初始化完毕后,spring以与servlet的名字相关(此处并非简单以servlet名为Key,通过转换码生存)的属性为属性Key,也将其存到ServletContext中,以便后续使用。这样每个servlet就持有自己的上下文,即拥有自己独立的bean空间,同时各个servlet共享相同的根上下文中持有的bean

当然,在根容器创建与子容器创建之间,还会创建监听器、过滤器等,完整的加载顺序为;

  • ServletContext -> context-param -> listener-> filter -> servlet

由以上过程,即确定了一个bean的使用范围(该使用范围,是比bean的scope定义的singleton、prototype、request、session、global-session的五种作用域更上层的内容)。

Spring&SpringMVC容器布局

容器布局的根本是确定bean的使用范围。就Spring与SpringMVC布局,也大致分为了两种方向:

传统型

父容器:保存数据源、服务层、DAO层、事务的bean。

子容器:保存MVC相关的controller的bean。

概述:事务控制在服务层。由于父容器不能访问文容器中内容,事务的bean在父容器中,无法访问子容器中内容,就无法对子容器中controller进行AOP(事务),不过做为传统型方案,也没有必要这要做。

激进型

父容器:不关我事~

子容器:管理所有bean。

概述:不使用listener监听器来加载spring的配置文件,只使用DispatcherServlet来加载Spring的配置,不使用父容器,只使用一个DispatcherServlet,抛弃层次概念。

场景:在增删改查为主业务的系统里,Dao层接口,Dao层实现类,Service层接口,Service层实现类,Action父类,Action。再加上众多的O(vo\po\bo)和jsp页面,在满足分层的前提下,做一些相对较小功能时会变得非常冗余,所以“激进型”方案就出现了,没有接口、没有Service层、可以没有众多的O(vo\po\bo),所有事务控制上升到controller层。

关于布局选择吗,引用一句很合景的总结:大项目求稳,小项目求快


SpringAOP&AspectJ

AOP(Aspect-OrientedProgramming,面向方面编程)是OOP(Object-Oriented Programing,面向对象编程)的良好补充与完善,后者侧重于解决从上到下的存在明显层次逻辑关系的问题,而前者则侧重于由左至右的水平散布的无明显逻辑关系但具备相同行为的问题。AOP抽象的是相同的行为而非关联的行为,用于提供通用服务支持,而非业务逻辑处理,其核心思想是“将应用程序中的商业逻辑同对其提供支持的通用服务进行分离。”

Spring中的AOP

Spring中对AOP提供了良好支持,用于解决符合切面思想的问题,使用场景涵盖:

  • Authentication 权限
  • Caching 缓存
  • Context passing 内容传递
  • Error handling 错误处理
  • Lazy loading 懒加载
  • Debugging 调试
  • logging, tracing, profiling and monitoring 跟踪
  • Performance optimization 性能优化
  • Persistence 持久化
  • Resource pooling 资源池
  • Synchronization 同步
  • Transactions 事务

AOP植入有多种不同方式,主要分为以下三种方式:

  1. 编译期植入
  2. 类装载期植入
  3. 动态代理植入

Spring中AOP具体的配置过程,可通过以下途径:

  1. 配置ProxyFactoryBean,显式设置advisors, advice, target等
  2. 配置AutoProxyCreator,使用定义bean的方式不变,但是从容器中获得的是代理对象
  3. 通过<aop:config>来配置
  4. 通过<aop:aspectj-autoproxy>来配置,使用AspectJ的注解来标识通知及切入点

配置

在实际过程中,SpringAOP一般会使用在一类对象上,使用比较多的方式为<aop:config>与AspectJ这两种。

<aop:config>

在使用<aop:config>时,相对比较明确:

1
2
3
4
5
6
7
<aop:config>
<aop:aspect ref="aopBean">
<aop:after pointcut="@annotation(...) || @within(...) || ..." method="doAfter" />
<aop:after-throwing pointcut="@annotation(...) || @within(...) || ..." method="doAfterThrowing" />
</aop:aspect>
</aop:config>
<bean id="aopBean" class="xxxxxxxxxx" />

定义切点以及不同Advice所做操作。完成配置后,所有同作用域内符合切点条件的对象在出现对应行为时,都会调用指定方法。

@Aspect

在使用时,需要首先指定SpringAOP的实现方式:

1
<aop:aspectj-autoproxy/>

配置会使AspectJ生效

1
2
3
4
Element : aspectj-autoproxy
Enables the use of the @AspectJ style of Spring AOP. See
org.springframework.context.annotation.EnableAspectJAutoProxy Javadoc for information on code-
based alternatives to this XML element.

明确定义后,才会自动扫描项目中的@Aspect(切记,如果漏配置此项,@Aspect中定义的切面方法不会生效)。

对于AOP对象,定义也较为简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Aspect
public class aopObj
{

@AfterReturning("@annotation(xxx) || @within(xxx)")
public void afterReturning(JoinPoint jp){
// do after returning
}

@AfterThrowing("@annotation(xxxx) || @within(xxxx)")
public void afterThrowing(){
// do after throwing
}
}

定义Advice所做操作即切面方法,并规定生效的切点规范。

Java动态代理&CGLib动态代理

AOP配置中有一个较为关键的属性:

1
proxy-target-class="ture"

proxy-target-class属性值决定是基于接口的还是基于类的代理被创建。如果proxy-target-class 属性值被设置为true,那么基于类的代理将起作用(需要CGLib库)。如果proxy-target-class属值被设置为false或者这个属性被省略,那么标准的JDK基于接口的代理将起作用。当然,即便未声明 proxy-target-class=”true”,但运行类没有继承接口,Spring也会自动使用CGLib代理。

原理区别Java动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用具体方法前调用InvokeHandler来处理。而CGLib动态代理是利用asm开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理。使用情况如下:

  1. 如果目标对象实现了接口,默认情况下会采用JDK的动态代理实现AOP
  2. 如果目标对象实现了接口,可以强制使用CGLib实现AOP
  3. 如果目标对象没有实现了接口,必须采用CGLib库,Spring会自动在JDK动态代理和CGLib之间转换

Spring AOP与AspectJ区别

植入时期不同

Spring Aop采用的动态植入,而Aspectj是静态植入。

使用对象不同

Spring AOP的通知是基于该对象是Spring bean对象才可以,而AspectJ可以在任何Java对象上应用通知。

  • Spring AOP:如果希望在通过this对象调用的方法上应用通知,那么你必须使用currentProxy对象,并调用其上的相应方法;如果希望在某对象的方法上应用通知,则必须使用与该对象相应的Spring bean

  • AspectJ:使用AspectJ的一个间接局限是,因为AspectJ通知可以应用于POJO之上,它有可能将通知应用于一个已配置的通知之上,可能会作用到一个本不希望作用的对象上。

对于以上两种AOP方式的选择,可以参考以下关注点:

  • 明确关注对象,是POJO还是Spring的bean。
  • 明确希望的植入时期,是使用时植入还是编译时植入。
  • 明确配置风格,如果两种方式均能满足需求时,可参考项目中bean管理风格是xml还是扫描。

案例

其实Spring中的AOP相关使用比较简单,出现问题也较为易排查,但是本文之所以与Spring父子容器一并讨论,是因为当实际案例出现时,很难在第一时间将之联系在一起进行思考,从而导致发现原因过程较为曲折。

案例描述

贵金属量化平台项目在使用Redis过程中,对Jedis操作过程封装至bean CommonRedis内,对Jedis的资源的释放过程由切面完成,切面动作主要分为两个:

  1. after-returing advice : 正常返回解果,则释放正常资源
  2. after-throwing advice : 抛出异常,则释放损坏资源

某定时任务设定1min执行一次,每次从Redis中获取当前任务的offset(时间戳),开始执行当前周期内任务,执行完成后,设定下一次的offset,插入Redis。

2017-08-04 07:52:00 服务器出现短暂的Redis超时异常

2017-08-04 07:53:00 起开始频繁出现数字转换报错,报错内容:

1
2
3
4
5
java.lang.NumberFormatException: For input string: "OK"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.<init>(Long.java:965)
...

报错的方法内尝试从Redis中取出offset进行long转换,却发现获取的值为“OK”,且执行该方法的有多个线程,只有部分线程出现问题,其他线程正常。

原因分析

分析过程:

  1. 所有使用该Redis键值进行set操作的位置,不可能出现long以外的类型,排除逻辑错误。
  2. 登入Redis客户端,查询该键值,返回结果为long,排除Redis键值内容错乱可能。
  3. 返回内容为”OK”,这个键值使用只有set与get两种行为,应是set返回值。

分析原因:前一个调用过程抛出了超时异常,导致出现了broken的Jedis资源。但该broken资源没有正常归还,导致Redis结果buffer未释放,本次get使用了上一个资源set的结果。

通过代码走查以及实验,发现在搭建项目时,在applicationContext.xml配置AOP方式为:

1
<aop:config proxy-target-class="true"/>

而没有存在如下配置:

1
<aop:aspectj-autoproxy/>

故Redis相关的切面操作是使用SpringAOP方式(有部分旧代码遗留),由于@Aspect对象才是应该正常被采用的切面方式,所以释放失败了。

实验过程

分析出以上原因后,本以为这件事就这样结束了,但在实验过程中出现了一个十分值得注意的现象,而这个现象才是引出本文的关键:

  1. 使用<aop:config><aop:aspectj-autoproxy/>,两种配置方式在JUnit测试用例中,都表现正常。
  2. 通过cron定时任务操作Jedis,表现正常。
  3. 通过controller编写接口操作Jedis,未释放资源

那么问题来了,为何在cron中生效的AOP未在controller生效呢

项目中的Spring容器布局为传统型,即子容器SpringMVC持有controller相关bean,父容器持有其余部分。

父容器配置文件applicationContext.xml配置为:

1
2
3
<context:component-scan base-package="com.netease">
<context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/>
</context:component-scan>

子容器配置文件spring-mvc.xml配置为:

1
2
3
<context:component-scan base-package="com.netease">
<context:include-filter type="annotation" expression="org.springframework.stereotype.Controller"/>
</context:component-scan>

父容器exlude注解@Controller,子容器include注解@Controller,表面上看起来没什么问题,但其实子容器遗漏了一个非常重要的属性设置:

1
use-default-filters="false"

这个设置的描述为:

1
2
3
4
5
6
7
8
9
Attribute : use-default-filters
Indicates whether automatic detection of classes annotated with @Component, @Repository,
@Service, or @Controller should be enabled. Default is "true".

Data Type : boolean
Default Value : true
Enumerated Values :
- true
- false

注意,由于默认为true,如果不显式配置flase,即便是子容器include注解@Controller希望只扫描controller,也会扫描全部的bean,也就是说,@Controller的bean确实只在子容器中,但@Component、@Repository、@Service注解的bean却是父容器一份,子容器也有一份的!

正确的子容器配置方法为:

1
2
3
<context:component-scan base-package="com.netease" use-default-filters="false">
<context:include-filter type="annotation" expression="org.springframework.stereotype.Controller"/>
</context:component-scan>

原因分析:由于ComonRedis其实被扫描了两次,但是AOP的声明只在父容器定义过,所以具备AOP行为的只有父容器的CommonRedis,子容器中的是不具备AOP行为的。由于controller被子容器持有,所以在注解CommonRedis时,会优先使用子容器的bean,由于在子容器已经找到了该bean,则不会继续去父容器中寻找;但对于cron而言,相关的bean是被父容器持有的,所以也只会注解父容器的CommonRedis。这也就造成AOP在controller未生效,但在cron生效的现象。

正确传统型布局应该是,子配置只扫描controller,父配置扫描除controller只外的配置,将公用的需要AOP的bean只配置在父容器内,这样子容器在使用时由于当前容器没有,就会去父容器中寻找,从而保证使用的bean以及行为的统一。当然,如果希望子容器中的bean拥有与父容器不同的行为,就可以在子容器中单独的配置,但是建议使用@Resource以名称进行装配,不要使用@Autowired以类型进行装配,这样更容易区分,从而避免不必要的麻烦


每一个异常的现象,都应该也值得去发掘其本质的原因,人在错误中成长,技术亦然。