苍穹外卖技术总结——AOP:面向切面编程

 —— 你是否有过这样的苦恼?在每个业务方法里重复写日志、事务、权限校验代码,代码臃肿难维护。今天我们就来揭秘如何用AOP这把"代码手术刀"解决这些问题。

目录

1、初识AOP:编程界的"外科医生"

1.1 什么是AOP?

1.2 为什么需要AOP?

2、AOP核心概念解析

2.1连接点(Join Point)

2.2通知(Advice)

 2.3切入点(Pointcut)

2.4. 切面(Aspect)

2.5.目标对象(Target)与代理机制

3.Spring AOP进阶指南:通知类型、执行顺序与表达式全解析

3.1通知类型

3.2通知顺序

3.2.1默认执行顺序规则

3.2.2手动控制执行顺序

3.3切入点表达式

3.3.1切入点表达式的作用

3.3.2execution表达式深度解析

3.3.3@annotation


1、初识AOP:编程界的"外科医生"

1.1 什么是AOP?

AOP(面向切面编程)就像代码世界中的"微创手术刀",它通过动态代理技术,在不修改源代码的情况下,为程序添加横切关注点(如日志、事务)。它的核心思想是:将通用功能从业务逻辑中剥离,实现关注点分离

举个例子:

比如,我们这里有一个项目,项目中开发了很多的业务功能。然而有一些业务功能执行效率比较低,执行耗时较长,我们需要针对于这些业务方法进行优化。 那首先第一步就需要定位出执行耗时比较长的业务方法,再针对于业务方法再来进行优化。
当我们需要给所有Service方法添加执行耗时统计时,传统方式要在每个方法里添加long start = System.currentTimeMillis();。而使用AOP只需要写一次切面,所有匹配的方法自动拥有该功能!

        例如下面代码:

public List<Dept> list() {
    List<Dept> deptList = deptMapper.list();
    return deptList;
}

public void delete(Integer id) {
    deptMapper.delete(id);
}

public Dept getById(Integer id) {
    Dept dept = deptMapper.getById(id);
    return dept;
}

此时我们就需要统计当前这个类当中每一个业务方法的执行耗时。那么统计每一个业务方法的执行耗时该怎么实现?

可能多数人首先想到的就是在每一个业务方法运行之前,记录这个方法运行的开始时间。在这个方法运行完毕之后,再来记录这个方法运行的结束时间。拿结束时间减去开始时间,不就是这个方法的执行耗时吗。

        比如像这样:

public List<Dept> list() {
    long beginTime = System.currentTimeMillis();
    List<Dept> deptList = deptMapper.list();
    long endTime = System.currentTimeMillis();
    System.out.println("执行耗时: (" + (endTime - beginTime) + " ms)");
    return deptList;
}

public void delete(Integer id) {
    long beginTime = System.currentTimeMillis();
    deptMapper.delete(id);
    long endTime = System.currentTimeMillis();
    System.out.println("执行耗时: (" + (endTime - beginTime) + " ms)");
}

public Dept getById(Integer id) {
    long beginTime = System.currentTimeMillis();
    Dept dept = deptMapper.getById(id);
    long endTime = System.currentTimeMillis();
    System.out.println("执行耗时: (" + (endTime - beginTime) + " ms)");
    return dept;
}

但是这样代码就显得十分繁琐重复,而这个功能如果通过AOP来实现,我们只需要单独定义下面这一小段代码即可,不需要修改原始的任何业务方法即可记录每一个业务方法的执行耗时。

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class RecordTimeAspect {
    @Around("execution(* com.itheima.service.*.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        long beginTime = System.currentTimeMillis();
        Object result = pjp.proceed();
        long endTime = System.currentTimeMillis();
        log.info("执行耗时:{}ms", endTime - beginTime);
        return result;
    }
}

1.2 为什么需要AOP?

场景传统实现问题AOP解决方案
日志记录每个方法首尾添加日志代码,重复率高统一拦截方法,自动记录日志
事务管理事务代码与业务代码耦合声明式事务,注解驱动
权限校验校验逻辑分散在各个控制器方法统一权限校验切面
性能监控监控代码侵入业务逻辑非侵入式埋点,随时启停

(代码级对比)

传统实现痛点:重复性代码太多,污染了业务代码,降低了可读性

