【阶段 1】Spring Framework 核心:IoC 与 AOP

96次阅读
没有评论

版本:Spring Boot 3.2.x / JDK 17 · 定位:理解 Spring Boot
一切「魔法」的地基

1. 导语

你写的第一段 Spring Boot 代码,大概率长这样:类上加
@SpringBootApplication,业务类上加
@Service,字段前加
private final UserMapper userMapper,再配上 Lombok 的
@RequiredArgsConstructor——然后程序就「魔法般」跑起来了。你没
new 任何对象,依赖却自动装配好了;给方法贴一个
@Transactional,事务就生效了;给类贴一个
@Aspect,日志和耗时统计就自动打印了。

这些不是黑盒魔法,它们全部建立在本篇要讲的两个地基之上:

  • IoC(Inversion of
    Control,控制反转)
    :对象创建和依赖管理的控制权,从「你自己
    new」反转到「容器统一装配」。依赖注入(Dependency
    Injection,DI)是 IoC 最常见的落地方式。
  • AOP(Aspect Oriented
    Programming,面向切面编程)
    :在不修改业务代码的前提下,横向插入公共逻辑(日志、事务、权限、限流)。@Transactional
    本质就是一个 AOP 切面。

不理解这两块,你永远在「背配置」而不是「懂原理」;理解之后,后面所有阶段——自动配置、Web
开发、事务、缓存、安全——你都能一眼看穿它的实现套路。本篇会带你从零搭一个纯注解驱动的项目,讲透构造器注入为什么是生产唯一解AOP
代理机制为什么会导致自调用失效
,并手写日志切面、事件解耦、声明式事务,最后串成一个完整的用户/订单分层项目。

学完本篇,你就能回答那个最经典的问题:「给
@Transactional 的方法加上 try-catch
吞掉异常,事务为什么没回滚?」——答案不在配置里,在代理机制里。


2. 学习目标与前置要求

学完你能…

  1. 讲清 IoC 与 DI 的关系,并说出字段注入、Setter
    注入、构造器注入三种方式的取舍,以及为什么生产环境一律用构造器注入。
  2. 独立写出@RequiredArgsConstructor +
    final 字段」的构造器注入代码,并用
    @Autowired/@Qualifier/@Primary
    解决多个候选 Bean 的歧义。
  3. 画出 Bean 完整生命周期,知道
    @PostConstruct/@PreDestroy 何时执行,以及
    BeanPostProcessor 为什么是 AOP 的基石。
  4. 写出健壮的 AOP 切面:掌握 5
    种通知、execution 切点表达式、环绕通知签名,以及自定义注解
    @LogExecutionTime
  5. 解释 AOP 失效两大场景(同类自调用、非 public
    方法),能给出「JDK 动态代理 vs CGLIB」的准确区别,并知道 Spring Boot
    默认用哪种。
  6. 用事件机制做模块解耦ApplicationEvent
    + @EventListener,以及事务场景下必须用
    @TransactionalEventListener 的原因。
  7. 正确使用
    @Transactional
    :讲清传播行为、隔离级别、4
    个高频失效场景,并能写转账事务的自调用失效验证 Demo。

前置依赖

  • 阶段 0(Java
    基础)
    :重点是注解@interface、元注解、注解的运行时反射读取)和反射ClassMethodProxy)。AOP
    的代理机制本质就是动态代理 + 反射,没这两个基础,第 3 章会很难啃。
  • 若未掌握,建议先回看阶段 0
    中「反射与动态代理」一节,再继续本篇。

本文技术栈

组件 版本 用途
JDK 17 运行环境
Spring Boot 3.2.x(文中以 3.2.5 为例) 框架底座
Maven 3.8+ 构建工具
Lombok 由 Spring Boot 管理 @Data/@Slf4j/@RequiredArgsConstructor
H2 运行时 内存数据库,演示真实事务

3. 环境准备

本篇是第一个真正引入 Spring Boot
的篇章,先用官方脚手架初始化一个最小可运行工程。

3.1 初始化工程

任选一种方式:

方式 A:Spring Initializr 网页https://start.spring.io/)

选项 取值
Project Maven
Language Java
Spring Boot 3.2.5
Group com.example
Artifact demo
Java 17
Dependencies Web、AOP、Validation、JDBC API、H2 Database、Lombok

方式 B:命令行生成后解压

curl https://start.spring.io/starter.zip 
  -d type=maven-project 
  -d language=java 
  -d bootVersion=3.2.5 
  -d groupId=com.example 
  -d artifactId=demo 
  -d name=demo 
  -d packageName=com.example.demo 
  -d javaVersion=17 
  -d dependencies=web,aop,validation,jdbc,h2,lombok 
  -o demo.zip
unzip demo.zip && cd demo

3.2 核对 pom.xml

生成后确认 pom.xml 关键部分如下(版本号由
spring-boot-starter-parent
统一管理,子依赖无需手写版本):

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Spring Framework 核心:IoC 与 AOP</description>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Web:提供 Spring MVC + 内嵌 Tomcat -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- AOP:提供 @Aspect / @EnableAspectJAutoProxy 支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <!-- Validation:提供 JSR-303 参数校验(@NotNull 等) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- JDBC:提供 JdbcTemplate + DataSourceTransactionManager(真实事务) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <!-- H2:内存数据库,跑完即丢,适合演示事务 -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Lombok:@Data / @Slf4j / @RequiredArgsConstructor -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

3.3 初始化
schema.sql(供后续事务章节使用)

src/main/resources/ 下建 schema.sql,H2
启动时自动建表:

-- 用户账户表:余额字段用于演示转账事务
CREATE TABLE IF NOT EXISTS t_account (
    id       BIGINT PRIMARY KEY,
    username VARCHAR(64)  NOT NULL,
    balance  DECIMAL(10,2) NOT NULL DEFAULT 0
);

application.yml 配置 H2 与建表脚本:

spring:
  datasource:
    url: jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1   # 内存库,连接断开也不清空
    driver-class-name: org.h2.Driver
    username: sa
    password: ""
  sql:
    init:
      mode: always                 # 每次启动执行 schema.sql
      schema-locations: classpath:schema.sql

环境就绪后,正文各章节的示例都可在本工程里直接跑。前 1–4
章不依赖数据库,第 5 章事务需要上面这张表。


4. 正文章节

4.1 IoC 容器与依赖注入

4.1.1 什么是 IoC,它解决了什么

先看一段「没有 Spring」的代码:

// 反例:对象自己 new 依赖,控制权在自己手里
public class UserController {
    // 依赖被硬编码在类内部,UserController 与 UserServiceImpl 强耦合
    private UserService userService = new UserServiceImpl();

    public void register(String username) {
        userService.register(username);
    }
}

这段代码的问题:

  1. 强耦合UserController
    直接依赖具体实现 UserServiceImpl。将来想换一个
    UserService 实现(比如换成调用 RPC 的版本),必须改
    UserController 源码。
  2. 难测试:单元测试时无法注入一个 Mock 对象,只能真跑
    UserServiceImpl,还会连累它依赖的数据库。
  3. 生命周期失控:每个用到 UserService
    的类都 new
    一个实例,内存里散落着大量重复对象,无法统一管理创建、初始化、销毁。

IoC
的思路
:把「创建对象、装配依赖」的控制权,从代码里拿出来,交给一个容器(Container)。你不再
new,而是「声明我需要的依赖」,容器在启动时统一创建、装配、管理这些对象(在
Spring 里叫 Bean)。

// 正例:只声明依赖,不关心它从哪来、怎么创建
@RestController
public class UserController {
    private final UserService userService;   // 只声明「我需要 UserService」

    public UserController(UserService userService) {  // 由容器通过构造器注入
        this.userService = userService;
    }
}

DI(依赖注入)是 IoC 的落地手段:容器创建 Bean
时,发现 UserController 需要
UserService,就自动把后者「注入」进前者的构造器。IoC
是思想,DI 是具体做法,二者常被混用,你心里分清楚即可。

一句话总结:IoC
把「对象与对象之间如何连接」这件事从业务代码中剥离,交给容器统一管理,从而获得解耦、可测试、可统一治理三大收益。

4.1.2 三种注入方式对比

Spring 支持三种注入方式,生产环境的选择只有一个。

方式一:字段注入(Field Injection)—— 生产禁用

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserMapper userMapper;   // 直接注入到字段
    @Autowired
    private CacheService cacheService;
}

字段注入是网上示例最常见的写法,但它有四个硬伤:

硬伤 说明
依赖可变 final 无法与字段注入共存(final
字段必须在构造器里赋值),字段随时可被改掉,破坏不可变性
难测试 单测时没有构造器/Setter 入口,只能用反射
ReflectionTestUtils.setField 强行塞 Mock,又丑又脆弱
易 NPE 对象创建后字段可能还没注入完就被使用(尤其在构造器里访问依赖时),容易
NullPointerException
隐藏依赖 依赖藏在字段里,一个类塞 10
个字段注入也不显眼,类膨胀了都看不出来

方式二:Setter 注入(Setter Injection)——
可选依赖才用

@Service
public class UserServiceImpl implements UserService {
    private UserMapper userMapper;

    @Autowired
    public void setUserMapper(UserMapper userMapper) {  // 通过 Setter 注入
        this.userMapper = userMapper;
    }
}

Setter 注入的问题:依赖可变(任何地方都能再调
setUserMapper 换掉),且可能漏注入(忘了加
@Autowired,字段就是
null,编译不报错,运行才炸)。它唯一合理的用途是可选依赖(这个依赖可以有默认实现、不注入也能工作),但实际生产中这种场景极少。

方式三:构造器注入(Constructor Injection)——
生产唯一解

@Service
public class UserServiceImpl implements UserService {
    private final UserMapper userMapper;      // final:一旦注入,不可再变
    private final CacheService cacheService;

    // 构造器参数就是依赖清单,一目了然
    public UserServiceImpl(UserMapper userMapper, CacheService cacheService) {
        this.userMapper = userMapper;
        this.cacheService = cacheService;
    }
}

构造器注入的四个优势,正好一一对应字段注入的四个硬伤:

  1. 依赖不可变final
    字段只能在构造器里赋值一次,注入后谁也别想改。
  2. 天然非空:构造器要求所有参数必须传入,容器装配时发现缺依赖会直接启动失败,而不是运行到一半
    NPE。
  3. 易测试:单测里直接
    new UserServiceImpl(mockUserMapper, mockCacheService),不用任何框架。
  4. 依赖显式:构造器签名就是依赖清单,参数一多,你就该警惕「这个类是不是干太多事了」。

还有一个隐藏收益,第 4.1.4
节展开:构造器注入会暴露循环依赖,这反而是好事。

4.1.3 用 Lombok 省掉样板代码

手写构造器没问题,但依赖一多就啰嗦。Lombok 的
@RequiredArgsConstructor为所有
final 字段自动生成构造器
,配合 final
字段,就是生产标准姿势:

// 生产级:构造器注入 + final 字段 + Lombok
// @RequiredArgsConstructor 等价于手写「包含所有 final 字段的构造器」
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
    private final UserMapper userMapper;      // final:不可变,必然注入
    private final CacheService cacheService;
    // Lombok 自动生成:public UserServiceImpl(UserMapper userMapper, CacheService cacheService) { ... }
}

关键点@RequiredArgsConstructor 只对
final 字段和 @NonNull
字段生成构造器参数。所以「构造器注入」的完整配方是「final
字段 + @RequiredArgsConstructor」,缺一不可——字段不加
final,Lombok 就不会把它放进构造器,注入就失效了。

4.1.4
循环依赖:构造器注入「暴露」它,是好事

循环依赖:A 依赖 B,B 又依赖 A。

@Service
@RequiredArgsConstructor
public class AService {
    private final BService bService;   // A 依赖 B
}

@Service
@RequiredArgsConstructor
public class BService {
    private final AService aService;   // B 依赖 A
}

构造器注入时,这段代码会让应用启动直接失败,报错类似:

The dependencies of some of the beans in the application context form a cycle:
┌─────┐
|  aService defined in file [...]
↑     ↓
|  bService defined in file [...]
└─────┘

这是好事:循环依赖 99%
是设计问题(职责划分不清),构造器注入让你在启动时就暴露它,逼你重构——把公共逻辑抽到第三个类,或者用事件/回调解耦。

对比之下,字段注入会用 Spring
的三级缓存(singletonFactories /
earlySingletonObjects)在「半成品」状态偷偷解决循环依赖,应用能正常启动,但设计缺陷被掩盖了。随着代码膨胀,这种隐式依赖会变成维护灾难。

本篇的立场:遇到循环依赖报错,不要改回字段注入「绕过」它,要重构消除它。Spring
Boot 3.x
甚至默认禁用了循环引用(spring.main.allow-circular-references=false),态度已经很明确。

4.1.5 多个候选 Bean
的歧义处理

@Autowired(含构造器注入)默认**按类型(byType)**匹配。当接口有多个实现时,容器不知道该注入哪个,会报
NoUniqueBeanDefinitionException。三种解法:

public interface UserService { void register(String username); }

// 实现一:默认实现
@Service
@Primary                       // 解法 2:标记为主候选,类型匹配时优先选它
public class UserServiceImpl implements UserService { ... }

// 实现二:VIP 用户的特殊实现
@Service
public class VipUserServiceImpl implements UserService { ... }

解法 1:@Qualifier
按名字指定
(推荐,语义明确)

@Service
@RequiredArgsConstructor
public class VipOrderFacade {
    // 指定注入 Bean 名为 "vipUserServiceImpl" 的那个实现
    private final @Qualifier("vipUserServiceImpl") UserService userService;
}

解法 2:@Primary 标记默认实现(一个默认
+ 少数例外时用)

@Primary
@Service
public class UserServiceImpl implements UserService { ... }

当注入点没写 @Qualifier 时,容器默认选
@Primary 标注的实现;写了 @Qualifier
的注入点,@Qualifier 优先级更高。

解法 3:注入 List/Map
收集所有实现
(策略模式常用)

@Service
@RequiredArgsConstructor
public class UserServiceRouter {
    // 容器会把所有 UserService 实现收集成一个 Map,key 是 beanName
    private final Map<String, UserService> userServiceMap;

    public UserService pick(String type) {
        return userServiceMap.getOrDefault(type, userServiceMap.get("userServiceImpl"));
    }
}

选择建议:多数场景用「一个 @Primary
默认实现 + 例外处
@Qualifier」最清晰;要做插件化/策略路由时用
Map 收集。

4.1.6 最小可用 vs 生产级

先给一个最小可用示例,讲清「注入」本身:

// 最小可用:一个接口 + 一个实现 + 一个消费方,三行看穿 DI
public interface GreetingService { String greet(String name); }

@Service
public class GreetingServiceImpl implements GreetingService {
    @Override
    public String greet(String name) { return "Hello, " + name; }
}

@RestController
@RequiredArgsConstructor
public class GreetingController {
    private final GreetingService greetingService;   // 容器注入实现

    @GetMapping("/greet")
    public String greet(@RequestParam String name) {
        return greetingService.greet(name);
    }
}

再给生产级完整版(带校验、异常、日志、分层):

// 生产级:UserService 接口 + 实现,业务失败抛 BizException,由全局异常处理器统一兜底
public interface UserService {
    Long register(RegisterDTO dto);
}
// DTO:入参对象,带 JSR-303 校验注解,禁止直接用 Entity 接请求
@Data
public class RegisterDTO {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度需在 3-20 之间")
    private String username;
}
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
    // 构造器注入:依赖不可变、天然非空、易测试
    private final UserMapper userMapper;
    private final CacheService cacheService;

    @Override
    public Long register(RegisterDTO dto) {
        // 生产习惯:业务校验失败抛 BizException,而不是返回 null 或 -1
        if (userMapper.existsByUsername(dto.getUsername())) {
            throw new BizException(40010, "用户名已存在");
        }
        User user = User.from(dto);
        userMapper.insert(user);
        cacheService.evictUserCache(user.getId());   // 写入后失效缓存,保证一致性
        log.info("用户注册成功: id={}, username={}", user.getId(), dto.getUsername());
        return user.getId();
    }
}

4.1.7 三种注入方式对比
Demo(可运行)

用同一个场景把三种注入方式并排写出来,跑起来对比。三种方式都能让应用启动、注入也都会成功——差别不在「能不能跑」,而在「可维护性」:

// 字段注入:能跑,但依赖不可变、难测试、易 NPE、隐藏依赖
@Service
public class FieldInjectionService {
    @Autowired
    private UserMapper userMapper;   // 依赖藏在字段里,构造器看不到,final 也加不上
}

// Setter 注入:依赖可变,@Autowired 忘加时字段就是 null,编译不报错、运行才炸
@Service
public class SetterInjectionService {
    private UserMapper userMapper;

    @Autowired
    public void setUserMapper(UserMapper userMapper) {
        this.userMapper = userMapper;
    }
}

// 构造器注入:依赖不可变、非空、显式、易测试
@Service
public class ConstructorInjectionService {
    private final UserMapper userMapper;   // final:只赋值一次

    public ConstructorInjectionService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }
}

三种方式逐一对比:

