【Spring6笔记】 - 13 - 面向切面编程(AOP)

【Spring6笔记】 - 13 - 面向切面编程(AOP)

1. AOP 介绍

1.1 什么是 AOP?

AOP (Aspect Oriented Programming),即面向切面编程。它是 OOP(面向对象编程)的有效补充和完善。

  • OOP: 核心是对象。将需求功能划分为不同的对象,其逻辑是自上而下的垂直结构。
  • AOP: 核心是切面。它通过"横向抽取"机制,将散落在各个业务模块中的公共代码(如日志记录、事务控制、权限校验)抽取出来,形成独立模块,在程序运行期间再动态地"织入"到业务逻辑中。

1.2 AOP 的底层原理

AOP 的底层是基于 动态代理 实现的:

  1. 如果目标类实现了接口,默认使用 JDK 动态代理
  2. 如果目标类没有实现接口,使用 CGLIB 动态代理

2. AOP 的七大术语

理解 AOP 的核心就在于理解这七个名词,它们定义了"在哪里干"、"干什么"、"怎么干"。

2.1 连接点 (JoinPoint)

  • 描述: 连接点描述的是位置
  • 定义: 在程序的整个执行流程中,可以织入切面的特定位置。例如:方法调用前、方法调用后、异常抛出后等。
  • 形象理解: 森林里的每一棵树都可以被标记,这些可标记的点就是连接点。

2.2 切点 (PointCut)

  • 描述: 切点本质上是筛选后的连接点
  • 定义: 真正织入切面的那个位置(方法)。通过切点表达式来定位具体的连接点。
  • 形象理解: 虽然每棵树都能标记,但我只在"挂了红绳"的树上操作。

2.3 通知 / 增强 (Advice)

  • 描述: 通知描述的是具体要做什么代码
  • 定义: 注入到切点上的那段代码。
  • 分类:
    • 前置通知 (@Before): 目标方法执行前执行。
    • 后置通知 (@AfterReturning): 目标方法成功执行并返回后执行。
    • 环绕通知 (@Around): 目标方法执行前后都执行,功能最强。
    • 异常通知 (@AfterThrowing): 目标方法抛出异常后执行。
    • 最终通知 (@After): 无论是否发生异常,最后都会执行(类似 finally)。

2.4 切面 (Aspect)

  • 公式: 切面 = 切点 + 通知
  • 定义: 它是一个具体的类(使用 @Aspect 标注),包含了"往哪切"和"切了干什么"的完整逻辑。

2.5 织入 (Weaving)

  • 描述: 织入是一个过程
  • 定义: 把通知应用到目标对象上的动态过程。

2.6 代理对象 (Proxy)

  • 定义: 目标对象被织入通知后,由 Spring 容器生成的那个"加了特技"的新对象。

2.7 目标对象 (Target)

  • 定义: 被织入通知的原始业务对象(如 UserService)。

2.8 形象化案例:警察查酒驾

1. 目标对象 (Target)
  • 例子 :公路上正常行驶的所有司机
2. 连接点 (JoinPoint)
  • 例子 :马路上的每一个十字路口红绿灯口收费站。这些地方理论上都可以设岗查车。
3. 切点 (Pointcut)
  • 例子:警察最终决定,今晚只在**"北京路与南京路交叉口"**设岗。
4. 通知 / 增强 (Advice)
  • 例子 :警察拦截后的具体动作:拿出测酒仪让司机吹气,并记录结果。
5. 切面 (Aspect)
  • 例子"交警大队的查酒驾计划书"。上面写清楚了:要在哪个路口(切点)干什么事(通知)。
6. 织入 (Weaving)
  • 例子"设岗检查这个动作"。警察真的站到了路口,把查酒驾的逻辑应用到了司机的行驶路径中。
7. 代理对象 (Proxy)
  • 例子"被拦截检查后的司机"。现在的司机不是简单的行驶了,而是"行驶 + 吹气"的组合体。

总结:
术语 查酒驾例子 程序员视角
目标对象 (Target) 开车的司机 原始的业务类
连接点 (JoinPoint) 每一个十字路口 每一个方法
切点 (Pointcut) 选中的某个路口 选中的某个方法
通知 (Advice) 吹气测酒精 增强的代码逻辑
切面 (Aspect) 查酒驾方案 切点 + 通知的组合类
织入 (Weaving) 设岗检查的动作 动态生成的代理过程
代理对象 (Proxy) 配合检查的司机 增强后的新对象

