AOP的简介
Spring有两大核心,IOC(Inverse of Control 控制反转)和AOP(Aspect Oriented Programming 面向切面编程)。在日常编程中,很多同学在使用@Autowired或@Resource这类注解的时候,不经意间已经在使用IOC了。不过今天要分享的是另外一个核心的使用——AOP。
简单先认识一下AOP
在软件业,AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期间动态代理实现程序功能的统一维护的一种技术。AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。—— 百度百科.
名称 | 概念 |
---|---|
Aspect | 切面。切入点和通知的结合。Aspect 声明类似于 Java 中的类声明,在 Aspect 中会包含着一些 Pointcut 以及相应的 Advice。 |
Joint point | 连接点。表示在程序中明确定义的点,在 Spring 中,可以被动态代理拦截目标类的方法。 |
Pointcut | 切入点。定义了相应的 Advice 将要发生的地方。 |
Advice | 通知。定义了在 pointcut 里面定义的程序点具体要做的操作。 |
快速入门
目前流行的 AOP 框架有两个,分别为 Spring AOP 和 AspectJ。以下基于注解Annotation方式,使用AspectJ在Spring boot下实现。本文使用 Aliyun Java Initializr 来创建Spring boot项目。
准备工作
POM主要依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions><!-- 去掉springboot默认logging配置 -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency> <!-- 引入log4j2依赖,非必要,可自行选择logging组件 -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
新建测试入口
@RestController
@RequestMapping("/demoaop/api/v1/test")
public class TestController {
private static final Logger log = LoggerFactory.getLogger(TestController.class);
@RequestMapping(value = "/aop", method = RequestMethod.GET)
public ResponseEntity testAop() {
log.info("上班");
return ResponseEntity.ok().build();
}
}
控制台日志
2020-07-29T09:30:00.577 | [http-nio-8080-exec-2] | [INFO] | TestController | 上班
尝试使用切面
定义切点
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Breakfast {
}
定义切面
@Aspect
@Component
@Order(1)//多切面时,设置先后顺序
public class BreakfastAspect {
private static final Logger log = LoggerFactory.getLogger(BreakfastAspect.class);
@Around(value = "@annotation(com.jalchemy.demoaop.aspect.annotation.Breakfast)")
public void around(ProceedingJoinPoint pjp) throws Throwable {
log.info("吃早餐");//此日志打印的位置相当于前置增强
pjp.proceed();
}
}
修改测试入口
@Breakfast //增加Breakfast注解
@RequestMapping(value = "/aop", method = RequestMethod.GET)
public ResponseEntity testAop() {
log.info("上班");
return ResponseEntity.ok().build();
}
控制台日志
2020-07-29T16:45:15.415 | [http-nio-8080-exec-3] | [INFO] | BreakfastAspect | 吃早餐
2020-07-29T16:45:15.415 | [http-nio-8080-exec-3] | [INFO] | TestController | 上班
可以看出在代码无侵入的情况下,完成了一个“前置”增强。因为@Around环绕增强的强大,可以同时满足前置增强@Before和后置增强@After,所以作者比较常用@Around。读者可以根据需求研究并使用@Before,@AfterReturning,@Around,@AfterThrowing,@After。
分享三个使用场景
性能日志
业务/技术需求——“我想知道这个方法调用的耗时”。要是收到这个需求,读者们的实现是怎样的?下面分享AOP的实现方案,AOP + StopWatch
定义测试入口
@RequestMapping(value = "/performance", method = RequestMethod.GET)
public ResponseEntity testPerformance() throws InterruptedException {
//log.info("上班打卡");
Thread.sleep(2 * 1000);//模拟方法耗时
//log.info("下班打卡");
return ResponseEntity.ok().build();
}
思考:如果只监控少量的方法的话,在方法开始和结束加上日志,是个不错的选择。但是考虑到代码入侵和方法较多的情况,我们可以设计一个切面。
定义切点
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Performance {
}
定义切面
@Aspect
@Component
public class PerformanceAspect {
private static final Logger log = LoggerFactory.getLogger(PerformanceAspect.class);
@Around(value = "@annotation(com.jalchemy.demoaop.aspect.annotation.Performance)")
public void around(ProceedingJoinPoint pjp) throws Throwable {
StopWatch sw = new StopWatch();
sw.start();
pjp.proceed();
sw.stop();
MethodSignature signature = (MethodSignature) pjp.getSignature();
log.info("Cost Millis:{}, Method:{}", sw.getTotalTimeMillis(), signature.getMethod().toGenericString());
}
}
修改测试入口
@Performance
@RequestMapping(value = "/performance", method = RequestMethod.GET)
public ResponseEntity testPerformance() throws InterruptedException {
//log.info("上班打卡");
Thread.sleep(2 * 1000);//模拟方法耗时
//log.info("下班打卡");
return ResponseEntity.ok().build();
}
控制台日志
2020-07-30T14:56:25.150 | [http-nio-8080-exec-5] | [INFO] | PerformanceAspect | Cost Millis:2000, Method:public org.springframework.http.ResponseEntity com.jalchemy.demoaop.controller.TestController.testPerformance() throws java.lang.InterruptedException
请求的参数校验
业务/技术需求——产品的需求提出到开发编码期间,在对流程的理解上,大家常常有歧义。即便在相对简单的CRUD上,产品对每个字段的复杂业务限制往往也让开发们焦头烂额。
假设开发已经写好代码,准备提测的时候,产品经理突然加了需求——“这个表单的年龄要大于等于18,名字不能为空,身份证不能跟已入库的重复”。前两个字段还不算难,可以使用 Hibernate Validator。但是身份证不能重复,普通的validator实现不了,只能另外想办法。
定义请求对象
@Data
public class PersonRequestVO {
private int age;
private String name;
private String uId;
}
定义测试入口
@RequestMapping(value = "/params", method = RequestMethod.POST)
public ResponseEntity testParamsValid(@RequestBody PersonRequestVO vo) throws PersonServiceException {
personService.add(vo);
return ResponseEntity.ok().build();
}
@Override
public void add(PersonRequestVO vo) throws PersonServiceException {
// save to db
}
定义切点
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PersonRequestVOValidator {
}
定义切面
@Aspect
@Component
public class PersonRequestVOValidatorAspect {
@Resource
private PersonService personService;
@Around(value = "@annotation(com.jalchemy.demoaop.aspect.annotation.PersonRequestVOValidator)")
public void around(ProceedingJoinPoint pjp) throws Throwable {
PersonRequestVO vo = (PersonRequestVO) pjp.getArgs()[0];
boolean isExist = personService.isExist(vo);
if (!isExist) {
pjp.proceed();
} else {
throw new PersonServiceException("params.invalid");
}
}
}
修改测试入口
@PersonRequestVOValidator
@Override
public void add(PersonRequestVO vo) throws PersonServiceException {
// save to db
}
控制台日志
2020-07-30T16:20:40.229 | [http-nio-8080-exec-5] | [ERROR] | [dispatcherServlet] | Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is com.jalchemy.demoaop.exception.PersonServiceException: params.invalid] with root cause
com.jalchemy.demoaop.exception.PersonServiceException: params.invalid
……省略异常信息
对于Controller层抛出的异常,读者可以使用全局异常处理,本文不涉及。
幂等设计
业务/技术需求——对于某些方法/接口,我们希望是幂等的。幂等的设计多种多样,可以是前置判断、可以是结果幂(例如数据库的锁使用)。以下结合Redis,实现前置判断的幂等设计。
定义切点
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IdempotentValidator {
int[] paramsIndex() default {};
long expireInMilliSeconds() default 86400000;//默认一天过期
}
定义切面
@Aspect
@Component
public class IdempotentValidatorAspect {
private static final Logger log = LoggerFactory.getLogger(IdempotentValidatorAspect.class);
private static final String PREFIX_IDEMPOTENT = "Idempotent::";
@Resource
private RedisClient redisClient;
@Around(value = "@annotation(com.jalchemy.demoaop.aspect.annotation.IdempotentValidator)")
public void around(ProceedingJoinPoint pjp) throws Throwable {
boolean isDuplicate = false;
MethodSignature signature = (MethodSignature) pjp.getSignature();
IdempotentValidator annotation = signature.getMethod().getAnnotation(IdempotentValidator.class);
int parameterCount = signature.getMethod().getParameterCount();
String signatureKey = getSignature(annotation, parameterCount, pjp.getArgs());
if (signatureKey != null) {
String key = PREFIX_IDEMPOTENT + signatureKey;
Long r = redisClient.increment(key);
if (r != null && r.longValue() == 1L) {
log.info("first time");
redisClient.expire(key, annotation.expireInMilliSeconds(), TimeUnit.MILLISECONDS);
} else {
isDuplicate = true;
}
}
if (!isDuplicate) {
pjp.proceed();
} else {
log.warn("Duplicate request");
}
}
private String getSignature(IdempotentValidator annotation, int parameterCount, Object[] args) {
String signature = null;
int[] paramsIndexes = annotation.paramsIndex();
if (paramsIndexes != null && paramsIndexes.length > 0) {
StringBuilder sb = new StringBuilder();
for (int i : paramsIndexes) {
if (i + 1 > parameterCount) {
continue;
}
Object o = args[i];
sb.append(o.toString());
}
if (!StringUtils.isEmpty(sb.toString())) {
signature = getMD5(sb.toString());
}
}
return signature;
}
private String getMD5(String plainText) {
return DigestUtils.md5DigestAsHex(plainText.getBytes());
}
}
修改测试入口
@IdempotentValidator(expireInMilliSeconds = 2000, paramsIndex = {0})
@RequestMapping(value = "/idempotent", method = RequestMethod.POST)
public ResponseEntity testIdempotent(@RequestBody PersonRequestVO vo) throws PersonServiceException {
return ResponseEntity.ok().build();
}
控制台日志
2020-07-30T17:01:55.463 | [http-nio-8080-exec-3] | [INFO] | IdempotentValidatorAspect | first time
2020-07-30T17:01:55.997 | [http-nio-8080-exec-5] | [WARN] | IdempotentValidatorAspect | Duplicate request
2020-07-30T17:01:56.517 | [http-nio-8080-exec-6] | [WARN] | IdempotentValidatorAspect | Duplicate request
2020-07-30T17:01:57.034 | [http-nio-8080-exec-4] | [WARN] | IdempotentValidatorAspect | Duplicate request
2020-07-30T17:01:57.556 | [http-nio-8080-exec-7] | [INFO] | IdempotentValidatorAspect | first time
2020-07-30T17:01:58.081 | [http-nio-8080-exec-8] | [WARN] | IdempotentValidatorAspect | Duplicate request
2020-07-30T17:01:58.603 | [http-nio-8080-exec-9] | [WARN] | IdempotentValidatorAspect | Duplicate request
2020-07-30T17:01:59.129 | [http-nio-8080-exec-10] | [WARN] | IdempotentValidatorAspect | Duplicate request
思路:切点IdempotentValidator定义了两个属性paramsIndex, expireInMilliSeconds。分别是参数的索引数组和过期时间。切面IdempotentValidatorAspect会把指定参数通过to.String()之后拼接起来,用于生成一个MD5签名,然后通过Redis递增判断是否第一次进入,从而达到环绕判断的效果。参数拼接后加上MD5是我的思路,读者也可以自行实现判断参数是否重复的逻辑。
小结
本文由浅入深,分享AOP的几个实战场景。善用AOP可以减少代码侵入,提供代码复用率;通过切面思想,达到增强业务逻辑的效果。
GitHub: https://github.com/vinccito/demoaop
Wechat: