☰
Spring 5.x源码与SSM整合:IoC、AOP、Bean生命周期及事务失效排查
2026/11/5 4:38:16 网站建设 项目流程

简介:这份 zip 压缩包围绕 Spring Framework 5.2.8.RELEASE 整理,面向正在搭建 SSM 整合项目、补齐 Spring 依赖或做版本对照的 Java 开发者与课程实验人群,可用于本地环境准备、老项目依赖修复及学习 Spring 核心包结构的参考。包体约 86.01MB;上游文件清单统计为 0、文件类型明细暂无数据,描述中指向 Spring 的 jar 包,因此具体 jar 组成与数量不宜臆测,使用前建议先核对目录与依赖版本。资源页显示已有 445 人浏览/学习,说明该版本仍有一定查阅需求。对读者而言,价值主要在于获取 Spring 5.2.8.RELEASE 相关依赖线索,配合 SSM 项目排查缺失包、统一版本,并了解该版本 jar 包在工程中的组织方式;若用于正式项目,仍应结合构建工具校验依赖完整性与兼容性。

1. 接手一套 Spring 5.x 老系统,先搞清楚容器到底装了什么

接手一套跑了多年的后台系统,登录进去点开列表页要等两三秒,日志里只有一行看不出所以然的 SQL,配置里 spring 的 bean 散在四五个 XML 文件里,改一个数据源要顺着contextConfigLocation一路翻。这种场面我遇到过不止一次。业务代码本身不复杂,麻烦的是没人说得清容器启动那一刻到底装配了什么、代理是什么时候织进去的、事务为什么没回滚。

这份资源正好对着这个痛点:一份spring-framework-5.2.8.RELEASE的源码,加一个能直接跑起来的 SSM 整合工程ssm.zip。前者用来查“为什么”,后者用来验证“怎么改”。适合正在维护 Spring 5.x 老项目、被 XML 配置绕晕、想把 IoC 和 AOP 从“会用”推到“看懂”的后端开发者,也适合准备面试想亲手翻一遍refresh()的人。

2. 工程结构与源码地图:ssm.zip 里到底放了什么

拿到压缩包第一件事不是急着跑,是先看清楚目录。很多人解压完直接mvn tomcat7:run,报一串ClassNotFoundException就卡住了,其实问题往往出在目录职责没分清楚——哪个 XML 归父容器管、哪个归子容器管,一旦搞混,后面所有报错都是连锁反应。

2.1 分层目录与各层职责

解压后的工程结构大致是这样,不同人整理习惯会有出入,但骨架基本一致:

ssm/ ├── pom.xml # 统一管理 Spring 5.2.8、MyBatis、Servlet API 版本 ├── src/main/java/com/example/ssm/ │ ├── controller/ # 只做参数校验和转发,不写业务判断 │ ├── service/ # 接口 + impl,事务注解打在这一层 │ ├── mapper/ # MyBatis 接口,与 mapper/*.xml 一一对应 │ ├── entity/ # 与表结构对应的 POJO │ └── common/ # Result 包装、全局异常处理、常量 ├── src/main/resources/ │ ├── spring/ │ │ ├── applicationContext.xml # 父容器入口,被 ContextLoaderListener 加载 │ │ ├── spring-dao.xml # 数据源、SqlSessionFactory、Mapper 扫描 │ │ ├── spring-tx.xml # 事务管理器 + AOP 切面 │ │ └── spring-mvc.xml # 子容器入口,被 DispatcherServlet 加载 │ ├── mapper/UserMapper.xml # SQL 写在这里,不放注解里 │ ├── jdbc.properties # 账号密码单独抽出,方便按环境替换 │ └── logback.xml ├── src/main/webapp/ │ ├── WEB-INF/web.xml # 容器入口,启动顺序全靠它 │ └── static/ # 静态资源,需要在 mvc 配置里放行 └── src/test/java/ # 用 @ContextConfiguration 起容器做单元测试