3. 切点表达式 (Pointcut Expression)

切点表达式决定了你的"横切逻辑"要作用于哪些方法。

3.1 表达式语法

java 复制代码
execution([访问权限修饰符] 返回值类型 [全限定类名] 方法名(形式参数列表) [异常])

3.2 符号详解

  • * :匹配任意数量的字符。
  • ..:用在包名处表示当前包及其子包;用在参数列表处表示任意参数。

3.3 常用示例

表达式 描述
execution(* com.zzz..*(..)) 匹配 com.zzz 包及其子包下所有类的所有方法
execution(public * *(..)) 匹配所有公开(public)的方法
execution(* com.zzz.service.*.delete*(..)) 匹配 service 包下所有类中以 delete 开头的方法
execution(* *(String, ..)) 匹配第一个参数是 String 的所有方法

4、使用Spring的AOP

4.1 基于 AspectJ 的 AOP 注解式开发

4.1.1 环境准备

在 Spring6 中使用 AOP,首先需要引入 spring-aspects 依赖,但是我们使用的是 spring-context,它通常已经包含了基础 AOP 功能。

xml 复制代码
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>6.2.11</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>6.2.11</version>
</dependency>

关键配置(二选一):

  • 方式 A:XML 启用

    xml 复制代码
    <aop:aspectj-autoproxy proxy-target-class="true"/>
  • 方式 B:全注解启用(推荐)

    java 复制代码
    @Configuration
    @ComponentScan("com.zzz.service")
    @EnableAspectJAutoProxy(proxyTargetClass = true) // 开启自动代理
    public class SpringConfig { }

注意 proxy-target-class

  • true:强制使用 CGLIB 动态代理(基于继承)。
  • false(默认):优先使用 JDK 动态代理(基于接口)。

4.1.2 目标类定义 (UserService.java)

这是我们要"切入"的对象,它只负责核心业务。

java 复制代码
@Service("userService")
public class UserService {
    // 目标方法
    public String login(String username, String password) {
        System.out.println("系统正在验证身份...");
		//用作异常测试
        //if(1 == 1){
            //throw new RuntimeException("异常");
        //}
        return ("admin".equals(username) && "123456".equals(password)) ?
                "登录成功" : "登录失败";
    }

}
4.1.3 切面类结构分解

一个完整的切面类需要使用 @Component 交给 Spring 管理,并使用 @Aspect 标注其为切面。

1. 定义切点 (Reusable Pointcut)

为了避免在每个通知注解里重复写切点表达式,建议定义一个通用的切点方法。

java 复制代码
@Pointcut("execution(* com.zzz.service.UserService.*(..))")
public void pointCut() {
    // 此方法仅作为切点标识,不写代码
}
2. 五种通知类型的实现
java 复制代码
@Component("logAspect")
@Aspect // 标注这是一个切面类
@Order(2) // 设置优先级,数字越大优先级越低(在 SecurityAspect 之后执行)
public class LogAspect {

    /**
     * 【切点 Pointcut】:定义往哪些方法上切入。
     * 抽取公共表达式,方便后续复用。
     */
    @Pointcut("execution(* com.zzz.service.UserService.*(..))")
    public void pointCut() {}

    /**
     * 【前置通知 @Before】:在目标方法执行前执行。
     * @param joinPoint 能够获取目标方法的签名、参数等情报
     */
    @Before("pointCut()")
    public void beforeAdvice(JoinPoint joinPoint) {
        System.out.println("前置日志通知");
        // 获取目标方法签名:public String login(String, String)
        System.out.println("目标方法签名:" + joinPoint.getSignature());
    }

    /**
     * 【后置通知 @AfterReturning】:目标方法"正常结束后"执行。
     */
    @AfterReturning("pointCut()")
    public void afterReturning() {
        System.out.println("后置日志通知");
    }

    /**
     * 【环绕通知 @Around】:最强大的通知,包围了整个目标方法。
     * @param joinPoint 必须使用 ProceedingJoinPoint,它有 proceed() 方法
     */
    @Around("pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("前置环绕通知:计时/日志开始...");
        
        // 【关键】:手动调用目标方法执行!
        Object proceed = joinPoint.proceed(); 
        
        System.out.println("后置环绕通知:计时/日志结束...");
        return proceed;
    }