// 业务方法被非功能代码"污染"
public class PaymentService {
    public void processPayment(PaymentRequest request) {
        // 1.权限校验
        if (!checkPermission()) {
            throw new SecurityException("无操作权限");
        }
        
        // 2.日志记录
        log.info("开始处理支付:{}", request);
        
        // 3.事务管理
        Transaction tx = startTransaction();
        try {
            // 核心业务逻辑
            deductMoney(request);
            updateOrderStatus(request);
            
            // 4.性能监控
            monitor.recordSuccess();
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            monitor.recordFailure();
            throw e;
        } finally {
            // 5.资源清理
            releaseResources();
        }
    }
}

AOP改造后:纯净的业务逻辑,可读性好

// 纯净的业务逻辑
public class PaymentService {
    @Secured("ROLE_PAYMENT")  // 安全切面
    @Transactional           // 事务切面
    public void processPayment(PaymentRequest request) {
        deductMoney(request);
        updateOrderStatus(request);
    }
}

// 所有非功能需求通过切面实现
@Aspect
@Component
public class PaymentAspect {
    @Before("execution(* processPayment(..)) && args(request)")
    public void logRequest(PaymentRequest request) {
        log.info("开始处理支付:{}", request);
    }
    
    @AfterReturning("execution(* processPayment(..))")
    public void recordSuccess() {
        monitor.recordSuccess();
    }
}

所以,AOP的优势主要体现在以下四个方面:

  • 减少重复代码:不需要在业务方法中定义大量的重复性的代码,只需要将重复性的代码抽取到AOP程序中即可。

  • 代码无侵入:在基于AOP实现这些业务功能时,对原有的业务代码是没有任何侵入的,不需要修改任何的业务代码。

  • 提高开发效率

  • 维护方便

2、AOP核心概念解析

 通过SpringAOP的快速入门,感受了一下AOP面向切面编程的开发方式。下面我们再来学习AOP当中涉及到的一些核心概念。

2.1连接点(Join Point)

定义:程序执行过程中能够被AOP拦截的潜在位置(Spring AOP中特指方法执行)

可以被AOP控制的方法(暗含方法执行时的相关信息)

代码示例

public class ProductService {
    // 所有方法都是潜在的连接点
    public Product getProductById(Long id) { /*...*/ }
    public void updateStock(Long productId, int quantity) { /*...*/ }
}

关键信息获取:可以通过反射来获取方法信息

@Before("execution(* com.ecommerce.service.*.*(..))")
public void logJoinPoint(JoinPoint jp) {
    String className = jp.getTarget().getClass().getSimpleName(); // 类名
    String methodName = jp.getSignature().getName();             // 方法名
    Object[] args = jp.getArgs();                                // 参数
    System.out.printf("【拦截】%s.%s() 参数:%s\n", className, methodName, Arrays.toString(args));
}

2.2通知(Advice

  • Advice,指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)

    • 在入门程序中是需要统计各个业务方法的执行耗时的,此时我们就需要在这些业务方法运行开始之前,先记录这个方法运行的开始时间,在每一个业务方法运行结束的时候,再来记录这个方法运行的结束时间。

    • 是在AOP面向切面编程当中,我们只需要将这部分重复的代码逻辑抽取出来单独定义。抽取出来的这一部分重复的逻辑,也就是共性的功能。

性能监控案例

@Aspect
@Component
public class PerformanceMonitor {
    // 定义Service层切入点
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void serviceLayer() {}

    // 环绕通知统计耗时
    @Around("serviceLayer()")
    public Object monitorPerformance(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = pjp.proceed();  // 执行原方法
        long duration = System.currentTimeMillis() - start;
        
        System.out.printf("[%s] 执行耗时:%dms\n",
                pjp.getSignature().toShortString(), duration);
        return result;
    }
}

 2.3切入点(Pointcut)

  • 匹配连接点的条件,通知仅会在切入点方法执行时被应用。

    • 在通知当中,我们所定义的共性功能到底要应用在哪些方法上?此时就涉及到了切入点pointcut概念。切入点指的是匹配连接点的条件。通知仅会在切入点方法运行时才会被应用。

    • 在aop的开发当中,我们通常会通过一个切入点表达式来描述切入点.

表达式语法示例

// 匹配UserService所有方法
execution(* com.example.service.UserService.*(..))

// 匹配service包下所有public方法  
execution(public * com.example.service.*.*(..))

// 匹配以find开头的方法  
execution(* com.example.service.*.find*(..))

// 匹配带特定注解的方法  
@annotation(com.example.annotation.AuditLog)

2.4. 切面(Aspect)

  • 描述通知与切入点的对应关系(通知+切入点)

