关于 Spring 的 IoC 和 AOP

这张图片非常生动形象地描述了 IoC 的性质,它还能进行更多扩展——比如图上的女孩拿到衣服后,可以烫衣服,缝补……以此说明 IoC 容器可以对 Bean 进行非常多的操作,AOP 离开了 IoC,实现也是比较麻烦的。

虽然毕设后端使用的是 Spring Boot,但我实际上对 Spring 斌没有真正的了解过,也就能敲一些业务代码罢了(比较重要的测试也不会用)。现在为准备工作,学一些 Spring 基础,顺便做一些笔记。这里只是粗糙地了解一下,等真正熟练使用了再去了解更深层的东西。

Spring 是什么

Spring 是什么?它是什么条件下产生的,基于怎样的理念,提供了什么功能,解决了什么问题?这几个问题是重要的,但是我目前可没法全部回答。

广义上来说,我们常说的 Spring 实际上指的是 Spring 生态圈,它包括核心的 Spring 框架,方便配置的 Spring Boot 框架,提供分布式和微服务的 Spring Cloud,负责数据持久化的 Spring Data,负责安全的 Spring Security……其中 Spring 框架是其的核心。

Spring 是一个后端框架,可用于编写服务端应用,桌面端应用(哪有人这么用!),同分布式,微服务等进行结合也是容易的。Spring 是非侵入式的(但 Spring Boot 不是,作为一个脚手架,它要求项目完全在它的框架里演化)。Spring 也提供了各种功能丰富的框架——Web,响应式 Web,安全,模板,持久化,运行时监控等,其也容易和很多其它 java 库结合使用。

应用程序是由许多组件构成的,每个组件都负责一部分功能,一般来说其会通过和其他组件进行协调和交互来完成自己的任务(和 OOP 很相似)。Spring 的核心是提供了一个容器——Spring 应用上下文(Spring Application Context),其将对应用程序中各组件进行创建和管理,最终装配到一起。被管理的组件称为** bean**。

为什么要将组件的生命周期交给 Spring 容器进行管理?为了实现所谓的控制反转(IoC,Inversion of Control),对程序进行解耦,同时让组件更加专注于业务——不需要负责依赖对象的创建和管理了。

这种装配的过程是通过依赖注入(DI,Dependency Injection)实现的,对每个 bean,容器将对其的所有依赖(即其持有的其它 bean)进行注入。可以认为 Spring 应用上下文所维护的就是各个 bean(或者说各个组件)的生命周期及其相互关系。

装配的配置可以使用 xml 或 java 类,但最常用的仍旧是自动装配——Spring 自动发现包中的组件并进行装配。

IoC 是什么以及其的意义

我们自己每次用到什么依赖对象都要主动地去获取,这是否真的必要?我们最终所要做的,其实就是直接调用依赖对象所提供的某项服务而已。只要用到这个依赖对象的时候,它能够准备就绪,我们完全可以不管这个对象是自己找来的还是别人送过来的……

—— 《Spring 揭秘》

IoC(控制反转)即对所需对象的创建不由程序进行,而是由用户进行,将控制权交由用户,Spring 中的 IoC 即是将对象的创建,管理交由 Spring 提供的 IoC 容器进行,其实现的方式是 DI(依赖注入)。IoC 可以认为是工厂方法模式的一种应用。

就如大多数特定的技术一样,IoC 的意义也在于对程序解耦合,提高程序的可维护性。

考虑这样一个场景,在某个 Service 里通过 DAO 层获取用户信息。这里系统使用了 MySQl 作为数据库。于是得到了这样的代码——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface UserService {
fun getUser(): User;
}

interface UserDao {
fun getUser(): User;
}

class UserDaoMySQLImpl : UserDao {
override fun getUser(): User {
println("查询了 MySQL")
return User()
}
}

class UserServiceImpl : UserService {
private val userDao : UserDao = UserDaoMySQLImpl()
override fun getUser(): User {
return userDao.getUser()
}
}

然后,出于某种奇怪的业务需要,系统又换成了 SQL Server 数据库,然后下面的程序员就编写了 UserDaoSQLServerImpl,并把各种 Service 里的所有的 UserDaoMySQLImpl 改成 UserDaoSQLServerImpl。

再然后,需求又改了,要用 sqlite 数据库了,于是程序员们又重复了上面的步骤,周而复始……