    /**
     * 【异常通知 @AfterThrowing】:目标方法抛出异常时执行。
     */
    @AfterThrowing("pointCut()")
    public void afterThrowing() {
        System.out.println("异常通知:检测到异常,触发告警!");
    }

    /**
     * 【最终通知 @After】:相当于 finally 块,无论是否有异常都会执行。
     */
    @After("pointCut()")
    public void after() {
        System.out.println("最终通知:释放资源...");
    }
}

4.1.4 多个切面的顺序控制 (@Order)

当一个方法被多个切面(如 LogAspectSecurityAspect)同时拦截时,谁先执行?

  • 解决方案: 使用 @Order(数字) 注解。
  • 规则: 数字越小,优先级越高。
  • 执行逻辑: 进入 时:数字小的先执行(如安全校验先于日志记录)。
    • 退出时:数字小的后执行(类似剥洋葱,最外层先切入,最后离开)。

代码示例:

java 复制代码
@Component("securityAspect")
@Aspect
@Order(1) // 数字越小,优先级越高。SecurityAspect(1) 会在 LogAspect(2) 外层执行。
public class SecurityAspect {
    
    // 跨类引用切点:需要写全类名路径
    @Before("com.zzz.service.LogAspect.pointCut()")
    public void before() {
        System.out.println("前置安全通知:权限校验中...");
    }
}

@Aspect
@Order(2) // 优先级低,内层
public class LogAspect { ... }

4.1.5 核心细节补充:JoinPoint 参数

在通知方法中,我们可以通过 JoinPoint(环绕通知用 ProceedingJoinPoint)获取目标方法的"情报":

  • joinPoint.getArgs():获取目标方法的入参。
  • joinPoint.getSignature():获取方法签名(包括方法名、声明类型等)。
  • joinPoint.getTarget():获取被代理的目标对象。

4.1.6 测试验证
java 复制代码
@Test
public void testNoXml() {
    // 加载配置类
    AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(SpringConfig.class);
    // 获取代理对象(注意:即便类型写 UserService,拿到的也是增强后的代理类)
    UserService userService = context.getBean("userService", UserService.class);
    userService.login("admin", "123456");
}

通知的执行顺序

当调用 userService.login() 且没有异常时,执行顺序如下:

  1. SecurityAspect @Before (Order 1)
  2. LogAspect @Around (前半部分)
  3. LogAspect @Before (Order 2)
  4. UserService login() (目标方法执行)
  5. LogAspect @Around (后半部分)
  6. LogAspect @AfterReturning
  7. LogAspect @After (最终通知)

测试结果:

现在我们在UserService.login()方法中抛出异常

异常情况下的执行顺序

如果在 UserService.login() 执行过程中发生了异常,执行链条如下:

  1. SecurityAspect @Before (Order 1) ------ 正常拦截。
  2. LogAspect @Around (前半部分) ------ 正常开始。
  3. LogAspect @Before (Order 2) ------ 正常拦截。
  4. UserService login() ------ [ 抛出异常 ]
  5. LogAspect @Around (后半部分) ------ 不会执行 ,因为 joinPoint.proceed() 抛出了异常,环绕通知后续逻辑被中断。
  6. LogAspect @AfterThrowing ------ 异常通知触发,捕获到异常并执行增强代码。
  7. LogAspect @After ------ 最终通知触发,无论成败,这里都会执行。

测试结果:

正常 vs 异常 执行对比表

我们将两种情况放在一起对比:

执行阶段 正常执行 (Success) 异常发生 (Exception)
1. 外部切面 SecurityAspect @Before SecurityAspect @Before
2. 环绕开始 LogAspect @Around (前) LogAspect @Around (前)
3. 内部前置 LogAspect @Before LogAspect @Before
4. 目标方法 UserService.login() UserService.login() (报错)
5. 环绕结束 LogAspect @Around (后) 跳过
6. 结果通知 LogAspect @AfterReturning LogAspect @AfterThrowing
7. 最终通知 LogAspect @After LogAspect @After

4.2 基于 XML 配置方式的 AOP

4.2.1 核心思想

在 XML 配置方式中,目标类切面类都是普通的 POJO(Plain Old Java Object),它们内部不包含任何 Spring 特有的 AOP 注解。所有的连接逻辑(哪个方法切入哪段代码)全部由 XML 文件统一管理。