这个结构的核心约定是:父容器管 Service、DAO、事务、数据源,子容器管 Controller、视图解析、静态资源。spring-dao.xml和spring-tx.xml通过applicationContext.xml的<import>串起来,而spring-mvc.xml是独立的,只被DispatcherServlet读取。分开的意义在于:写单元测试时只用加载父容器,不需要启动 Web 环境;而 Controller 层要引 Service,靠的是子容器能访问父容器这条单向规则。

注意:子容器能拿到父容器的 Bean,父容器拿不到子容器的。所以把@Transactional写在 Controller 方法上一定不生效——Controller 在子容器里,事务代理压根没扫到它。

2.2 Spring 5.2.8 源码包的模块地图

光看工程代码只能知道“怎么配”,出了问题还是得回源码。spring-framework-5.2.8.RELEASE这个源码包按模块切开,常用的是下面这些:

模块解决什么问题什么场景会翻到它
spring-core资源加载、类型转换、SpEL 基础看ClassPathResource怎么找文件
spring-beansBeanDefinition、BeanFactory、依赖注入断点看getBean创建流程
spring-contextApplicationContext、事件、注解扫描看refresh()十二步
spring-aop代理创建、切点匹配排查事务/日志切面不生效
spring-tx事务抽象、传播级别分析事务回滚边界
spring-webmvcDispatcherServlet、HandlerMapping查 404、参数绑定失败
spring-jdbcJdbcTemplate、异常转换看数据库异常怎么变成DataAccessException

spring-context是整个源码包里最该反复读的模块,AbstractApplicationContext#refresh()这一个方法串起了几乎所有后续行为。而spring-beans里的AbstractAutowireCapableBeanFactory和DefaultSingletonBeanRegistry,是排查“Bean 为什么创建了两遍”“循环依赖为什么报错”的必经之路。

源码包体积不小,我的习惯是先在 IDE 里只 import 需要的几个模块,用“Find in Path”定位方法名,比如查registerBeanPostProcessors,比从头通读高效得多。

提示:源码包不要当成一个完整项目去编译,里面大量测试依赖和模块间循环引用会让构建时间非常长。当成一份可以跳转阅读的代码库就够了。

2.3 依赖版本对齐:pom.xml 最容易翻车的地方

Spring 5.2.8 这条线有几个硬约束必须记牢:JDK 最低 8(建议 8 或 11),Servlet API 是javax.servlet命名空间,javax.annotation里的@PostConstruct需要额外引入(JDK 11 之后从 JDK 里移除了)。典型的pom.xml片段:

<properties> <spring.version>5.2.8.RELEASE</spring.version> <mybatis.version>3.5.6</mybatis.version> <mybatis.spring.version>2.0.6</mybatis.spring.version> <servlet.api.version>4.0.1</servlet.api.version> </properties> <dependencies> <!-- 用 spring-webmvc 作为聚合入口,会带上 core/beans/context/aop/expression --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- 事务需要显式引入,spring-webmvc 不会带 spring-tx --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 与 Spring 的粘合层,版本号是 2.x 不是 1.x,别抄错 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis.spring.version}</version> </dependency> <!-- provided 作用域,打包时不能进 war,否则和容器自带版本打架 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>${servlet.api.version}</version> <scope>provided</scope> </dependency> <!-- JDK 11 环境必须显式引入,否则 @PostConstruct 不执行 --> <dependency> <groupId>javax.annotation</groupId> <artifactId>javax.annotation-api</artifactId> <version>1.3.2</version> </dependency> </dependencies>

参数上要注意几个点。spring-webmvc是个聚合包,引入它会传递带入spring-core、spring-beans、spring-context、spring-aop、spring-expression,所以不用逐个声明,逐个声明反而容易出现版本不统一。spring-tx不在传递链里,做声明式事务必须单独加。mybatis-spring的 2.x 版本对 Spring 5.x 才完整支持,如果从网上抄来 1.3.x 的坐标,启动时会报NoSuchMethodError,错误信息指向SqlSessionTemplate,很容易误导你去怀疑 MyBatis 本身。