维度 字段注入 Setter 注入 构造器注入
依赖可变性 可变 可变 不可变(final
缺依赖时 运行才 NPE 运行才 NPE 启动即失败
单测注入 Mock 需反射 可 Setter 直接 new
依赖是否显式 隐藏 较显式 构造器签名即清单
能否暴露循环依赖 被三级缓存掩盖 被掩盖 启动报错,逼你重构

Spring 官方与 IntelliJ IDEA 都会对字段注入给出 “Field injection is
not recommended” 警告。把它当成生产红线即可:字段注入和 Setter
注入都只在「理解概念」时写,生产代码一律构造器注入。

本章小结

IoC 把对象创建与依赖装配交给容器,DI
是它的落地手段;三种注入方式里,构造器注入(final
字段 +
@RequiredArgsConstructor)是生产唯一解
——不可变、非空、易测试、显式依赖,还能在启动期暴露循环依赖。


4.2 Bean 配置与生命周期

4.2.1 三种配置 Bean 的方式

方式一:注解(stereotype,主流)——你只需要在类上标注,容器自动扫描注册。

注解 语义 附加能力
@Component 通用组件
@Service 业务层 无(语义化,便于阅读)
@Repository 数据访问层 额外提供持久层异常翻译(把各数据库厂商异常统一转成
Spring 的 DataAccessException
@Controller / @RestController Web 层 @RestController = @Controller +
@ResponseBody

这四个注解本质相同(都是 @Component
的特化),区分它们主要为了语义清晰,让你一眼看出类的职责分层。

方式二:Java Config(@Configuration +
@Bean
——装配第三方类(你无法往别人源码里加
@Component)。

// 生产级:用 @Bean 装配第三方对象(RestTemplate 属于 Spring 但需要定制超时,不能用默认值)
@Configuration
public class BeanConfig {

    // @Bean 方法返回的对象会被注册成 Bean,方法名默认就是 beanName
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplateBuilder()
                .setConnectTimeout(Duration.ofSeconds(3))  // 连接超时 3 秒
                .setReadTimeout(Duration.ofSeconds(5))     // 读取超时 5 秒
                .build();
    }
}

@Configuration 类本身也是一个 Bean,它内部的
@Bean 方法会被容器调用并注册返回值。相比 XML,Java Config
类型安全、可断点调试、支持 IDE 跳转,是现代 Spring 的标准做法。

方式三:XML——遗留项目才用,新项目基本淘汰,本篇不展开。

4.2.2 Bean 作用域(Scope)

作用域 说明 生产注意
singleton 默认,整个容器只有一个实例 无状态 Bean 用这个(绝大多数 Service/Mapper 都是)
prototype 每次获取都 new 一个新实例 慎用;且原型 Bean 的销毁回调容器不会完整执行
request / session 一次 HTTP 请求 / 会话一个实例 仅 Web 环境,少用
@Scope("prototype")   // 每次注入都拿新实例
@Component
public class PrototypeBean { ... }

高频生产坑:singleton 里注入
prototype,拿到的永远是同一个

@Service
@RequiredArgsConstructor
public class OrderService {
    // 期望每次调用都拿到新 PrototypeBean,实际不是!
    private final PrototypeBean prototypeBean;
}

原因:OrderService
singleton,只在创建时注入一次
PrototypeBean,之后永远持有那同一个实例。容器不会因为你调用了
prototypeBean 就重新创建。

两种正确解法

// 解法 1:ObjectProvider 每次显式获取
@Service
@RequiredArgsConstructor
public class OrderService {
    private final ObjectProvider<PrototypeBean> prototypeProvider;

    public void handle() {
        PrototypeBean bean = prototypeProvider.getObject();   // 每次 get 都是新实例
    }
}
// 解法 2:@Lookup 方法注入(抽象方法由容器生成实现)
@Service
public abstract class OrderService2 {
    @Lookup
    public abstract PrototypeBean createPrototype();   // 每次调用返回新实例

    public void handle() {
        PrototypeBean bean = createPrototype();
    }
}

实际生产中,如果发现自己在纠结 prototype
的获取,多半是设计错了——应该用
ObjectProvider,或者干脆把「有状态」的数据放进方法参数而不是
Bean 里。

4.2.3 Bean 完整生命周期

Spring Bean 从创建到销毁要经过一串回调,这是理解「为什么
@PostConstruct 能初始化、AOP
为什么能生效」的关键。完整流程:

① 实例化(调用构造器,new 出对象)
   ↓
② 属性填充(依赖注入,构造器注入其实在①就完成了,这里是字段/Setter 注入)
   ↓
③ Aware 回调(BeanNameAware → BeanFactoryAware → ApplicationContextAware,
   让 Bean 拿到容器相关信息,如 beanName、容器引用)
   ↓
④ BeanPostProcessor # postProcessBeforeInitialization
   (★ AOP 代理就是在这里完成的:容器判断该 Bean 是否需要代理,
     需要则返回代理对象替代原始对象)
   ↓
⑤ 初始化回调
   - @PostConstruct 注解的方法
   - InitializingBean # afterPropertiesSet()
   ↓
⑥ BeanPostProcessor # postProcessAfterInitialization
   ↓
⑦ Bean 就绪,放入容器供使用
   ↓
⑧ 销毁回调
   - @PreDestroy 注解的方法
   - DisposableBean # destroy()

两个生产最常用的钩子

@Service
@Slf4j
public class CacheWarmService {

    // @PostConstruct:Bean 创建、依赖注入完成后执行,适合初始化缓存/加载配置
    @PostConstruct
    public void init() {
        log.info("Bean 初始化完成,开始预热缓存");
        // loadHotDataToCache();
    }

    // @PreDestroy:容器关闭、Bean 销毁前执行,适合释放连接、关闭线程池
    @PreDestroy
    public void destroy() {
        log.info("Bean 即将销毁,释放资源");
        // threadPool.shutdown();
    }
}

关键点BeanPostProcessor(第 ④⑥ 步)是
AOP 的基石。Spring 就是在这里判断「这个 Bean
有没有被切面命中」,命中就用 ProxyFactory
生成代理对象,把原始对象替换掉。所以你在业务代码里拿到的其实不是原始对象,而是代理对象——这一点在第
4.3 章会展开,务必先记住它的位置。

4.2.4
生命周期演示(各阶段打印日志)

下面用一个完整 Demo
把生命周期每一阶段都打出来,建议你亲手跑一遍,看日志顺序。

// 1) 实现 Aware 接口,观察容器回调
@Component
@Slf4j
public class LifecycleBean implements BeanNameAware, InitializingBean, DisposableBean {

    public LifecycleBean() {
        log.info("① 实例化:构造器被调用");
    }

    // BeanNameAware:容器把 beanName 告诉你
    @Override
    public void setBeanName(String name) {
        log.info("③ Aware 回调:beanName = {}", name);
    }

    // @PostConstruct:比 InitializingBean 更推荐(不耦合 Spring 接口)
    @PostConstruct
    public void postConstruct() {
        log.info("⑤ 初始化:@PostConstruct 执行");
    }

    // InitializingBean 接口回调,在 @PostConstruct 之后
    @Override
    public void afterPropertiesSet() {
        log.info("⑤ 初始化:afterPropertiesSet 执行");
    }

    @PreDestroy
    public void preDestroy() {
        log.info("⑧ 销毁:@PreDestroy 执行");
    }

    @Override
    public void destroy() {
        log.info("⑧ 销毁:DisposableBean#destroy 执行");
    }
}
// 2) 自定义 BeanPostProcessor,观察它在初始化前后的介入点
@Component
@Slf4j
public class LogBeanPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        if ("lifecycleBean".equals(beanName)) {
            log.info("④ BeanPostProcessor#before 执行:beanName = {}", beanName);
        }
        return bean;   // 必须返回 bean(或它的代理),否则容器里就没有这个对象了
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        if ("lifecycleBean".equals(beanName)) {
            log.info("⑥ BeanPostProcessor#after 执行:beanName = {}", beanName);
        }
        return bean;
    }
}

启动应用,日志顺序会严格对应上面的 8 步流程。推荐
@PostConstruct/@PreDestroy 注解而非
InitializingBean/DisposableBean
接口——注解不依赖 Spring 接口,代码更干净。

4.2.5 用 @Bean
装配一个可配置线程池(生产级)

线程池是「必须用 @Bean
装配第三方/复杂对象」的典型场景(第 4.4 章异步事件会用到它):

@Configuration
public class BeanConfig {

    // 用 @ConfigurationProperties 绑定配置,避免 @Value 满天飞(类型安全 + 可校验)
    @Bean
    @ConfigurationProperties(prefix = "app.thread-pool")
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);                          // 核心线程数
        executor.setMaxPoolSize(8);                           // 最大线程数
        executor.setQueueCapacity(200);                       // 队列容量
        executor.setThreadNamePrefix("app-exec-");            // 线程名前缀,排障时一眼认出
        executor.setRejectedExecutionHandler(
                new ThreadPoolExecutor.CallerRunsPolicy());    // 队列满时由调用线程执行,防止丢任务
        return executor;
    }
}

application.yml 里通过 app.thread-pool
前缀覆盖默认值(属性优先级高于代码 setter):

app:
  thread-pool:
    core-pool-size: 8
    max-pool-size: 16

关键点@ConfigurationProperties
把松散命名的配置(core-pool-size)自动映射到
setCorePoolSize,比一堆 @Value("${...}")
类型安全、可校验、可 IDE 跳转,生产强烈建议用它替代 @Value
读配置。

4.2.6
@Configuration 的 full 与 lite 模式(隐蔽坑)

@Bean 方法写在 @Configuration
类里时,Spring 会用 CGLIB
代理这个配置类,保证「@Bean
方法里调用另一个 @Bean
方法时,返回的是容器里的同一个单例」——这叫 full
模式

@Configuration
public class BeanConfig {

    @Bean
    public DataSource dataSource() { return new HikariDataSource(); }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        // 这里调用的 dataSource() 不是「再 new 一个」,而是容器里那个单例
        return new JdbcTemplate(dataSource());
    }
}

如果类上没标 @Configuration、只标了
@Component(即 lite 模式),则
@Bean
方法之间互相调用不会走代理dataSource()
会被真的再执行一次、产生多个实例——行为差异极隐蔽,且不报错。

结论:配置类一律用 @Configuration,不要用
@Component 凑合。

4.2.7 @PostConstruct /
@PreDestroy 的使用注意

@PostConstruct
public void init() { ... }   // 签名必须是 void 且无参数,private 也允许

@PreDestroy
public void destroy() { ... }

两个易踩的隐蔽坑:

  1. 方法签名:必须是 void
    返回、无参数,否则注解静默失效(不报错)。
  2. 导包:Spring Boot 3.x(基于 Spring 6 / Jakarta EE
    9+)里,@PostConstruct/@PreDestroy 来自
    jakarta.annotation 包,不是老项目的
    javax.annotation。导错包不会编译报错,但注解不生效——迁移老项目时尤其常见。

本章小结

Bean 的配置有注解、Java Config、XML 三种,新项目用「注解 + Java
Config」;生命周期 8 步里,BeanPostProcessor 是 AOP
生效的位置,@PostConstruct/@PreDestroy
是生产最常用的两个钩子;singleton 注入 prototype 拿不到新实例,要用
ObjectProvider@Lookup