这里就可以看到问题,每次需求一改变,都要改源代码,这里的问题在于,各种 serviceImpl 实际上和各种 daoImpl 是紧耦合的,要更换使用的 dao 就必须侵入式地修改代码,显然这里可以使用工厂方法模式,通过反射和配置文件生成相应的类。

但是这里先不提工厂方法模式,一个比较简单的修改方式是,将使用哪个 daoImpl 的权力交给“用户”(使用相关类的代码),让用户通过构造器或 set 方法,对想要使用的 dao 进行设定。如下面的形式——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UserServiceImpl : UserService {
private var userDao : UserDao? = null
fun setUserDao(userDao : UserDao) {
this.userDao = userDao;
}
override fun getUser(): User {
return userDao?.getUser()!! // 这里写的非常不优雅
}
}

// 假设这是用户代码
fun userCode() {
val userService : UserService = UserServiceImpl()
(userService as UserServiceImpl).setUserDao(UserDaoMySQLImpl()) // 在这里注入
userService.getUser()
}

用户想用 mySQL?想用 SQL Server?自己选择相应的实现就是了!这就是所谓的控制权交给用户,即控制反转的一个示例。更换实现也不需要改变源代码了。但是显然,这也是比较复杂和麻烦的——用户需要手动注入所需依赖,且需要自己创建所需的实际对象,引入了本不必要的复杂性,抽象程度降低了。“如果有人能够在我们需要时将某个依赖对象送过来,为什么还要大费周折地自己去折腾?”

IoC 的使用

在 Spring 中,对象创建的控制权交给了 IoC 容器,被接管的对象称为** Bean**。用户需要通过 xml,Java 代码或注解对 Bean 进行配置。使用时,根据 xml 或其它形式获取相关配置的上下文,并手动或自动对 Bean 进行获取,下面是一个手动获取 Bean 的示例。

IoC 容器接管的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// DAO 层
interface HelloDao {
public void getHappyEnergy();
}
public class HelloDaoImpl implements HelloDao {
public void getHappyEnergy() {
System.out.println("Happy, Lucky, Smile, Yeah!");
}
}
// Service 层
interface HelloService {
public void getHappyEnergy();
}
public class HelloServiceImpl implements HelloService {
private HelloDao helloDao;
// 用于注入的 setter,必须要有此方法,Spring 才能进行依赖注入,或者使用 Autowired 注解域也可,Spring 将通过反射设置属性。
public void setHelloDao(HelloDao helloDao) {
this.helloDao = helloDao;
}
public void getHappyEnergy() {
helloDao.getHappyEnergy();
}
}

xml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- ApplicationContext.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:p="http://www.springframework.org/schema/p"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<!-- bean 在这里定义,要修改使用的实现?修改 class 即可!
这里还能够进行更加精细的配置,如通过构造器参数配置,
对每个成员进行配置(各种容器类也提供了相应的配置标签),
其成员也可以引用其它 Bean,从而让各实现类能够相互引用,
也可在此配置 autowire,令 IoC 进行自动注入 -->

<!-- 也可通过注解和 component-scan,annotation-config 这两个注解进行自动注入 -->
<bean id="helloDao" class="xyz.yukina.dao.HelloDaoImpl" />
<bean id="helloService" class="xyz.yukina.service.HelloServiceImpl" p:helloDao-ref="helloDao" />
</beans>

用户代码:

1
2
3
4
5
6
public static void main(String[] args) {
// ClassPathXmlApplicationContext——通过 xml 获取配置,简写 CPX
ApplicationContext context = new ClassPathXmlApplicationContext("ApplicationContext.xml");
HelloService helloService = context.getBean("helloService", HelloService.class);
helloService.getHappyEnergy();
}

可见,现在用户要获取的实例是通过配置文件给定的,而对配置文件的修改是不需要修改源代码的,这就让各个模块解耦了——在使用 IoC 之前,各个模块是紧密相连,需要手动处理(new)相互的依赖关系,使用 IoC 容器后,它们实际上只需要与 IoC 容器交互即可,这样互相的耦合性就降低了,且用户也可以不必关心自己究竟用的是哪个具体类,提高了系统的抽象性。且将对象的创建和管理交管给 IoC 容器,这也让容器对对象进行特定的处理也成为可能,或许 AOP 就是在此条件下实现的吧!