这种方式的内核依然是 "切点 + 通知 = 切面" ,只是将原本写在 Java 类上的注解全部"外迁"到了 spring.xml 中。

4.2.2 目标类与切面类实现

由于不使用注解,代码显得非常"干净":

1. 目标类 (UserService.java)

java 复制代码
public class UserService { // 目标对象:纯粹的业务逻辑
    public void logout(){
        System.out.println("系统正在安全退出");
        //用作异常测试
        //if(1 == 1){
            //throw new RuntimeException("异常");
        //}
    }
}

2. 切面类 (TimerAspect.java)

这里只需要编写增强逻辑的方法,方法名aroundAdvice可以自定义,不需要加 @Around 等注解。

java 复制代码
public class TimerAspect {
    // 这是一个环绕通知逻辑
    public void aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        // 前环绕:记录开始时间
        long begin = System.currentTimeMillis();
        
        // 执行目标方法
        joinPoint.proceed();
        
        // 后环绕:记录结束时间
        long end = System.currentTimeMillis();
        System.out.println("耗时:" + (end - begin) + "毫秒");
    }
    public void beforeAdvice() throws Throwable {
        System.out.println("前置通知");
    }
    public void afterAdvice() throws Throwable {
        System.out.println("最终通知");
    }
    public void afterReturningAdvice() throws Throwable {
        System.out.println("后置通知");
    }
    public void afterThrowingAdvice() throws Throwable {
        System.out.println("异常通知");
    }
}

4.2.3 XML 核心配置 (spring.xml)

这是 XML 方式的精髓。你需要使用 <aop:config> 标签及其子标签来手动完成"编织"过程。

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns: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="userService" class="com.zzz.service.UserService"/>
    <bean id="timerAspect" class="com.zzz.service.TimerAspect"/>

    <aop:config>
        <aop:pointcut id="pointCut" expression="execution(* com.zzz.service..*(..))"/>
        
        <aop:aspect ref="timerAspect">
			<aop:around method="aroundAdvice" pointcut-ref="pointCut"/>
            <aop:before method="beforeAdvice" pointcut-ref="pointCut"/>
            <aop:after-throwing method="afterThrowingAdvice" pointcut-ref="pointCut"/>
            <aop:after-returning method="afterReturningAdvice" pointcut-ref="pointCut"/>
            <aop:after method="afterAdvice" pointcut-ref="pointCut"/>
        </aop:aspect>
    </aop:config>

</beans>

4.2.4 测试验证
java 复制代码
public class SpringAOPTest {
    @Test
    public void testXML(){
        // 加载 XML 配置文件
        ClassPathXmlApplicationContext applicationContext = new ClassPathXmlApplicationContext("spring.xml");
        
        // 从容器中获取代理对象
        UserService userService = applicationContext.getBean("userService", UserService.class);
        
        // 调用方法,此时会自动触发 TimerAspect 中的 aroundAdvice 逻辑
        userService.logout();
    }
}

正常运行结果:

异常运行结果:

4.2.5 注解式 vs XML 配置式对比
特点 基于 AspectJ 注解 基于 XML 配置
便利性 极高,开发速度快 较低,需要频繁切换 XML
侵入性 较高(代码中带有 @Aspect 等注解) 极低(Java 类是纯净的 POJO)
集中性 分散在各个切面类中 高度集中,在一个 XML 中纵览全局
适用场景 现代主流开发、微服务 老项目维护、不希望修改源代码的第三方库集成

5. AOP 实际案例:事务处理 (Transaction Management)

5.1 案例背景

在银行转账或订单系统中,一个业务方法通常包含多个数据库操作。为了保证数据的原子性,我们必须:

  1. 执行前:开启事务
  2. 成功后:提交事务
  3. 异常时:回滚事务

如果每个业务方法都手动写这些代码,会导致大量的冗余且难以维护。


5.2 业务目标类实现

这些类作为 Target(目标对象),其内部代码非常纯粹,只包含核心业务逻辑。

1. 账户业务 (AccountService.java)

java 复制代码
@Service
public class AccountService {
    // 转账业务
    public void transfer(){
        System.out.println("银行账户正在完成转账操作...");
    }

    // 取款业务
    public void withdraw(){
        System.out.println("银行账户正在完成取款操作...");
    }
}