4.3 AOP 切面编程

AOP
是理解「事务为什么有时会失效」的钥匙,也是本篇最重的一章。请务必吃透代理机制。

4.3.1 什么是 AOP,为什么需要它

业务代码里总有一堆「横向」逻辑:日志、耗时统计、权限校验、事务、限流。如果不用
AOP,它们会像下面这样侵入每一个方法:

// 反例:横切逻辑散落在每个方法里,重复且污染业务
public OrderVO createOrder(CreateOrderDTO dto) {
    long start = System.currentTimeMillis();       // 计时开始
    log.info("调用 createOrder, 参数={}", dto);      // 入参日志
    checkPermission();                              // 权限校验
    try {
        Order order = orderMapper.insert(...);      // 真正的业务
        return OrderVO.from(order);
    } catch (Exception e) {
        log.error("createOrder 失败", e);
        throw e;
    } finally {
        log.info("createOrder 耗时 {}ms", System.currentTimeMillis() - start);
    }
}

每一个方法都要复制这套「计时 + 日志 +
权限」,一旦要统一改格式,就得改几百处。AOP
的思路
:把这些横切逻辑抽出来,集中写在一个「切面」里,声明「在哪些方法上、什么时候、执行什么」,业务代码恢复成一行。

AOP 核心术语(先记住,后面一一对应到代码):

术语 含义 类比
JoinPoint(连接点) 程序执行的某个点,如方法调用 候选的「可以插入增强」的位置
Pointcut(切点) 匹配连接点的规则,定位「在哪些方法上增强」 筛选条件
Advice(通知) 增强逻辑本身,定义「做什么、何时做」 要插入的代码
Aspect(切面) Pointcut + Advice 的组合 一个完整功能模块

4.3.2 5 种通知(Advice)

通知 执行时机 能否拿到返回值 能否吞异常
@Before 目标方法执行
@AfterReturning 目标方法正常返回后 能(returning 指定参数名)
@AfterThrowing 目标方法抛异常后 否(拿异常)
@After 目标方法执行后(无论正常还是异常)
@Around 环绕,可同时控制前后 能,且能替换返回值 (不调 proceed() 即吞掉)

@Around
最强大(能拿到整个执行过程,可决定是否放行、是否替换返回值),也最容易用错(忘记调
proceed()
会导致目标方法根本不被执行)。

4.3.3 切点表达式(execution)

@Around("execution(* com.example.demo.service..*.*(..))")
//        └修饰符(可省)┘└──返回类型──┘└──包──┘└类┘└方法┘└参数┘
//  语法:execution([修饰符] 返回类型 包.类.方法(参数))

常用通配符:

符号 含义 示例
* 匹配任意一个部分 * com.example..*.*(..)
.. 包路径中匹配任意层级;参数中匹配任意个参数 com.example..* 匹配 com.example
下任意层级
(..) 任意参数 *(..)
execution(* com.example.demo.service..*.*(..))         // service 包及子包下所有类的所有方法
execution(* com.example.demo.service.UserService.*(..)) // UserService 接口的所有方法
execution(* com.example.demo..*.insert*(..))            // 任意包下 insert 开头的方法

4.3.4 生产级切面:日志 +
耗时统计

先给一个最小可用的耗时切面,理解
@Around 的骨架:

@Aspect      // 声明这是一个切面
@Component   // 切面本身也要注册成 Bean 才能被容器管理
@Slf4j
public class CostAspect {

    // 环绕通知:ProceedingJoinPoint 代表被拦截的方法
    @Around("execution(* com.example.demo.service..*.*(..))")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = pjp.proceed();   // ★ 放行,执行目标方法;不调用它,目标方法就不执行
        long cost = System.currentTimeMillis() - start;
        log.info("{}.{} 耗时 {}ms",
                pjp.getTarget().getClass().getSimpleName(),   // 目标类名
                pjp.getSignature().getName(),                  // 方法名
                cost);
        return result;   // 把目标方法的结果原样返回给调用方
    }
}

ProceedingJoinPoint 常用方法:

方法 作用
proceed() 执行目标方法(可传参 proceed(args) 替换入参)
getTarget() 拿到被代理的原始对象
getSignature() 拿到方法签名(方法名、声明类型)
getArgs() 拿到调用参数

生产级版本要处理异常记录参数打印(避免日志把密码、token
打出去):

@Aspect
@Component
@Slf4j
public class WebLogAspect {

    // 拦截 Controller 层所有方法:记录入参、耗时、异常
    @Around("execution(* com.example.demo.controller..*.*(..))")
    public Object log(ProceedingJoinPoint pjp) throws Throwable {
        String method = pjp.getTarget().getClass().getSimpleName() + "." + pjp.getSignature().getName();
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();
            // 生产注意:入参里可能有敏感字段(密码/token),脱敏后再打日志
            log.info("[OK] {} 耗时 {}ms", method, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable t) {
            // 异常要打全栈,同时原样抛出,交给全局异常处理器统一转 ApiResult
            log.error("[FAIL] {} 耗时 {}ms", method, System.currentTimeMillis() - start, t);
            throw t;
        }
    }
}

4.3.5 自定义注解 +
切面(@LogExecutionTime

execution
表达式适合「按包/类/方法名」批量拦截;若只想对标注了某个注解的方法生效,用
@annotation 切点 + 自定义注解,更精准、更显式:

// 自定义注解:标在方法上,表示「我要记录这个方法耗时」
@Target(ElementType.METHOD)   // 只能标在方法上
@Retention(RetentionPolicy.RUNTIME)  // 运行时保留,AOP 靠反射读取
public @interface LogExecutionTime {
    String value() default "";   // 可选:给这段耗时起个业务名
}
@Aspect
@Component
@Slf4j
public class LogExecutionTimeAspect {

    // @annotation(logExecutionTime):拦截所有标注了该注解的方法,
    // 注解对象通过同名参数传入
    @Around("@annotation(logExecutionTime)")
    public Object log(ProceedingJoinPoint pjp, LogExecutionTime logExecutionTime) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = pjp.proceed();
        String label = logExecutionTime.value().isEmpty()
                ? pjp.getSignature().getName()
                : logExecutionTime.value();
        log.info("[耗时统计] {} 耗时 {}ms", label, System.currentTimeMillis() - start);
        return result;
    }
}

业务代码里使用:

@Service
public class OrderServiceImpl implements OrderService {

    @Override
    @LogExecutionTime("创建订单")   // 该方法会被切面自动记录耗时
    public OrderVO createOrder(CreateOrderDTO dto) {
        // 业务代码里完全没有计时/日志,横切逻辑被切面接管
        ...
    }
}

4.3.6 AOP
代理机制(本篇重点,务必吃透)

Spring AOP
不是修改你的类,而是生成一个代理对象来替代它。

你在业务代码里拿到的、注入到别的 Bean
里的,其实是这个代理对象,不是原始对象。

两种代理实现

维度 JDK 动态代理 CGLIB
原理 基于接口,用 java.lang.reflect.Proxy +
InvocationHandler 生成一个「实现了相同接口」的代理类
基于继承,生成一个「目标类的子类」作为代理
前提 目标类必须实现接口 无需接口,但不能代理 final
类/final 方法
依赖 JDK 内置 第三方字节码库(Spring 已内置)
特点 只能拦截接口方法 能拦截非接口的 public 方法,稍快

Spring Boot 默认用哪种?Spring Boot 2.0
起默认
proxyTargetClass=true
,即:只要目标类没实现接口,或实现了接口但
Spring 判断更适合,就默认用 CGLIB
。所以哪怕你的 Service
实现了接口,Spring Boot 也经常直接用 CGLIB 生成子类代理。第 4.5.5 节「非
public 方法失效」就与 CGLIB 的继承机制直接相关。

代理调用链(文字配图)

调用方(比如 Controller)
   │  调用 userService.register(...)
   ▼
┌───────────────────────────────────────────┐
│  代理对象(CGLIB 生成的 UserServiceImpl 子类)│
│  register(dto) {                          │
│      // ① 切面前置逻辑:打印入参、开启事务      │
│      before();                            │
│      // ② 反射调用真实对象的方法               │
│      Object r = target.register(dto);     │
│      // ③ 切面后置逻辑:记录耗时、提交事务      │
│      after(r);                            │
│      return r;                            │
│  }                                        │
└───────────────────────────────────────────┘
   │ 持有原始对象引用(target)
   ▼
┌───────────────────────────────────────────┐
│  原始对象(真正的 UserServiceImpl)           │
│  register(dto) { ...业务代码... }           │
└───────────────────────────────────────────┘

关键结论:切面逻辑只在「从外部调用代理对象的方法」时才触发。一旦进入原始对象内部,this.xxx()
调用的是原始对象自己,不经过代理——这就是下面「自调用失效」的根源。

4.3.7 AOP
失效场景一:同类自调用(最高频,必背)

@Service
@Slf4j
public class OrderServiceImpl implements OrderService {

    @Override
    public void placeOrder(Long orderId) {
        // ★ 自调用:this.methodA() 调用本类另一个方法
        // 这里调用的 reduceStock 不经过代理,其上的 @Transactional / 切面全部失效
        this.reduceStock(orderId);
    }

    @LogExecutionTime("扣减库存")
    public void reduceStock(Long orderId) {
        // 期望:这里被切面记录耗时
        // 实际:placeOrder 内部 this.reduceStock 不经过代理,切面静默失效
    }
}

