Spring使用篇(六)—— Spring AOP的基本概念
文章目录
1、AOP的概念
显示中有一些内容并不是面向对象(OOP)可以解决的,比如数据库事务,它对于企业级的Java EE应用而言是十分重要的,又如在电商网站购物需要经过交易系统、财务系统,对于交易系统存在一个交易记录的对象,而财务系统则存在账户的信息对象。从这个角度而言,我们需要对交易记录和账户操作形成一个统一的事务管理。交易和账户的事务要么全部成功,要么全部失败。这样我们就可以得到如下的流程图:
交易记录和账户记录都是对象,这两个对象需要在同一个事务中控制,这就不是面向对象可以解决的问题,而需要用到面向切面的编程,这里的切面环境就是数据库事务。
AOP编程有着重要的意义,首先它可以拦截一些方法,然后把各个对象组织成一个整体,比如网站的交易记录需要记录日志,如果我们约定好了动态的流程,那么久可以在交易前后,交易正常完成后或者交易异常发生时,通过这些约定记录相关的日志了。
2、实验环境搭建
由于AOP的实例很多都是以数据库事务开展的,因此实例可能会涉及到操作数据库,因此在Spring环境的基础上,引入了数据库持久化框架MyBatis。关于MyBatis的使用,可以参考系列博客 《MyBatis使用篇系列博客传送门》。
在Spring_Demo项目中,新建名为“AOPBasicConcept”的模块(module),并在其lib文件中导入关于Spring、MyBatis以及测试工具JUnit的相关jar包,并将其全部Add as Library。具体如下:
这里需要特别说明一点:由于主要是演示Spring的AOP编程,因此在引入MyBatis框架时并没有将其与Spring框架进行整合。
在搭建初始实验环境时,首先通过MyBatis框架完成一个简单的电商购物数据库事务:对于一个购买记录,如果购买量小于等于该购买产品的库存量时,则完成购买,更新库存,并将该购买记录写入数据库。如果其中发生异常,则回滚数据库。
在MySQL数据库中创建名为“mybatis”的数据库,数据库的字符集为UTF-8,并在数据库中创建名为“product”的产品表和名为“purchase_record”的购买记录表。
创建产品表及添加数据的sql语句如下:
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for `product`
-- ----------------------------
DROP TABLE IF EXISTS `product`;
CREATE TABLE `product` (
`productId` int(255) NOT NULL auto_increment,
`productStock` int(255) default NULL,
PRIMARY KEY (`productId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of product
-- ----------------------------
INSERT INTO `product` VALUES ('1', '100');
INSERT INTO `product` VALUES ('2', '200');
INSERT INTO `product` VALUES ('3', '300');
创建购买表的sql语句如下:
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for `purchase_record`
-- ----------------------------
DROP TABLE IF EXISTS `purchase_record`;
CREATE TABLE `purchase_record` (
`recordId` int(255) NOT NULL auto_increment,
`customerId` int(255) default NULL,
`productId` int(255) default NULL,
`quantity` int(255) default NULL,
PRIMARY KEY (`recordId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
3、常规数据库事务处理
第一步,在config文件夹下创建名为“jdbc.properties”的属性文件,该属性文件中记录了连接数据库的四要素,具体配置如下:
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8
jdbc.username=root
jdbc.password=root
第二步,在config文件夹下创建名为“log4j.properties”的属性文件,该属性文件中记录日志信息的配置,具体配置如下:
# Global logging configuration
#在开发环境下日志级别要设成DEBUG,生产环境设为INFO或ERROR
log4j.rootLogger=DEBUG, stdout
# Console output...
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%5p [%t] - %m%n
第三步,在config文件夹下创建名为“SqlMapConfig.xml”的MyBatis全局配置文件,具体配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<!--注册DB连接四要素的属性文件-->
<properties resource="jdbc.properties" />
<!--全局参数设置-->
<settings>
<!-- 配置LOG信息 -->
<setting name="logImpl" value="LOG4J" />
</settings>
<!--配置别名-->
<typeAliases>
<package name="com.ccff.spring.concept.model"/>
</typeAliases>
<!-- 和spring整合后 environments配置将废除-->
<environments default="development">
<environment id="development">
<!-- 使用jdbc事务管理-->
<transactionManager type="JDBC" />
<!-- 数据库连接池-->
<dataSource type="POOLED">
<property name="driver" value="${jdbc.driver}"/>
<property name="url" value="${jdbc.url}"/>
<property name="username" value="${jdbc.username}"/>
<property name="password" value="${jdbc.password}"/>
</dataSource>
</environment>
</environments>
<!--配置SQL映射文件的位置-->
<mappers>
<mapper resource="sqlmap/ProductMapper.xml"/>
<mapper resource="sqlmap/PurchaseRecordMapper.xml"/>
</mappers>
</configuration>
第四步,在com.ccff.spring.concept.datasource包下创建名为“DataConnection”的加载工具类,该工具类用于通过SqlSessionFactory获取SqlSession对象,具体代码如下所示:
package com.ccff.spring.concept.datasource;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.IOException;
import java.io.InputStream;
public class DataConnection {
//mybatis配置文件
private String resource = "SqlMapConfig.xml";
private SqlSessionFactory sqlSessionFactory;
private SqlSession sqlSession;
public SqlSession getSqlSession() throws IOException {
if (sqlSessionFactory == null){
InputStream inputStream = Resources.getResourceAsStream(resource);
//创建会话工厂,传入mybatis配置文件的信息
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
}
sqlSession = sqlSessionFactory.openSession();
return sqlSession;
}
}
第五步,在com.ccff.spring.concept.model包下创建名为“Product”的产品POJO类,具体代码如下所示:
package com.ccff.spring.concept.model;
public class Product {
private int productId;
private int productStock;
public int getProductId() {
return productId;
}
public void setProductId(int productId) {
this.productId = productId;
}
public int getProductStock() {
return productStock;
}
public void setProductStock(int productStock) {
this.productStock = productStock;
}
@Override
public String toString() {
return "Product{" +
"productId=" + productId +
", productStock=" + productStock +
'}';
}
}
第六步,在com.ccff.spring.concept.model包下创建名为“PurchaseRecord”的购买记录POJO类,具体代码如下所示:
package com.ccff.spring.concept.model;
public class PurchaseRecord {
private int recordId;
private int customerId;
private int productId;
private int quantity;
public PurchaseRecord() {
}
public PurchaseRecord(int customerId, int productId, int quantity) {
this.customerId = customerId;
this.productId = productId;
this.quantity = quantity;
}
public int getRecordId() {
return recordId;
}
public void setRecordId(int recordId) {
this.recordId = recordId;
}
public int getCustomerId() {
return customerId;
}
public void setCustomerId(int customerId) {
this.customerId = customerId;
}
public int getProductId() {
return productId;
}
public void setProductId(int productId) {
this.productId = productId;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
@Override
public String toString() {
return "PurchaseRecord{" +
"recordId=" + recordId +
", customerId=" + customerId +
", productId=" + productId +
", quantity=" + quantity +
'}';
}
}
第七步,在com.ccff.spring.concept.dao包下创建名为“IProductDao”的产品操作接口,具体代码如下:
package com.ccff.spring.concept.dao;
import com.ccff.spring.concept.model.Product;
public interface IProductDao {
//根据产品id获取该产品
Product getProduct(int productId);
//更新该产品
void updateProduct(Product product);
}
第八步,在com.ccff.spring.concept.dao包下创建名为“IPurchaseRecordDao”的购买记录操作接口,具体代码如下:
package com.ccff.spring.concept.dao;
import com.ccff.spring.concept.model.PurchaseRecord;
public interface IPurchaseRecordDao {
//根据购买记录id获取购买该产品的数量
PurchaseRecord getPurchaseRecord(int recordId);
//添加购买该产品的数量
int addPurchaseRecord(PurchaseRecord record);
}
第九步,在config文件夹下的sqlmap文件夹下创建名为“ProductMapper.xml”的Mapper映射文件,具体配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ccff.spring.concept.dao.IProductDao">
<!--根据产品id获取该产品的库存-->
<select id="getProduct" resultType="Product">
select * from product where productId = #{productId}
</select>
<!--更新该产品-->
<update id="updateProduct" parameterType="Product">
update product set productStock=#{productStock} where productId=#{productId}
</update>
</mapper>
第十步,在config文件夹下的sqlmap文件夹下创建名为“PurchaseRecordMapper.xml”的Mapper映射文件,具体配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ccff.spring.concept.dao.IPurchaseRecordDao">
<!--根据购买记录id获取购买该产品的数量-->
<select id="getPurchaseRecord" resultType="PurchaseRecord">
select * from purchase_record where recordId = #{recordId}
</select>
<!--添加购买该产品的数量-->
<insert id="addPurchaseRecord" parameterType="PurchaseRecord">
insert into purchase_record (recordId,customerId,productId,quantity) values (#{recordId},#{customerId},#{productId},#{quantity})
<selectKey resultType="int" keyProperty="recordId" order="AFTER">
select @@identity
</selectKey>
</insert>
</mapper>
第十一步,在com.ccff.spring.concept.service包下创建名为“IDealService”的业务逻辑接口,具体代码如下所示:
package com.ccff.spring.concept.service;
import com.ccff.spring.concept.model.PurchaseRecord;
public interface IDealService {
void savePurchaseRecord(int productId,PurchaseRecord record);
}
第十一步,在com.ccff.spring.concept.service包下创建名为“DealServiceImpl”的业务逻辑接口实现类,并实现了IDealService接口,具体代码如下所示:
package com.ccff.spring.concept.service;
import com.ccff.spring.concept.dao.IProductDao;
import com.ccff.spring.concept.dao.IPurchaseRecordDao;
import com.ccff.spring.concept.datasource.DataConnection;
import com.ccff.spring.concept.model.Product;
import com.ccff.spring.concept.model.PurchaseRecord;
import org.apache.ibatis.session.SqlSession;
import java.io.IOException;
public class DealServiceImpl implements IDealService {
private SqlSession sqlSession;
@Override
public void savePurchaseRecord(int productId, PurchaseRecord record) {
DataConnection dataConnection = new DataConnection();
try {
sqlSession = dataConnection.getSqlSession();
IProductDao productDao = sqlSession.getMapper(IProductDao.class);
Product product = productDao.getProduct(productId);
//判断库存是否大于购买量
if (product.getProductStock() >= record.getQuantity()){
//减库存
product.setProductStock(product.getProductStock()-record.getQuantity());
//更新数据库中的库存
productDao.updateProduct(product);
//保存交易记录
IPurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(IPurchaseRecordDao.class);
int recordId = purchaseRecordDao.addPurchaseRecord(record);
sqlSession.commit();
System.out.println("库存大于购买量,购买记录【"+record.getRecordId()+"】购买成功!");
}
} catch (IOException e) {
//发生异常,回滚事务
e.printStackTrace();
sqlSession.rollback();
}finally {
//关闭资源
if (sqlSession != null)
sqlSession.close();
}
}
}
第十二步,在com.ccff.spring.concept.test包下创建名为“FinacialTest”的测试类,用于测试该业务逻辑,具体代码如下所示:
package com.ccff.spring.concept.test;
import com.ccff.spring.concept.model.PurchaseRecord;
import com.ccff.spring.concept.service.DealServiceImpl;
import com.ccff.spring.concept.service.IDealService;
import org.junit.Test;
public class FinacialTest {
@Test
public void test(){
PurchaseRecord record1 = new PurchaseRecord(1,1,50);
PurchaseRecord record2 = new PurchaseRecord(2,2,200);
PurchaseRecord record3 = new PurchaseRecord(3,3,350);
IDealService dealService = new DealServiceImpl();
dealService.savePurchaseRecord(1,record1);
dealService.savePurchaseRecord(2,record2);
dealService.savePurchaseRecord(3,record3);
}
}
最后,执行该测试类中的test测试方法,并查看输出在控制台上的日志信息如下:
此时查看mybatis数据中的product表,数据如下:
此时查看mybatis数据中的purchase_record表,数据如下:
至此,常规数据库事务处理完成。
4、使用Spring进行改进
上面一小节介绍的对常规数据库事务的处理并非一个很好的设计,按照Spring AOP设计思维,它希望写成如下样例代码所示的样子:
@AutoWired
private IProductDao productDao = null;
@AutoWired
private IPurchaseRecordDao purchaseRecordDao = null;
......
@Transactional
public void savePurchaseRecord(int productId, PurchaseRecord record) {
Product product = productDao.getProduct(productId);
//判断库存是否大于购买量
if (product.getProductStock() >= record.getQuantity()){
//减库存
product.setProductStock(product.getProductStock()-record.getQuantity());
//更新数据库中的库存
productDao.updateProduct(product);
//保存交易记录
purchaseRecordDao.addPurchaseRecord(record);
}
}
上面这段代码除了一个注解@Transactional,没有任何关于打开或者关闭数据库资源的代码,更没有任何提交或者回滚数据库事务的代码,但它却能够完成常规数据库事务操作的功能。注意,这段代码更简洁,也更容易维护,主要都集中在业务逻辑的处理上,而不是数据库事务和资源管控上,这就是Spring AOP的魅力。
这里需要说明一点的是,由于并没有完成MyBatis框架和Spring的整合,因此在本小节仅给出了Spring AOP处理数据库事务的样例代码,该篇博客的侧重并不是通过Spring操作数据库及进行事务处理,而是对Spring AOP有一个整体性的认识,具体的实现将在之后的系列博客中讲解。
至此我们不禁提出如下问题:Spring AOP是如何做到这一点的呢?答案将在下一小节给出。
5、使用AOP的原因
为了回答前面提出的问题,首先来了解正常执行SQL的逻辑步骤,一个正常的SQL是:
① 打开通过数据库连接池获得数据库连接资源,并做一定的设置工作。
② 执行对应的SQL语句,对数据进行操作。
③ 如果SQL执行过程中发生异常,则回滚事务。
④ 如果SQL执行过程中没有发生异常,最后提交事务。
⑤ 到最后的阶段,需要关闭一些连接资源。
于是,我们得到正常SQL的逻辑执行流程图如下所示:
在我的上一篇系列博客《Spring使用篇(五)—— Spring AOP之动态代理》中当一个对象通过代理工厂工具类ProxyBeanUtil中的getBean方法后,存在的约定可以用如下流程图描述:
至此我们可以发现,上面这两个流程图的执行过程十分相似,也就是说作为AOP,完全可以根据这个流程做一定的封装,然后通过动态代理技术,将代码织入到对应的流程环节中。换句话说,类似于这样的流程我们可以设计为:
① 打开获取数据库连接在before方法中完成。
② 执行SQL,按照逻辑会采用反射的机制调用。
③ 如果发生异常,则通过afterThrowing方法回滚事务;如果没有发生异常,则通过afterRunning方法提交事务。
④ 最后关闭数据库连接资源。
如果一个AOP框架不需要我们去实现流程中的方法,而是在流程中提供一些通用的方法,并可以通过一定的配置满足各种功能,比如AOP框架帮助你完成了获取数据库,你就不需要知道如何获取数据库连接功能了,此外再增加一些关于事务的重要约定:
① 当方法标注注解@Transactional时,则方法启用数据库事务功能。
② 在默认的情况***意是默认情况下,可以通过配置改变),如果原有方法出现异常,则回滚事务;如果没有发生异常,那么就提交事务,这样整个事务管理AOP就完成了整个流程,无须开发者编写任何代码去实现。
③ 最后关闭数据库资源,这点也比较通用,这里AOP框架也帮你完成。
有了上面的约定,我们就得到了AOP框架约定SQL流程图如下:
这是使用最广的执行流程,符合约定优于配置的开发原则。这些约定的方法加入默认实现后,你要做的只是执行SQL这步而已,而AOP框架默认实现了数据库资源的获取和关闭,事务的回滚和提交。在大部分的情况下,只需要使用默认的约定即可,或者进行一些特定的配置,来完成你所需要的功能,这样对于开发者而言就更为关注业务开发,而不是资源控制、事务异常处理,这些AOP框架都可以完成。
以上只讨论了事务同时成功或者同时失败的情况,比如信用卡还款存在一个批量任务,总的任务按照一定的顺序调度各张信用卡,进行还款处理,这个时候不能把所有的卡都视为同一个事务。如果这样,只要有一张卡出现异常,那么所有卡的事务都会失败,这样就会导致有些用户正常还款也出现了问题,这显然不符合真实场景的需要。这个时候必须要允许存在部分成功、部分失败的场景,这个时候各个对象在事务的管控就更为复杂了,不过通过AOP的手段也可以比较容易的控制它们,这就是Spring AOP的魅力所在。
AOP是通过动态代理模式,带来管控各个对象操作的切面环境,管理包括日志、数据库事务操作,让我们拥有可以在反射原有对象方法之前正常返回、异常返回事后插入自己的逻辑代码的能力,有时候甚至取代原始方法。在一些常用的流程中,比如数据库事务,AOP会提供默认的实现逻辑,也会提供一些简单的配置,我们就能比较方便地修改默认的实现,达到符合真实应用的效果,这样就可以大大降低开发的工作量,提高代码的可读性和可维护性,将开发集中在业务逻辑上。
6、面向切面编程的术语
6.1 切面(Aspect)
切面就是在一个怎么样的环境中工作。比如在之前的代码中,数据库的事务直接贯穿了整个代码层面,这就是一个切面,它可以定义后面需要介绍的各类通知、切点和引入等内容,然后Spring AOP会将其定义的内容织入到约定的流程中,在动态代理中可以把它理解成一个拦截器。
6.2 通知(Advice)
通知是切面开启后,切面的方法。它根据在代理对象真实方法调用前、后的顺序和逻辑区分。
① 前置通知(before):在动态代理反射原有对象方法或者执行环绕通知前执行的通知功能。
② 后置通知(after):在动态代理反射原有对象方法或者执行环绕通知后执行的通知功能。
③ 返回通知(afterRunning):在动态代理反射原有对象方法或者执行环绕通知后正常返回(无异常)执行的通知功能。
④ 异常通知(afterThrowing):在动态代理反射原有对象方法或者执行环绕通知后产生异常后执行的通知功能。
⑤ 环绕通知(around):在动态代理中,它可以取代当前被拦截对象的方法,提供回调原有被拦截对象的方法。
6.3 引入(Introduction)
引入允许我们在现有的类里添加自定义的类和方法。
6.4 切点(Pointcut)
这是一个告诉Spring AOP在什么时候启动拦截并织入对应的流程中,因为并不是所有的开发都需要启动AOP的,它往往通过正则表达式进行限定。
6.5 连接点(join point)
连接点对应的是具体需要拦截的东西,比如通过切点的正则表达式去判断哪些方法是连接点,从而织入对应的通知。
6.6 织入(Weaving)
织入是一个生成代理对象并将切面内容放入到流程中的过程。实际代理的方法分为静态代理和动态代理。静态代理是在编译class文件时生成的代码逻辑,但是在Spring中并不适用这样的方式,所说义我们就不再讨论了。一种是通过ClassLoader也就是类加载器的时候生成的代码逻辑,但是她在应用程序代码运行前就生成对应的逻辑。还有一种是运行期,动态生成代码的方式,这就是Spring AOP所采用的方式,Spring是以JDK和CGLIB动态代理来生成代理对象的。
综上,我们可以得出AOP的流程图如下所示:
7、Spring对AOP的支持
AOP并不是Spring框架特有的,Spring只是支持AOP编程的框架之一。每一个框架对AOP的支持各有特点,有些AOP能够对方法的参数进行拦截,有些AOP对方法进行拦截。而Spring AOP是一种基于方法拦截的AOP,换句话说Spring只能支持方法拦截的AOP。在Spring中有4中方式去实现AOP的拦截功能,分别为:
① 使用ProxyFactoryBean和对应的接口去实现AOP。
② 使用XML配置AOP。
③ 使用注解@AspectJ驱动切面。
④ 使用AspectJ注入切面。
AspectJ注入切面。在Spring AOP的拦截方式中,真正常用的是使用注解@AspectJ的方式实现的切面,有时候XML配置也有一定的辅助作用。