需要注意的是,Bean 默认是单例的,非惰性的——在执行new ClassPathXmlApplicationContext("ApplicationContext.xml")时,所有 bean 默认将被初始化而无论是否使用。

关于注入的方式

Spring 提供的注入方式有三种——域注入,set 注入,构造器注入。默认在 xml 中进行配置的话使用的是 set 注入和构造器注入,将 Autowired 注解作用到域中则是域注入。Spring 不推荐使用域注入。

Spring 推荐使用构造器注入,其次是 set 注入,其中构造器注入用于注入必需的(不为 null)依赖,set 注入用于注入可选的,或者给定默认值的依赖。kotlin 推荐的方式也是构造器注入。构造器注入的优势在于其能够定义注入对象为 final(或者 kotlin 的 val),语义更加明确。

其中,对于构造器注入,Spring 推荐使用断言来保证为非 null(这里姑且使用 lombok 提供的 NonNull 注解)。一个示例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class HelloServiceImpl implements HelloService {
@Autowired // 域注入
private AbcDao abcDao;

private AnotherDao anotherDao;
@Autowired // set 注入
public void setAnotherDao(AnotherDao anotherDao) {
this.anotherDao = anotherDao;
}

private HelloDao helloDao;
@Autowired // 构造器注入
public HelloServiceImpl(
@Qualifier("HelloDaoImpl") @NonNull HelloDao helloDao // 如果有歧义,可以在这里用 Qualifier 注解
) {
this.HelloDao = helloDao;
}
public void getHappyEnergy() {
helloDao.getHappyEnergy();
}
}

需要注意的是,构造器注入法可能导致循环依赖问题!

静态代理

关于 AOP,我曾经以为它意义并不重大,直到最近我发现,很多地方如 Spring MVC 中都使用了 AOP,且一些可能对业务很重要的地方也是通过 AOP 实现的。比如控制器中方法的返回值将会经由切面转换成相应视图,JSON 或其它对象,这个转换是可以通过自定义切面进行自定义的,可见学习 AOP 是比较有意义的。

Spring 的 AOP 的实现利用了代理模式。实际上我在编写毕设时已经接触过了代理模式——vue 的 ref 对象将原对象的 get 和 set 方法进行劫持(我觉得这个词更加形象)以保证监听数据改变,这样,在操作 ref 对象的时候,我们采用和普通对象一样的方式,而 vue 则在后台做更多的操作,如更新视图,维护相关计算属性,方法,监听器等。

在这里,代理模式的作用在于,真实角色(即原对象)的操作完全不需要关心任何视图层的事情,简单纯粹,而相应的业务交由代理角色(ref 对象)执行,容易进行维护和扩展。代码的编写者只需要对代理角色像真实角色一样进行操作即可,摆脱了手动管理 DOM 的麻烦。我认为代理模式最大的意义在于,通过特定的抽象,客户端可以完全不用关注自己所使用的是真实角色还是代理角色,从而提高了系统的抽象程度

代理模式有四个角色:

  1. 接口

接口代表真实角色需要进行代理的方法。在 vue 中,这些方法是对象的各个属性的 get 和 set,因此这里的接口角色是隐含的。

  1. 真实角色

真实角色即为被代理的角色,它应当实现接口。

  1. 代理角色

代理角色应当持有真实角色(通过组合和继承均可,组合最优,这时就通过委托进行代理;这里我认为使用继承在语义上更加符合)并实现接口,在接口中,除调用真实角色的相应方法,也应加入自身的处理。代理角色和真实角色都实现接口,以保证其提供的 API 一致。

  1. 客户端

客户端为调用接口的角色,在这里,客户端应当使用代理角色,并期待它通过真实角色进行操作时同时执行自己的业务。

AOP 其实就是这样的东西——它对特定对象进行切入,在这些对象执行特定方法时能够进行“劫持”,添加进自己的操作,比如输出,计数,监控 ,发给某些企业 ,异常处理(!)等,甚至可修改传递给原对象的方法的参数。将方法标识为事务也是利用了 AOP。下面编写了一个实例——通过代理对原有操作进行计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
interface MyService {
void doSomething();
}
class MyServiceImpl implements MyService {
@Override
public void doSomething() {
System.out.println("做点什么事");
}
}
class ServiceProxy implements MyService {
private final MyService myService;
private int counter = 0;
public ServiceProxy(MyService myService) {
this.myService = myService;
}
@Override
public void doSomething() {
System.out.println("第"+(++counter)+"次执行");
System.out.println("执行前");
myService.doSomething();
System.out.println("执行后");
}
}
public class Main {
public static void main(String[] args) {
MyService myService = new MyServiceImpl(); // 也可以不暴露原对象给客户端
MyService myServiceProxy = new ServiceProxy(myService);
myServiceProxy.doSomething();
}
}