为什么失效placeOrder
被外部调用时走的是代理;但进入代理后,代理把控制权交给了原始对象
target.placeOrder()。原始对象内部再调
this.reduceStock(),这里的 this
是原始对象,调用的是原始对象自己的方法,根本没经过代理,所以切面(和事务)统统不生效。

外部 → 代理.placeOrder() → 原始对象.placeOrder()
                                  │
                                  └→ this.reduceStock()  ← 直接执行,绕过代理,切面失效

两种解决方案

// 解法 1:拆分到另一个 Bean(推荐,从根上消除自调用)
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
    private final StockService stockService;   // 把被增强的逻辑拆到独立 Bean

    @Override
    public void placeOrder(Long orderId) {
        stockService.reduceStock(orderId);   // 跨 Bean 调用 → 走代理 → 切面/事务生效
    }
}

@Service
public class StockServiceImpl implements StockService {
    @LogExecutionTime("扣减库存")
    @Override
    public void reduceStock(Long orderId) { ... }
}
// 解法 2:注入自身代理(特殊场景兜底,慎用:耦合容器、易循环依赖)
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
    private final OrderService self;   // 注入自己(容器会把代理对象注入进来)

    @Override
    public void placeOrder(Long orderId) {
        self.reduceStock(orderId);   // 通过代理调用 → 切面/事务生效
    }

    @LogExecutionTime("扣减库存")
    @Override
    public void reduceStock(Long orderId) { ... }
}

首选解法 1。拆分 Bean
是消除自调用、也是消除循环依赖的正道;注入自身代理(self)虽然能救急,但会让类依赖容器、还可能触发循环依赖报错,只在无法拆分的特殊场景用。

4.3.8 AOP 失效场景二:非
public 方法

@Service
public class OrderServiceImpl implements OrderService {

    @LogExecutionTime("私有方法")
    private void internalMethod() { ... }   // ★ private + 切面:不生效
}

为什么失效:Spring Boot 默认用
CGLIB,它通过继承生成子类代理。子类只能
@Override 父类的 public/protected
方法
private
方法根本无法被子类重写,代理自然拦截不到。而 @Transactional
的官方约定更严:只对 public
方法生效
(protected/private 都不行)。

正确做法:需要被增强的方法一律声明为
public,并放到能被代理拦截的 Bean 边界上。

4.3.9 更多切点表达式与组合

除了 execution
@annotation,常用的还有:

// within:拦截某个类(及其子类)的所有方法
@Before("within(com.example.demo.service.UserServiceImpl)")

// @within:拦截「类上标注了某注解」的类的所有方法(匹配类注解,非方法注解)
@Before("@within(org.springframework.stereotype.Service)")

// 组合:&&(且)、||(或)、!(非)
@Around("execution(* com.example.demo.service..*.*(..)) && !execution(* com.example.demo.service..*.internal*(..))")

切点复用:把表达式抽成一个具名方法,多个通知共用:

@Aspect
@Component
public class WebLogAspect {

    // @Pointcut 定义具名切点,供多个通知复用,避免表达式到处复制
    @Pointcut("execution(* com.example.demo.controller..*.*(..))")
    public void controllerLayer() {}

    @Before("controllerLayer()")
    public void before(JoinPoint jp) { ... }   // 前置通知用 JoinPoint(没有 proceed 方法)

    @Around("controllerLayer()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable { ... }  // 环绕用 ProceedingJoinPoint
}

注意 JoinPointProceedingJoinPoint
的区别:JoinPoint
只能「看」(拿参数、签名),不能「推进」方法;ProceedingJoinPoint
继承自 JoinPoint,多了
proceed(),所以只有 @Around
能用它
,其余四种通知只能用 JoinPoint

4.3.10 代理方式如何切换

spring:
  aop:
    proxy-target-class: true   # 默认 true:CGLIB;false 则优先 JDK 动态代理(要求接口)
// 代码方式(与上面配置等价)
@EnableAspectJAutoProxy(proxyTargetClass = true)

生产一般不用改:Spring Boot 默认 CGLIB
能覆盖「有无接口」两种情况。只在极个别「必须 JDK
代理」的边界场景(如某框架强依赖 Proxy 类)才需要调整。

4.3.11
多个切面的执行顺序(@Order)

多个切面命中同一个方法时,用 @Order
控制顺序,值越小越先执行(越靠近外层):

@Order(1)   // 先执行,包在最外层
@Aspect
@Component
public class LogAspect { ... }

@Order(2)   // 后执行,包在内层
@Aspect
@Component
public class PermissionAspect { ... }

这解释了为什么事务切面(Spring 内置,@Order
值很小)和你的日志切面能同时工作、互不干扰——它们是同一个代理链上的不同节点,各自负责自己的横切逻辑。

本章小结

AOP 靠「代理对象替换原始对象」实现横切增强;Spring Boot 默认用
CGLIB(继承生成子类)。切面只在外部经过代理调用时生效,因此同类自调用(this.xxx())与非
public 方法
是两大失效根源——这也是后面
@Transactional 失效的同一把钥匙。


4.4 Spring 事件机制

4.4.1
为什么用事件而不是直接调用

「订单支付成功后,要发短信通知 + 加积分 +
同步数据到下游」——最直接的写法是在支付方法里依次调这三个服务:

// 反例:支付方法里串行耦合了 4 件事,加一个新动作就得改这里
@Transactional
public void pay(Long orderId) {
    orderMapper.markPaid(orderId);      // 核心动作:改订单状态
    notifyService.sendSms(userId);       // 动作 2
    pointService.addPoints(userId, 100); // 动作 3
    syncService.syncToDataWarehouse(orderId); // 动作 4
}

问题:核心业务与一堆「可延后、可失败重试」的动作强耦合;任何一步慢了,支付接口就慢;任何一个动作失败,可能连累支付回滚。

事件机制的思路:支付方法只做「改订单状态」这一件核心事,然后发布一个事件,谁关心谁来监听。发布者不关心有哪些监听者,新增/删除监听者都不用改发布者代码——这就是观察者模式
Spring 落地,是模块解耦的利器。

4.4.2
基础用法:ApplicationEvent + @EventListener

// 1) 定义事件:携带业务数据
public class OrderPaidEvent extends ApplicationEvent {
    private final Long orderId;
    private final Long userId;

    public OrderPaidEvent(Object source, Long orderId, Long userId) {
        super(source);              // source 是发布者对象,框架要求
        this.orderId = orderId;
        this.userId = userId;
    }

    public Long getOrderId() { return orderId; }
    public Long getUserId() { return userId; }
}
// 2) 发布事件:注入 ApplicationEventPublisher
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
    private final ApplicationEventPublisher publisher;

    public void pay(Long orderId, Long userId) {
        // ... 改订单状态 ...
        publisher.publishEvent(new OrderPaidEvent(this, orderId, userId));  // 发布
    }
}
// 3) 监听事件
@Component
@Slf4j
public class OrderPaidListener {

    // @EventListener:容器会自动把匹配类型的事件传给该方法(默认同步执行)
    @EventListener
    public void onPaid(OrderPaidEvent event) {
        log.info("收到订单支付事件,发短信通知: orderId={}", event.getOrderId());
    }
}

4.4.3
事务场景的关键:@TransactionalEventListener

上面的 @EventListener
同步立即执行的。如果发布事件发生在事务提交之前,监听器可能读到「还没落库」的数据;更糟的是,监听器抛异常会导致事务回滚,把核心业务也拖下水。

// 反例:事务未提交就同步消费,可能读到脏数据,且监听器异常会连累主事务
@EventListener
public void onPaid(OrderPaidEvent event) { ... }

正确姿势:用
@TransactionalEventListener,让监听器在事务提交后再执行(默认
AFTER_COMMIT):

@Component
@Slf4j
public class OrderPaidListener {

    // 默认 phase = TransactionPhase.AFTER_COMMIT:主事务提交成功后才执行
    // 好处:① 此时数据一定已落库;② 监听器异常不会让主事务回滚
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onPaidAfterCommit(OrderPaidEvent event) {
        log.info("事务已提交,开始发通知/加积分: orderId={}", event.getOrderId());
        notifyService.sendSms(event.getUserId());
        pointService.addPoints(event.getUserId(), 100);
    }

    // fallbackExecution = true:发布方没有事务时也能触发(否则会被忽略)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
    public void onPaidNoTx(OrderPaidEvent event) { ... }
}

关键点@TransactionalEventListener
只有在发布方处于事务中时才生效;如果发布方根本没开事务,事件会被静默丢弃(除非加
fallbackExecution = true)。这是新手最容易踩的坑。

4.4.4 异步事件

通知、积分这类「可延后」的动作,应该异步执行,别拖慢主流程。两步:开异步
+ 监听器标 @Async

// 1) 启动类上加 @EnableAsync
@SpringBootApplication
@EnableAsync
public class DemoApplication { ... }
// 2) 监听器方法加 @Async,交给线程池异步执行
@Component
@Slf4j
public class OrderPaidListener {

    // @Async:用第 4.2.5 节配置的 taskExecutor 线程池执行,不阻塞主线程
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onPaidAsync(OrderPaidEvent event) {
        log.info("异步发短信: orderId={}", event.getOrderId());
    }
}

4.4.5
监听器的其他写法与执行顺序

@EventListener 是注解式写法,Spring
还提供接口式写法(二者可混用):

// 接口式监听器:实现 ApplicationListener<事件类型>
@Component
public class LegacyListener implements ApplicationListener<OrderPaidEvent> {
    @Override
    public void onApplicationEvent(OrderPaidEvent event) {
        log.info("接口式监听: orderId={}", event.getOrderId());
    }
}

