ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSM框架实战:从XML配置到事务避坑的完整链路解析

SSM框架实战:从XML配置到事务避坑的完整链路解析

简介:本资源是一套完整的Java毕业设计项目——基于SSM框架开发的学习交流论坛系统,面向计算机专业本科生及Java初学者,解决课程设计、毕设选题与Web全栈实践落地难题。系统采用B/S架构,后端整合Spring+SpringMVC+MyBatis+Maven,前端使用Vue+CSS+JS,配套MySQL 5.7数据库及开题报告、毕业论文等文档,覆盖从开发到交付的全流程需求。压缩包为ZIP格式,共含源码、数据库脚本、前后台页面、配置文件及文档类资源,整体大小20.79MB,结构清晰,模块划分明确,便于快速部署与二次开发。已有63人学习下载,读者可直接运行调试后台管理(含用户、文章、论坛、公告、轮播图等12类功能)与前台交互(注册登录、发帖评论、博客收藏、留言反馈等),并参考配套论文与开题材料规范撰写毕设文档。

1. 为什么一个“学习交流论坛”的SSM项目,成了Java应届生简历里最常被问穿的靶子?

你手上有份标着【ssm+B/S架构+源码+数据库+开题报告+毕业论文】的Java项目压缩包,解压后看到spring-config.xml、mybatis-config.xml、web.xml三件套,还有UserMapper.java和一堆JSP页面——这不像玩具,更像一份“可执行的考卷”。它不追求高并发或微服务治理,但恰恰卡在Java Web工程师能力验证的黄金分割点上:既要懂Spring IoC容器怎么把Service塞进Controller,又要会MyBatis XML里<if test="username != null">这种动态SQL的边界处理;既得写完增删改查让Tomcat跑起来,又得解释清楚为什么用<%@ page contentType="text/html;charset=UTF-8" %>而不是<meta charset="UTF-8">来防中文乱码。这不是炫技项目,而是用最朴素的B/S架构,把Java基础、框架协作、数据库交互、前端耦合这四根骨头一根根拆给你看。适合刚学完Servlet/JDBC、正啃《Spring实战》第3章的在校生,也适合面试前72小时想快速过一遍技术链路的转岗者——因为所有翻车点,都藏在那些看似“理所当然”的配置里。


2. 搭建SSM骨架:从零初始化一个能跑通登录的最小闭环

2.1 选型依据:为什么不是Spring Boot,而是原生SSM?

很多人看到“SSM”第一反应是“过时”,但这是个典型认知偏差。Spring Boot确实省去了XML配置,但它把自动装配的黑匣子藏得更深——当你在面试中被问到“@Autowired到底在哪个阶段注入Bean”,而你只答得出“启动时”,那SSM的XML配置反而成了你的救命稻草。原生SSM强制你直面三个核心容器的组装逻辑:

  • Spring MVC负责HTTP请求分发(DispatcherServlet注册、HandlerMapping匹配)
  • Spring Core管理业务Bean生命周期(ApplicationContext加载、AOP代理生成)
  • MyBatis承接SQL与Java对象映射(SqlSessionFactory构建、MapperProxy动态代理)

提示:本项目采用spring 4.3.20.RELEASE+mybatis 3.4.6+spring-mybatis 1.3.2组合,这是Maven中央仓库中兼容性最稳的版本对。避免使用spring 5.x搭配mybatis 3.2.x,后者会因DefaultSqlSession线程安全机制变更导致多线程下SqlSession复用异常。

2.2 Maven依赖配置:精准控制传递依赖的“三明治”结构

关键不是堆依赖,而是切断冗余传递。比如spring-webmvc默认带入spring-web,而spring-web又带入commons-logging——但本项目需用slf4j-log4j12统一日志,必须排除冲突包:

<!-- spring-webmvc --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>4.3.20.RELEASE</version> <exclusions> <exclusion> <groupId>commons-logging</groupId> <artifactId>commons-logging</artifactId> </exclusion> </exclusions> </dependency> <!-- MyBatis核心 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.4.6</version> </dependency> <!-- Spring-MyBatis桥接器 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>1.3.2</version> </dependency> <!-- 数据库驱动(MySQL 5.7) --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> </dependency>

逻辑说明:mybatis-spring 1.3.2是专为Spring 4.x设计的适配层,它提供了SqlSessionFactoryBean和MapperScannerConfigurer两个关键类。前者将MyBatis的SqlSessionFactory注册为Spring Bean,后者扫描@Mapper接口并生成代理对象——没有这个桥接器,MyBatis的Mapper就只是个空接口,永远得不到实现。

2.3 web.xml:B/S架构的入口契约,每个标签都在说“我是谁”

