封面画师:T5-茨舞(微博) 封面ID:84928287_p0
本文参考:2018 黑马程序员 57 期 张阳 Spring 教程 IDEA 版
1. Spring 概述
1.1 Spring 是什么
Spring 是分层的 Java SE/EE 应用 full-stack 轻量级开源框架,以 IoC(Inverse Of Control:反转控制)和AOP(Aspect Ori ented Programming:面向切面编程)为内核,提供了展现层Spring MVC和持久层 Spring JDBC 以及业务层事务管理等众多的企业级应用技术,还能整合开源世界众多著名的第三方框架和类库,逐渐成为使用最多的 Java EE 企业应用开源框架。
1.2 Spring 发展概述
1997 年 IBM 提出了 EJB 的思想
1998 年,SUN 制定开发标准规范 EJB 1.0
1999 年,EJB 1.1 发布
2001 年,EJB 2.0 发布
2003 年,EJB2.1 发布
2006 年,EJB3.0 发布
Rod Johnson (Spring 之父),著名作者。Rod 在悉尼大学不仅获得了计算机学位,同时还获得了音乐学位。在回到软件开发领域之前,他还获得了音乐学的博士学位。
Expert 0ne-to-0ne J2EE Design and Development (2002) 阐述了 J2EE 使用 EJB 开发设计的优点及解决方案
Expert One-to-0ne J2EE Development without EJB (2004) 阐述了 J2EE 开发不使用 EJB 的解决方式(Spring雏形)
2017年9月份发布了 Spring 的最新版本 Spring 5.0 通用版(GA)
1.3 Spring 的优势
方便解耦,简化开发
通过 Spring 提供的 IoC 容器,可以将对象间的依赖关系交由 Spring 进行控制,避免硬编码所造成的过度程序耦合。用户也不必再为单例模式类、属性文件解析等这些很底层的需求编写代码,可以更专注于上层的应用。
AOP 编程的支持
通过 Spring 的 AOP 功能,方便进行面向切面的编程,许多不容易用传统 OOP 实现的功能可以通过AOP 轻松应付。
声明式事务的支持
可以将我们从单调烦闷的事务管理代码中解脱出来,通过声明式方式灵活的进行事务的管理,提高开发效率和质量。
方便程序的测试
可以用非容器依赖的编程方式进行几乎所有的测试工作,测试不再是昂贵的操作,而是随手可做的事情。
方便集成各种优秀框架
Spring 可以降低各种框架的使用难度,提供了对各种优秀框架(Struts、Hibernate、Hessian、Quartz
等)的直接支持。
降低 JavaEE API 的使用难度
Spring 对 JavaEE API (如 JDBC、JavaMail、 远程调用等)进行了薄薄的封裝层,使这些 API 的使用难度大为降低。
Java 源码是经典学习范例
Spring 的源代码设计精妙、结构清晰、匠心独用,处处体现着大师对 Java 设计模式灵活运用以及对 Java 技术的高深造诣。它的源代码无疑是 Java 技术的最佳实践的范例。
1.4 Spring 的体系结构
2. 程序的耦合
2.1 耦合与解耦的分析
耦合的测试
创建一个 Maven 项目导入对应的 mysql 依赖:
1 2 3 4 5 6 7 <dependencies > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > <version > 8.0.20</version > </dependency > </dependencies >
编写以下测试代码进行测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class JdbcDemo1 { public static void main (String[] args) throws SQLException { DriverManager.registerDriver(new com .mysql.cj.jdbc.Driver()); Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/ssm?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8" , "root" , "123456" ); PreparedStatement pstm = connection.prepareStatement("select * from account" ); ResultSet resultSet = pstm.executeQuery(); while (resultSet.next()) { System.out.println(resultSet.getString("name" )); } resultSet.close(); pstm.close(); connection.close(); } }
数据库中对应的 account 表:
运行后测试结果:
如果 IDEA 在运行时出现:
1 IntelliJ IDEA 源值1.5 已过时,将在未来所有版本中删除
进入 Project Structure 修改:
然后前往 pom.xml 中添加:
1 2 3 4 <properties > <maven.compiler.source > 1.8</maven.compiler.source > <maven.compiler.target > 1.8</maven.compiler.target > </properties >
得到运行结果后我们有这样的一个思考:上述代码中存在一个驱动类 com.mysql.cj.jdbc.Driver()
,如果在 pom.xml 中对导入的依赖进行注释,再次运行代码,控制台会直接在代码编译的时候就报错(依赖注释后,驱动类已经不存在,因此在编译期报错)。
我们的类依赖了数据库的具体驱动类(MySQL),如果这时候更换了数据库品牌(比如 Oracle),需要修改源码来重新数据库驱动。这显然不是我们想要的。
这就是程序的一种耦合 ,所谓耦合就是程序间的依赖关系。这包括:类之间的依赖、方法间的依赖 。有些耦合可以降低,但是有些耦合无法降低,就像上文代码中的 mysql 依赖一样,如果去除会直接报错。我们应当想方设法降低耦合。
而解耦,就是降低程序间的依赖关系。还有一点很重要:耦合只能降低,不能被消除(有点类似物理中的误差)。
在实际开发中,我们应该做到:编译期不依赖,运行时才依赖 。
在最初学习 JDBC 时,我们并不是像上文那样注册驱动的,而是采用反射:
1 Class.forName("com.mysql.cj.jdbc.Driver" );
这样注册驱动有一个好处,是根据字符串来注册的,不像最开始通过一个类进行注册。
如果这个时候我们将 pom.xml 中导入的 mysql 依赖注释,并再次运行代码:程序依然无法跑通,但是并没有在编译期报错,而是运行时出现了一个异常 ClassNotFoundException
。
同时,也产生了一个新的问题,mysql 驱动的全限定类名字符串是在 java 类中写死的,一旦要改还是要修改源码。解决这个问题也很简单, 使用配置文件配置 。
解耦的思路
通过上面的代码和分析,我们不难得到解耦的思路:
1、通过反射来创建对象,而避免使用 new
关键词
2、通过读取配置文件来获取要创建对象的全限定类名
2.2 代码问题分析
在一个 Web 项目中,通常有这样三层:表现层、业务层、持久层。我们从后层向前书写,那么则有:
持久层接口:
1 2 3 4 5 6 7 public interface IAccountDao { void saveAccount () ; }
持久层实现类:
1 2 3 4 5 6 7 8 9 10 11 public class AccountDaoImpl implements IAccountDao { public void saveAccount () { System.out.println("保存了账户..." ); } }
业务层接口:
1 2 3 4 5 6 7 public interface IAccountService { void saveAccount () ; }
业务层实现类:
1 2 3 4 5 6 7 8 9 10 11 public class AccountServiceImpl implements IAccountService { private IAccountDao accountDao = new AccountDaoImpl (); public void saveAccount () { accountDao.saveAccount(); } }
模拟表现层调用业务层:
1 2 3 4 5 6 7 8 9 10 11 public class Client { public static void main (String[] args) { IAccountService as = new AccountServiceImpl (); as.saveAccount(); } }
运行上述代码后,控制台会打印出:保存了账户…
但是在上述代码中,我们使用了大量的 new
关键词。如果删除某个实现类,再运行代码,也和最初测试 JDBC 一样,会在编译时就报错,而这就是类之间依赖的耦合。
2.3 工厂模式解耦
概念介绍
在开始进入本节主要内容之前,我们得先明白几个概念:
Bean
:在计算机英语中,Bean
有可重用组件的含义。
我们以前还接触了一个概念,叫 JavaBean
,当时对其的理解就是实体类。实则非也,JavaBean
表示用 Java
语言创建的可重用组件。因此,JavaBean
并不等于实体类。
在 Web 项目中,像 xxxService
或者 xxxDao
都是可以重用的,因此我们不难想到创建一个类,这个类名为 BeanFactory
,这个类就是创建 service 和 dao 对象的工厂。
那么如何创建呢?
1、需要一个配置文件来配置我们的 service 和 dao。配置的内容:唯一标识符 = 全限定类名 (类似于 key = value)。
2、通过反射读取配置文件中的配置内容,反射创建对象。配置文件可以是 xml 也可以是 properties。
具体实现
为了简单起见,我们编写一个 bean.properties
文件来保存配置文件信息,其内容如下:
1 2 accountService = com.yang.service.impl.AccountServiceImpl accountDao = com.yang.dao.impl.AccountDaoImpl
然后完善 BeanFactory
类:
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 public class BeanFactory { private static Properties properties; static { try { properties = new Properties (); InputStream in = BeanFactory.class.getClassLoader().getResourceAsStream("bean.properties" ); properties.load(in); } catch (IOException e) { throw new ExceptionInInitializerError ("初始化 properties 失败" ); } } public static Object getBean (String beanName) { Object bean = null ; try { String beanPath = properties.getProperty(beanName); System.out.println(beanPath); bean = Class.forName(beanPath).newInstance(); } catch (Exception e) { e.printStackTrace(); } return bean; } }
最后我们需要改写前面编写的业务层和表现层代码,改写业务层实现类:
1 2 3 4 5 6 7 8 9 public class AccountServiceImpl implements IAccountService { private IAccountDao accountDao = ((IAccountDao) BeanFactory.getBean("accountDao" )); public void saveAccount () { accountDao.saveAccount(); } }
改写模拟的表现层:
1 2 3 4 5 6 7 public class Client { public static void main (String[] args) { IAccountService as = (IAccountService) BeanFactory.getBean("accountService" ); as.saveAccount(); } }
最后运行代码,运行结果如下:
通过上面的运行结果与最初的运行结果比较,我们发现并无差异。
那这真的做到了解耦吗?
我们可以删除某个实现类,比如:AccountServiceImpl
或 AccountDaoImpl
,然后再运行代码,虽然控制台依旧无法正常打印出结果,但是并没有在编译期报错,而是运行后抛出 ClassNotFoundException
的异常。
很显然,我们通过上面的工厂模式实现了解耦。 😎
2.4 存在的问题与改进
通过 2.3 的分析,我们已经利用工厂模式实现了解耦,但仍然存在可以优化的地方。
我们改写一下模拟的表现层,利用 for
循环,打印 IAccountService
对象,同时注释 BeanFactory
类中 getBean()
方法打印 beanPath
:
1 2 3 4 5 6 7 public static void main (String[] args) { for (int i = 0 ; i < 5 ; i++) { IAccountService as = (IAccountService) BeanFactory.getBean("accountService" ); System.out.println(as); } }
控制台输出如下:
从打印结果我们可以看出,每个 IAccountServiceImpl
对象都不相同,简单来说就是实例化的 IAccountServiceImpl
对象是多例的,而非单例的。
我们再改造一下 AccountServiceImpl
类:
1 2 3 4 5 6 7 8 9 10 11 12 public class AccountServiceImpl implements IAccountService { private IAccountDao accountDao = ((IAccountDao) BeanFactory.getBean("accountDao" )); private int i = 1 ; public void saveAccount () { accountDao.saveAccount(); System.out.println(i); i++; } }
然后我们再进入模拟的表现层在 for
循环中调用 saveAccount()
并运行,控制台输出如下:
我们知道类中的方法可以操作类中的成员变量,并可以改变成员变量的值。由于实例化的对象是多例的,每个对象都有一个独立的实例,从而保证了类对象在创建时重新初始化类中的属性(成员变量),因此成员变量 i
一直都输出 1,就算是 i++
后也不会增加 i
的值。
但如果实例化的对象是单例的,对象只会被创建一次,从而类中的成员变量也只会被初始化一次。
在多个线程或多个对象访问单例对象成员变量的值,就会引起值的改变,而对于多例对象就不会出现这样的问题。不过反过来想,多例对象会被创建多次,那么执行效率会没有单例对象高。
在 Web 项目的 service 和 dao 中不存在类中成员变量会被类中的方法调整的情况,也不会有线程安全问题,因此在 Web 项目中使用单例的对象更好,可以提高效率。
实例化的对象是多例的主要原因是在反射创建对象是调用了 newInstance()
方法,调用这个方法后每次都会调用默认构造函数创建对象,因此实例化的对象是多例的。
如果想让创建的 Bean 对象是单例的,我们只能调用 newInstance()
一次,同时还要将创建的对象存入容器中。因为不存入容器中,且长时间不使用这个对象时,由于 Java 的垃圾回收机制,这个对象会被回收,等到下次还想用时,原本创建的对象就不存在了。
这个我知道,那再创建一个对象呗。
创建个 🔨,如果再创建一个不就是多例的了,合着我前面都白讲了? 😡
那怎么存这个对象?或者说用什么容器存?
前面说了配置文件中存在一个唯一标识符,且一个唯一标识符对应一个全限定类名,这是一种 key-value 形式,那很显然,我们可以使用 Map
来存这个对象。
那对象在什么时候创建?
由于对象要是单例的,那么在静态代码块中读取配置文件后,就可以创建对象了,并将对象存入容器中。
最终修改 BeanFactory
类,得到以下代码:
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 public class BeanFactory { private static Properties properties; private static Map<String, Object> beans; static { try { properties = new Properties (); InputStream in = BeanFactory.class.getClassLoader().getResourceAsStream("bean.properties" ); properties.load(in); beans = new HashMap <>(); Enumeration<Object> keys = properties.keys(); while (keys.hasMoreElements()) { String key = keys.nextElement().toString(); String beanPath = properties.getProperty(key); Object value = Class.forName(beanPath).newInstance(); beans.put(key, value); } } catch (Exception e) { throw new ExceptionInInitializerError ("初始化 properties 失败" ); } } public static Object getBean (String beanName) { return beans.get(beanName); } }
然后其他代码不变,进入模拟的表现层运行 main()
方法,控制台输出如下:
从输出结果我们看到创建的 IAccountServiceImpl
对象是单例的,只创建了一个 IAccountServiceImpl
对象,并且随着循环的执行,输出的 i
值也逐渐增加,这也验证了创建的对象是单例的。
到此,我们不仅实现了利用工厂模式解耦,还让我们利用反射创建的对象是单例的,在 Web 项目中拥有更高的执行效率。
2.5 控制反转 - IoC
前文中叙述了利用工厂模式实现解耦,但还是没解释什么是工厂。
工厂就是负责从容器中获取指定对象的类,而这时候我们获取对象的方式发生了改变。
在没使用工厂时,我们采用这种方式创建对象:
1 private IAccountDao accountDao = new AccountDaoImpl ();
这种方式获取对象时,采用 new
的方式,是主动 的。
使用这种方式,APP 或者说应用跟资源存在必然的联系,并且这种联系是无法相处的。如果我们想要 APP 或资源独立,就很难做到,因为应用和资源之间有必然的联系。
当我们使用工厂来获取对象时,使用这种方式:
1 private IAccountDao accountDao = ((IAccountDao) BeanFactory.getBean("accountDao" ));
这种方式获取对象时,就会向工厂要,有工厂为我们查找或者创建对象,是被动 的。
使用这种方式查找或创建对象时,APP(应用)与资源之间的联系就断开了,而是找工厂要资源,工厂负责跟资源取得联系,并把 APP(应用)想要的资源给它。
这种被动获取对象的思想就是控制反转 ,它是 Spring 框架的核心之一。
控制反转 (Inversion of Control,缩写为IoC ),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度,这种方式把创建对象的权利交给了框架,是框架的重要特性,并非面向对象编程的专用术语。其中最常见的方式叫做依赖注入 (Dependency Injection,简称DI ),还有一种方式叫“依赖查找”(Dependency Lookup)。
那这为什么叫做控制反转而不叫降低依赖呢?
最开始使用 new
关键词来创建对象时,会明确指定需要的那个资源(类),可以自己自主找到需要的资源。而后我们放弃使用这种方式来获取资源,使用工厂来查询或创建资源,工厂会利用一个指定的名称来找到某个资源,但这个资源是否可用、是否是我们想要的,就无从得知了,这是根据指定的名称由工厂获取的。
最开始的方式有自主获取资源的权利,想要谁就要谁,new
一下就行了,后面的方式就不同了,没有了自主获取资源的权利,而是利用指定的名称由工厂获取,控制权交给了工厂,控制发生了反转,因此就叫做控制反转,控制反转可以降低程序之间的耦合。
明确 IoC 的作用: 削减计算机程序的耦合(解除我们代码中的依赖关系)。
在实际开发过程中,我们可以自己编写利用工厂获取资源的代码,但是这种底层代码过于繁琐,经常书写会很麻烦,这个时候 Spring 就出现了,利用这个框架就可以实现 IoC。
3. Spring 与 IoC
3.1 使用 Spring 创建对象
在前文中代码的基础上使用 Spring 来创建对象,IAccountDao
和 IAccountService
接口不变,AccountDaoImpl
实现类也不进行修改,将 IAccountService
的实现类 AccountServiceImpl
进行如下修改:
1 2 3 4 5 6 7 8 public class AccountServiceImpl implements IAccountService { private IAccountDao accountDao = new AccountDaoImpl (); public void saveAccount () { accountDao.saveAccount(); } }
最重要的是需要在我们创建的 Maven 项目中导入 Spring 依赖:
1 2 3 4 5 6 7 <dependencies > <dependency > <groupId > org.springframework</groupId > <artifactId > spring-context</artifactId > <version > 5.2.7.RELEASE</version > </dependency > </dependencies >
并在 resources 目录下创建 Spring 的配置文件,假设取名为 bean.xml
:
1 2 3 4 5 6 7 8 9 10 <?xml version="1.0" encoding="UTF-8" ?> <beans xmlns ="http://www.springframework.org/schema/beans" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" > <bean id ="accountService" class ="com.yang.service.impl.AccountServiceImpl" > </bean > <bean id ="accountDao" class ="com.yang.dao.impl.AccountDaoImpl" > </bean > </beans >
最后修改 Client
类并运行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class Client { public static void main (String[] args) { ApplicationContext ac = new ClassPathXmlApplicationContext ("bean.xml" ); IAccountService as = (IAccountService) ac.getBean("accountService" ); IAccountDao adao = ac.getBean("accountDao" , IAccountDao.class); System.out.println(as); System.out.println(adao); } }
控制台输出结果如下:
从控制台的输出结果可以看出,我们使用 Spring 成功创建了对象。🎉
3.2 ApplicationContext
ApplicationContext 三个常用的实现类
1、ClassPathXmlApplicationContext
:它可以加载类路径下的配置文件,要求配置文件必须在类路径下。如果不在,当然就加载不了(相比于第二个实现类,这个更常用)。
2、FileSystemXmlApplicationContext
:它可以加载磁盘任意路径下的配置文件(必须有访问权限)
3、AnnotationConfigApplicationContext
:它用于读取注解来创建容器
ApplicationContext 与 BeanFactory
我们选中 ApplicationContext
后,在 IDEA 中右击它,选中 Diagrams ,再点击 Show Diagram… 可得以下类图:
可以看出 ApplicationContext
确实是个借口,但并不是最底层借口,它还继承了 BeanFactory
接口。
那么这两个接口有啥区别呢?
ApplicationContext
:它在创建核心容器时,创建对象采取的策略是 立即加载 的方式。也就是说,只要一读取完配置文件就会马上创建配置文件中配置的对象(单例对象适用)。
BeanFactory
:它在构建核心容器时,创建对象采取的策略是 延迟加载 的方式。也就是说,什么时候根据 id 获取对象了,什么时候才真正创建对象(多例对象适用)。
那么问题来了,既然有这两个接口,我们实际使用时采取哪个居多呢?
前文中说,我们创建的对象都是单例的,既然如此,哪什么时候创建更好?
不用多说,显然是读取完配置文件就创建对象,即采用 ApplicationContext
接口创建。同时,BeanFactory
作为一个顶层接口,它里面的抽象方法显然不如其子类 ApplicationContext
完善的,因此,ApplicationContext
更加常用。
3.3 Spring 中 Bean 的细节
三种创建 Bean 的方式
1、使用默认构造函数 创建 Bean
在 Spring 配置文件中使用 <bean>
标签,配以 id 和 class 属性后,且没有其他属性和标签时,采用的就是默认构造函数创建 Bean 对象。
如果类中没有默认构造函数,则对象无法创建。
1 <bean id ="accountService" class ="com.yang.service.impl.AccountServiceImpl" > </bean >
2、使用普通工厂中的方法 创建对象(使用某个类的方法创建对象,并存入 Spring 容器)
工厂类:
1 2 3 4 5 6 7 8 9 10 11 public class InstanceFactory { public IAccountService getAccountService () { return new AccountServiceImpl (); } }
Spring 配置文件:
1 2 3 <bean id ="instanceFactory" class ="com.yang.factory.InstanceFactory" > </bean > <bean id ="accountService" factory-bean ="instanceFactory" factory-method ="getAccountService" > </bean >
3、使用工厂中的静态方法 创建对象(使用某个类中的静态方法创建对象,并存入 Spring 容器)
工厂类与静态方法:
1 2 3 4 5 6 7 public class StaticFactory { public static IAccountService getAccountService () { return new AccountServiceImpl (); } }
Spring 配置文件:
1 2 <bean id ="accountService" class ="com.yang.factory.StaticFactory" factory-method ="getAccountService" > </bean >
Bean 对象的作用范围
Spring 中创建的 Bean 对象默认是单例的。那么我们应该怎么调整 Bean 的左右范围呢?
可以使用 <bean>
标签中的 scope
属性。这个属性用于指定 Bean 的作用范围,它有如下取值:
singleton:单例(默认值)
prototype:多例
request:作用于 Web 应用的请求范围
session:作用于 Web 应用的会话范围
global-session:作用于集群环境的会话范围(全局会话范围),如果当前环境不是集群环境,效果等于 session
(涉及负载均衡、集群等知识点)
application:作用于 Web 应用的 ServletContext
。Web 容器用到此 Bean 时创建,容器关闭时销毁
Bean 对象的生命周期
首先得明白一点:单例对象和多例对象的生命周期肯定是不一样的。
对单例对象来说:当容器创建时,对象出生;只要容器还在,对象一直活着;如果容器销毁,对象就死亡了。
总结:单例对象的生命周期和容器相同。
对多例对象来说:当我们使用对象时,Spring 框架为我们创建;对象只要是在使用过程中就一直活着;当对象长时间不用且没有别的对象引用时,由 Java 的垃圾回收器回收。
3.4 依赖注入
基本概念
依赖注入:Dependency Injection。它是 Spring 框架核心 IoC 的具体实现。
我们知道 IoC 的作用是:降低程序间的依赖关系,或者说降低程序间的依赖关系。
同时将依赖关系的管理都交给 Spring 来维护,在当前类需要用到其他类的对象时,由 Spring 为我们提供,我们只需要在配置文件中说明即可。
这种依赖关系的维护就称之为:依赖注入。
依赖注入能够注入的数据有三类,分别是:基本数据类型和 String
、其他 Bean 类型(在配置文件中或者注解配置过的 Bean),复杂类型 / 集合类型。
依赖注入的方式也有三种,分别是:使用构造函数提供、使用 set 方法提供,使用注解提供。
构造函数注入
使用构造函数注入时,需要在 <bean>
标签内部使用一个新的标签 <constructor-arg>
。
这个标签中有以下几个属性:
type:用于指定要注入的数据的数据类型,该数据类型也是构造函数中某个或某些参数的类型
index:用于指定要注入的数据在构造函数中参数的索引,索引的位置从 0 开始
name:用于指定要注入的数据在构造函数中参数的名称
PS:以上三个属性用于指定给构造函数中哪个参数进行赋值,下面两个指的是赋什么值
value:用于赋值基本数据类型和 String
类型的数据
ref:用于赋值其他 Bean 类型,也就是说,必须得是在配置文件中配置过的 Bean
特点:在获取 Bean 对象时,注入的数据是必须的操作,否则对象无法创建成功。
弊端:改变了 Bean 对象的实例化方式,使我们在创建对象时,如果用不到某些数据时,也必须提供(注入)。
set 方法注入
使用 set 方法注入时,要求被注入的属性必须有 set 方法,set 方法的方法名由 set + 属性首字母大写,如果属性是 boolean
类型就没有 set 方法 , 是 is。(尽量使用 IDEA 的自动生成,还需注意命名规范)
使用 set 方法注入时,需要在 <bean>
标签内部使用一个新标签 <property>
。
这个标签有以下几个属性:
name:用于指定注入时所调用的 set 方法名称
value:用于赋值基本数据类型和 String
类型的数据
ref:用于赋值其他 Bean 类型,也就是说,必须得是在配置文件中配置过的 Bean
特点:创建对象时没有明确的限制,可以直接使用默认构造函数。
弊端:如果有某个成员必须有值,获取对象的 set 方法可能没有执行(可能没有 set 方法)。
注入集合数据
假设我们依旧采用 set 方法注入集合数据,那么可以像下面的代码一样进行数据注入:
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 <bean id ="accountService3" class ="com.yang.service.impl.AccountServiceImpl3" > <property name ="myStrs" > <array > <value > AAA</value > <value > BBB</value > <value > CCC</value > </array > </property > <property name ="myList" > <list > <value > AAA</value > <value > BBB</value > <value > CCC</value > </list > </property > <property name ="mySet" > <set > <value > AAA</value > <value > BBB</value > <value > CCC</value > </set > </property > <property name ="myMap" > <map > <entry key ="TestA" value ="aaa" > </entry > <entry key ="TestB" > <value > BBB</value > </entry > </map > </property > <property name ="myProps" > <props > <prop key ="TestC" > CCC</prop > <prop key ="TestD" > DDD</prop > </props > </property > </bean >
从上面的代码可以看到,用于给 List 结构集合注入的标签有 <array>
、<list>
、<set>
,用于给 Map 结构集合注入的标签有 <map>
和 <props>
,结构相同,标签可以互换。也就是说,注入数组时,除了可以使用<array>
,可以使用 <list>
和 <set>
。
PS:注意 Set 集合内的数据是无序的。
3.5 IoC 相关注解
见【Spring 注解】一文。
3.6 整合 JUnit5
我们知道,应用程序的入口是 main()
方法。但是在 JUnit 单元测试中,没有 main()
也可以执行,这是因为 JUnit 集成了一个 main()
方法,该方法会判断当前测试类中哪些方法有 @Test
注解,JUnit 会让有 @Test
注解的方法执行。
同时,JUnit 不会关心我们是否使用了某个框架(比如 Spring 框架),在执行测试方法时,JUnit 根本不知道我们是否使用了 Spring 框架,所以也就不会为我们读取配置文件 / 配置类创建 Spring 核心容器。
综上可知,当测试方法执行时,是没有 IoC 容器的。因此就算写了 @Autowired
注解,也是无法实现注入的(容器都没有,注入个 🔨)。
那咋搞?Spring 怎么整合 JUnit 呢?
Spring 整合 JUnit 的配置
1、导入 Spring 整合 JUnit 的依赖和 JUnit5 的依赖
1 2 3 4 5 6 7 8 9 10 11 12 <dependency > <groupId > org.springframework</groupId > <artifactId > spring-test</artifactId > <version > 版本号与导入的 Spring 依赖一致</version > <scope > test</scope > </dependency > <dependency > <groupId > org.junit.jupiter</groupId > <artifactId > junit-jupiter-api</artifactId > <version > 5.3.1</version > <scope > test</scope > </dependency >
2、使用 JUnit5 提供了一个注解 @ExtendWith
把原有的 main()
方法替换成 Spring 提供的
1 2 @ExtendWith(SpringExtension.class) public class AccountServiceTest {}
3、告知 Spring 的运行器,Spring 和 IoC 创建是基于 xml 的还是注解的,并说明位置
这里需要用到另外一个注解 @ContextConfiguration
。
这个注解有两个主要属性:
locations:指定 xml 文件的位置,加上 classpath 关键字,表示在类路径下
classes:指定注解类所在的位置
注意: 当使用 Spring 5.x 版本时,要求 JUnit 的版本是在 4.12 及以上。
测试类及部分测试方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @ExtendWith(SpringExtension.class) @ContextConfiguration(locations = "classpath:bean.xml") public class AccountServiceTest { @Autowired private IAccountService as = null ; @Test public void testFindAll () { List<Account> accounts = as.findAllAccount(); for (Account account:accounts){ System.out.println(account); } } }
4. 动态代理
动态代理的特点是字节码使用的时候才创建,使用的时候才加载。使用动态代理可以在不修改源码的基础上对方法进行增强。
动态代理有两种:
4.1 基于接口的动态代理
涉及到的类:Proxy
,提供者:JDK 官方
那么如何创建代理对象?使用 Proxy
类的 newProxyInstance()
方法
创建代理对象有什么要求? 被代理类至少实现一个接口,否则不能使用
newProxyInstance()
方法的参数:
ClassLoader:类加载器,用于加载代理对象的字节码。需要和被代理对象使用相同的类加载器。固定写法。
Class[]:字节码数组,用于让代理对象和被代理对象有相同的方法。固定写法。
InvocationHandler:用于提供增强的代码。它是让我们写如何代理,我们一般都是写一个该接口的实现类,通常情况下都是匿名内部类,但不是必须的。此接口的实现类都是谁用谁写。
代码测试
我们来模拟一个场景:生产厂家生产东西,并交给经销商卖出,消费者从经销商处购买商品。经销商也要吃饭,因此他要收取卖出商品得到的金额的 20% 作为利润,最后生产厂家只能拿到卖出金额的 80%。
对生产厂家要求的接口:
1 2 3 4 5 6 7 8 9 10 11 public interface IProducer { void saleProduct (float money) ; void afterService (float money) ; }
生产厂家:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class Producer implements IProducer { public void saleProduct (float money) { System.out.println("销售商品,并拿到钱 " + money); } public void afterService (float money) { System.out.println("提供售后服务,并拿到钱 " + money); } }
模拟消费者消费,控制台打印生产厂家从经销商处获取的金额:
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 public class Client { public static void main (String[] args) { final Producer producer = new Producer (); IProducer proxyProducer = (IProducer) Proxy.newProxyInstance(producer.getClass().getClassLoader(), producer.getClass().getInterfaces(), new InvocationHandler () { public Object invoke (Object proxy, Method method, Object[] args) throws Throwable { Object returnValue = null ; Float money = (Float) args[0 ]; if ("saleProduct" .equals(method.getName())) { returnValue = method.invoke(producer, money * 0.8f ); } return returnValue; } }); proxyProducer.saleProduct(10000f ); } }
控制台输出结果:
4.2 基于子类的动态代理
通过前文的分析,我们知道基于接口的动态代理是如何实现的了,但是这种动态代理的类必须实现一个接口,如果不实现一个接口就⑧行。
难道就必须实现一个接口才能动态代理,不然就不行?就没有其他的方法?
那肯定不是!这就得说到基于子类的动态代理了。
基于接口的动态代理使用的都是 JDK 提供的类,要想实现基于子类的动态代理就需要导入第三方依赖:
1 2 3 4 5 <dependency > <groupId > cglib</groupId > <artifactId > cglib</artifactId > <version > 3.3.0</version > </dependency >
涉及到的类:Enhancer
,提供者:第三方 cglib
库
那么如何创建代理对象?使用 Enhancer
类的 create()
方法
创建代理对象有什么要求? 被代理类不能是最终类(被 final
修饰的类,被 final
修饰的类不能被继承)
create()
方法的参数:
Class:字节码。用于指定被代理对象的字节码。
Callback:用于提供增强的代码。我们一般写的都是 Callback
的子接口实现类:MethodInterceptor
代码测试
和基于接口的动态代理模拟的场景一样,那么则有:
生产厂家:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class Producer { public void saleProduct (float money) { System.out.println("销售商品,并拿到钱 " + money); } public void afterService (float money) { System.out.println("提供售后服务,并拿到钱 " + money); } }
模拟消费者消费,控制台打印生产厂家从经销商处获取的金额:
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 public class Client { public static void main (String[] args) { final Producer producer = new Producer (); Producer cglibProducer = (Producer)Enhancer.create(producer.getClass(), new MethodInterceptor () { public Object intercept (Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { Object returnValue = null ; Float money = (Float) objects[0 ]; if ("saleProduct" .equals(method.getName())) { returnValue = method.invoke(producer, money * 0.8f ); } return returnValue; } }); cglibProducer.saleProduct(10000f ); } }
控制台输出结果:
5. Spring 与 AOP
5.1 AOP 概述
什么是 AOP
AOP:全称是 Aspect Oriented Programming,即:面向切面编程。
百度百科是这么说的:
简单的说,就是把我们程序中重复的代码抽取出来,在需要执行的时候,使用动态代理的技术,在不修改源码的
基础上,对我们的已有方法进行增强。
AOP 的作用和优势
作用:在程序运行期间,不修改源码对已有方法进行增强。
优势:
AOP 的实现方式
使用动态代理技术。前文说了,动态代理分为基于接口的动态代理和基于子类的动态代理,那么 Spring 是如何选择的呢?
在 Spring 中,框架会根据目标类 是否实现了接口 来决定采用哪种动态代理的方式。
5.2 AOP 相关概念
Joinpoint(连接点): 所谓连接点是指那些被拦截到的点。在 Spring 中,这些点指的就是方法,因为 Spring 只支持方法类型的连接点。
Pointcut(切入点): 所谓切入点是指我们要对哪些 Joinpoint 进行拦截的定义。
接口中所有方法都是连接点,但只有真正被增强了的方法是切入点。所有切入点都是连接点,但连接点不一定是切入点。
Advice(通知 / 增强): 所谓通知是指拦截到 Joinpoint 之后所要做的事情就是通知。 通知的类型:前置通知、后置通知、异常通知、最终通知、环绕通知。 可以通过下面这张动态代理的图示来解释:
Introduction(引介): 引介是一种特殊的通知。在不修改类代码的前提下,Introduction 可以在运行期为类动态地添加一些方法或 Field。
Target(目标对象): 代理的目标对象。
Weaving(织入): 织入是指把增强应用到目标对象来创建新的代理对象的过程。 Spring 采用动态代理织入,而 AspectJ 采用编译期织入和类装载期织入。
Proxy(代理): 一个类被 AOP 织入增强后,就产生一个结果代理类。
Aspect(切面): 切入点和通知(引介)的结合。
需要明确的事
开发阶段:
1、编写核心业务代码(开发主线)
2、把公用代码抽取出来,制作成通知。
3、在配置文件中,声明切入点与通知间的关系,即切面。
运行阶段(Spring框架完成的) :
Spring 框架监控切入点方法的执行。一旦监控到切入点方法被运行,使用代理机制,动态创建目标对象的代理对象,根据通知类别,在代理对象的对应位置,将通知对应的功能织入,完成完整的代码逻辑运行。
5.3 基于 XML 配置的 AOP
搭建一个简单的环境进行测试基于 XML 配置的 AOP。
导入依赖:
1 2 3 4 5 6 7 8 9 10 11 <dependency > <groupId > org.springframework</groupId > <artifactId > spring-context</artifactId > <version > 5.2.7.RELEASE</version > </dependency > <dependency > <groupId > org.aspectj</groupId > <artifactId > aspectjweaver</artifactId > <version > 1.9.5</version > </dependency >
service 接口:
1 2 3 4 5 6 7 8 9 10 public interface AccountService { void saveAccount () ; void updateAccount (int i) ; int deleteAccount () ; }
service 实现类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class AccountServiceImpl implements AccountService { @Override public void saveAccount () { System.out.println("执行了保存" ); } @Override public void updateAccount (int i) { System.out.println("执行了更新 " + i); } @Override public int deleteAccount () { System.out.println("执行了删除" ); return 0 ; } }
日志类,这个类用于打印日志,每次执行业务层的方法就打印日志:
1 2 3 4 5 6 7 8 9 10 11 public class Logger { public void printLog () { System.out.println("Logger类中的printLog方法开始记录日志了..." ); } }
配置文件内容:
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 <?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:aop ="http://www.springframework.org/schema/aop" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd" > <bean id ="accountService" class ="com.yang.service.impl.AccountServiceImpl" > </bean > <bean id ="logger" class ="com.yang.util.Logger" > </bean > <aop:config > <aop:aspect id ="logAdvice" ref ="logger" > <aop:before method ="printLog" pointcut ="execution(* com.yang.service.impl.*.*(..))" > </aop:before > </aop:aspect > </aop:config > </beans >
测试类:
1 2 3 4 5 6 7 8 9 10 11 12 public class AOPTest { public static void main (String[] args) { ApplicationContext ac = new ClassPathXmlApplicationContext ("bean.xml" ); AccountService as = ac.getBean("accountService" , AccountService.class); as.saveAccount(); as.updateAccount(666 ); as.deleteAccount(); } }
运行测试类后的打印结果:
根据上图的数据结果可以看到:每次执行业务层的方法都相应地打印出了日志,我们的入门案例也就成功了。
总结
入门案例已经成功,我们来总结一下流程:
1、首先是编写普通的业务代码,然后根据需要编写需要切入的方法
2、编写相应的 Spring 配置文件:
配置 Spring 的 IoC 容器,将 Service 对象配置进去
把通知的 Bean 也交给 Spring 管理
使用 <aop:config>
标签表明开始 AOP 的配置
使用 <aop:aspect>
标签表明配置切面
在 <aop:aspect>
内部使用对应的标签来配置通知的类型,建立通知方法和切入点方法的联系
3、编写测试方法进行测试
涉及的标签
<aop:config>
标签:表明开始 AOP 的配置
<aop:aspect>
标签:表明配置切面。这个标签内有两个属性:
id 属性:给切面提供一个唯一的标志
ref 属性:指定通知类的 bean 的 id
<aop:before>
标签:该标签是 <aop:aspect>
标签内部的标签,用于配置通知的类型。在上述案例中,我们计划让 printLog()
方法在切入点方法执行前执行,因此是前置通知,使用 <aop:before>
标签。这个标签下有两个主要属性:
method 属性:用于指定 Logger 类中哪个方法是前置通知
pointcut 属性:指定切入点表达式。该表达式的含义指的是对业务层中哪些方法增强
这里就涉及到 切入点表达式 的写法,需要细说一手 ~
5.4 切入点表达式
切入点表达式的写法是:execution(表达式)
,需要使用到 execution
关键字,但这并不是重点,重点是这个关键字后括号包裹的表达式应该怎么写。
注意: execution
关键字不是本节的重点,实际操作中千万别忘记书写了!
表达式的标准写法是:
1 访问修饰符 返回值 包名.包名.包名...类名.方法名(参数列表)
根据以上写法,在上述案例中,假设我们需要对 saveAccount()
方法添加前置通知,那么则有:
1 public void com.yang.service.impl.AccountServiceImpl.saveAccount()
这是标准表达式的写法,但是这种写法只能针对单一的方法进行增强,实际情况下肯定不可能只针对一个方法进行增强。切入点表达式还有以下简洁写法:
1、访问修饰符可以 省略
1 void com.yang.service.impl.AccountServiceImpl.saveAccount()
这样修改后,还是只能对 saveAccount()
方法进行增强。
2、返回值可以使用通配符表示任意返回值
1 * com.yang.service.impl.AccountServiceImpl.saveAccount()
虽然业务层有两种返回值类型,void
和 int
,但是由于明确指定了方法,依旧只能对 saveAccount()
方法进行增强。
3、包名可以使用通配符表示任意包,但是有几级包就需要写几个 *
1 * *.*.*.*.AccountServiceImpl.saveAccount()
同样,依旧只能对 saveAccount()
方法进行增强。但如果包有很多,岂不是要写很多通配符?
4、包名可以使用 ..
表示当前包及其子包
1 * *..AccountServiceImpl.saveAccount()
由于方法名还是没改变,还是只能对 saveAccount()
方法进行增强。
5、类名和方法名都可以使用 *
来实现通配
这下就不一样了,由于业务层中有两个方法都没有参数,因此会对 saveAccount()
和 deleteAccount()
这两个方法进行增强。
6、对应方法的参数列表来说:
可以直接写数据类型,基本数据类型直接写名称,引用类型写包名.类名
的方式
这样书写后,业务层只有 updateAccount(int i)
方法有 int
类型的参数,因此只有它被增强。
可以在 ()
内指定匹配方法的参数类型,如果是基本数据类型和 java.lang
包下的类型可以不用书写全类名,否则需要书写全类名。
类型可以使用通配符 *
表示任意类型,但 必须有参数
这样书写后,业务层只有 updateAccount(int i)
方法有参数,因此只有它被增强。
可以使用 ..
表示有无参数均可,有参数可以是任意类型
这样的书写属于全通配,所有方法都会被增强。
最终可得全通配写法:* *..*.*(..)
这表示任意包下的任意类中任意方法都会被增强。
但是实际开发中一般不会使用切入点表达式的全通配写法,我们会切到业务层实现类下的所有方法:
1 * com.yang.service.impl.*.*(..)
表明 com 包下 yang 包下 service 包下 impl 包下所有类的所有方法(无论是否有参)都会被增强。
切入点表达式可以这样简洁地书写,就是因为我们导入了以下依赖:
1 2 3 4 5 <dependency > <groupId > org.aspectj</groupId > <artifactId > aspectjweaver</artifactId > <version > 1.9.5</version > </dependency >
5.5 常见四种通知类型
我们对基于 XML 配置的 AOP 案例中的代码进行部分修改,service 包下的 AccountService
接口和 AccountServiceImpl
实现类不做任何改变。
在 Logger
类中添加另外三个方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class Logger { public void beforePrintLog () { System.out.println("前置通知Logger类中的beforePrintLog方法开始记录日志了..." ); } public void afterReturningPrintLog () { System.out.println("后置通知Logger类中的afterReturningPrintLog方法开始记录日志了..." ); } public void afterThrowingPrintLog () { System.out.println("异常通知Logger类中的afterThrowingPrintLog方法开始记录日志了..." ); } public void afterPrintLog () { System.out.println("最终通知Logger类中的afterPrintLog方法开始记录日志了..." ); } }
修改 Spring 配置文件,增加三种通知的配置:
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 <?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:aop ="http://www.springframework.org/schema/aop" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd" > <bean id ="accountService" class ="com.yang.service.impl.AccountServiceImpl" > </bean > <bean id ="logger" class ="com.yang.util.Logger" > </bean > <aop:config > <aop:aspect id ="logAdvice" ref ="logger" > <aop:before method ="beforePrintLog" pointcut ="execution(* com.yang.service.impl.*.*(..))" > </aop:before > <aop:after-returning method ="afterReturningPrintLog" pointcut ="execution(* com.yang.service.impl.*.*(..))" > </aop:after-returning > <aop:after-throwing method ="afterThrowingPrintLog" pointcut ="execution(* com.yang.service.impl.*.*(..))" > </aop:after-throwing > <aop:after method ="afterPrintLog" pointcut ="execution(* com.yang.service.impl.*.*(..))" > </aop:after > </aop:aspect > </aop:config > </beans >
测试类:
1 2 3 4 5 6 7 8 9 10 public class AOPTest { public static void main (String[] args) { ApplicationContext ac = new ClassPathXmlApplicationContext ("bean.xml" ); AccountService as = ac.getBean("accountService" , AccountService.class); as.saveAccount(); } }
运行测试类中的测试方法,控制台打印结果如下:
不是配置了四种通知吗?怎么只有三种输出?
修改 AccountServiceImpl
实现类中的 saveAccount()
方法:
1 2 3 4 5 @Override public void saveAccount () { System.out.println("执行了保存" ); int i = 1 / 0 ; }
再次运行测试类的测试方法,控制台输出如下:
代码运行出现异常,异常通知成功输出,但是后置通知消失不见。
总结
常见四种通知类型分别是:
1、前置通知:在切入点方法执行之前执行
2、后置通知:在切入点方法正常执行之后执行,它和异常通知永远只会执行一个
3、异常通知:在切入点方法产生异常之后执行,它和后置通知永远只会执行一个
4、最终通知:无论切入点方法是否正常执行它都会在其后面执行
通用化切入点表达式
在上述代码中,我们配置了四种通知类型,每种通知类型都有属于其本身的标签,每个标签也都有一个 pointcut
属性,因此我们写了四次(长长的)切入点表达式。
但是,这也太蠢了,不能简化一点?优雅一下?
这就涉及到另外一个标签:<aop:pointcut>
见名识意,显然这个标签是用于指定切入点表达式的。这个标签有两个属性:
id:用于指定表达式的唯一标识符
expression:用于指定切入点表达式的内容
那么我们可以对 Spring 配置文件中配置 AOP 部分的代码进行如下优化:
1 2 3 4 5 6 7 8 9 10 11 12 <aop:config > <aop:aspect id ="logAdvice" ref ="logger" > <aop:before method ="beforePrintLog" pointcut-ref ="pt1" > </aop:before > <aop:after-returning method ="afterReturningPrintLog" pointcut-ref ="pt1" > </aop:after-returning > <aop:after-throwing method ="afterThrowingPrintLog" pointcut-ref ="pt1" > </aop:after-throwing > <aop:after method ="afterPrintLog" pointcut-ref ="pt1" > </aop:after > <aop:pointcut id ="pt1" expression ="execution(* com.yang.service.impl.*.*(..))" /> </aop:aspect > </aop:config >
这个标签可以写在 <aop:aspect>
标签内部,只能当前切面使用。
它还可以写在 <aop:aspect>
标签外面,让所有切面都可以使用。
需要注意的是,根据导入的 xml 约束,<aop:pointcut>
标签只能写在 <aop:aspect>
标签前面,写后面就会报错,还不带提示的那种!
1 2 3 4 5 6 7 8 9 10 11 <aop:config > <aop:pointcut id ="pt1" expression ="execution(* com.yang.service.impl.*.*(..))" /> <aop:aspect id ="logAdvice" ref ="logger" > <aop:before method ="beforePrintLog" pointcut-ref ="pt1" > </aop:before > <aop:after-returning method ="afterReturningPrintLog" pointcut-ref ="pt1" > </aop:after-returning > <aop:after-throwing method ="afterThrowingPrintLog" pointcut-ref ="pt1" > </aop:after-throwing > <aop:after method ="afterPrintLog" pointcut-ref ="pt1" > </aop:after > </aop:aspect > </aop:config >
5.6 环绕通知
我们对基于 XML 配置的 AOP 案例中的代码进行部分修改,service 包下的 AccountService
接口和 AccountServiceImpl
实现类不做任何改变。
在 Logger
类中添加环绕通知的方法:
1 2 3 public void aroundPrintLog (ProceedingJoinPoint pjp) { System.out.println("Logger类中的aroundPrintLog方法开始记录日志了..." ); }
在 Spring 配置文件中,修改 AOP 的配置:
1 2 3 4 5 6 <aop:config > <aop:pointcut id ="pt1" expression ="execution(* com.yang.service.impl.*.*(..))" /> <aop:aspect id ="logAdvice" ref ="logger" > <aop:around method ="aroundPrintLog" pointcut-ref ="pt1" > </aop:around > </aop:aspect > </aop:config >
测试方法也不变,直接运行:
这输出结果是个啥玩意?😤
通知方法执行了,但是切入点方法没有执行,搞个 🔨
还记得动态代理的环绕通知代码吗?
动态代理的环绕通知有明确的切入点调用,而我们的代码没有。那怎么搞?
Spring 框架为我们提供了一个接口:ProceedingJoinPoint
,该接口有一个 proceed()
方法,此方法就相当于明确调用切入点方法。
ProceedingJoinPoint
接口可以作为环绕通知的方法参数,在程序执行时,Spring 框架会为我们提供该接口的实现类供我们使用。
因此,我们可以在 proceed()
方法执行前编写前置通知,在 proceed()
执行后编写后置通知,proceed()
方法需要抛出 Throwable
类型的异常,可以在 catch
块里编写异常通知,在 finally
块里编写最终通知。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public Object aroundPrintLog (ProceedingJoinPoint pjp) { Object rtValue; try { Object[] args = pjp.getArgs(); System.out.println("Logger类中的aroundPrintLog方法开始记录日志了...前置" ); rtValue = pjp.proceed(args); System.out.println("Logger类中的aroundPrintLog方法开始记录日志了...后置" ); return rtValue; } catch (Throwable throwable) { System.out.println("Logger类中的aroundPrintLog方法开始记录日志了...异常" ); throw new RuntimeException (throwable); } finally { System.out.println("Logger类中的aroundPrintLog方法开始记录日志了...最终" ); } }
运行测试类的测试方法,控制台打印如下:
修改 AccountServiceImpl
实现类中的 saveAccount()
方法:
1 2 3 4 public void saveAccount () { System.out.println("执行了保存" ); int i = 1 / 0 ; }
再次运行测试类的测试方法,控制台打印如下:
控制台的输出都正确!我们自己编写的环绕通知没有问题! 🎉
根据上面环绕通知的编写过程,不难得出:Spring 中的环绕通知是 Spring 框架为我们提供的一种可以在代码中手动控制增强方法何时执行的方式。
5.7 基于注解的 AOP
还是根据基于 XML 配置的 AOP 案例中的代码进行修改,完成基于注解的 AOP。
AccountService
接口不变,但我们知道需要将 service 对象配置到 IoC 容器中,因此需要在 AccountServiceImpl
实现类上添加 @Service
注解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Service("accountService") public class AccountServiceImpl implements IAccountService { public void saveAccount () { System.out.println("执行了保存" ); } public void updateAccount (int i) { System.out.println("执行了更新" +i); } public int deleteAccount () { System.out.println("执行了删除" ); return 0 ; } }
对于日志类 Logger
类来说,也需要将其注入 IoC 容器中,这个类不属于任何一层,因此使用 @Component
注解。
然后我们还需要使用 @Aspect
注解表示 Logger
类是一个切面类。
对于 Logger
类中的方法,需要将它们设置成通知方法,与 XML 中的标签一样,这些通知分别对应了一些注解:
@Before
:前置通知
@AfterReturning
:后置通知
@AfterThrowing
:异常通知
@After
:最终通知
@Around()
:环绕通知
使用了这些注解指定哪些方法是哪些通知之外,还需要指定切入点表达式,这时候需要用到 @Pointcut
注解。
最终,Logger
类改写如下:
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 @Component("logger") @Aspect public class Logger { @Pointcut("execution(* com.yang.service.impl.*.*(..))") private void pt1 () {} public void beforePrintLog () { System.out.println("前置通知Logger类中的beforePrintLog方法开始记录日志了。。。" ); } public void afterReturningPrintLog () { System.out.println("后置通知Logger类中的afterReturningPrintLog方法开始记录日志了。。。" ); } public void afterThrowingPrintLog () { System.out.println("异常通知Logger类中的afterThrowingPrintLog方法开始记录日志了。。。" ); } public void afterPrintLog () { System.out.println("最终通知Logger类中的afterPrintLog方法开始记录日志了。。。" ); } @Around("pt1()") public Object aroundPrintLog (ProceedingJoinPoint pjp) { Object rtValue = null ; try { Object[] args = pjp.getArgs(); System.out.println("Logger类中的aroundPrintLog方法开始记录日志了。。。前置" ); rtValue = pjp.proceed(args); System.out.println("Logger类中的aroundPrintLog方法开始记录日志了。。。后置" ); return rtValue; } catch (Throwable throwable) { throwable.printStackTrace(); System.out.println("Logger类中的aroundPrintLog方法开始记录日志了。。。异常" ); } finally { System.out.println("Logger类中的aroundPrintLog方法开始记录日志了。。。最终" ); } return rtValue; } }
由于那四种常见的通知类型和环绕通知不能共存,因此我们将其中一种进行注释并分别测试。
这下就真的完了?
没有!我们还没有开启注解。
可以使用 XML 的方式开启注解与注解扫描:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <?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:aop ="http://www.springframework.org/schema/aop" xmlns:context ="http://www.springframework.org/schema/context" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd" > <context:component-scan base-package ="com.yang" > </context:component-scan > <aop:aspectj-autoproxy > </aop:aspectj-autoproxy > </beans >
还可以使用配置类的方式开启注解与注解扫描:
1 2 3 4 5 @Configuration @ComponentScan(basePackages="com.yang") @EnableAspectJAutoProxy public class SpringConfiguration { }
对于使用了 XML 方式开启注解和注解扫描的情况,可以使用如下测试类:
1 2 3 4 5 6 7 8 9 10 public class AOPTest { public static void main (String[] args) { ApplicationContext ac = new ClassPathXmlApplicationContext ("bean.xml" ); IAccountService as = (IAccountService) ac.getBean("accountService" ); as.saveAccount(); } }
如果使用的是配置类开启注解和注解扫描,就需要使用 AnnotationConfigApplicationContext
来读取配置类。如:
1 ApplicationContext ac1 = new AnnotationConfigApplicationContext (SpringConfiguration.class);
注意事项
1、使用 @Before
等注解指定切入点表达式时,不要漏掉括号。正确示例:@Before("pt1()")
2、在低版本的 Spring 2.x 中,使用基于注解的 AOP 时,可能会出现消息输出异常的问题(环绕通知不会出现问题)。建议使用的 Spring 版本为 5.2.7 及其以上!
6. JdbcTemplate
6.1 准备工作
Account
实体类:
1 2 3 4 5 6 7 8 public class Account implements Serializable { private int id; private String name; private float money; }
account 数据表字段:
导入的依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <dependency > <groupId > org.springframework</groupId > <artifactId > spring-context</artifactId > <version > 5.2.7.RELEASE</version > </dependency > <dependency > <groupId > org.springframework</groupId > <artifactId > spring-jdbc</artifactId > <version > 5.2.7.RELEASE</version > </dependency > <dependency > <groupId > org.springframework</groupId > <artifactId > spring-tx</artifactId > <version > 5.2.7.RELEASE</version > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > <version > 8.0.13</version > </dependency >
6.2 基本使用
直接创建一个类,直接编写一个方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class JdbcTemplateDemo1 { public static void main (String[] args) { DriverManagerDataSource ds = new DriverManagerDataSource (); ds.setDriverClassName("com.mysql.cj.jdbc.Driver" ); ds.setUrl("jdbc:mysql:///ssm?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=UTC" ); ds.setUsername("root" ); ds.setPassword("123456" ); JdbcTemplate jt = new JdbcTemplate (); jt.setDataSource(ds); jt.execute("insert into account(name,money)values ('ddd',1000)" ); } }
编写好后,直接 run 就完事了!
6.3 与 Spring IoC 结合
首先得先编写 Spring 的配置文件,配置数据源、将 JdbcTemplate
注入 Spring 容器中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?xml version="1.0" encoding="UTF-8" ?> <beans xmlns ="http://www.springframework.org/schema/beans" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" > <bean id ="jdbcTemplate" class ="org.springframework.jdbc.core.JdbcTemplate" > <property name ="dataSource" ref ="dataSource" > </property > </bean > <bean id ="dataSource" class ="org.springframework.jdbc.datasource.DriverManagerDataSource" > <property name ="driverClassName" value = "com.mysql.cj.jdbc.Driver" > </property > <property name ="url" value = "jdbc:mysql:///ssm?zeroDateTimeBehavior=convertToNull& serverTimezone=GMT%2b8" > </property > <property name ="username" value = "root" > </property > <property name ="password" value = "123456" > </property > </bean > </beans >
编写测试方法:
1 2 3 4 5 6 7 8 9 10 public class JdbcTemplateDemo2 { public static void main (String[] args) { ApplicationContext ac = new ClassPathXmlApplicationContext ("bean.xml" ); JdbcTemplate jt = ac.getBean("jdbcTemplate" ,JdbcTemplate.class); jt.execute("insert into account(name,money)values ('eee',1000)" ); } }
6.4 CRUD 操作
继续使用 【6.3 与 Spring IoC 结合】的环境,那么 JdbcTemplate 相关的 CRUD 代码如下:
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 public class JdbcTemplateDemo3 { public static void main (String[] args) { ApplicationContext ac = new ClassPathXmlApplicationContext ("bean.xml" ); JdbcTemplate jt = ac.getBean("jdbcTemplate" ,JdbcTemplate.class); jt.update("insert into account(name,money)values (?,?)" ,"fff" ,1200 ); jt.update("update account set name=?, money=? where id=?" ,"test" ,1200 ,6 ); jt.update("delete from account where id=?" ,7 ); List<Account> accounts = jt.query("select * from account where money > ?" ,new BeanPropertyRowMapper <Account>(Account.class),1000 ); for (Account account:accounts){ System.out.println(account); } List<Account> accounts = jt.query("select * from account where id = ?" ,new BeanPropertyRowMapper <Account>(Account.class),1 ); System.out.println(accounts.isEmpty()?"No values" :accounts.get(0 )); Long count = jt.queryForObject("select count(*) from account where money > ?" ,Long.class,1000 ); System.out.println(count); } }
6.2 与 Dao 结合
IAccountDao
接口:
1 2 3 4 5 6 7 8 9 10 11 public interface IAccountDao { Account findAccountById (Integer accountId) ; Account findAccountByName (String accountName) ; void updateAccount (Account account) ; }
实现类:
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 import org.springframework.jdbc.core.support.JdbcDaoSupport;import java.util.List;public class AccountDaoImpl extends JdbcDaoSupport implements IAccountDao { public Account findAccountById (Integer accountId) { List<Account> accounts = super .getJdbcTemplate().query("select * from account where id = ?" , new BeanPropertyRowMapper <Account>(Account.class), accountId); return accounts.isEmpty() ? null : accounts.get(0 ); } public Account findAccountByName (String accountName) { List<Account> accounts = super .getJdbcTemplate().query("select * from account where name = ?" , new BeanPropertyRowMapper <Account>(Account.class), accountName); if (accounts.isEmpty()) { return null ; } if (accounts.size() > 1 ) { throw new RuntimeException ("结果不唯一" ); } return accounts.get(0 ); } public void updateAccount (Account account) { super .getJdbcTemplate().update("update account set name = ?,money=? where id = ?" ,account.getName(),account.getMoney(),account.getId()); } }
Spring 配置文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?xml version="1.0" encoding="UTF-8" ?> <beans xmlns ="http://www.springframework.org/schema/beans" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" > <bean id ="accountDao" class ="com.yang.dao.impl.AccountDaoImpl" > <property name ="dataSource" ref ="dataSource" > </property > </bean > <bean id ="dataSource" class ="org.springframework.jdbc.datasource.DriverManagerDataSource" > <property name ="driverClassName" value = "com.mysql.cj.jdbc.Driver" > </property > <property name ="url" value = "jdbc:mysql:///ssm?zeroDateTimeBehavior=convertToNull& serverTimezone=GMT%2b8" > </property > <property name ="username" value = "root" > </property > <property name ="password" value = "123456" > </property > </bean > </beans >
测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class JdbcTemplateDemo4 { public static void main (String[] args) { ApplicationContext ac = new ClassPathXmlApplicationContext ("bean.xml" ); IAccountDao accountDao = ac.getBean("accountDao" ,IAccountDao.class); Account account = accountDao.findAccountById(1 ); System.out.println(account); account.setMoney(66666f ); accountDao.updateAccount(account); } }
相关要点
在 service 的实现类中,我们继承了 JdbcDaoSupport
类。当代码中存在多个 Dao 并使用了 JdbcTemplate
时,在这些 Dao 文件中会多次书写 JdbcTemplate
及其 set 方法,然后在配置文件中注入。
这样的代码属于多余的,完全可以消除,只需要实现 JdbcDaoSupport
类即可(具体原因可以点开这个类看看),然后按照上面提供的 XML 配置文件进行配置即可。
但是这种方式必须是基于 XML 进行 Spring 配置时才可以使用,如果是使用了注解,就不能继承 JdbcDaoSupport
类,而是需要在 Dao 类中添加:
1 2 @Autowired private JdbcTemplate jdbcTemplate;
因为 JdbcDaoSupport
是 Spring 提供的,里面的代码仅是可读的,我们不能修改这里面的代码。
7. 事务管理
7.1 手动实现事务管理
事务回顾
事务管理是企业级应用程序开发中必备技术,用来确保数据的完整性和一致性。
事务就是把一系列的数据库操作当成一个独立的工作单元,这些操作要么全部完成,要么全部不完成。
事务有四个属性(ACID):
1、原子性(atomicity):事务是原子性操作,由一系列动作组成,事务的原子性确保动作要么全部完成,要么完全不起作用
2、一致性(consistency):一旦所有事务动作完成,事务就要被提交。数据和资源处于一种满足业务规则的一致性状态中
3、隔离性(isolation):可能多个事务会同时处理相同的数据,因此每个事务都应该与其他事务隔离开来,防止数据损坏
4、持久性(durability):事务一旦完成,无论系统发生什么错误,结果都不会受到影响。通常情况下,事务的结果被写到持久化存储器中
案例测试
假设我们需要实现转账,要实现这个功能就需要涉及到两类用户,一个是转出账户,另一个是转入账户。转出账户减钱,转入账户加钱。如果中间出了意外,则转账失败,转出用户不减钱,转入用户不加钱。
这就涉及到事务管理,事务管理的一大核心就是:将事务自动提交改成手动提交,用于保证数据完整和一致。
根据前文讲述的 AOP 知识,我们可以使用 AOP 来实现事务管理。以下为主要代码:
账户实体类:
1 2 3 4 5 6 7 public class Account implements Serializable { private Integer id; private String name; private Float money; }
业务层实现类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class AccountServiceImpl implements IAccountService { public void transfer (String sourceName, String targetName, Float money) { System.out.println("transfer...." ); Account source = accountDao.findAccountByName(sourceName); Account target = accountDao.findAccountByName(targetName); source.setMoney(source.getMoney()-money); target.setMoney(target.getMoney()+money); accountDao.updateAccount(source); int i = 1 / 0 ; accountDao.updateAccount(target); } }
Dao 层接口和实现类都是涉及的数据库操作,比较简单,篇幅所限就不贴出了。
数据库连接工具类 ConnectionUtils
:
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 package com.yang.utils;import javax.sql.DataSource;import java.sql.Connection;public class ConnectionUtils { private ThreadLocal<Connection> tl = new ThreadLocal <Connection>(); private DataSource dataSource; public void setDataSource (DataSource dataSource) { this .dataSource = dataSource; } public Connection getThreadConnection () { try { Connection conn = tl.get(); if (conn == null ) { conn = dataSource.getConnection(); tl.set(conn); } return conn; }catch (Exception e){ throw new RuntimeException (e); } } public void removeConnection () { tl.remove(); } }
事务管理工具类:
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 package com.yang.utils;public class TransactionManager { private ConnectionUtils connectionUtils; public void setConnectionUtils (ConnectionUtils connectionUtils) { this .connectionUtils = connectionUtils; } public void beginTransaction () { try { connectionUtils.getThreadConnection().setAutoCommit(false ); }catch (Exception e){ e.printStackTrace(); } } public void commit () { try { connectionUtils.getThreadConnection().commit(); }catch (Exception e){ e.printStackTrace(); } } public void rollback () { try { connectionUtils.getThreadConnection().rollback(); }catch (Exception e){ e.printStackTrace(); } } public void release () { try { connectionUtils.getThreadConnection().close(); connectionUtils.removeConnection(); }catch (Exception e){ e.printStackTrace(); } } }
接下来就是使用配置文件将这些 Bean 注入到 Spring IoC 容器中使用了:
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 55 56 57 58 59 60 61 62 63 64 <?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:aop ="http://www.springframework.org/schema/aop" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd" > <bean id ="accountService" class ="com.yang.service.impl.AccountServiceImpl" > <property name ="accountDao" ref ="accountDao" > </property > </bean > <bean id ="accountDao" class ="com.yang.dao.impl.AccountDaoImpl" > <property name ="runner" ref ="runner" > </property > <property name ="connectionUtils" ref ="connectionUtils" > </property > </bean > <bean id ="runner" class ="org.apache.commons.dbutils.QueryRunner" scope ="prototype" > </bean > <bean id ="dataSource" class ="com.mchange.v2.c3p0.ComboPooledDataSource" > <property name ="driverClass" value = "com.mysql.cj.jdbc.Driver" > </property > <property name ="jdbcUrl" value = "jdbc:mysql:///ssm?zeroDateTimeBehavior=convertToNull& serverTimezone=GMT%2b8" > </property > <property name ="user" value = "root" > </property > <property name ="password" value = "123456" > </property > </bean > <bean id ="connectionUtils" class ="com.yang.utils.ConnectionUtils" > <property name ="dataSource" ref ="dataSource" > </property > </bean > <bean id ="txManager" class ="com.yang.utils.TransactionManager" > <property name ="connectionUtils" ref ="connectionUtils" > </property > </bean > <aop:config > <aop:pointcut id ="pt1" expression ="execution(* com.yang.service.impl.*.*(..))" /> <aop:aspect id ="txAdvice" ref ="txManager" > <aop:before method ="beginTransaction" pointcut-ref ="pt1" /> <aop:after-returning method ="commit" pointcut-ref ="pt1" /> <aop:after-throwing method ="rollback" pointcut-ref ="pt1" /> <aop:after method ="release" pointcut-ref ="pt1" /> </aop:aspect > </aop:config > </beans >
测试类测试一手:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @ExtendWith(SpringExtension.class) @ContextConfiguration(locations = "classpath:bean.xml") public class AccountServiceTest { @Autowired private IAccountService as; @Test public void testTransfer () { as.transfer("aaa" ,"bbb" ,100f ); } }
测试类中,我们让账户名为 aaa 的用户向用户 bbb 转账 100 元,但是由于业务层实现类中存在我们人为制造的转账意外,因此应该是无法转账成功的。
运行代码后,控制台会打印出除数为 0 的异常,也会打印出在业务层实现类中输出的“transfer…”,如果前往数据库查看数据,会发现两个账户的余额并没有改变。
证明我们自己实现的事务控制没有问题!
7.2 XML 配置的声明式事务管理
Spring 在不同的事务管理 API 之上定义了一个抽象层,使得开发人员不必了解底层的事务管理 API 就可以使用 Spring 的事务管理机制。Spring 支持编程式事务管理和声明式的事务管理。
先来说说声明式事务管理,这种事务管理又分为基于 XML 配置的声明式事务管理和基于注解配置的声明式事务管理。
如果要使用基于 XML 配置的声明式事务管理,我们需要在 Spring 配置文件中导入以下约束,千万别导错了,不然标签提供的属性不对:
1 2 3 4 5 6 7 8 9 10 <beans xmlns ="http://www.springframework.org/schema/beans" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xmlns:tx ="http://www.springframework.org/schema/tx" xmlns:aop ="http://www.springframework.org/schema/aop" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd http://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd" >
为了代码的完整,在此贴上所有代码。
账户实体类:
1 2 3 4 5 6 7 public class Account implements Serializable { private Integer id; private String name; private Float money; }
Dao 层接口:
1 2 3 4 5 6 7 8 9 10 11 public interface IAccountDao { Account findAccountById (Integer accountId) ; Account findAccountByName (String accountName) ; void updateAccount (Account account) ; }
Dao 层实现类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class AccountDaoImpl extends JdbcDaoSupport implements IAccountDao { public Account findAccountById (Integer accountId) { List<Account> accounts = super .getJdbcTemplate().query("select * from account where id = ?" , new BeanPropertyRowMapper <Account>(Account.class), accountId); return accounts.isEmpty() ? null : accounts.get(0 ); } public Account findAccountByName (String accountName) { List<Account> accounts = super .getJdbcTemplate().query("select * from account where name = ?" , new BeanPropertyRowMapper <Account>(Account.class), accountName); if (accounts.isEmpty()) { return null ; } if (accounts.size() > 1 ) { throw new RuntimeException ("结果不唯一" ); } return accounts.get(0 ); } public void updateAccount (Account account) { super .getJdbcTemplate().update("update account set name = ?,money=? where id = ?" ,account.getName(),account.getMoney(),account.getId()); } }
service 层接口:
1 2 3 4 5 6 7 8 9 10 11 12 public interface AccountService { Account findAccountById (Integer accountId) ; void transfer (String sourceName, String targetName, Float money) ; }
service 层实现类:
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 public class AccountServiceImpl implements AccountService { private IAccountDao accountDao; public void setAccountDao (IAccountDao accountDao) { this .accountDao = accountDao; } public Account findAccountById (Integer accountId) { return accountDao.findAccountById(accountId); } public void transfer (String sourceName, String targetName, Float money) { System.out.println("transfer...." ); Account source = accountDao.findAccountByName(sourceName); Account target = accountDao.findAccountByName(targetName); source.setMoney(source.getMoney()-money); target.setMoney(target.getMoney()+money); accountDao.updateAccount(source); int i = 1 / 0 ; accountDao.updateAccount(target); } }
接下来就是重头戏 —— Spring 配置文件中关于声明式事务管理的配置,先直接上代码,然后再来一一解析:
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 <?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:tx ="http://www.springframework.org/schema/tx" xmlns:aop ="http://www.springframework.org/schema/aop" xsi:schemaLocation ="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd http://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd" > <bean id ="accountService" class ="com.yang.service.impl.AccountServiceImpl" > <property name ="accountDao" ref ="accountDao" > </property > </bean > <bean id ="accountDao" class ="com.yang.dao.impl.AccountDaoImpl" > <property name ="dataSource" ref ="dataSource" > </property > </bean > <bean id ="dataSource" class ="org.springframework.jdbc.datasource.DriverManagerDataSource" > <property name ="driverClassName" value ="com.mysql.cj.jdbc.Driver" > </property > <property name ="url" value ="jdbc:mysql:///ssm?zeroDateTimeBehavior=convertToNull& serverTimezone=GMT%2b8" > </property > <property name ="username" value ="root" > </property > <property name ="password" value ="123456" > </property > </bean > <bean id ="transactionManager" class ="org.springframework.jdbc.datasource.DataSourceTransactionManager" > <property name ="dataSource" ref ="dataSource" > </property > </bean > <tx:advice id ="txAdvice" transaction-manager ="transactionManager" > <tx:attributes > <tx:method name ="transfer" propagation ="REQUIRED" read-only ="false" /> <tx:method name ="find*" propagation ="SUPPORTS" read-only ="true" /> </tx:attributes > </tx:advice > <aop:config > <aop:pointcut id ="pt1" expression ="execution(* com.yang.service.impl.*.*(..))" /> <aop:advisor advice-ref ="txAdvice" pointcut-ref ="pt1" /> </aop:config > </beans >
在上述配置中,我们先配置数据源、将数据源注入持久层、将持久层注入业务层,然后就是基于 XML 的声明式事务控制。
Spring 中基于 XML 的声明式事务控制配置步骤:
1、配置事务管理器:
1 2 3 <bean id ="transactionManager" class ="org.springframework.jdbc.datasource.DataSourceTransactionManager" > <property name ="dataSource" ref ="dataSource" > </property > </bean >
2、配置事务的通知,需要导入事务的约束 tx 和 aop 的名称空间和约束,这里的约束千万不要导错了,建议手动复制导入,而不是使用 IDEA 的自动导入。配置事务通知时需要使用到一个新标签 <tx:advice>
,这个标签有两个属性:
id:给事务管理器取一个唯一标志
transaction-manager:给事务通知提供一个事务管理器引用
1 <tx:advice id ="txAdvice" transaction-manager ="transactionManager" > </tx:advice >
3、配置 AOP 中的通用切入点表达式,建立事务通知和切入点表达式的对应关系
1 2 3 4 5 6 <aop:config > <aop:pointcut id ="pt1" expression ="execution(* com.yang.service.impl.*.*(..))" /> <aop:advisor advice-ref ="txAdvice" pointcut-ref ="pt1" /> </aop:config >
4、配置事务的属性,在事务的通知 <tx:advice>
标签的内部进行配置。
1 2 3 4 5 6 7 <tx:advice id ="txAdvice" transaction-manager ="transactionManager" > <tx:attributes > <tx:method name ="transfer" propagation ="REQUIRED" read-only ="false" /> <tx:method name ="find*" propagation ="SUPPORTS" read-only ="true" /> </tx:attributes > </tx:advice >
标签 <tx:method>
中有多个属性,其左右如下:
name:指定某一切入点方法,支持通配符。如:find*
表示以 find 开头的切入点方法。优先级是:全匹配 > 半匹配半通配 > 全通配。
isolation:用于指定事务的隔离级别,默认值 DEFAULT
,表示使用数据库的隔离级别
propagation:用于指定事务的传播行为,默认值 REQUIRED
,表示一定会有事务,是增删改的选择,查询方法可以选择 SUPPORTS
read-only:用于指定事务是否只读。只有查询方法才能设置为 true
。默认值是 false
,表示读写
timeout:用于指定事务的超时时间,默认值是 -1,表示所使用数据库的默认超时时间。如果指定了数值,以秒为单位
rollback-for:用于指定一个异常,当产生该异常时,事务回滚,产生其他异常时,事务不会滚。没有默认值,表示任何异常都回滚。
no-rollback-for:用于指定一个异常,当产生该异常时,事务不会滚,产生其他异常时,事务回滚。没有默认值,表示任何异常都回滚。
7.3 事务的隔离级别
什么是事务的隔离级别
事务隔离级反映事务提交并发访问时的处理态度。
这里所指的并发访问就是多个事务(用户)在同一时间访问了相同的数据,而同一时间实际上也有微小的差距,并不是真正意义上的同一时间。
在并发访问数据库时,可能会产生以下问题:脏读 、不可重复读 和 幻影读 。
这些问题可以通过 事务的隔离级别 来解决,通过在隔离级别中设置不同的值,解决并发处理过程中的问题。
脏读
脏读(dirty read):一个事务读取了另一个事务未提交的数据,进而在本事务中产生了数据不一致的现象。
要解决脏读,需要使用 @Transactional(isolation = Isolation.READ_COMMITTED)
。
《数据库系统概论(第五版)》(王珊 萨师煊著)中是这样描述的:事务 T1 修改某一数据并将其写回磁盘,事务 T2 读取同一数据后,T1 由于某些原因被撤销,这时 T1 修改过的数据恢复原值,T2 读到的数据就与数据库中的数据不一致,则 T2 读到的数据就为“脏”数据,即不正确的数据。
T1
T2
①
R© = 100; C = C * 2 W© = 200
②
R© = 200
③
ROLLBACK C 恢复为 200
不可重复读
不可重复读(non-repeatable read):一个事务中,多次读取相同的数据,但是读取的结果不一样。
要解决不可重复读,需要使用 @Transactional(isolation = Isolation.REPEATABLE_READ)
。其本质就是行锁。
《数据库系统概论(第五版)》(王珊 萨师煊著)中是这样描述的:事务 T1 读取数据后,事务 T2 执行了更新操作,使 T1 无法再现前一次读取结果。具体地讲,不可重复读包括三种情况:
1、事务 T1 读取了某一数据后,事务 T2 对其进行了修改,当事务 T1 再次读该数据时,得到与前一次不同的值。
T1
T2
①
R(A) = 50 R(B) = 100 求和 150
②
R(B) = 100 B = B * 2 W(B) = 200
③
R(A) = 50 R(B) = 200 求和 250,验算不对
2、事务 T1 按一定条件从数据库中读取了某些数据记录后,事务 T2 删除了其中部分记录,当 T1 再次按相同条件读取数据时,发现某些记录神秘地消失了。
3、事务 T1 按一定条件从数据库中读取某些数据记录后,事务 T2 插入了一些数据,当 T1 再次按相同条件读取数据时,发现多了一些记录。
后两种不可重复读有时也称为幻影(phantom row)现象。
幻影读
幻影读:一个事务中,多次对整表(多条数据)进行查询统计,但是结果不一致。
与《数据库系统概论(第五版)》(王珊 萨师煊著)一书中提到的 不可重复读的后两种情况 类似。
要解决幻影读,需要使用 @Transactional(isolation = Isolation.SERIALIZABLE)
。其本质就是表锁。
丢失修改
《数据库系统概论(第五版)》(王珊 萨师煊著)一书中认为并发操作带来的数据不一致的情况有丢失修改、不可重复读和读“脏”数据。
这里补充一下丢失修改(lost update):两个事务 T1 和 T2 读入同一数据并修改,T2 提交的结果破坏了 T1 提交的结果,导致 T1 的修改被丢失。
T1
T2
①
R(A) = 16
②
R(A) = 16
③
A = A - 1 W(A) = 15
④
A = A - 1 W(A) = 15
SQL92 没有定义这种现象,解决丢失修改的办法就是加锁。
数据库对于隔离级别的支持
隔离属性的值
MySQL
Oracle
READ_COMMITTED
✔️
✔️
REPEATABLE_READ
✔️
❌
SERIALIZABLE
✔️
✔️
Oracle 不支持 REPEATABLE_READ
,那如何解决不可重复读呢?采用的是多版本对比的方式来解决不可重复读。
Spring 中事务的隔离级别
在 Spring 中,事务有如下五种隔离级别:
DEFAULT
:默认级别 ,使用后端数据库默认的隔离级别。如 MySQL 默认采用 REPEATABLE_READ
,Oracle 默认采用 READ_COMMITTED
;
READ_UNCOMMITTED
:最低的隔离级别,允许可以读取未提交数据,可能会导致脏读、幻读、不可重复读;
READ_COMMITTED
:允许读取并发事务已经提交的数据,可以解决脏读,但幻读、不可重复读仍可能发生;
REPEATABLE_READ
:对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以解决脏读和不可重复读,但幻读仍可能发生;
SERIALIZABLE
:最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,事务之间不会产生干扰,该级别可以解决脏读、不可重复读以及幻读 。这种级别下将严重影响程序的性能,通常情况下不会使用该级别。
从并发安全的角度来讲:SERIALIZABLE 大于 REPEATABLE_READ 大于 READ_COMMITTED;
从运行效率的角度来讲:READ_COMMITTED 大于 REPEATABLE_READ 大于 SERIALIZABLE。
MySQL InnoDB 存储引擎的默认支持的隔离级是 REPEATABLE-READ(可重读) 。可以通过以下命令来查看:
1 2 SELECT @@tx_isolation ; # MySQL 8.0 之前SELECT @@transaction_isolation ; # MySQL 8.0 以后
实战中的建议
推荐使用 Spring 指定的默认隔离级别 DEFAULT
即可,它将使用后端数据库默认的隔离级别。
在未来的实战中,实际的并发访问情况很低,如果真的遇到了并发问题,可以使用乐观锁来解决。如果使用 Hibernate(JPA)可以使用 Version,如果使用 Mybatis,就需要通过拦截器自定义开发。
7.4 事务的传播行为
事务的传播行为描述了事务解决嵌套问题的特征。Service 在调用 Service 时,极有可能出现事务的嵌套。所谓事务的嵌套,就是在一个大事务中包含了若干个小事务,它们彼此影响,最终就会导致外部大的事务丧失了事务的原子性。在 Spring 中,共有以下 7 个传播行为:
REQUIRED
:如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中,一般的选择(默认值)
SUPPORTS
:使用当前事务,如果当前没有事务,就以非事务方式执行
REQUERS_NEW
:新建事务,如果当前在事务中,把当前事务挂起
NOT_SUPPORTED
:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起
NEVER
:以非事务方式运行,如果当前存在事务,抛出异常
MANDATORY
:使用当前的事务,如果当前没有事务,就抛出异常
NESTED
:如果当前没有事务,则执行 REQUIRED 类似的操作。如果当前存在事务,则在嵌套事务内执行,作为子事务。也就是说:如果外部存在事务,NESTED
修饰的内部方法属于外部事务的子事务,外部主事务回滚时,子事务也会回滚,而内部子事务可以单独回滚但不影响外部主事务和其他子事务。
值
外部不存在事务
外部存在事务
用法
备注
REQUIRED
开启新的事务
融合到外部事务中
@Transactional(propagation = Propagation.REQUIRED)
增删改方法
SUPPORTS
不开启事务
融合到外部事务中
@Transactional(propagation = Propagation.SUPPORTS)
查询方法
REQUERS_NEW
开启新的事务
挂起外部事务,创建新的事务
@Transactional(propagation = Propagation.REQUERS_NEW)
日志记录方法
NOT_SUPPORTED
不开启事务
挂起外部事务
@Transactional(propagation = Propagation.NOT_SUPPORTED)
极其不常用
NEVER
不开启事务
抛出异常
@Transactional(propagation = Propagation.NEVER)
极其不常用
MANDATORY
抛出异常
融合到外部事务中
@Transactional(propagation = Propagation.MANDATORY)
极其不常用
NESTED
开启新的事务
作为外部事务的子事务
@Transactional(propagation = Propagation.NESTED)
极其不常用
7.5 注解配置的声明式事务管理
虽然是基于注解的声明式事务控制,但并不是全注解的声明式事务控制,我们依旧需要在 XML 中进行一些配置。
使用注解配置的声明式事务管理,需要在 Spring 配置文件中添加新的约束:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <beans xmlns ="http://www.springframework.org/schema/beans" xmlns:aop ="http://www.springframework.org/schema/aop" xmlns:tx ="http://www.springframework.org/schema/tx" xmlns:context ="http://www.springframework.org/schema/context" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation =" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd" >
配置 Spring 创建容器时要扫描的包:
1 <context:component-scan base-package ="com.yang" > </context:component-scan >
我们的 Dao 实现类继承了 JdbcDaoSupport
,使用基于XML的配置时可以去除多余代码,但现在要使用注解,因此就不能继承 JdbcDaoSupport
类,地老老实实注入 JdbcTemplate。
Dao 实现类:
1 2 3 4 5 6 7 8 @Repository("accountDao") public class AccountDaoImpl implements IAccountDao { @Autowired private JdbcTemplate jdbcTemplate; }
虽然注入了 JdbcTemplate,但 Spring 容器中并没有啊,因此需要在 Spring 容器中添加 JdbcTemplate:
1 2 3 <bean id ="jdbcTemplate" class ="org.springframework.jdbc.core.JdbcTemplate" > <property name ="dataSource" ref ="dataSource" > </property > </bean >
添加 JdbcTemplate 时,需要注入数据源,数据源与基于 XML 的配置相同,这里就不列举了。
至于向容器中添加业务层实现类、Dao 成实现类,以及数据的注入,都可以使用相关注解在代码中实现。
Spring 中基于注解的声明式事务管理配置步骤:
1、配置事务管理器并注入数据源
1 2 3 4 <bean id ="transactionManager" class ="org.springframework.jdbc.datasource.DataSourceTransactionManager" > <property name ="dataSource" ref ="dataSource" > </property > </bean >
2、开启 Spring 对注解事务的支持
1 2 <tx:annotation-driven transaction-manager ="transactionManager" />
3、在需要事务支持的地方使用 @Transactional
注解(一般在业务层实现类添加)
使用了 @Transactional
注解后,XML 中配置事务通知的 <tx:advice>
标签和配置 AOP的 <aop:config>
标签就可以省略了!
@Transactional
注解的属性和 XML 中 <tx:method>
标签的属性含义一致(不明白可以进入该注解查看)。
该注解可以出现在接口上,类上和方法上。
出现接口上,表示该接口的所有实现类都有事务支持。
出现在类上,表示类中所有方法有事务支持
出现在方法上,表示方法有事务支持。
以上三个位置的优先级:方法 > 类 > 接口
如果不想使用 XML 配置文件,也可以使用全注解配置,那么就需要创建一个配置类:
1 2 3 4 5 6 7 8 @Configuration @ComponentScan(basePackages="com.yang") @Import({JdbcConfig.class, TransactionConfig.class}) @PropertySource("jdbcConfig.properties") @EnableTransactionManagement public class SpringTxConfiguration { }
7.6 与编程式事务管理的对比
最开始就说了 Spring 支持编程式事务管理和声明式的事务管理,但是在实际开发过程中我们很少使用编程式事务管理。对于一下声明式事务管理和编程式事务管理:
编程式事务管理:
将事务管理代码嵌到业务方法中来控制事务的提交和回滚
缺点:必须在每个事务操作业务逻辑中包含额外的事务管理代码
PS:在 SpringBoot 中,编程式事务管理的使用被大大简化,因此可以使用编程式事务管理来避免提交大事务。
声明式事务管理:
一般情况下比编程式事务好用。
将事务管理代码从业务方法中分离出来,以声明的方式来实现事务管理。
将事务管理作为横切关注点,通过 AOP 方法模块化。Spring 中通过 Spring AOP 框架支持声明式事务
管理。
如果想了解编程式事务管理,可以参考以下视频:Spring教程IDEA版-4天-2018黑马SSM-02 P80
本文也是根据此视频编写的,感谢黑马程序员的张阳老师! 👍
Spring 基础完