provided作用域是另一个高频坑。Servlet API 不打进 war,是因为运行容器自己会提供;一旦写成了compile作用域,war 包里就带了一份,容器加载时到底用哪一份取决于类加载器实现,表现出来的症状是过滤器、监听器行为诡异,或者instanceof判断莫名返回 false。

3. 启动链路:从 web.xml 到 DispatcherServlet 的一次冷启动

容器启动这条链路是排查所有“Bean 找不到”“注解不生效”类问题的基准线。把这条线走通一遍,之后再遇到报错就知道该在哪一段停下来看。

3.1 web.xml 里实际存在两个容器

<!-- 父容器:由监听器启动,加载 Service、DAO、数据源、事务 --> <context-param> <param-name>contextConfigLocation</param-name> <!-- 通配符会按 applicationContext*.xml 全部加载,顺序不保证 --> <param-value>classpath:spring/applicationContext*.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 编码过滤器必须排在其他过滤器前面,否则请求参数已经按默认编码解析过 --> <filter> <filter-name>encodingFilter</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> <!-- 强制覆盖请求侧编码,POST 表单中文乱码基本靠这个 --> <param-name>forceRequestEncoding</param-name> <param-value>true</param-value> </init-param> <init-param> <param-name>forceResponseEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 子容器:DispatcherServlet 自己维护一个 WebApplicationContext --> <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/spring-mvc.xml</param-value> </init-param> <!-- 大于 0 表示容器启动时初始化,不等第一个请求 --> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>

ContextLoaderListener是 Servlet 规范里的ServletContextListener,容器启动时回调contextInitialized,里面调用的正是AbstractApplicationContext#refresh()。DispatcherServlet作为 Servlet,init()方法里会调用FrameworkServlet#initWebApplicationContext,创建子容器时把父容器 set 进去,这一步决定了子容器能不能getBean到 Service。

<url-pattern>/</url-pattern>的语义是“接管除 JSP 之外的所有请求”,包括.js、.css、.png。所以只要用了/,静态资源必须在spring-mvc.xml里显式放行,否则页面样式全丢,控制台还看不到任何报错。

3.2 持久层装配:三个 Bean 撑起数据访问

<!-- 数据源:HikariCP 是 Spring Boot 2.x 默认,手动配也不复杂 --> <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close"> <property name="jdbcUrl" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <!-- 连接池大小要结合数据库最大连接数和应用实例数一起算 --> <property name="maximumPoolSize" value="10"/> <property name="connectionTimeout" value="3000"/> </bean> <!-- SqlSessionFactory:把 MyBatis 的 Configuration 交给 Spring 托管生命周期 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- XML 映射文件位置,不支持通配符时必须写全路径 --> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.ssm.entity"/> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <!-- 下划线转驼峰,能省掉大量 resultMap --> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <!-- Mapper 扫描:把接口注册成 BeanDefinition,调用时由代理生成实现 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.ssm.mapper"/> <!-- 关键:用 value 传字符串而不是 ref 传引用 --> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> </bean>

MapperScannerConfigurer是个BeanDefinitionRegistryPostProcessor,执行时机极早,早于PropertySourcesPlaceholderConfigurer。如果这里写ref="sqlSessionFactory",Spring 必须提前创建sqlSessionFactory才能注入,而这个 Bean 内部的${jdbc.url}还没被解析,最终结果是数据源里塞进了字面量"${jdbc.url}",报错信息通常是驱动找不到数据库。改成sqlSessionFactoryBeanName传字符串,就是把解析推迟到真正需要的时候。

maximumPoolSize这个参数我一般按“数据库最大连接数 ÷ 应用实例数 × 0.8”估,池子开太大反而会把数据库连接打满,表现出来是偶发的Connection is not available,很难复现。

3.3 一个请求从进入到返回的完整链路

