简介:这份压缩包围绕 Spring Framework 5.2.8.RELEASE 版本整理,面向正在学习或使用 SSM 架构的 Java 后端开发者,适合在项目搭建、依赖版本对齐、离线环境部署以及旧项目维护时,作为 Spring 相关 jar 包的获取来源。压缩包体积约 86.01MB;因文件总数与类型明细暂无数据,更适合按 Spring 发布版本整体取用,而非逐项检索零散资料。当前页面显示已有 445 人学习或下载,说明该版本在同类资源中具备一定参考需求。对于需要复现 Spring 5.2.x 运行环境、排查依赖冲突或补齐 SSM 工程基础包的读者,可借助它减少四处查找版本的时间,并对照项目实际依赖进行替换与验证。使用前建议确认 JDK、Spring 与持久层、Web 框架的版本兼容关系,再结合构建工具完成引入。
1. 拿到 spring-framework-5.2.8.RELEASE//ssm.zip 先分清楚:源码包和整合工程是两码事
spring-framework-5.2.8.RELEASE//ssm.zip这个名字里其实装了两个东西:前半截是 Spring 框架 5.2.8 的完整源码树,后半截是一套 SSM 整合工程骨架。很多人解压后直接丢进 IDE,结果 Gradle 同步卡在依赖解析、父子容器互相看不见、@Transactional死活不生效,一周跑不出一条完整链路——既觉得源码无法读懂,又觉得工程无法运行。它适合两类人:想顺着源码把 IoC 容器和 AOP 代理这条主线看明白的后端,以及手上有一套 SSM 老项目要接、需要一份能对照着改的工程结构的人。下面按「源码怎么读 → 工程怎么配 → 链路怎么通 → 坑在哪」拆开讲,每一步都给到能直接抄的命令和配置。
2. Spring 5.2.8 源码包拆解:十几个模块里你真正要读的只有四个
2.1 目录分层与模块依赖顺序:先认清谁依赖谁
解开压缩包,根目录是一堆以spring-开头的子项目,外加build.gradle、settings.gradle、gradlew这一套 Gradle 文件。源码包的组织方式是「一个模块一个子工程」,模块之间的依赖是单向的,最底层是spring-core,往上依次是spring-beans、spring-context,再往上是spring-aop、spring-expression,再往上是spring-jdbc、spring-tx、spring-orm、spring-web、spring-webmvc,最上面挂spring-test、spring-aspects这类粘合模块。读懂这个顺序很关键:你在spring-context里看到ApplicationContext调用了BeanFactory,那个接口在spring-beans;看到代理创建走ProxyFactory,实现在spring-aop。跳错了模块,断点永远打不进去。
常见做法是先把模块按「必读 / 查阅 / 忽略」分三档,不要试图从spring-core一行行读到spring-webmvc,那样大概率读到一半就放弃。我一般的分法是:
| 模块 | 入口类 | 什么时候需要动它 |
|---|---|---|
| spring-beans | DefaultListableBeanFactory、AbstractAutowireCapableBeanFactory | 追踪getBean、循环依赖、BeanDefinition 注册 |
| spring-context | AbstractApplicationContext#refresh、ConfigurationClassPostProcessor | 追踪启动流程、注解扫描、@Bean解析 |
| spring-aop | ProxyFactory、AbstractAutoProxyCreator、AopProxy | 追踪事务、切面代理到底是用 JDK 还是 CGLIB 生成的 |
| spring-tx | TransactionInterceptor、DataSourceTransactionManager | 确认@Transactional是否真的通过代理执行 |
| spring-webmvc | DispatcherServlet、RequestMappingHandlerMapping | 请求 404、映射冲突、参数绑定异常 |
| spring-jdbc | JdbcTemplate、DataSourceUtils | 确认连接是否绑到当前线程事务 |
| spring-expression | SpelExpressionParser | @Value、缓存 Key 里的表达式求值 |
| spring-jcl | LogFactory | 日志冲突、commons-logging 桥接异常 |
spring-beans、spring-context、spring-aop、spring-tx这四个是 SSM 场景下高频出问题的位置,其余模块按需查阅就够。尤其spring-tx,事务不生效这类问题有八成答案都在TransactionInterceptor那几十行里,比在网上反复搜索快得多。
2.2 用 Gradle 把源码跑通:命令、参数与 IDEA 导入顺序
Spring 5.2.x 用 Gradle 构建,基线是 JDK 8。在源码根目录下按顺序执行下面几条命令,不要一上来就执行整个项目的build:
# 1) 先确认 JDK,5.2.x 的编译基线是 JDK 8,用 11 也能编但老工程对接时容易出字节码差异 java -version javac -version # 2) 官方给的第一步:先单独把 spring-oxm 的测试代码编过 # 这一步能把 Gradle 分发、依赖仓库、镜像配置的问题提前暴露出来 ./gradlew :spring-oxm:compileTestJava # 3) 再全量编译,跳过测试用例(Spring 自身测试很重,全跑要很久) ./gradlew build -x test # 4) 只想验证某个模块能不能编过时,用这条 ./gradlew :spring-context:compileJava --infoWindows 下把./gradlew换成gradlew.bat,其余参数一致。参数含义:-x test是排除 test 任务;--info会把依赖解析过程打出来,卡在下载阶段时用它定位是哪个仓库超时;--offline在本地缓存齐全时可以强行离线构建,避免反复重试拖慢进度。如果公司有内网私服,改根目录build.gradle的仓库列表比重配 Gradle 镜像更快:
// 根目录 build.gradle 顶部,仓库地址换成自己团队的私服即可 allprojects { repositories { mavenLocal() // 优先本地缓存,命中就不走网络 maven { url = uri("http://your-nexus/repository/maven-public/") } // 团队私服 mavenCentral() } }导入 IDEA 的次序也有讲究:走Import Project选中build.gradle,勾选Use Gradle Wrapper,Gradle JVM 选 JDK 8(不是 IDEA 自带的那份运行时),同步完成后再打开spring-context下的类。如果直接Open整个目录让 IDEA 自己猜,它可能把工程识别成普通文件夹,src/main/java不会被标记成源码根,跳转全是红的——这是新手最容易卡的第一关,症状是「类都在但 Ctrl+点击没反应」。另外,Gradle 的守护进程会缓存 JVM 参数,改完gradle.properties里的堆大小记得./gradlew --stop再重来。
2.3 refresh() 十二步:把断点打在这五个位置就够
容器启动的主线就是AbstractApplicationContext#refresh(),5.2.x 里它由十二个方法调用串起来。与其逐个读实现,不如先记住哪几步值得打断点:
| # | 方法 | 干了什么 | 断点价值 |
|---|---|---|---|
| 1 | prepareRefresh | 记录启动时间、校验必需属性、初始化占位符 | 看 profile 是否按预期激活 |
| 2 | obtainFreshBeanFactory | 创建DefaultListableBeanFactory,加载 BeanDefinition | 看beanDefinitionNames的数量 |
| 3 | prepareBeanFactory | 注册ApplicationContext、ResourceLoader等可解析依赖 | 排查注入ApplicationContext为 null |
| 4 | postProcessBeanFactory | 子类扩展点 | Web 场景注册 request/session scope |
| 5 | invokeBeanFactoryPostProcessors | 执行ConfigurationClassPostProcessor,解析@ComponentScan、@Bean | 扫描结果不对一律看这里 |
| 6 | registerBeanPostProcessors | 注册BeanPostProcessor,AOP 的自动代理创建器在此 | 切面不生效时看它有没有注册上 |
| 7 | initMessageSource | 国际化 | 基本不用管 |
| 8 | initApplicationEventMulticaster | 事件广播器 | 事件不触发时看 |
| 9 | onRefresh | 子类扩展,原生 Spring 为空实现 | 原生工程可跳过 |
| 10 | registerListeners | 注册监听器 | 同上 |
| 11 | finishBeanFactoryInitialization | 实例化所有非懒加载单例 | getBean全流程都在这条线下 |
| 12 | finishRefresh | 发布ContextRefreshedEvent、启动 Lifecycle | 启动完成后的收尾动作 |
验证用最小启动类,别一开始就起 Web 容器:
// 一个不依赖 Web 容器的最小启动类,用于在源码工程里打断点 public class DebugBootstrap { public static void main(String[] args) { // AnnotationConfigApplicationContext 的构造器内部已经调了一次 refresh() // 所以断点要打在 AbstractApplicationContext#refresh 里,而不是这行之后 AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class); System.out.println("beanDefinition 数量 = " + ctx.getBeanFactory().getBeanDefinitionCount()); System.out.println(ctx.getBean(UserService.class).hello()); ctx.close(); // 走 destroy(),看销毁回调的执行顺序 } }AppConfig上标@Configuration和@ComponentScan即可;getBeanFactory().getBeanDefinitionCount()这行是为了确认第 5 步扫描结果和你预期一致——数量对不上,说明扫描包路径写错或@Component没生效,后面所有问题都不用查了。
再往下追单个 Bean 的创建,主线是doGetBean → getSingleton → createBean → doCreateBean → populateBean → initializeBean。循环依赖之所以能解,靠的是三级缓存:singletonObjects存成品、earlySingletonObjects存提前暴露的半成品、singletonFactories存能产出半成品的工厂。注意这套机制只对单例、且是 setter 或字段注入的场景生效,构造器注入的循环依赖它救不了,会直接抛BeanCurrentlyInCreationException——这条规则在 SSM 工程里同样适用,Service 之间互相构造器注入必炸。
3. ssm.zip 工程结构:父容器与子容器到底谁管谁
3.1 两份配置文件的分工:applicationContext 与 spring-mvc
SSM 工程最核心的设计是「两个容器」。ContextLoaderListener负责启动父容器,读applicationContext.xml,里面装数据源、SqlSessionFactory、事务管理器、Service;DispatcherServlet负责启动子容器,读spring-mvc.xml,里面装HandlerMapping、视图解析器、Controller、消息转换器。父容器的 Bean 对子容器可见,子容器的 Bean 对父容器不可见——这一条方向不能反,反了就是一堆NoSuchBeanDefinitionException。
常见误配是把<tx:annotation-driven/>写在spring-mvc.xml里。事务管理器在父容器,注解驱动在子容器,子容器的后处理器只处理自己容器里的 Bean,Service 在父容器,@Transactional就完全失效,而且不报错、不告警,只是更新语句一条都没回滚。相反,如果把@Controller扫进了父容器的扫描范围,子容器里就找不到 handler,访问接口一律 404。我一般的划分是父容器扫com.xxx.service、com.xxx.mapper,子容器只扫com.xxx.controller,扫描路径物理隔离,杜绝串味。
<!-- applicationContext.xml:父容器只负责 Service / Mapper / 事务 --> <context:component-scan base-package="com.example.ssm.service"/> <context:component-scan base-package="com.example.ssm.mapper"/> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- mapper xml 位置按工程实际目录填,多个位置用 list 包裹 --> <property name="mapperLocations" value="classpath*:mapper/**/*.xml"/> <!-- 下划线转驼峰,省掉大量 resultMap --> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <!-- 扫描 Mapper 接口,生成代理对象注册进容器 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.ssm.mapper"/> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> </bean> <!-- 事务管理器 + 注解驱动,两个必须待在同一个容器里 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>几个参数要专门说:MapperScannerConfigurer里用sqlSessionFactoryBeanName而不是sqlSessionFactory引用,前者是字符串延迟解析,能避开「扫描器比工厂先初始化」导致的循环引用;mapUnderscoreToCamelCase打开后user_name能自动映射到userName,省掉一堆resultMap,但字段类型转换(如tinyint转枚举)它不管,仍需手写 TypeHandler。
3.2 web.xml 的加载顺序与参数
web.xml的顺序决定容器启动次序,contextConfigLocation有两个,名字一样但属于不同组件,写混了就是「父容器读成了 mvc 配置」这种诡异现象。
<!-- 1) 父容器:监听器启动,读 applicationContext.xml --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 2) 编码过滤器必须排在其他过滤器之前,否则参数已经按错误编码解过了 --> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 3) 子容器:DispatcherServlet,独立的一份 contextConfigLocation --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>load-on-startup写1表示 Tomcat 启动时就初始化 Servlet,不写则首次请求才初始化——后者会把配置错误推迟到第一次访问才暴露,排查成本翻倍。url-pattern用/而不是/*,/*会连 JSP 请求一起拦走,导致视图解析后返回 404;用/时静态资源需要额外配<mvc:default-servlet-handler/>或<mvc:resources/>。
3.3 依赖清单与版本对齐
SSM 里最容易翻车的地方不是代码,是依赖版本。Spring 统一锚到5.2.8.RELEASE,其余依赖版本以工程 pom 里的 properties 为准,别随手在子模块里写死版本号。
<properties> <!-- 与源码包保持一致,全工程只在这一处改版本 --> <spring.version>5.2.8.RELEASE</spring.version> <!-- 下面这些按你本地仓库已有的版本填,不要凭空升到最新 --> <mybatis.version>3.x</mybatis.version> <mybatis.spring.version>2.x</mybatis.spring.version> <servlet.version>按容器实际 Servlet 规范选</servlet.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- servlet api 必须是 provided,打进 war 会和容器自带的冲突 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>${servlet.version}</version> <scope>provided</scope> </dependency> </dependencies>mybatis-spring的版本与 Spring 5.x 的匹配关系要特别留意:1.x 系列对 Spring 5 的兼容性存在缺口,工程里用的是 1.x 就先把版本抬到 2.x 再排查其他问题,否则会出现启动阶段NoSuchMethodError这种看不出根因的错误。scope写provided的还有jsp-api、tomcat-embed之类,凡是容器已经提供的都别打进 war,这是部署到外部容器时最经典的ClassCastException来源。
4. 手写一条最小可跑的 SSM 链路:从 Mapper 到 Controller
4.1 Mapper 层与 mybatis-spring 的装配
先跑通一条只查一条记录的链路,再往里加东西。Mapper 接口不用写实现类,MapperScannerConfigurer会生成代理;XML 里的namespace必须写接口全限定名,id必须和方法名一字不差,否则运行期抛BindingException: Invalid bound statement (not found)。
// Mapper 接口:不需要 @Repository,扫描器已经负责注册 public interface UserMapper { // 参数只有一个且是对象时,XML 里可直接用属性名引用 User selectById(Long id); }<!-- resources/mapper/UserMapper.xml --> <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!-- namespace 必须等于接口全限定名,写错就是 Invalid bound statement --> <mapper namespace="com.example.ssm.mapper.UserMapper"> <select id="selectById" resultType="com.example.ssm.entity.User"> select id, user_name, status from t_user where id = #{id} </select> </mapper>逻辑说明:MapperScannerConfigurer在容器启动时扫描指定包下所有接口,为每个接口注册一个MapperFactoryBean,真正的 SQL 执行被委托给SqlSessionTemplate。参数说明:#{id}是预编译占位符,会走PreparedStatement;写成${id}则是字符串拼接,既有注入风险,也不会被一级缓存识别。另外 XML 放在resources/mapper/下时,Maven 默认会把 resources 打进classes,如果放在src/main/java旁边,必须在 pom 里配<resources>把**/*.xml包含进去,否则编译后 XML 丢失——这是「本地 IDE 能跑、打包后必挂」的头号原因。
4.2 Service 层事务:注解式和 XML 切面的边界
事务写法二选一,别混用。注解式简单,但生效条件苛刻:方法必须是public、必须从容器外部调用、必须走代理。XML 切面式啰嗦,但切点可以用通配符精确控制,适合批量给老代码加事务。
@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; // rollbackFor 必须显式写 Exception,默认只回滚 RuntimeException @Override @Transactional(rollbackFor = Exception.class, timeout = 5, propagation = Propagation.REQUIRED) public void transfer(Long from, Long to, int amount) { userMapper.deduct(from, amount); if (amount > 1000) { throw new IllegalStateException("超过单笔限额"); // 触发回滚 } userMapper.add(to, amount); } }参数说明:rollbackFor = Exception.class是必写的,默认配置下受检异常不回滚,很多「明明抛异常了数据还在」的事故就出在这里;timeout = 5是秒级超时,超时会抛TransactionTimedOutException并回滚;propagation = REQUIRED表示加入当前事务,没有就新建,绝大多数业务用它。生效前提是调用方拿到的是代理对象——同一个类里this.transfer(...)这种自调用不走代理,事务直接失效,这一点在源码里表现为TransactionInterceptor根本没被触发过,用下一节的断点法一验就知道。
4.3 Controller 与返回值、编码、静态资源
Controller 层只做参数接收和结果包装,业务逻辑一律下沉。返回值建议统一包一层结果对象,别直接把实体丢给前端。
@RestController @RequestMapping("/user") public class UserController { @Autowired private UserService userService; // produces 显式声明,避免客户端 Accept 不匹配导致 406 @GetMapping(value = "/{id}", produces = "application/json;charset=UTF-8") public User getUser(@PathVariable("id") Long id) { return userService.getById(id); } // 表单提交时 @RequestBody 换成普通参数绑定,注意日期字段要配 @DateTimeFormat @PostMapping(value = "/transfer") public String transfer(@RequestParam Long from, @RequestParam Long to, @RequestParam int amount) { userService.transfer(from, to, amount); return "ok"; } }@RestController等于@Controller + @ResponseBody,返回值走HttpMessageConverter;工程里如果还留着 JSP 视图,对应的类必须改回@Controller并返回视图名,混用会出现「返回字符串被当成 JSON」的现象。静态资源在DispatcherServlet映射/的情况下要被显式放行:
<!-- spring-mvc.xml:放行静态资源,否则 js/css 全部 404 --> <mvc:default-servlet-handler/> <mvc:annotation-driven> <mvc:message-converters> <!-- 统一 UTF-8,防止 @ResponseBody 返回中文乱码 --> <bean class="org.springframework.http.converter.StringHttpMessageConverter"> <constructor-arg value="UTF-8"/> </bean> </mvc:message-converters> </mvc:annotation-driven>5. 避坑与排查:五个让 SSM 起不来的真实原因
5.1 启动报 ClassNotFoundException: ContextLoaderListener
现象是 Tomcat 起得来,但日志里一路ClassNotFoundException,页面 404。原因通常是spring-web没进依赖,或者servlet-api用了compile作用域被打进 war,与容器自带的版本冲突。解决办法是先mvn dependency:tree -Dincludes=org.springframework确认spring-web在树里,再把javax.servlet-api的 scope 改成provided,清掉target重新打包。
5.2 @Transactional 加了但数据没回滚
现象是方法里抛了异常,数据库里第一条更新还在。原因有三类:方法不是public;同类内部自调用绕过代理;事务管理器与注解驱动分处父子两个容器。排查手法是在TransactionInterceptor#invoke上打断点,能进断点说明代理生效,进不去就是上面三条之一。最隐蔽的是第三条,<tx:annotation-driven/>写在spring-mvc.xml里,日志一片正常,只有对比两份配置文件才能发现。
5.3 Invalid bound statement (not found)
现象是 Mapper 接口注入成功,一调方法就抛这个异常。原因是 XML 没被打进classes,或namespace与接口全限定名不一致,或方法名与id差一个字母。先看target/classes下有没有对应的 XML 文件,没有就补 pom 的<resources>配置;有的话用 IDE 的全局搜索比对namespace,两分钟能定位。
5.4 接口返回中文乱码
现象是 JSON 里中文变成问号或测试。原因是CharacterEncodingFilter没配forceEncoding,或StringHttpMessageConverter用了平台默认编码。解决方法是过滤器补上forceEncoding=true,同时在spring-mvc.xml里显式给消息转换器指定 UTF-8,两处都配才彻底。
5.5 循环依赖启动即失败
现象是BeanCurrentlyInCreationException,堆栈指向某个 Service 的构造器。原因是两个单例 Bean 用构造器互相注入,Spring 的三级缓存只对 setter / 字段注入有效,构造器注入拿不到提前暴露的引用。解决办法是改成字段注入,或者抽一个中间层打破环——后者更干净,改注入方式只是把问题藏起来。
6. 用断点确认 Bean 归属的验证手法
父子容器的问题光看日志看不出来,日志里 Bean 名字长得一模一样。我习惯在启动完成之后加一段自检代码,把两个容器的 Bean 清单分别打出来,谁在哪个容器一目了然:
// 放在一个 ApplicationListener 里,容器刷新完成后自动执行 public class ContextDumpListener implements ApplicationListener<ContextRefreshedEvent> { @Override public void onApplicationEvent(ContextRefreshedEvent event) { ConfigurableApplicationContext ctx = (ConfigurableApplicationContext) event.getApplicationContext(); // 子容器的 parent 就是 ContextLoaderListener 建的那个父容器 System.out.println("current = " + ctx.getId() + " , parent = " + (ctx.getParent() == null ? "null" : ctx.getParent().getId())); // 判断某个 Bean 到底属于哪一层,事务不生效时第一时间看这个 String target = "userServiceImpl"; System.out.println("in current context? " + ctx.containsBean(target)); if (ctx.getParent() != null) { System.out.println("in parent context? " + ctx.getParent().containsBean(target)); } // 确认拿到的是不是代理对象,false 说明事务/切面根本没织进去 if (ctx.containsBean(target)) { Object bean = ctx.getBean(target); System.out.println("isAopProxy = " + org.springframework.aop.support.AopUtils.isAopProxy(bean)); } } }这段代码的价值在于把「猜」变成「看」。containsBean的返回值直接告诉你 Bean 归属,isAopProxy直接告诉你代理有没有生成,两个布尔值就能把前面提到的绝大多数事务问题锁死在一个方向上,不用再靠删配置试错。
几个配套的调试习惯:把logging.level.org.springframework=DEBUG打开,能直接看到ConfigurationClassPostProcessor扫描到了哪些候选类;IDEA 里对AbstractAutowireCapableBeanFactory#doCreateBean设条件断点beanName.equals("userServiceImpl"),避免被上千个 Bean 淹掉;排查完记得把自检代码删掉,ContextRefreshedEvent在父子容器各触发一次,留着会打两遍日志、容易误判。
血泪经验是:我早期接手 SSM 工程时,遇到事务不生效第一反应永远是去翻 Service 代码,折腾两三天才发现是注解驱动写错了配置文件。从那以后,我每次接手新工程都强制走一遍「打印父子容器 Bean 清单 +isAopProxy」这套动作,五分钟就能把环境摸清楚,比读十页文档管用。希望帮到你。
本文还有配套的精品资源,点击获取