当通知和切入点结合在一起,就形成了一个切面。通过切面就能够描述当前aop程序需要针对于哪个原始方法,在什么时候执行什么样的操作。

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class RecordTimeAspect {
    @Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        long beginTime = System.currentTimeMillis();
        Object result = pjp.proceed();
        long endTime = System.currentTimeMillis();
        log.info(" 执行耗时: {} ms", endTime - beginTime);
        return result;
    }
}

而切面所在的类,称之为切面类(被@Aspect注解标识的类)。

2.5.目标对象(Target)与代理机制

  • 通知所应用的对象

目标对象指的就是通知所应用的对象,我们就称之为目标对象。

AOP的核心概念我们介绍完毕之后,接下来我们再来分析一下我们所定义的通知是如何与目标对象结合在一起,对目标对象当中的方法进行功能增强的。

Spring的AOP底层是基于动态代理技术来实现的,也就是说在程序运行的时候,会自动的基于动态代理技术为目标对象生成一个对应的代理对象。在代理对象当中就会对目标对象当中的原始方法进行功能的增强。

3.Spring AOP进阶指南:通知类型、执行顺序与表达式全解析

在掌握AOP基础后,深入理解通知类型执行顺序切入点表达式的细节,能帮助我们实现更精准的切面控制。本文将结合电商、金融等领域的实战案例,解析这三个核心进阶知识点。

3.1通知类型

Spring AOP提供了5种通知类型,覆盖方法执行的全生命周期,如下表:

Spring AOP 通知类型

@Around

环绕通知,此注解标注的通知方法在目标方法前、后都被执行

@Before

前置通知,此注解标注的通知方法在目标方法前被执行

@After

后置通知,此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行

@AfterReturning

返回后通知,此注解标注的通知方法在目标方法后被执行,有异常不会执行

@AfterThrowing

异常后通知,此注解标注的通知方法发生异常后执行

下面我们通过代码演示,来加深对于不同通知类型的理解:

切面类:

@Slf4j
@Component
@Aspect
public class MyAspect1 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(JoinPoint joinPoint){
        log.info("before ...");

    }

    //环绕通知
    @Around("execution(* com.itheima.service.*.*(..))")
    public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        log.info("around before ...");

        //调用目标对象的原始方法执行
        Object result = proceedingJoinPoint.proceed();
        
        //原始方法如果执行时有异常,环绕通知中的后置代码不会在执行了
        
        log.info("around after ...");
        return result;
    }

    //后置通知
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(JoinPoint joinPoint){
        log.info("after ...");
    }

    //返回后通知(程序在正常执行的情况下,会执行的后置通知)
    @AfterReturning("execution(* com.itheima.service.*.*(..))")
    public void afterReturning(JoinPoint joinPoint){
        log.info("afterReturning ...");
    }

    //异常通知(程序在出现异常的情况下,执行的后置通知)
    @AfterThrowing("execution(* com.itheima.service.*.*(..))")
    public void afterThrowing(JoinPoint joinPoint){
        log.info("afterThrowing ...");
    }
}

 被aop控制的类:

@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;

    @Override
    public List<Dept> list() {

        List<Dept> deptList = deptMapper.list();
       
        return deptList;
    }
    
    //省略其他代码...
}

启动SpringBoot服务,进行测试:

1). 没有异常情况下:

程序没有发生异常的情况下,@AfterThrowing标识的通知方法不会执行。

2). 出现异常情况下:

修改DeptServiceImpl业务实现类中的代码: 添加异常

@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;

    @Override
    public List<Dept> list() {

        List<Dept> deptList = deptMapper.list();
        //模拟异常
        int num = 10/0;
        return deptList;
    }
    
    //省略其他代码...
}

启动SpringBoot服务,进行测试:

程序发生异常的情况下:

  • @AfterReturning标识的通知方法不会执行,@AfterThrowing标识的通知方法执行了

  • @Around环绕通知中原始方法调用时有异常,通知中的环绕后的代码逻辑也不会在执行了 (因为原始方法调用已经出异常了)

3.2通知顺序

讲解完了Spring中AOP所支持的5种通知类型之后,接下来我们再来研究通知的执行顺序。

当在项目开发当中,我们定义了多个切面类,而多个切面类中多个切入点都匹配到了同一个目标方法。此时当目标方法在运行的时候,这多个切面类当中的这些通知方法都会运行。

此时我们就有一个疑问,这多个通知方法到底哪个先运行,哪个后运行?