多个监听器的执行顺序:用 @Order
控制,值越小越先执行:

@Component
public class OrderPaidListeners {

    @Order(1)   // 先发通知
    @EventListener
    public void sendNotify(OrderPaidEvent e) { ... }

    @Order(2)   // 再加积分
    @EventListener
    public void addPoints(OrderPaidEvent e) { ... }
}

条件监听@EventListener 支持
condition,用 SpEL 过滤事件:

// 只处理 orderId 非空的事件(表达式里的 #event 指方法参数)
@EventListener(condition = "#event.orderId != null")
public void onPaid(OrderPaidEvent event) { ... }

本章小结

事件机制用「发布/订阅」解耦核心业务与可延后动作;事务内发布必须用
@TransactionalEventListener(AFTER_COMMIT),确保「数据落库后再消费、监听器异常不连累主事务」;可延后的动作再加
@Async 异步化。


4.5 声明式事务基础

4.5.1 @Transactional
的本质:一个 AOP 切面

你写的 @Transactional 其实没有「魔法」——Spring
在启动时,给所有标注了它的 Bean 生成代理,把事务逻辑织入方法前后:

外部调用 transfer(...)
   │
   ▼
代理对象.transfer(...)
   ├─ 前置:TransactionInterceptor 拦截,通过 DataSourceTransactionManager 开启事务
   ├─ 调用:真实对象.transfer(...)  ← 执行你的业务代码
   └─ 后置:正常则提交(commit),抛 RuntimeException/Error 则回滚(rollback)

其中 TransactionInterceptor 就是
@Transactional 对应的
Advice,DataSourceTransactionManager
负责真正操作数据库连接(setAutoCommit(false)
commit/rollback)。

这带来一个必然推论:第 4.3 章讲的所有 AOP
失效场景,对 @Transactional
同样成立——自调用失效、非 public
失效,本质是同一件事。

4.5.2 传播行为(Propagation)

传播行为回答:「当前已有一个事务时,被调用的方法该怎么办?

传播行为 含义 生产用途
REQUIRED 默认。有事务就加入,没有就新建 绝大多数场景,保证「要么都成功要么都回滚」
REQUIRES_NEW 总是新建独立事务,挂起当前事务 记录日志/审计:即使主事务回滚,日志也要落库
NESTED 嵌套事务,内层可独立回滚到保存点 批量处理:单条失败不影响整体
SUPPORTS 有就加入,没有就以非事务执行 只读查询
NOT_SUPPORTED 有事务则挂起,以非事务执行 长耗时操作,避免占用连接
MANDATORY 必须在事务中,否则抛异常 强制调用方开事务
NEVER 必须不在事务中,否则抛异常 极少用
// REQUIRED(默认):transfer 与它内部调用的方法共用同一个事务
@Transactional   // 等价于 @Transactional(propagation = Propagation.REQUIRED)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    accountMapper.deduct(fromId, amount);   // 若这里抛出异常,add 也不会生效
    accountMapper.add(toId, amount);
}

// REQUIRES_NEW:独立事务,不受外层回滚影响(典型:审计日志)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(String content) { ... }

4.5.3 隔离级别(Isolation)

隔离级别回答:「并发事务之间能看到多少对方的中间数据?」,本质是「性能
vs 一致性」的权衡。

级别 脏读 不可重复读 幻读 说明
READ_UNCOMMITTED 几乎不用
READ_COMMITTED 多数数据库默认(Oracle/PG)
REPEATABLE_READ MySQL 默认
SERIALIZABLE 串行化,性能最差
// 生产一般显式指定,避免依赖不同数据库的默认值差异
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transfer(Long fromId, Long toId, BigDecimal amount) { ... }

4.5.4
转账事务(生产级完整示例)

用 H2 内存库 + JdbcTemplate 写一个真实可跑的转账:

@Service
@Slf4j
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService {

    private final JdbcTemplate jdbcTemplate;

    // 转账:扣 A + 加 B,任一步失败都整体回滚
    @Override
    @Transactional   // 默认 REQUIRED:整个方法一个事务
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // 1) 扣款:余额不足时抛异常,触发回滚
        int updated = jdbcTemplate.update(
                "UPDATE t_account SET balance = balance - ? WHERE id = ? AND balance >= ?",
                amount, fromId, amount);
        if (updated == 0) {
            // 抛业务异常,事务回滚(扣款不会生效)
            throw new BizException(ErrorCode.INSUFFICIENT_BALANCE);
        }
        // 2) 入账
        jdbcTemplate.update(
                "UPDATE t_account SET balance = balance + ? WHERE id = ?",
                amount, toId);
        log.info("转账成功: {} -> {}, 金额={}", fromId, toId, amount);
    }
}

为什么 updated == 0
要抛异常
balance >= ? 这个 WHERE
条件在余额不足时更新不到任何行,update 返回
0。此时必须显式抛异常,事务才会回滚——如果吞掉继续走,就会出现「A 没扣、B
却加了」的资损事故。

4.5.5 事务失效四大场景(必背)

场景一:同类自调用(同第 4.3.7 节)

@Service
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService {

    // 外部调用 createAndTransfer(),它内部调用了带 @Transactional 的 doTransfer()
    public void createAndTransfer(Long fromId, Long toId, BigDecimal amount) {
        this.doTransfer(fromId, toId, amount);   // ★ 自调用:事务不生效!
    }

    @Transactional
    public void doTransfer(Long fromId, Long toId, BigDecimal amount) {
        // 期望有事务,实际 this 调用绕过代理,事务静默失效
    }
}

验证doTransfer
内部扣款后抛异常,你会看到扣款依然生效了(没回滚)——因为根本没开事务。

场景二:非 public 方法

@Service
public class AccountServiceImpl {
    @Transactional
    private void doTransfer(...) { ... }   // ★ private:事务不生效
}

场景三:异常被吞

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    try {
        jdbcTemplate.update("UPDATE t_account SET balance = balance - ? WHERE id = ?", amount, fromId);
        int i = 1 / 0;   // 抛 ArithmeticException(RuntimeException)
    } catch (Exception e) {
        // ★ 吞掉异常:Spring 感知不到异常,事务照常提交,数据不一致
        log.error("转账异常", e);
    }
}

正确做法:异常上抛(让代理感知并回滚),或在
catch 里显式标记回滚:

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    try {
        ... 
    } catch (Exception e) {
        log.error("转账异常,标记回滚", e);
        // 显式标记当前事务只回滚,然后(可选)把异常转成业务异常上抛
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        throw new BizException(ErrorCode.SYSTEM_ERROR);
    }
}

场景四:异常类型不对(受检异常默认不回滚)

@Transactional 默认只对
RuntimeExceptionError
回滚
,受检异常(Exception
及其非运行时子类)不回滚。

// 反例:抛出受检异常,事务不会回滚
@Transactional
public void transfer(...) throws IOException {
    // 业务代码抛出 IOException(受检异常)→ 事务提交,数据不一致
}
// 正例:显式声明回滚范围,覆盖所有异常
@Transactional(rollbackFor = Exception.class)
public void transfer(...) { ... }

四个失效场景的共同根因:要么「绕过了代理」(场景一、二),要么「代理感知不到该回滚」(场景三、四)。理解了代理机制,这四个坑不用死记硬背。

4.5.6 @Transactional
的更多生产细节

readOnly 优化:只读方法标
readOnly = true,数据库可据此做优化(如路由到只读副本、跳过脏检查):

@Transactional(readOnly = true)
public UserVO getUser(Long id) { ... }

timeout 超时:防止长事务占用数据库连接:

@Transactional(timeout = 5)   // 超过 5 秒强制回滚,单位秒
public void transfer(...) { ... }

REQUIRES_NEW
演示
(审计日志独立提交,不受主事务回滚影响):

@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final AuditService auditService;

    @Transactional
    public void cancelOrder(Long orderId) {
        orderMapper.cancel(orderId);               // 主事务:取消订单
        auditService.record("订单取消");             // 独立事务:主事务回滚,日志也落库
        throw new BizException(ErrorCode.SYSTEM_ERROR);  // 故意让主事务回滚
        // 结果:订单取消被回滚,但审计日志因 REQUIRES_NEW 已提交
    }
}

@Service
public class AuditServiceImpl implements AuditService {
    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String content) {
        // 挂起外层事务,新建独立事务写审计日志
    }
}

关键点REQUIRES_NEW
这类传播行为依赖代理拦截,所以 auditService.record()
必须跨 Bean 调用;如果写成同类
this.record(),就又掉回「自调用失效」的坑里——传播行为完全不会触发。这再次印证:理解代理机制,是理解事务一切行为的钥匙。

本章小结

@Transactional 本质是 AOP
切面(TransactionInterceptor +
DataSourceTransactionManager),所以 AOP
的失效规律对它完全适用;传播行为默认
REQUIRED、隔离级别要显式指定、默认只对
RuntimeException/Error
回滚。转账示例里「扣款失败必须抛异常」是生产防资损的底线。


4.6
生产级实战:用户服务 + 日志切面 + 事件解耦 + 事务

本章把前 5 章的知识串成一个完整分层项目:用户注册(事务 +
校验)→ 发布注册事件 → 切面记录耗时 →
监听器事务提交后异步发通知

4.6.1 项目需求与分层设计

需求:用户注册接口。注册成功后:

  1. 落库一条用户记录(事务);
  2. 切面自动打印接口耗时;
  3. 发布 UserRegisteredEvent 事件;
  4. 监听器在事务提交后异步发欢迎通知(解耦 +
    不拖慢主流程 + 不连累主事务)。