这是整个B/S架构的宪法性文件,必须逐行理解其职责:

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <!-- 1. 加载Spring根容器 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-config.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 2. 配置Spring MVC前端控制器 --> <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> <!-- 3. 字符编码过滤器(必须在所有Filter之前) --> <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> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app>

参数说明:

  • ContextLoaderListener加载spring-config.xml,创建根应用上下文(Root ApplicationContext),存放Service、Dao等非Web组件;
  • DispatcherServlet加载spring-mvc.xml,创建Web应用上下文(Web ApplicationContext),存放Controller、ViewResolver等Web组件;
  • CharacterEncodingFilter的forceEncoding=true是关键——它不仅设置请求编码,还会强制响应头Content-Type包含charset=UTF-8,否则JSP中<%=user.getName()%>输出中文仍可能乱码。

3. 数据库与MyBatis协同:让User表的增删改查不再“玄学”

3.1 MySQL建表脚本:字段类型选择背后的血泪经验

本项目用户表tb_user看似简单,但字段类型选择直接影响后续开发体验:

CREATE TABLE `tb_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(50) NOT NULL COMMENT '用户名(唯一)', `password` varchar(100) NOT NULL COMMENT 'BCRYPT加密密码', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1启用,0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

关键细节说明:

  • varchar(50)而非varchar(255):用户名长度有业务约束,过长字段浪费索引空间,且MySQL 5.7对utf8mb4字符集下varchar实际存储长度=字符数×4字节;
  • tinyint(1)存状态:比enum更易扩展(新增状态无需改表),比int节省3字节存储;
  • DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP:避免在Java代码中手动赋值updateTime,防止分布式部署时服务器时间不同步导致数据错乱。

3.2 MyBatis Mapper接口与XML:动态SQL的边界控制

UserMapper.java定义方法签名,UserMapper.xml实现SQL逻辑,二者通过namespace和id严格绑定:

// UserMapper.java public interface UserMapper { // 根据ID查询用户(带状态校验) User selectById(@Param("id") Long id); // 条件分页查询(支持用户名模糊搜索) List<User> selectByCondition(@Param("username") String username, @Param("offset") int offset, @Param("limit") int limit); // 插入用户(返回自增主键) int insert(User user); }

对应XML片段(UserMapper.xml):

<mapper namespace="com.example.mapper.UserMapper"> <!-- 1. 查询单个用户 --> <select id="selectById" resultType="com.example.entity.User"> SELECT id, username, email, status, create_time, update_time FROM tb_user WHERE id = #{id} AND status = 1 </select> <!-- 2. 动态条件查询 --> <select id="selectByCondition" resultType="com.example.entity.User"> SELECT id, username, email, status, create_time, update_time FROM tb_user WHERE status = 1 <if test="username != null and username != ''"> AND username LIKE CONCAT('%', #{username}, '%') </if> ORDER BY create_time DESC LIMIT #{offset}, #{limit} </select> <!-- 3. 插入并回填主键 --> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO tb_user (username, password, email, status, create_time) VALUES (#{username}, #{password}, #{email}, #{status}, NOW()) </insert> </mapper>

参数说明:

  • <if>标签内test="username != null and username != ''"是双重防护——既防null空指针,又防空字符串""导致LIKE '%%'全表扫描;
  • useGeneratedKeys="true"配合keyProperty="id",让MyBatis在INSERT后自动将数据库生成的AUTO_INCREMENT值回填到User对象的id字段,避免插入后还需执行SELECT LAST_INSERT_ID();
  • CONCAT('%', #{username}, '%')比${username}安全:#{}是预编译占位符,防SQL注入;${}是字符串拼接,仅用于表名/列名等无法参数化的场景。

3.3 Service层事务控制:@Transactional失效的3个隐藏陷阱

UserService中register方法需保证“插入用户+发送激活邮件”原子性,但以下写法会让事务失效:

@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; // ❌ 错误:this.register()调用绕过Spring代理 public void register(User user) { userMapper.insert(user); this.sendActivationEmail(user); // 此处this调用,事务不生效 } @Transactional(rollbackFor = Exception.class) private void sendActivationEmail(User user) { ... } // 私有方法无法被AOP拦截 // ✅ 正确:注入自身Bean,通过代理调用 @Autowired private UserService self; // 自注入 public void register(User user) { userMapper.insert(user); self.sendActivationEmail(user); // 代理对象调用,事务生效 } @Transactional(rollbackFor = Exception.class) public void sendActivationEmail(User user) { ... } // 必须是public }

现象→原因→解决:

  • 现象:sendActivationEmail抛出异常,但userMapper.insert已提交到数据库;
  • 原因:Spring事务基于动态代理,this.sendActivationEmail()是内部直接调用,未经过代理对象;私有方法无法被AOP织入;
  • 解决:自注入UserService接口,用self.sendActivationEmail()触发代理调用;或改用TransactionTemplate编程式事务。

4. 前后台分离雏形:JSP+jQuery如何规避“前后端强耦合”陷阱

4.1 后台Controller设计:RESTful风格的渐进式改造

虽然项目标称B/S架构,但原始JSP方案存在URL语义混乱问题(如/user/list.jsp)。我们通过@ResponseBody逐步向REST靠拢:

@Controller @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; // ✅ 返回JSON数据(供前端AJAX调用) @GetMapping("/list") @ResponseBody public Result<List<User>> listUsers(@RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size) { int offset = page * size; List<User> users = userService.selectByCondition(null, offset, size); return Result.success(users); } // ✅ 接收JSON参数(@RequestBody) @PostMapping("/register") @ResponseBody public Result<String> register(@RequestBody User user) { try { userService.register(user); return Result.success("注册成功"); } catch (Exception e) { return Result.fail("注册失败:" + e.getMessage()); } } }

关键改进点:

  • @RequestMapping("/api/user")统一API前缀,明确区分页面跳转(/user/list.jsp)和数据接口(/api/user/list);
  • @ResponseBody替代ModelAndView,避免JSP中混杂Java逻辑;
  • @RequestBody解析JSON,要求前端发送Content-Type: application/json,比传统x-www-form-urlencoded更符合现代前端框架习惯。

4.2 前端JSP页面:用jQuery AJAX解耦,拒绝scriptlet滥用

原始JSP常含<% while(rs.next()){ %>等Java脚本,我们改为纯HTML+jQuery:

<%@ page contentType="text/html;charset=UTF-8" %> <!DOCTYPE html> <html> <head> <title>用户列表</title> <script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script> </head> <body> <table id="userTable"> <thead> <tr><th>ID</th><th>用户名</th><th>邮箱</th><th>操作</th></tr> </thead> <tbody></tbody> </table> <script> $(document).ready(function() { // ✅ 用AJAX获取JSON数据 $.get('/api/user/list', {page: 0, size: 10}, function(res) { if (res.code === 200) { const tbody = $('#userTable tbody'); tbody.empty(); res.data.forEach(user => { tbody.append(`<tr> <td>${user.id}</td> <td>${user.username}</td> <td>${user.email}</td> <td><button onclick="deleteUser(${user.id})">删除</button></td> </tr>`); }); } }); }); function deleteUser(id) { if (confirm('确定删除?')) { $.ajax({ url: '/api/user/' + id, type: 'DELETE', success: function(res) { alert(res.msg); location.reload(); // 简单刷新,真实项目应局部更新DOM } }); } } </script> </body> </html>

逻辑说明:

  • JSP退化为静态HTML容器,所有数据渲染由JavaScript完成;
  • $.get和$.ajax统一走/api/路径,与后台Controller完全对应;
  • location.reload()虽粗糙,但比<% response.sendRedirect("list.jsp") %>更可控——重定向会丢失当前页滚动位置,而AJAX局部更新需额外维护DOM状态,对毕业设计项目而言,简单即可靠。

5. 避坑指南:SSM项目里90%新手栽在这些“理所当然”的配置上

5.1 现象:Tomcat启动成功,但访问/login.jsp报404

原因:web.xml中<url-pattern>/</url-pattern>被DispatcherServlet拦截,而JSP文件未配置视图解析器。Spring MVC默认不处理.jsp后缀,需显式配置InternalResourceViewResolver。
解决:在spring-mvc.xml中添加:

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean>

并确保JSP文件放在src/main/webapp/WEB-INF/jsp/login.jsp路径下——WEB-INF目录下资源无法被浏览器直接访问,必须经Controller转发,这是安全设计,不是bug。

5.2 现象:MyBatis执行SELECT * FROM tb_user返回null,但Navicat能查到数据

原因:实体类User.java字段名与数据库列名不一致,且未配置resultMap或开启mapUnderscoreToCamelCase。例如数据库列create_time,实体类属性为createTime,MyBatis默认按字段名精确匹配。
解决:在mybatis-config.xml中开启自动映射:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>

或在UserMapper.xml中定义resultMap:

<resultMap id="UserResultMap" type="com.example.entity.User"> <id property="id" column="id"/> <result property="username" column="username"/> <result property="createTime" column="create_time"/> <result property="updateTime" column="update_time"/> </resultMap> <select id="selectById" resultMap="UserResultMap">...</select>

5.3 现象:<if test="status == 1">在XML中不生效,始终执行AND status = 1

原因:MyBatis 3.4+默认使用OGNL表达式引擎,==在OGNL中是引用比较(status.equals(1)),而数据库字段是TINYINT,Java中对应Byte类型,1是Integer,类型不匹配导致恒为false。
解决:改用eq方法或==配合类型转换:

<if test="status != null and status.byteValue() == 1"> AND status = 1 </if> <!-- 或更简洁 --> <if test="status != null and status == @java.lang.Byte@valueOf('1')"> AND status = 1 </if>

5.4 现象:@Transactional方法内调用另一个@Transactional方法,外层事务不回滚

原因:Spring事务传播行为默认为REQUIRED,即“如果当前存在事务,则加入该事务;否则新建事务”。但当methodA调用methodB时,若methodB在同一个类中,仍是内部调用,未经过代理,methodB的@Transactional注解被忽略。
解决:

  • 方案1:将methodB提取到独立Service类中;
  • 方案2:使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚;
  • 方案3:如前述,通过自注入self.methodB()触发代理调用。

5.5 现象:JSP中<%=user.getUsername()%>显示null,但Debug发现user对象不为空

原因:User实体类缺少getUsername()方法,或方法名拼写错误(如getUsername()写成getUserName()),JSP的EL表达式通过反射调用getter,找不到方法则返回null。
解决:检查实体类是否遵循JavaBean规范——属性username对应getUsername()/setUsername(String)方法;IDEA中右键→Generate→Getter and Setter可一键生成,避免手误。


6. 进阶技巧:用Log4j2+Druid监控SQL执行,把“黑匣子”变成透明流水线

6.1 Log4j2配置:让MyBatis SQL日志可读、可过滤、可归档

log4j2.xml中配置MyBatis日志级别为DEBUG,但需精准定位到SQL执行层,避免刷屏:

<Configuration status="WARN"> <Appenders> <Console name="Console" target="SYSTEM_OUT"> <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> </Console> <!-- 专门记录SQL的文件Appender --> <File name="SqlFile" fileName="logs/sql.log"> <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %msg%n"/> </File> </Appenders> <Loggers> <!-- 关键:只记录MyBatis的Statement日志 --> <Logger name="org.apache.ibatis.executor.statement" level="DEBUG" additivity="false"> <AppenderRef ref="SqlFile"/> </Logger> <!-- 其他日志走控制台 --> <Root level="INFO"> <AppenderRef ref="Console"/> </Root> </Loggers> </Configuration>

效果:logs/sql.log中只记录类似==> Preparing: SELECT * FROM tb_user WHERE id = ?和<== Parameters: 1(Long)的原始SQL及参数,不包含MyBatis内部的BaseExecutor、RoutingStatementHandler等无关日志,排查慢SQL时一眼定位。

6.2 Druid连接池监控:暴露数据库连接的真实健康度

在spring-config.xml中替换HikariCP为Druid,并启用监控页面:

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <!-- 关键监控配置 --> <property name="filters" value="stat,wall,log4j2"/> <property name="connectionProperties" value="druid.stat.mergeSql=true;druid.stat.slowSqlMillis=1000"/> </bean>

启动后访问http://localhost:8080/druid(需配置DruidStatViewServlet),可查看:

  • SQL监控:执行次数、平均耗时、最慢SQL(如SELECT * FROM tb_post WHERE content LIKE '%xxx%'未加索引);
  • 连接池状态:活跃连接数、等待线程数——若长期>0,说明数据库响应慢或连接泄漏;
  • 防火墙日志:wall过滤器拦截的危险SQL(如DROP TABLE),毕业设计中故意写DELETE FROM tb_user测试,会被Druid直接拦截并记日志。

6.3 数据库同步技巧:用mysqldump+git管理schema变更

毕业设计常需多人协作修改数据库,用mysqldump导出结构再纳入Git,比口头约定更可靠:

# 导出不含数据的建表语句(--no-data),保存为db-schema.sql mysqldump -u root -p --no-data --skip-triggers forum_db > db-schema.sql # 同步时先备份再执行(防止误操作) mysql -u root -p forum_db < db-schema.sql

注意:--skip-triggers避免导出触发器,因SSM项目极少用触发器,且不同环境触发器权限可能不一致。

我带过3届毕业设计,最常看到学生花3天调试ClassNotFoundException,结果是mybatis-spring版本与Spring不匹配;也见过有人为<if>标签里的空字符串判断写了5种写法,最后发现test="username != null and username.trim() != ''"才是最稳的。SSM不是过时的技术栈,而是把“框架怎么工作”这件事,摊开在你眼皮底下教——它不替你思考,但每一步都给你留了追问的线索。把web.xml读透,比背100道Java面试题更能让你在真实项目里站稳脚跟。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进