2. 订单业务 (OrderService.java)

java 复制代码
@Service
public class OrderService {
    public void generate(){
        System.out.println("正在生成订单...");
    }

    // 模拟异常场景:取消订单时抛出空指针异常
    public void cancel(){
        System.out.println("订单已取消...");
        String s = null;
        s.toString(); // 这里会触发 NullPointerException
    }
}

5.3 事务切面实现 (TransactionAspect.java)

这是 AOP 的精华所在。我们使用 @Around(环绕通知) ,因为它能完整控制整个事务的生命周期,并利用 try-catch 捕捉异常实现回滚。

java 复制代码
@Aspect
@Component
public class TransactionAspect {

    /**
     * 环绕通知:模拟 Spring 底层的声明式事务控制
     * 匹配 com.zzz.service 包下所有类的所有方法
     */
    @Around("execution(* com.zzz.service..*(..))")
    public Object aroundAdvice(ProceedingJoinPoint joinPoint) {
        Object proceed = null;
        try {
            // 【前置增强】:相当于开启事务
            System.out.println("==> 开启事务 (Transaction Begin)");
            
            // 执行目标方法
            proceed = joinPoint.proceed();
            
            // 【后置增强】:如果没有异常,正常提交事务
            System.out.println("==> 提交事务 (Transaction Commit)");
            
        } catch (Throwable e) {
            // 【异常增强】:一旦捕获到 Throwable,立即回滚
            System.out.println("==> 检测到异常 [" + e.getClass().getSimpleName() + "],回滚事务 (Transaction Rollback)");
            // 注意:在实际开发中,通常会将异常继续抛出,以便让调用者知道出错
        }
        return proceed;
    }
}

5.4 运行逻辑分析

java 复制代码
@Test
public void testTransactionAOP() {
    // 1. 初始化 Spring 容器
    ApplicationContext applicationContext = new ClassPathXmlApplicationContext("spring.xml");

    // 2. 从容器中获取 AccountService (正常情况)
    System.out.println("--- 场景 A:模拟正常转账 ---");
    AccountService accountService = applicationContext.getBean("accountService", AccountService.class);
    accountService.transfer();

    System.out.println("\n----------------------------\n");

    // 3. 从容器中获取 OrderService (异常情况)
    System.out.println("--- 场景 B:模拟异常取消订单 ---");
    OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
    orderService.cancel();
}

我们分析两种运行情况:

情况 A:正常转账 (AccountService.transfer)
  1. 触发切面,打印:开启事务
  2. 执行业务:银行账户正在完成转账操作...
  3. 业务结束,打印:提交事务
情况 B:异常取消订单 (OrderService.cancel)
  1. 触发切面,打印:开启事务
  2. 执行业务:订单已取消...
  3. 触发代码 s.toString() -> 抛出异常
  4. 切面 catch 到异常,跳过提交逻辑,打印:回滚事务

测试结果:


5.5 案例总结

通过这个案例,我们可以深刻体会到 AOP 的优势:

  • 零侵入OrderServiceAccountService 完全不知道事务切面的存在。
  • 高复用 :只需写一个 TransactionAspect,就可以保护 com.zzz.service 包下成千上万个方法。
  • 易维护:如果以后要更换事务实现方式,只需修改切面类一处即可。
  • 代理对象的本质 : 在测试类中,虽然我们写的代码是 applicationContext.getBean("orderService", OrderService.class),但 Spring 容器返回给你的其实是 OrderService 的代理对象 。当你调用 cancel() 时,实际上是先进入了代理对象的逻辑。
  • 异常捕获的逻辑 : 在 TransactionAspect 的环绕通知中,我们通过 try-catch 包裹了 joinPoint.proceed()
    • 如果没有 Catch:异常会直接抛给调用者,事务无法自动回滚(在 AOP 层面)。
    • 有了 Catch :我们可以在 catch 块中执行 System.out.println("回滚事务")。这正是 Spring 声明式事务处理的核心原理。

6. AOP 实际案例:安全日志 (Security Logging)

6.1 案例背景

需求描述: 系统要求对所有业务模块(User、Vip 等)的"增、删、改"操作进行审计。记录具体是谁、在什么时间、访问了哪个类的哪个方法。