分层(严格遵循总规范:DTO/VO/Entity 分离、接口 +
impl):

com.example.demo
├── DemoApplication.java          # 启动类(@EnableAsync)
├── common
│   ├── ApiResult.java            # 统一响应体
│   └── ErrorCode.java            # 统一错误码
├── exception
│   ├── BizException.java         # 业务异常
│   └── GlobalExceptionHandler.java # 全局异常处理
├── config
│   └── BeanConfig.java           # 线程池 @Bean
├── dto
│   └── RegisterDTO.java          # 入参(校验注解)
├── vo
│   └── UserVO.java               # 出参(裁剪字段)
├── entity
│   └── User.java                 # 数据库实体
├── mapper
│   └── UserMapper.java           # 数据访问(JdbcTemplate)
├── service
│   ├── UserService.java          # 接口
│   └── impl
│       └── UserServiceImpl.java  # 实现(构造器注入 + @Transactional)
├── controller
│   └── UserController.java
├── event
│   ├── UserRegisteredEvent.java  # 事件
│   └── UserRegisteredListener.java # 监听器(@Async + @TransactionalEventListener)
└── aspect
    └── WebLogAspect.java         # 日志耗时切面

4.6.2
基础设施层(贯穿全系列的公共类)

这几个类是 12
篇文章的公共基础设施,类名、字段名、方法名全系列固定,逐字给出完整实现。

// common/ApiResult.java —— 统一响应体:所有 Controller 返回该结构
package com.example.demo.common;

import lombok.Data;
import org.slf4j.MDC;

@Data
public class ApiResult<T> {
    private int code;        // 0=成功,非 0=错误码
    private String message;  // 提示信息
    private T data;          // 业务数据
    private String traceId;  // 链路追踪 id

    public static <T> ApiResult<T> ok(T data) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(ErrorCode.SUCCESS.getCode());
        r.setMessage(ErrorCode.SUCCESS.getMessage());
        r.setData(data);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> ok() {
        return ok(null);
    }
    public static <T> ApiResult<T> fail(int code, String message) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(code);
        r.setMessage(message);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> fail(ErrorCode ec) {
        return fail(ec.getCode(), ec.getMessage());
    }
}
// common/ErrorCode.java —— 统一错误码:基础码必须保留且数值不变
package com.example.demo.common;

public enum ErrorCode {
    SUCCESS(0, "success"),
    PARAM_ERROR(40001, "参数错误"),
    UNAUTHORIZED(40101, "未登录或登录已过期"),
    FORBIDDEN(40301, "无权限访问"),
    USER_NOT_FOUND(40401, "用户不存在"),
    SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"),
    // 本篇业务扩展
    INSUFFICIENT_BALANCE(50001, "账户余额不足"),
    ;

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() { return code; }

    public String getMessage() { return message; }
}
// exception/BizException.java —— 业务异常:业务代码抛出,由全局处理器统一转 ApiResult
package com.example.demo.exception;

import com.example.demo.common.ErrorCode;

public class BizException extends RuntimeException {
    private final int code;

    public BizException(ErrorCode ec) {
        super(ec.getMessage());
        this.code = ec.getCode();
    }

    public BizException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() { return code; }
}
// exception/GlobalExceptionHandler.java —— 全局异常处理:兜底异常对外只给模糊提示
package com.example.demo.exception;

import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 业务异常:code/message 直接透传给前端
    @ExceptionHandler(BizException.class)
    public ApiResult<Void> handleBiz(BizException e) {
        log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
        return ApiResult.fail(e.getCode(), e.getMessage());
    }

    // 参数校验异常(@Valid 触发):取第一条失败信息返回
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResult<Void> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .findFirst()
                .map(f -> f.getField() + " " + f.getDefaultMessage())
                .orElse(ErrorCode.PARAM_ERROR.getMessage());
        log.warn("参数校验失败: {}", msg);
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), msg);
    }

    // 兜底异常:对外返回模糊提示,详细堆栈只进日志,避免泄露内部结构
    @ExceptionHandler(Exception.class)
    public ApiResult<Void> handleOther(Exception e) {
        log.error("系统异常", e);
        return ApiResult.fail(ErrorCode.SYSTEM_ERROR);
    }
}

4.6.3
实体与分层对象(DTO / Entity / VO 分离)

// entity/User.java —— 数据库实体(映射 t_user 表)
package com.example.demo.entity;

import lombok.Data;

@Data
public class User {
    private Long id;
    private String username;
}
// dto/RegisterDTO.java —— 入参:带 JSR-303 校验注解,禁止直接接 Entity
package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class RegisterDTO {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度需在 3-20 之间")
    private String username;
}
// vo/UserVO.java —— 出参:按需裁剪字段,不暴露内部实体细节
package com.example.demo.vo;

import lombok.Data;

@Data
public class UserVO {
    private Long id;
    private String username;

    public static UserVO from(Long id, String username) {
        UserVO vo = new UserVO();
        vo.setId(id);
        vo.setUsername(username);
        return vo;
    }
}

4.6.4
数据访问层(JdbcTemplate)

// mapper/UserMapper.java —— 数据访问:本篇用 JdbcTemplate 演示(后续阶段再换 MyBatis-Plus)
package com.example.demo.mapper;

import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;

import java.sql.PreparedStatement;
import java.sql.Statement;

@Repository   // @Repository:数据访问层,提供持久层异常翻译
@RequiredArgsConstructor
public class UserMapper {

    private final JdbcTemplate jdbcTemplate;

    public Long insert(String username) {
        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(con -> {
            PreparedStatement ps = con.prepareStatement(
                    "INSERT INTO t_user(username) VALUES (?)", Statement.RETURN_GENERATED_KEYS);
            ps.setString(1, username);
            return ps;
        }, keyHolder);
        // 拿到自增主键
        Number key = keyHolder.getKey();
        return key == null ? null : key.longValue();
    }

    public boolean existsByUsername(String username) {
        Integer count = jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM t_user WHERE username = ?", Integer.class, username);
        return count != null && count > 0;
    }
}

注意 schema.sql 里要加上 t_user 表(第 5
章运行步骤会给出完整脚本)。

4.6.5 业务层(接口 +
impl,构造器注入 + 事务 + 事件)

// service/UserService.java —— 接口
package com.example.demo.service;

import com.example.demo.dto.RegisterDTO;
import com.example.demo.vo.UserVO;

public interface UserService {
    UserVO register(RegisterDTO dto);
}
// service/impl/UserServiceImpl.java —— 实现:构造器注入 + @Transactional + 发布事件
package com.example.demo.service.impl;

import com.example.demo.dto.RegisterDTO;
import com.example.demo.event.UserRegisteredEvent;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.UserService;
import com.example.demo.vo.UserVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserMapper userMapper;
    private final ApplicationEventPublisher publisher;   // 事件发布器

    @Override
    @Transactional   // 注册整体在一个事务内:落库 + 发布事件要么都成功,要么都回滚
    public UserVO register(RegisterDTO dto) {
        // 业务校验:用户名重复抛业务异常(由全局处理器转 ApiResult)
        if (userMapper.existsByUsername(dto.getUsername())) {
            throw new BizException(40010, "用户名已存在");
        }
        Long id = userMapper.insert(dto.getUsername());
        // 发布事件:监听器在「事务提交后」才异步消费,不拖慢、不连累本事务
        publisher.publishEvent(new UserRegisteredEvent(this, id, dto.getUsername()));
        log.info("用户注册成功: id={}, username={}", id, dto.getUsername());
        return UserVO.from(id, dto.getUsername());
    }
}

4.6.6 事件(解耦通知)

// event/UserRegisteredEvent.java
package com.example.demo.event;

import org.springframework.context.ApplicationEvent;

public class UserRegisteredEvent extends ApplicationEvent {
    private final Long userId;
    private final String username;

    public UserRegisteredEvent(Object source, Long userId, String username) {
        super(source);
        this.userId = userId;
        this.username = username;
    }

    public Long getUserId() { return userId; }
    public String getUsername() { return username; }
}
// event/UserRegisteredListener.java —— 事务提交后 + 异步发通知
package com.example.demo.event;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
public class UserRegisteredListener {

    // ① @TransactionalEventListener(AFTER_COMMIT):数据落库后才消费,异常不连累主事务
    // ② @Async:走第 4.2.5 节配置的线程池,不阻塞注册接口
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendWelcomeNotice(UserRegisteredEvent event) {
        // 生产里这里会对接短信/邮件/推送服务
        log.info("异步发送欢迎通知: userId={}, username={}", event.getUserId(), event.getUsername());
    }
}

4.6.7 切面与配置

// aspect/WebLogAspect.java —— 接口耗时 + 异常日志
package com.example.demo.aspect;

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 WebLogAspect {

    @Around("execution(* com.example.demo.controller..*.*(..))")
    public Object log(ProceedingJoinPoint pjp) throws Throwable {
        String method = pjp.getTarget().getClass().getSimpleName() + "." + pjp.getSignature().getName();
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();
            log.info("[OK] {} 耗时 {}ms", method, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable t) {
            log.error("[FAIL] {} 耗时 {}ms", method, System.currentTimeMillis() - start, t);
            throw t;   // 原样抛出,交给 GlobalExceptionHandler
        }
    }
}
// config/BeanConfig.java —— 线程池 @Bean(给 @Async 用)
package com.example.demo.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ThreadPoolExecutor;

@Configuration
public class BeanConfig {

