1. 为什么还要死磕 Spring SpringMVC 这套“老组合”先把结论摆在前面Spring SpringMVC 这套组合放到今天依然是最适合用来打通“框架底层认知”的练手项目。你可能已经在用 Spring Boot 写业务了注解一贴、启动类一跑接口就通了爽是爽但很多人其实说不清楚一个请求从浏览器发出到返回 JSON中间到底经过了哪些环节容器里的 Bean 是怎么被创建、装配、代理的事务又是在哪一层悄悄生效的。这些疑问只有把 Spring 和 SpringMVC 拆开、手动整合一遍才会真正落地。这个综合练习的核心目标就是脱离 Spring Boot 的自动装配用最原始的方式把 Spring 容器和 SpringMVC 前端控制器拼到一起跑通一个完整的增删改查接口。它解决的问题不是“怎么快速上线”而是“怎么真正理解这套框架的运转机制”。适合谁参考适合已经会写 Spring Boot 业务、但想回头补底层原理的开发者适合准备面试、被问到“SpringMVC 请求流程”“IOC 容器启动过程”时心里发虚的同学也适合想自己动手写个小框架、需要先摸清标准实现的人。我个人的判断是自动装配是“结果”手动整合才是“过程”。只吃结果遇到诡异问题就只能靠猜走过一遍过程很多报错你一眼就能定位。下面我就按实际动手的顺序把整个练习的设计思路、关键细节、完整流程和踩坑记录摊开讲。2. 整体设计与思路拆解2.1 为什么不用 Spring Boot偏要手动整合很多人第一反应是都什么年代了还手动配 web.xml这不是倒退吗。恰恰相反手动整合的价值在于“可控”。Spring Boot 帮你做了几百个自动配置类你写错一个依赖它可能默默帮你兜底也可能默默帮你埋雷。手动整合时每一个 Bean、每一个配置都得你自己声明出问题一定是你的配置有问题排查路径极短。从架构上看这个练习要搭的是一个典型的父子容器结构。Spring 的根容器Root ApplicationContext负责管理 Service、DAO、数据源、事务管理器这些“业务层组件”SpringMVC 的子容器WebApplicationContext负责管理 Controller、视图解析器、消息转换器这些“Web 层组件”。子容器可以访问父容器的 Bean父容器访问不到子容器的 Bean。这个设计不是随便定的它决定了你的事务能不能生效、Controller 能不能注入 Service。注意父子容器是这套练习里最容易翻车的点。如果你把 Service 扫进了 SpringMVC 子容器事务大概率失效因为事务管理器在父容器里子容器里的 Service 拿不到它。2.2 分层结构与职责边界整个项目我按经典的四层来切Controller 层、Service 层、DAO 层、实体层。Controller 只做参数接收和结果返回不写业务逻辑Service 负责业务编排和事务边界DAO 只跟数据库打交道实体层就是数据载体。这个分层看起来老套但它是理解 Spring 依赖注入最直观的载体——每一层的 Bean 由谁创建、注入给谁一目了然。选型上持久层我用最朴素的 JdbcTemplate而不是 MyBatis 或 JPA。原因很简单这个练习的重点是 Spring 的容器和 AOP不是 ORM。JdbcTemplate 足够轻配置少能让我把注意力集中在容器装配上。如果你用 MyBatis也没问题但要多配一个 SqlSessionFactory反而分散了主线。2.3 依赖版本的选择逻辑版本这块我踩过坑必须说清楚。Spring 5.x 和 Spring 6.x 差别很大Spring 6 要求 JDK 17 起步而且把很多 javax 包换成了 jakarta 包。如果你用的是 JDK 8 或 11老老实实选 Spring 5.3.x这是 5.x 最后一个稳定分支社区资料最全。Servlet API 对应的是 javax.servlet别手滑引成 jakarta.servlet否则启动直接报 ClassNotFound。组件推荐版本说明JDK8 / 11与 Spring 5.3 兼容性最好Spring5.3.x5.x 最终稳定分支Servlet API4.0.1对应 javax.servlet 命名空间数据库MySQL 8.x驱动用 mysql-connector-java 8.x连接池Druid 或 HikariCP二选一别同时引日志SLF4J Logback排查容器启动问题全靠它3. 核心细节解析与实操要点3.1 父子容器的配置要点根容器的配置我放在applicationContext.xml里只扫 Service 和 DAO 包同时配置数据源、事务管理器、事务注解驱动。子容器的配置放在spring-mvc.xml里只扫 Controller 包配置注解驱动、视图解析器、静态资源处理。两个配置文件各管一摊边界清晰。!-- applicationContext.xml 根容器 -- context:component-scan base-packagecom.demo.service,com.demo.dao/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property nameurl valuejdbc:mysql://localhost:3306/demo/ property nameusername valueroot/ property namepassword value123456/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/!-- spring-mvc.xml 子容器 -- context:component-scan base-packagecom.demo.controller/ mvc:annotation-driven/ mvc:default-servlet-handler/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean这里有个细节mvc:default-servlet-handler/一定要加否则静态资源css、js、图片会被 DispatcherServlet 拦截全部 404。这个坑我当年找了半天最后发现是 DispatcherServlet 默认映射/把所有请求都吃了。3.2 web.xml 的加载顺序web.xml 是整个应用的入口加载顺序错了容器就起不来。正确的顺序是先配 ContextLoaderListener 加载根容器再配 DispatcherServlet 加载子容器。ContextLoaderListener 默认读applicationContext.xmlDispatcherServlet 默认读[servlet-name]-servlet.xml我习惯显式指定避免歧义。listener listener-class org.springframework.web.context.ContextLoaderListener /listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param servlet servlet-namedispatcher/servlet-name servlet-class org.springframework.web.servlet.DispatcherServlet /servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping提示load-on-startup设为 1让 DispatcherServlet 随容器启动就初始化而不是等第一个请求来了才初始化。这样启动阶段就能暴露配置错误别等到线上才发现。3.3 事务边界的确定事务加在哪一层是个原则问题。我统一加在 Service 层的方法上用Transactional。Controller 不加事务DAO 也不加。原因在于事务的粒度应该对应一个完整的业务操作而业务操作是在 Service 层编排的。如果加在 DAO一个业务调多个 DAO 方法就没法保证原子性如果加在 Controller事务范围又太大把参数校验、结果封装都包进去了白白占用连接。Service public class UserServiceImpl implements UserService { Autowired private UserDao userDao; Override Transactional(rollbackFor Exception.class) public void transfer(int fromId, int toId, BigDecimal amount) { userDao.deduct(fromId, amount); userDao.add(toId, amount); } }注意rollbackFor Exception.class。Spring 默认只对 RuntimeException 和 Error 回滚受检异常不回滚。这个默认行为坑过无数人业务里抛了自定义的受检异常事务没回滚数据就脏了。养成显式声明的习惯。4. 实操过程与核心环节实现4.1 环境搭建与依赖引入Maven 的 pom 里Spring 的核心依赖是 spring-context、spring-webmvc、spring-jdbc、spring-tx再加 servlet-apiscope 设 provided、数据库驱动、连接池、日志。这里有个常见错误把 spring-web 和 spring-webmvc 搞混。spring-web 提供的是基础 Web 支持spring-webmvc 才是 MVC 框架本体两个都要引但 webmvc 会传递依赖 web所以引 webmvc 就够了。dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.30/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.30/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency4.2 一个完整请求的流转记录我拿“查询用户详情”这个接口来走一遍全流程把每个环节都标出来。请求是GET /user/detail?id1。第一步请求到达 Tomcat根据 url-pattern/匹配到 DispatcherServlet。第二步DispatcherServlet 从自己的子容器里拿到 HandlerMapping由mvc:annotation-driven/注册根据路径找到对应的 Controller 方法。第三步HandlerAdapter 负责调用这个方法期间完成参数绑定——把id1转成 int 注入方法参数。第四步方法内部调用 ServiceService 调用 DAODAO 用 JdbcTemplate 查库。第五步返回的 User 对象被消息转换器如果是 ResponseBody序列化成 JSON或者被视图解析器解析成 JSP 路径。第六步响应写回浏览器。这个流程里最值得琢磨的是第三步的参数绑定。SpringMVC 的RequestParam、PathVariable、RequestBody分别对应不同的解析器理解它们的区别才能写出正确的接口。Controller RequestMapping(/user) public class UserController { Autowired private UserService userService; RequestMapping(/detail) ResponseBody public User detail(RequestParam(id) int id) { return userService.getById(id); } }4.3 参数绑定与返回值处理参数绑定这块我总结了几个高频场景。简单类型用RequestParam路径变量用PathVariableJSON 体用RequestBody表单对象直接写实体类参数即可自动封装。返回值方面返回 String 默认当视图名加ResponseBody才当数据返回对象加ResponseBody会被 Jackson 序列化。场景注解示例查询参数RequestParam/detail?id1路径变量PathVariable/detail/1JSON 请求体RequestBodyPOST JSON表单对象无注解字段名匹配自动封装返回 JSONResponseBody对象自动序列化有个细节要注意RequestParam默认 requiredtrue参数不传直接 400。如果参数可选得写required false并给默认值否则前端漏传就报错。4.4 数据源与 JdbcTemplate 的装配数据源我用 Druid配置了初始连接数、最大连接数、超时时间。JdbcTemplate 直接注入 dataSource 即可Spring 会自动帮它管理连接的获取和释放。这里的关键是JdbcTemplate 是线程安全的可以做成单例 Bean全局共享一个实例。Repository public class UserDaoImpl implements UserDao { Autowired private JdbcTemplate jdbcTemplate; Override public User getById(int id) { String sql select id, name, balance from t_user where id ?; return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper(User.class), id); } }BeanPropertyRowMapper会自动把结果集的列名映射到实体属性列名user_name对应属性userName下划线转驼峰它帮你做了。这个类省了大量手写 RowMapper 的功夫但要注意列名和属性名必须能对上对不上就映射成 null。5. 常见问题与排查技巧实录5.1 容器启动失败的排查顺序容器起不来日志是第一手线索。我的排查顺序是先看有没有 ClassNotFound通常是依赖没引全或版本冲突再看有没有 NoSuchBeanDefinition通常是包扫描路径写错或注解没加最后看有没有循环依赖通常是构造器注入互相引用。这三类占了启动失败的九成。报错关键词大概率原因解决方向ClassNotFoundException依赖缺失/版本冲突mvn dependency:tree 排查NoSuchBeanDefinition扫描路径错/注解缺失检查 component-scanBeanCurrentlyInCreation循环依赖改 setter 注入或 LazyNoClassDefFoundError运行时缺包检查 scope 是否 provided5.2 事务不生效的三种典型情况事务不生效是这套练习里最经典的坑。第一种Service 没被 Spring 管理自己 new 出来的对象事务注解形同虚设。第二种方法不是 publicSpring 的 AOP 代理对非 public 方法不生效。第三种同类内部方法调用this.method()走的是原始对象绕过了代理。这三种我都踩过尤其是第三种隐蔽性极强。注意同类内部调用想走事务要么把方法拆到另一个 Bean要么注入自己Autowired private UserService self要么用 AopContext.currentProxy()。最推荐第一种结构最清晰。5.3 中文乱码与静态资源 404中文乱码分两种请求乱码和响应乱码。请求乱码用 CharacterEncodingFilter 解决注意要配在 DispatcherServlet 之前且 forceEncoding 设为 true。响应乱码在RequestMapping上配 produces application/json;charsetUTF-8。静态资源 404 前面提过加mvc:default-servlet-handler/即可但要注意它和mvc:annotation-driven/的配合两个都要有。filter filter-nameencodingFilter/filter-name filter-class org.springframework.web.filter.CharacterEncodingFilter /filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping5.4 独家避坑心得分享几个文档里不会写、但实际很要命的经验。第一父子容器的扫描包千万别重叠一旦某个 Bean 被两个容器都扫到会出现两个实例事务和注入全乱套。第二mvc:annotation-driven/和手动配 RequestMappingHandlerMapping 二选一同时配会冲突。第三JdbcTemplate 的 queryForObject 查不到数据会抛 EmptyResultDataAccessException不是返回 null业务里要 try-catch 或改用 query 方法。第四Druid 的监控页面记得关掉或加权限别裸奔在公网。这套练习做完我对 Spring 的理解从“会用”变成了“知道为什么”。后面再去看 Spring Boot 的自动配置源码那些 ConditionalOnMissingBean 的判断逻辑一下子就看懂了——因为我知道它到底在替我配什么。如果你也想补这一课建议别偷懒用 Boot老老实实手动整合一遍收获完全不一样。