@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; // 返回对象时走 HttpMessageConverter,不经过 ViewResolver @GetMapping("/{id}") public Result<User> detail(@PathVariable Long id) { return Result.ok(userService.getById(id)); } // @RequestBody 依赖 Jackson 转换器,前提是开启了 mvc:annotation-driven @PostMapping("/save") public Result<Long> save(@RequestBody @Validated User user) { return Result.ok(userService.save(user)); } }

请求进到DispatcherServlet#doDispatch之后依次是:getHandler拿到HandlerExecutionChain(包含拦截器链)、getHandlerAdapter拿到RequestMappingHandlerAdapter、handle内部完成参数绑定和反射调用、返回值经过RequestResponseBodyMethodProcessor序列化成 JSON。中间任何一环抛异常,都会进processHandlerException,走@ControllerAdvice里的处理逻辑。

@PathVariable Long id的转换靠ConversionService,如果 id 传了非数字,抛的是MethodArgumentTypeMismatchException,默认返回 400,页面上什么都没有——这也是新手最容易困惑的点之一。

Service 层的事务边界在这个链路之外,它由@Transactional生成的代理对象在方法调用前后开启和提交:

@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; // 默认传播级别 REQUIRED,默认只对 RuntimeException 和 Error 回滚 // 显式写 rollbackFor 才能让受检异常也触发回滚 @Transactional(rollbackFor = Exception.class) @Override public Long save(User user) { userMapper.insert(user); return user.getId(); } }

rollbackFor = Exception.class这行不是可选项。默认规则是只有RuntimeException和Error回滚,业务代码里抛个IOException或者自定义的受检异常,事务会照常提交,数据就脏了。这个坑我在早期的项目上踩过一次,排查了大半天才反应过来。

4. 核心源码:refresh() 十二步与 Bean 的完整生命周期

工程能跑起来只是第一步,真遇到“切面顺序不对”“循环依赖报错”“@PostConstruct 没执行”这类问题,还是得回到源码。Spring 5.2.8 这条线的源码结构清晰,值得逐段读。

4.1 refresh() 的主干流程

AbstractApplicationContext#refresh()是整个容器启动的总调度,方法体里十二个步骤串起了从解析配置到发布事件的完整流程:

@Override public void refresh() throws BeansException, IllegalStateException { synchronized (this.startupShutdownMonitor) { // 1. 记录启动时间、设置活跃标记、初始化占位符解析器 prepareRefresh(); // 2. 交给子类刷新 BeanFactory:解析 XML 或注解,注册 BeanDefinition ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory(); // 3. 填充基础能力:类加载器、SpEL 解析器、注册几个内置 Bean prepareBeanFactory(beanFactory); try { // 4. 留给子类的扩展点,Web 环境在这里注册 Request/Session 作用域 postProcessBeanFactory(beanFactory); // 5. 执行 BeanFactoryPostProcessor,占位符替换就是这一步生效的 invokeBeanFactoryPostProcessors(beanFactory); // 6. 注册 BeanPostProcessor,此处只注册,业务 Bean 还没开始创建 registerBeanPostProcessors(beanFactory); // 7. 初始化国际化资源,没配 messages.properties 也会兜底 initMessageSource(); // 8. 初始化事件广播器 initApplicationEventMulticaster(); // 9. 留给子类刷新,内嵌容器的启动一般挂在这里 onRefresh(); // 10. 注册监听器,把实现 ApplicationListener 的 Bean 挂到广播器上 registerListeners(); // 11. 实例化所有非懒加载的单例,耗时最长的一步 finishBeanFactoryInitialization(beanFactory); // 12. 发布 ContextRefreshedEvent,容器启动完成 finishRefresh(); } catch (BeansException ex) { // 启动失败要销毁已创建的单例,否则资源泄漏 destroyBeans(); cancelRefresh(ex); throw ex; } finally { // 清掉反射相关的缓存,避免类加载器无法释放 resetCommonCaches(); } } }

第 5 步和第 11 步是理解配置生效时机的关键。invokeBeanFactoryPostProcessors执行时只解析 BeanDefinition,不创建业务 Bean,所以@Value、<context:property-placeholder>这些在此时完成替换;而第 11 步才开始真正getBean,@Autowired注入的依赖是在这一步才建立关联。这也解释了为什么BeanFactoryPostProcessor里@Autowired注入会拿到 null——那时候连AutowiredAnnotationBeanPostProcessor都还没注册。

第 6 步registerBeanPostProcessors的顺序是有讲究的:实现了PriorityOrdered的排最前,其次是Ordered,最后是无序的。AutowiredAnnotationBeanPostProcessor属于PriorityOrdered,所以它总能在业务 Bean 初始化前就位。

4.2 三级缓存与循环依赖的处理边界

DefaultSingletonBeanRegistry#getSingleton是循环依赖的核心:

protected Object getSingleton(String beanName, boolean allowEarlyReference) { // 一级:完全初始化好的单例,可以直接用 Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { synchronized (this.singletonObjects) { // 二级:已经提前暴露,但属性填充和初始化还没走完 singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { // 三级:ObjectFactory,调用时才决定要不要生成 AOP 代理 ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } return singletonObject; }

三级缓存的设计目的只有一个:保证循环依赖中如果某个 Bean 需要 AOP 代理,别的地方拿到的是代理对象而不是原始对象。如果只有两级缓存,提前暴露的就只能是原始对象,等到初始化结束再生成代理,之前注入到别的 Bean 里的引用就指向了旧对象,切面自然不生效。

这套机制只解决单例 + setter/字段注入的循环依赖。构造器注入的循环依赖无解,因为对象还没造出来就没法暴露引用,启动时直接抛BeanCurrentlyInCreationException,错误信息里会列出完整的依赖环。prototype作用域的循环依赖同样无解。这两种情况只能靠重构代码打破环,把公共逻辑抽到第三个 Bean 里。

4.3 AOP 代理的创建时机与事务失效的根因

代理是在AbstractAutoProxyCreator#wrapIfNecessary里生成的:

protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) { if (StringUtils.hasLength(beanName) && this.targetSourcedBeans.contains(beanName)) { return bean; } // 已经判断过不需要代理的,直接命中缓存返回 if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) { return bean; } // 基础设施类(Advice、Pointcut、Advisor 自身)跳过,避免自我代理死循环 if (isInfrastructureClass(bean.getClass()) || shouldSkip(bean.getClass(), beanName)) { this.advisedBeans.put(cacheKey, Boolean.FALSE); return bean; } // 找出所有能切中这个 Bean 的增强器 Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null); if (specificInterceptors != DO_NOT_PROXY) { this.advisedBeans.put(cacheKey, Boolean.TRUE); // 默认 JDK 动态代理,目标类无接口时退化为 CGLIB Object proxy = createProxy(bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean)); this.proxyTypes.put(cacheKey, proxy.getClass()); return proxy; } this.advisedBeans.put(cacheKey, Boolean.FALSE); return bean; }

关键在createProxy这一步的返回值。postProcessAfterInitialization返回什么,容器里存的就是什么。返回代理,别处@Autowired注入进来的就是代理对象,方法调用会经过拦截器链,事务切面才有机会开启事务。

由此可以推出事务失效的经典场景:this.save()这种自调用,this指向的是原始对象而不是代理对象,调用链根本没经过拦截器,@Transactional形同虚设。同类里 A 方法没有事务、调用有事务的 B 方法,B 的事务注解同样不生效。private方法也一样,CGLIB 无法覆写私有方法。

提示:想确认某个 Bean 到底被代理没有,最直接的办法是在启动日志里加一行bean.getClass().getName(),看到$$EnhancerBySpringCGLIB$$或$Proxy前缀就说明代理生效了。

4.4 Bean 生命周期回调的执行顺序

把这些回调按顺序排一遍,排查“初始化方法没跑”时能省很多时间:

阶段触发点常见误用
实例化构造器 / 工厂方法在构造器里做耗时 IO,拖慢启动
属性填充@Autowired、@Value在构造器里读注入字段,拿到的是 null
Aware 回调BeanNameAware、ApplicationContextAware用来拿容器,但别在里面 getBean
前置处理BeanPostProcessor#postProcessBeforeInitialization在这里改对象,可能被后续覆盖
初始化@PostConstruct→afterPropertiesSet→init-method三个都写了,顺序容易记反
后置处理postProcessAfterInitializationAOP 代理在这一步生成
使用中业务调用无
销毁@PreDestroy→destroy→destroy-method单例才走销毁,prototype 不管

@PostConstruct排在afterPropertiesSet之前,init-method排最后。三者都配了的话会全部执行,不是互相覆盖的关系。@PreDestroy只对单例生效,prototype作用域的 Bean 由调用方自己负责释放。

5. 避坑与排查:五条踩过的坑

这套组合里,报错信息普遍指向性很差,很多时候真实原因在完全不相干的地方。下面五条都是我在实际项目上撞过的,按“现象 → 原因 → 解决”记下来,遇到类似症状可以直接对号入座。

5.1 启动期问题

现象一:启动报NoClassDefFoundError: javax/servlet/Filter,但明明引入过 Servlet API。

原因通常是作用域写错了,或者运行容器用的是jakarta.*命名空间而工程按javax.*编译。Spring 5.2.8 整条线都基于javax.servlet,如果部署到只认jakarta.servlet的容器上,类加载阶段就会失败,错误信息只提类名,不提命名空间差异。

解决办法是先确认容器版本,再决定是换容器还是把整条依赖链升到 Spring 6。确认依赖树里没有重复:mvn dependency:tree | grep servlet,如果看到两份不同 groupId 的 Servlet API,用<exclusions>排掉一份。provided作用域不是可选项,打成 war 之前一定要检查WEB-INF/lib里有没有javax.servlet-api-*.jar。

现象二:MapperScannerConfigurer配好了,启动不报错,但注入的 Mapper 一调用就空指针。

这多半是sqlSessionFactoryBeanName写成了ref。MapperScannerConfigurer执行时机太早,早于占位符解析器注册,ref会强制提前初始化SqlSessionFactory,导致${jdbc.url}变成字面量。

排查方式是启动后打印数据源配置,看到${开头的字符串就确诊了。改法就是把ref换成value,传 Bean 名字符串。同样的道理,MapperScannerConfigurer上的basePackage也别用${}拼接,它在占位符解析之前就要用到这个值,写了也是白写。

现象三:@Autowired注入的字段在@PostConstruct里能拿到,在构造器里却是 null。

这是生命周期顺序决定的,不是配置问题。构造器执行时,属性填充还没开始,注入字段必然为 null。很多人习惯在构造器里写初始化逻辑,依赖某个注入的 Mapper 去做校验,一跑就 NPE。

正确做法是把这段逻辑挪到@PostConstruct标注的方法里,或者实现InitializingBean。如果一定要在构造器里用,只能改成构造器注入,让 Spring 在调用构造器时就把依赖准备好。构造器注入还有个附带好处:字段可以声明成final,循环依赖会在启动期直接暴露,而不是拖到运行期。

5.2 运行期问题

现象四:@Transactional加了,抛异常了,数据还是写进去了。

先确认三件事:方法是不是public、有没有被同类内部调用、异常类型是不是受检异常。这三条任意一条命中,事务都不会回滚。日志上完全看不出来,因为根本没报错,只是事务被正常提交了。

排查方式是在事务方法里打断点,看调用栈里有没有TransactionInterceptor。没有就说明调用没经过代理。改法:受检异常补rollbackFor;自调用把逻辑抽到另一个 Service 里;private方法改成public并挪到被代理的 Bean 上。

还有一种更隐蔽的情况:Controller 和 Service 都被扫描了两遍,一遍在父容器、一遍在子容器,此时子容器里的那个 Service 实例是没有事务代理的。判断方法是启动时打印 Bean 定义数量,同一个类名出现两次就要检查component-scan的配置范围,父容器扫 Service 包,子容器只扫 Controller 包,不要都写com.example。

现象五:接口返回 404,Controller 上的@RequestMapping看着没问题。

先看DispatcherServlet的url-pattern有没有覆盖到请求路径。用/会接管静态资源,用*.do这种后缀匹配则会让 RESTful 风格的路径全部落在容器默认 Servlet 上。Spring 5.x 之后容器默认 Servlet 的映射行为有变化,直接访问/api/user/1可能直接被 404 掉。

其次是包扫描范围。子容器如果只配了<context:component-scan base-package="com.example.ssm.controller"/>,而 Controller 实际在别的包,那HandlerMapping里就是空的,doDispatch找不到 handler,日志级别不到 DEBUG 的话什么都没有。把日志级别调到 DEBUG,RequestMappingHandlerMapping启动时会打印所有注册的映射,对着看一目了然。

>最后补一句踩坑经验:这类问题九成不是配置写错了语法,而是配置生效的时机不对。Spring 的启动流程有严格的先后顺序,把refresh()的十二步记熟,判断某个配置“能不能在这个阶段生效”,比盲目改 XML 效率高得多。

6. 二次开发:用 BeanPostProcessor 反向验证容器行为

拿到源码之后,我自己最常用的一个技巧,是写个自定义BeanPostProcessor挂上去,把容器内部的执行顺序打印出来。比读文档直观,也比加断点省事:

@Component public class BeanLifecycleLogger implements BeanPostProcessor, ApplicationContextAware { private ApplicationContext ctx; @Override public Object postProcessBeforeInitialization(Object bean, String beanName) { // 只关心业务层,避免把框架内部的几百个 Bean 全打出来 if (beanName.endsWith("ServiceImpl")) { System.out.println("[before-init] " + beanName + " class=" + bean.getClass().getSimpleName()); } return bean; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) { if (beanName.endsWith("ServiceImpl")) { // 走到这里说明 @PostConstruct、afterPropertiesSet 都已执行完毕 // 若此时类名带 $$EnhancerBySpringCGLIB$$,说明 AOP 代理已生成 System.out.println("[after-init] " + beanName + " class=" + bean.getClass().getName()); } return bean; } @Override public void setApplicationContext(ApplicationContext applicationContext) { // 用 Aware 拿容器,别用 @Autowired 注入 ApplicationContext // 那会触发提前初始化,启动日志里会多一条 circular reference 警告 this.ctx = applicationContext; } }

这段代码有三个可改的点。第一,判断条件从endsWith("ServiceImpl")换成ctx.getBeanNamesForAnnotation(Transactional.class),就能精确列出所有带事务注解的 Bean,验证代理有没有生成。第二,postProcessAfterInitialization里可以加一句AopUtils.isAopProxy(bean),返回 false 就说明切点没匹配上,比看日志快。第三,把这个类改成实现Ordered接口并返回Ordered.LOWEST_PRECEDENCE,能让它在所有内置后置处理器之后执行,看到的是最终形态的 Bean。

验证方法也简单:启动工程,控制台会按依赖顺序打印[before-init]和[after-init]。如果某个 Service 只出现before-init没有after-init,说明它的初始化方法抛了异常被吞掉了,往上翻日志找BeanCreationException。如果after-init打印的类名带 CGLIB 前缀,说明代理生效;不带,就回去检查切点表达式和@EnableTransactionManagement有没有配。

另外提醒一句,这个类本身不要用@Autowired注入任何业务 Bean。BeanPostProcessor的创建时机极早,一旦它依赖了别的 Bean,那个 Bean 会被迫提前初始化,连锁反应是它身上的BeanPostProcessor还没注册,注解全部失效。我一般只用ApplicationContextAware或者ObjectProvider延迟获取。

从那以后我每次接手新的 Spring 5.x 工程,第一件事都是在refresh()上下断点,先看finishBeanFactoryInitialization阶段创建了多少单例、哪些带代理前缀,再动业务代码。这个习惯帮我省掉了很多在 XML 里瞎猜的时间。希望这份源码包和整合工程,也能帮你少走几段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询