动态代理

上面所说的是静态代理模式——代理角色是写死的,而还存在动态代理模式——动态生成代理角色(vue 的例子应当也是动态代理)。动态代理分为基于接口的动态代理基于类的动态代理

动态代理其实就是利用反射对方法调用进行劫持。这里展示了一个代理 handler 的例子,它劫持所有方法调用,在方法执行前和执行后都进行输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 代理 handler,每个 handler 都应当有自己的业务
public class ProxyInvocationHandler implements InvocationHandler {
// 获取代理实例
public static Object getProxyInstance(Object obj) {
return Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getInterfaces(), new ProxyInvocationHandler(obj));
}

// handler 是和被代理对象一一对应的,这非常让人误解
private Object t;
public ProxyInvocationHandler(@NotNull Object t) {
this.t = t;
}
@Override
// 对象进行方法调用时,实际调用的是这个方法
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("执行前");
Object res = method.invoke(t, args);
System.out.println("执行后");
return res;
}
}

// 业务代码
public static void main(String[] args) {
// 也可通过给定 Class 的方法避免强转,但是这语法我不会
UserService serviceProxy = (UserService) ProxyInvocationHandler.getProxyInstance(new UserServiceImpl());
serviceProxy.add();
}

代理类的接口为什么要这么设计?看上去简直就是在说,代理处理器是和特定方法(接口)绑定的,而非是和实例绑定的,但在实践中却是和实例进行绑定的。

这个代理 handler 可以进一步抽象成更加一般的形式——要求用户给定方法执行前,执行后将要执行的代码,给定对 args 的处理,给定对输出结果的处理……这里按本人想法进行了一些编码,可以意识到,invoke 方法采用了模板方法模式。相信 Spring 的 AOP 对此有更加具有实践性和更优雅的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 考虑到要持有对象,使用抽象类而非接口
public abstract class ProxyHandler implements InvocationHandler {
private final Object obj;
public ProxyHandler(Object obj) {
this.obj = obj;
}
public void before() {};
public void after() {};
public Object[] parseArg(Method method, Object[] args) {return args;};
public Object parseRes(Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {return method.invoke(obj, args);};
@Override
public final Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 这里可以根据 method.getName 对代理的方法做限定
before();
// 在某些情形,比如缓存时,甚至可以不调用原方法!
Object res = parseRes(method, parseArg(method, args));
after();
return res;
}
// 考虑到如果使用 static,则无法直接引用到类的 class,这里被迫使用抽象程度比较低的写法,同样的,这里可以整个 class 作为参数
public final Object getProxyInstance() {
return Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getInterfaces(), this);
}
}

// 一个代理的定义
public class CounterProxy extends ProxyHandler{
public CounterProxy(Object obj) {
super(obj);
}
private long startTime;
private long endTime;
@Override
public void before() {
System.out.println("执行前");
startTime = System.currentTimeMillis(); //获取开始时间
}
@Override
public void after() {
endTime = System.currentTimeMillis(); //获取开始时间
System.out.println("执行后");
System.out.println("程序运行时间: "+(endTime-startTime)+"ns");
}
}

// 业务代码
public static void main(String[] args) {
// 考虑到能够让用户不了解 ProxyHandler 便可编写新的代理,我觉得这种抽象程度已经足够
// 但若是容忍每次编写新的代理类时都把 getProxyInstance 静态方法重写一次,则可使用更加优雅的写法(伴生对象能不能解决这一问题?)
// 当然,或许更优的方式则是通过工厂方法模式获取相应 Class 并进行操作,这理论上可以通过注解或配置文件进行,应该是非常好的一个解决方案。
UserService service = (UserService) new CounterProxy(new UserServiceImpl()).getProxyInstance();
service.add();
service.delete();
}

AOP 的具体使用方法不再研究了,先研究其他更重要的东西去。