3.2.1默认执行顺序规则

类名排序原则

当多个切面未指定@Order时,Spring按切面类名的字母顺序执行:

@Aspect
@Component
public class LogAspect { /* 类名L在S前 */ }

@Aspect
@Component
public class SecurityAspect { /* 类名S在L后 */ }

执行顺序

LogAspect前置 → SecurityAspect前置 → 目标方法 → SecurityAspect后置 → LogAspect后置

② 同心圆模型

想象切面是包裹目标方法的同心圆,@Order值越小越在外层:

  • 前置通知:外层 → 中层 → 内层
  • 后置通知:内层 → 中层 → 外层

3.2.2手动控制执行顺序

@Order注解方案

@Aspect
@Component
@Order(2) // 数字越小优先级越高
public class LogAspect {
    @Before("execution(* com.example.service.*.*(..))")
    public void logStart() { System.out.println("【日志】开始"); }
}

@Aspect
@Component
@Order(1) // 更高优先级
public class SecurityAspect {
    @Before("execution(* com.example.service.*.*(..))")
    public void authCheck() { System.out.println("【安全】校验"); }
}

执行顺序

【安全】校验 → 【日志】开始 → 目标方法 → 【日志】结束 → 【安全】清理

②实现Ordered接口

适合需要动态控制顺序的场景:

@Aspect
@Component
public class TransactionAspect implements Ordered {
    @Override
    public int getOrder() {
        return 3; // 最低优先级
    }
    
    @Around("execution(* update*(..))")
    public Object manageTx(ProceedingJoinPoint pjp) throws Throwable {
        // 事务管理逻辑
    }
}

通知的执行顺序大家主要知道两点即可:

  1. 不同的切面类当中,默认情况下通知的执行顺序是与切面类的类名字母排序是有关系的

  2. 可以在切面类上面加上@Order注解,来控制不同的切面类通知的执行顺序

3.3切入点表达式

3.3.1切入点表达式的作用

切入点表达式是AOP的核心工具,用于精确描述需要被增强的方法。通过定义匹配规则,开发者可以灵活控制:

  • 日志记录的范围
  • 事务管理的边界
  • 权限校验的粒度
  • 性能监控的覆盖范围

3.3.2execution表达式深度解析

①标准语法结构