    @Bean
    @ConfigurationProperties(prefix = "app.thread-pool")
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("app-exec-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
}

4.6.8 控制层与启动类

// controller/UserController.java
package com.example.demo.controller;

import com.example.demo.common.ApiResult;
import com.example.demo.dto.RegisterDTO;
import com.example.demo.service.UserService;
import com.example.demo.vo.UserVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;   // 构造器注入

    // @Valid 触发 DTO 校验,失败抛 MethodArgumentNotValidException,由全局处理器兜底
    @PostMapping("/register")
    public ApiResult<UserVO> register(@Valid @RequestBody RegisterDTO dto) {
        return ApiResult.ok(userService.register(dto));
    }
}
// DemoApplication.java
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync   // 开启 @Async 异步支持
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

本章小结

一个生产级模块的骨架是「Controller(@Valid +
ApiResult)→ Service 接口 + impl(构造器注入 +
@Transactional + 发布事件)→ Mapper(数据访问)→
监听器(@Async +
@TransactionalEventListener)→
切面(日志耗时)」,每一层职责单一、依赖通过构造器注入、横切逻辑交给
AOP、可延后动作交给事件。


5. 生产级实战项目

第 4.6
章已经给出完整源码,本节把它组装成一个可直接运行的工程:目录结构、配置文件、启动步骤、验证命令一应俱全。

5.1 完整目录结构

demo
├── pom.xml
└── src/main
    ├── java/com/example/demo
    │   ├── DemoApplication.java
    │   ├── common
    │   │   ├── ApiResult.java
    │   │   └── ErrorCode.java
    │   ├── exception
    │   │   ├── BizException.java
    │   │   └── GlobalExceptionHandler.java
    │   ├── config
    │   │   └── BeanConfig.java
    │   ├── dto
    │   │   └── RegisterDTO.java
    │   ├── vo
    │   │   └── UserVO.java
    │   ├── entity
    │   │   └── User.java
    │   ├── mapper
    │   │   └── UserMapper.java
    │   ├── service
    │   │   ├── UserService.java
    │   │   ├── AccountService.java
    │   │   └── impl
    │   │       ├── UserServiceImpl.java
    │   │       └── AccountServiceImpl.java
    │   ├── controller
    │   │   ├── UserController.java
    │   │   └── AccountController.java
    │   ├── event
    │   │   ├── UserRegisteredEvent.java
    │   │   └── UserRegisteredListener.java
    │   └── aspect
    │       └── WebLogAspect.java
    └── resources
        ├── application.yml
        └── schema.sql

5.2 完整配置文件

src/main/resources/schema.sql(H2
启动建表,t_user 给注册用,t_account
给转账用):

-- 用户表(注册场景)
CREATE TABLE IF NOT EXISTS t_user (
    id       BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(64) NOT NULL UNIQUE
);

-- 账户表(转账事务场景)
CREATE TABLE IF NOT EXISTS t_account (
    id       BIGINT PRIMARY KEY,
    username VARCHAR(64)  NOT NULL,
    balance  DECIMAL(10,2) NOT NULL DEFAULT 0
);

src/main/resources/application.yml

spring:
  datasource:
    url: jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password: ""
  sql:
    init:
      mode: always
      schema-locations: classpath:schema.sql

app:
  thread-pool:
    core-pool-size: 4
    max-pool-size: 8

5.3
补充源码:转账 Controller(串起第 5 章事务知识)

第 4.6 章已给全注册链路源码,这里补一个转账
Controller,让事务也能通过 HTTP 验证(AccountService 接口 +
AccountServiceImpl 见第 4.5.4 节):

// controller/AccountController.java
package com.example.demo.controller;

import com.example.demo.common.ApiResult;
import com.example.demo.service.AccountService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;

@RestController
@RequestMapping("/api/accounts")
@RequiredArgsConstructor
public class AccountController {

    private final AccountService accountService;

    // 转账:余额不足时抛 BizException(INSUFFICIENT_BALANCE),全局处理器转成 ApiResult
    @PostMapping("/transfer")
    public ApiResult<Void> transfer(@RequestParam Long fromId,
                                    @RequestParam Long toId,
                                    @RequestParam BigDecimal amount) {
        accountService.transfer(fromId, toId, amount);
        return ApiResult.ok(null);
    }
}

service/AccountService.java 接口:

package com.example.demo.service;

import java.math.BigDecimal;

public interface AccountService {
    void transfer(Long fromId, Long toId, BigDecimal amount);
}

5.4 运行步骤

# 1) 启动应用
mvn spring-boot:run

# 2) 另一个终端:注册用户(触发切面 + 事务 + 事件)
curl -X POST http://localhost:8080/api/users/register 
  -H "Content-Type: application/json" 
  -d '{"username":"alice"}'

# 期望响应(ApiResult 结构,code=0 成功)
# {"code":0,"message":"success","data":{"id":1,"username":"alice"},"traceId":null}

# 3) 参数校验失败(用户名过短),验证 GlobalExceptionHandler
curl -X POST http://localhost:8080/api/users/register 
  -H "Content-Type: application/json" 
  -d '{"username":"ab"}'
# {"code":40001,"message":"username 用户名长度需在 3-20 之间","data":null,"traceId":null}

# 4) 初始化两个账户,验证转账事务
# 先通过 H2 控制台或测试代码插入:INSERT INTO t_account VALUES (1,'a',100.00),(2,'b',0.00);
curl -X POST "http://localhost:8080/api/accounts/transfer?fromId=1&toId=2&amount=50.00"
# {"code":0,"message":"success","data":null,"traceId":null}

# 5) 余额不足(amount > 100),验证事务回滚 + 业务异常
curl -X POST "http://localhost:8080/api/accounts/transfer?fromId=1&toId=2&amount=999.00"
# {"code":50001,"message":"账户余额不足","data":null,"traceId":null}

启动后观察控制台日志,应能看到切面打印的耗时日志、监听器打印的异步通知日志,验证
AOP 与事件链路都生效。

运行提示:H2
内存库每次重启会重建,测试转账前需先插入账户数据。可临时在
schema.sql 里追加
INSERT INTO t_account VALUES (1,'a',100.00),(2,'b',0.00);
作为演示种子数据。


6. 常见坑与排错指南

坑 / 现象 原因 解决方案
字段 @Autowired 注入的依赖为 null,运行
NPE
字段注入在构造器之后才填充,且依赖不可变/难测试 改用构造器注入(final +
@RequiredArgsConstructor
应用启动报 form a cycle 循环依赖错误 构造器注入暴露了 A↔︎B 循环依赖(设计问题) 重构:抽公共类 /
事件解耦;不要改回字段注入掩盖
加了 @Transactional 但事务不生效、数据没回滚 同类自调用(this.xxx())绕过了代理 拆分到另一个 Bean,或注入自身代理
@Transactional 标在
private/protected 方法上无效
CGLIB 靠继承重写,无法拦截非 public;事务官方只支持 public 标在 public 方法上
事务里 try-catch 吞异常,数据却没回滚 Spring 感知不到异常,照常提交 异常上抛,或 setRollbackOnly()
IOException 等受检异常,事务不回滚 默认只对 RuntimeException/Error 回滚 @Transactional(rollbackFor = Exception.class)
singleton 里注入 prototype Bean,每次拿到的还是同一个 单例只在创建时注入一次 ObjectProvider#getObject()
@Lookup
事件监听器在事务提交前执行,读到脏数据 / 异常连累主事务 用了同步 @EventListener 而非事务监听 @TransactionalEventListener(AFTER_COMMIT)
监听器收不到事件 发布方没有事务,且监听器没加 fallbackExecution fallbackExecution = true,或确保发布方有事务
转账时「扣款失败」却还继续入账,造成资损 扣款 UPDATE 返回 0 后未抛异常 判断影响行数,为 0 时抛 BizException

8. 总结与延伸阅读

本篇总结

Spring Boot 的一切「魔法」,归根到底是两件事:IoC
帮你装配对象,AOP 帮你增强对象
。IoC
的正确姿势是构造器注入(final +
@RequiredArgsConstructor),它带来不可变、非空、易测试,还能在启动期暴露循环依赖逼你重构;Bean
生命周期里 BeanPostProcessor 是 AOP 的落点;AOP
靠「代理对象替换原始对象」工作,Spring Boot 默认用
CGLIB(继承生成子类),这直接决定了「同类自调用」和「非 public
方法」两大失效场景;@Transactional 本质就是一个 AOP
切面,所以它的失效规律与 AOP
完全一致;事件机制(@TransactionalEventListener +
@Async)则是模块解耦的利器。把这些底层机制吃透,后续的自动配置、Web
开发、缓存、安全,你都能「看穿实现套路」而不是「背配置」。

延伸阅读

  1. Spring 官方文档 · Core Technologies:IoC
    容器、AOP、事件、事务的权威定义,查 API 首选。https://docs.spring.io/spring-framework/reference/core.html
  2. 《Spring 实战(第 6 版)》(Craig
    Walls)
    :循序渐进地讲透 IoC / AOP,配套 Spring Boot 3
    实践。
  3. 《Spring 揭秘》(王福强):专攻 IoC / AOP
    实现原理,讲容器和 AOP 的「为什么」。
  4. 《Spring Boot 编程思想(核心篇)》(小马哥):深入
    Spring Framework 设计思想,适合进阶。
  5. Spring 官方 Reference · Declarative Transaction
    Management
    :事务传播行为、隔离级别、失效场景的官方说明。https://docs.spring.io/spring-framework/reference/data-access/transaction.html
正文完
 0
评论(没有评论)