关键技术:

  1. 利用 execution 表达式匹配方法名。
  2. 使用 || (逻辑或) 组合多个切点。
  3. 通过 JoinPoint 获取目标类的全限定名和方法名。

6.2 业务目标类实现

这些类包含各种业务方法,我们需要通过 AOP 筛选出需要记录日志的方法。

1. 用户业务 (UserService.java)

java 复制代码
@Service
public class UserService {
    public void saveUser() { System.out.println("新增用户信息"); }
    public void deleteUser() { System.out.println("删除用户信息"); }
    public void modifyUser() { System.out.println("修改用户信息"); }
    public void getUser() { System.out.println("查询用户信息"); } // 读操作,不应触发日志
}

2. 会员业务 (VipService.java)

java 复制代码
@Service
public class VipService {
    public void saveVip() { System.out.println("新增Vip信息"); }
    public void deleteVip() { System.out.println("删除Vip信息"); }
    public void modifyVip() { System.out.println("修改Vip信息"); }
    public void getVip() { System.out.println("查询Vip信息"); } // 读操作,不应触发日志
}

6.3 安全日志切面实现 (SecurityLogAspect.java)

java 复制代码
@Component
@Aspect
public class SecurityLogAspect {

    // 【切点定义】:分别匹配以 save、delete、modify 开头的方法
    @Pointcut("execution(* com.zzz.service..save*(..))")
    public void savePointcut(){}

    @Pointcut("execution(* com.zzz.service..delete*(..))")
    public void deletePointcut(){}

    @Pointcut("execution(* com.zzz.service..modify*(..))")
    public void modifyPointcut(){}

    /**
     * 【前置通知】:使用逻辑运算符 || 组合多个切点
     * 只要满足其中任何一个切点,就会触发此增强逻辑
     */
    @Before("savePointcut() || deletePointcut() || modifyPointcut()")
    public void beforeAdvice(JoinPoint joinPoint){
        // 1. 获取系统当前时间
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String nowTime = simpleDateFormat.format(new Date());

        // 2. 获取目标信息
        // getDeclaringTypeName() 获取类名
        // getName() 获取方法名
        String className = joinPoint.getSignature().getDeclaringTypeName();
        String methodName = joinPoint.getSignature().getName();

        // 3. 输出审计日志
        System.out.println("【审计日志】" + nowTime + " 用户 [zhangsan] 访问了: " + className + "." + methodName);
    }
}

6.4 测试代码 (SecurityLogTest.java)

我们需要测试两个维度:

  1. 访问 save/delete/modify 时,日志是否正常打印。
  2. 访问 get 方法时,日志是否被过滤(不打印)。
java 复制代码
public class SecurityLogTest {

    @Test
    public void testLogAOP() {
        ApplicationContext applicationContext = new ClassPathXmlApplicationContext("spring.xml");

        UserService userService = applicationContext.getBean("userService", UserService.class);
        VipService vipService = applicationContext.getBean("vipService", VipService.class);

        System.out.println("--- 场景 A:测试 User 模块的写操作 ---");
        userService.saveUser();
        userService.modifyUser();

        System.out.println("\n--- 场景 B:测试 Vip 模块的写操作 ---");
        vipService.deleteVip();

        System.out.println("\n--- 场景 C:测试查询操作(预期不打印日志) ---");
        userService.getUser();
        vipService.getVip();
    }
}

测试结果:

相关推荐
宸津-代码粉碎机2 小时前
Spring Boot 4.0 进阶实战+源码解析系列(持续更新)—— 从落地到源码,搞定面试与工作
java·人工智能·spring boot·后端·python·面试
沐雪轻挽萤2 小时前
2. C++17新特性-结构化绑定 (Structured Bindings)
java·开发语言·c++
沐知全栈开发2 小时前
PHP JSON
开发语言
java1234_小锋2 小时前
Java高频面试题:Kafka的消费消息是如何传递的?
java·开发语言·mybatis
Z.风止2 小时前
Large Model-learning(4)
人工智能·pytorch·笔记·python·深度学习·机器学习
lly2024062 小时前
PHP 安全 E-mail
开发语言
滴滴答答哒2 小时前
c#将平铺列表转换为树形结构(支持孤儿节点作为独立根节点)
java·前端·c#
李少兄2 小时前
Windows系统JDK安装与环境配置指南(2026年版)
java·开发语言·windows
csbysj20202 小时前
PHP 包含
开发语言