execution(访问修饰符? 返回值类型 包名.类名.方法名(参数类型) 异常?)

  • 带?表示可省略项(访问修饰符、异常声明等)
  • 参数类型需完整路径(如java.lang.String

② 通配符使用技巧

符号作用示例
*匹配单个元素execution(* com.*.service.*.find*(String))
..匹配多级包路径或任意数量参数execution(* com..service.*.*(..))
+匹配子类类型execution(* com.service.BaseService+.*(..))

典型场景示例

// 匹配Service层以update开头的方法
execution(* com.example.service.*.update*(..))

// 匹配com包下任意层级的UserService方法
execution(* com..UserService.*(..))

切入点表达式的语法规则:

  1. 方法的访问修饰符可以省略

  2. 返回值可以使用*号代替(任意返回值类型)

  3. 包名可以使用*号代替,代表任意包(一层包使用一个*

  4. 使用..配置包名,标识此包以及此包下的所有子包

  5. 类名可以使用*号代替,标识任意类

  6. 方法名可以使用*号代替,表示任意方法

  7. 可以使用 * 配置参数,一个任意类型的参数

  8. 可以使用.. 配置参数,任意个任意类型的参数

注意事项:

  • 根据业务需要,可以使用 且(&&)、或(||)、非(!) 来组合比较复杂的切入点表达式。

// 匹配查询或删除操作
execution(* com.example.service.UserService.find*(..)) || 
execution(* com.example.service.UserService.delete*(..))

// 排除测试类方法
execution(* com.example.service.*.*(..)) && !within(*Test)

切入点表达式的书写建议:

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:查询类方法都是 find 开头,更新类方法都是update开头

  • 示例:

//业务类
@Service
public class DeptServiceImpl implements DeptService {
    
    public List<Dept> findAllDept() {
       //省略代码...
    }
    
    public Dept findDeptById(Integer id) {
       //省略代码...
    }
    
    public void updateDeptById(Integer id) {
       //省略代码...
    }
    
    public void updateDeptByMoreCondition(Dept dept) {
       //省略代码...
    }
    //其他代码...
}
  • //匹配DeptServiceImpl类中以find开头的方法

execution(* com.itheima.service.impl.DeptServiceImpl.find*(..))

  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性

execution(* com.itheima.service.DeptService.*(..))

  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名匹配尽量不使用 ..,使用 * 匹配单个包

 execution(* com.itheima.*.*.DeptServiceImpl.find*(..))

切入点表达式书写建议:

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:findXxx,updateXxx。

  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性。

  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名尽量不使用..,使用 * 匹配单个包。

3.3.3@annotation

为什么需要@annotation?

在实际开发中,我们常常遇到这样的场景:

  • 需要为功能不同、命名无规律的方法添加统一逻辑(如记录操作日志、权限校验)
  • 使用execution表达式需要编写复杂的匹配规则,甚至组合多个表达式
  • 新增方法时需要反复修改切入点表达式,维护成本高

@annotation切入点表达式通过“自定义注解标记”的方式,完美解决了上述痛点。下面通过一个操作日志记录的场景,详解如何优雅实现多方法匹配。

实现步骤:

①定义自定义注解

/**
 * 操作日志注解
 * 1. 作用在方法上
 * 2. 运行时保留
 */
@Target(ElementType.METHOD)    
@Retention(RetentionPolicy.RUNTIME) 
public @interface LogOperation {
    String module() default "";     // 业务模块
    String type() default "操作";    // 操作类型
}

②标记目标方法

在需要增强的业务方法上添加注解:

@Service
public class UserServiceImpl implements UserService {
    
    @Override
    @LogOperation(module = "用户管理", type = "查询")
    public List<User> list() {
        return userMapper.selectAll();
    }

    @Override
    @LogOperation(module = "用户管理", type = "删除")
    public void delete(Long id) {
        userMapper.deleteById(id);
    }

    // 无注解方法不受影响
    @Override
    public User getById(Long id) { 
        return userMapper.selectById(id);
    }
}

③编写切面类

@Slf4j
@Aspect
@Component
public class OpLogAspect {

    // 当方法成功返回时触发
    @AfterReturning("@annotation(opLog)") // ← 关键点1:通过注解匹配
    public void logSuccess(JoinPoint jp, OpLog opLog) {
        saveLog(jp, opLog, true, null);
    }

    // 当方法抛出异常时触发
    @AfterThrowing(value = "@annotation(opLog)", throwing = "ex")
    public void logError(JoinPoint jp, OpLog opLog, Exception ex) {
        saveLog(jp, opLog, false, ex.getMessage());
    }

    /** 封装日志保存逻辑 */
    private void saveLog(JoinPoint jp, OpLog opLog, 
                        boolean success, String errorMsg) {
        // 关键点2:获取方法信息
        MethodSignature signature = (MethodSignature) jp.getSignature();
        String methodName = signature.getName(); // 获取方法名
        Object[] args = jp.getArgs(); // 获取方法参数

        // 构建日志对象
        OperationLog log = new OperationLog();
        log.setModule(opLog.module());  // 从注解获取模块名
        log.setType(opLog.type());      // 从注解获取操作类型
        log.setMethod(methodName);      // 记录方法名
        log.setParams(Arrays.toString(args)); // 记录参数
        log.setSuccess(success);        // 是否成功
        log.setErrorMsg(errorMsg);      // 错误信息
        log.setOperateTime(LocalDateTime.now());

        // 保存到数据库(实际项目需注入Service)
        logService.insert(log); 
    }
}

调用list()成功时

[OperationLog] 
module: 用户管理 | type: 查询 | method: list 
params: [] | success: true | time: 2023-08-20T10:15:30

调用delete(100L)失败时

[OperationLog] 
module: 用户管理 | type: 删除 | method: delete 
params: [100] | success: false | error: 用户不存在 
time: 2023-08-20T10:20:45

到此我们两种常见的切入点表达式我已经介绍完了。

  • execution切入点表达式

    • 根据我们所指定的方法的描述信息来匹配切入点方法,这种方式也是最为常用的一种方式

    • 如果我们要匹配的切入点方法的方法名不规则,或者有一些比较特殊的需求,通过execution切入点表达式描述比较繁琐

  • annotation 切入点表达式

    • 基于注解的方式来匹配切入点方法。这种方式虽然多一步操作,我们需要自定义一个注解,但是相对来比较灵活。我们需要匹配哪个方法,就在方法上加上对应的注解就可以了

~~感兴趣的宝子可以点个关注哈,后续会有更多内容更新的!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

让我上个超影吧

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值