Spring+SpringMVC学生信息管理系统:分层架构与避坑实战
简介:这份资源是一套面向Java Web初学者与进阶开发者的SSM学生信息管理项目实践包,以Spring、SpringMVC与MyBatis三层架构为核心,围绕学生信息、课程与成绩等数据完成增删改查与页面交互,适合用来理解框架整合流程、熟悉分层职责划分,也可作为课程设计或毕业设计的参考底稿。压缩包共2个文件,包含1个zip工程包与1个sql数据库脚本,整体约16.32MB;sql脚本用于初始化学生、课程、成绩等表结构,zip内则是可直接导入运行的源码与资源文件,便于快速搭建开发环境。该资源已有2395人学习,具备一定的参考基础。学习者可从中获取Controller、Service、DAO各层的调用关系示例,MyBatis映射与SQL编写方式,以及登录认证、权限控制与JSP或Thymeleaf页面渲染的实现思路,从而对照自身项目排查分层混乱、数据库连接与请求映射等问题,并在此基础上做功能扩展与代码优化。
1. 学生信息管理系统:Spring+SpringMVC 这套老组合,为什么还值得拿来练手
需求方要一个能录学生、能分班、能查成绩的后台,工期两周,预算不高,登录后还得区分管理员和普通老师——这种活我见过好几次,最后落地大多是一个 Spring+SpringMVC 的学生信息管理系统:Maven 管依赖,JSP 做页面,MySQL 存数据,角色不同进不同菜单。这类项目在搜索里常年有人找,搜 Spring crm、SSM 学生管理之类的词,翻几页就能撞见几个改过名的版本。它技术不新,但链路是完整的:从 web.xml 到 Controller 再到 Mapper,一条线拉通,没有框架帮你把细节藏起来。刚学完 Servlet 想弄明白「容器到底管了什么」的人适合拿它练手;接私活需要一个能改的底子的人,也适合先把它跑通再动刀。
2. 分层架构与目录结构:两级容器、依赖关系和一次请求的走向
2.1 技术栈与依赖关系
这个项目的骨架是 Spring + SpringMVC,持久层用 MyBatis,数据库 MySQL,视图层 JSP + JSTL,构建工具 Maven,跑在 Tomcat 上。选型逻辑不复杂:Spring 管 Bean 和事务,SpringMVC 管 URL 到方法的映射,MyBatis 管 SQL 和结果集映射,四者职责不重叠,出了问题也容易定位是哪一层。
版本上有个血泪经验:Spring 5.x 配 JDK 8 最稳,Tomcat 用 8.5 或 9.0,别上 Tomcat 10——它把 javax.servlet 换成了 jakarta.servlet,老项目的 JSP 和过滤器会集体报 ClassNotFoundException。下面是我一般会写的依赖骨架:
<properties> <spring.version>5.3.30</spring.version> <mybatis.version>3.5.13</mybatis.version> </properties> <dependencies> <!-- Spring 核心容器,提供 IoC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <!-- SpringMVC,提供 DispatcherServlet 和注解映射 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- 事务与 JDBC 支持 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 本体与 Spring 整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- 数据库驱动与连接池 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>4.0.3</version> </dependency> <!-- Servlet API 由 Tomcat 提供,scope 必须是 provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>这段 pom 里有两个参数值得单独说。scope=provided的 servlet-api 不能省,也不能改成默认的 compile,否则打 war 包时会把 servlet-api 塞进WEB-INF/lib,Tomcat 启动时类加载器冲突,报ClassCastException或者过滤器初始化失败。HikariCP 的版本要和 JDK 对齐,JDK 8 用 4.x,JDK 11 以上才考虑 5.x,版本错配的典型现象是启动时抛NoSuchMethodError,堆栈指向连接池内部,跟业务代码一点关系都没有。
2.2 目录结构逐层说明
老项目的目录结构一般长这样,我按「谁被谁调用」的顺序标一下:
student-management ├── pom.xml ├── src/main/java/com/example/sms │ ├── controller # 只放 Controller,被 SpringMVC 容器扫描 │ │ ├── LoginController.java │ │ └── StudentController.java │ ├── service # 业务接口 │ │ ├── StudentService.java │ │ └── impl │ │ └── StudentServiceImpl.java # 被根容器扫描 │ ├── mapper # MyBatis 接口,由 MapperScannerConfigurer 生成代理 │ │ └── StudentMapper.java │ ├── entity # 与表对应的 POJO │ │ └── Student.java │ ├── interceptor # 登录拦截器 │ │ └── LoginInterceptor.java │ └── util │ ├── PageResult.java │ └── BizException.java ├── src/main/resources │ ├── applicationContext.xml # 根容器配置 │ ├── spring-mvc.xml # 子容器配置 │ ├── jdbc.properties # 数据库四要素 │ └── mapper │ └── StudentMapper.xml └── src/main/webapp ├── WEB-INF │ ├── web.xml │ └── views # JSP 全部放在 WEB-INF 下,禁止浏览器直接访问 │ ├── login.jsp │ └── student/list.jsp └── static # css/js/图片这个结构里最关键的一条约定是:Controller 和 Service 分属两个容器扫描。spring-mvc.xml只扫controller包,applicationContext.xml扫其余包并排除@Controller。如果两个配置都写成扫全包,Controller 会被创建两次,事务代理也会套两层,症状是某些请求进不去、某些 AOP 切面执行两遍。
views目录放在WEB-INF下也不是随意为之。WEB-INF是 Servlet 规范里浏览器无法直接访问的目录,JSP 放进去意味着所有页面必须经过 Controller 转发,登录校验才有意义;如果 JSP 直接放在 webapp 根目录,用户敲 URL 就能绕过拦截器看到列表页。
2.3 一次查询请求的完整链路
拿「打开学生列表」这个动作走一遍,能把这套架构串起来:
- 浏览器请求
/student/list?page=1,被web.xml里映射为/的DispatcherServlet接住; DispatcherServlet从自己的子容器里找HandlerMapping,匹配到StudentController.list();- 执行拦截器链,
LoginInterceptor.preHandle()检查 session 里有没有loginUser,没有就重定向到登录页,直接短路; - 进入 Controller,方法参数由
@RequestParam绑定,调用studentService.page(...); - Service 是根容器里的 Bean,内部持有
StudentMapper的代理对象,调用后由 MyBatis 执行 XML 里的 SQL; - 结果封装成
PageResult放回Model,返回逻辑视图名student/list; InternalResourceViewResolver拼成/WEB-INF/views/student/list.jsp,转发渲染。
这条链路里有两个容易混淆的点。DispatcherServlet 自己有容器(子容器),它能看见根容器的 Bean,反过来根容器看不见 Controller——所以 Service 里注入 Controller 一定失败,这个方向别搞反。另外拦截器是在 Controller 之前、HandlerMapping 之后执行的,它拿不到方法参数,只能看 request 和 session,所以权限判断尽量放在这一层,业务校验放 Service。
3. 核心模块拆解:登录拦截、学生 CRUD、分页搜索
3.1 两级容器的配置文件
web.xml是整个应用的入口,它决定了根容器怎么起、DispatcherServlet 怎么配:
<!-- 根容器:加载 Service、Mapper、事务,随应用启动 --> <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> <!-- 子容器:SpringMVC 前端控制器 --> <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> <!-- 编码过滤器,必须排在所有过滤器最前面 --> <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>load-on-startup=1的意思是 Tomcat 启动时就初始化 DispatcherServlet,而不是等第一个请求进来。好处是配置错误在启动日志里就能看到,坏处是启动会慢一点——我倾向于打开,因为线上环境最怕的是「服务起来了但第一个用户访问才炸」。
对应的applicationContext.xml负责数据源、SqlSessionFactory、Mapper 扫描和事务:
<!-- 扫 Service 等组件,排除 Controller,避免和子容器重复 --> <context:component-scan base-package="com.example.sms"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <context:property-placeholder location="classpath:jdbc.properties"/> <!-- 数据源:最大连接数按并发量调,学生系统 10 足够 --> <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="jdbcUrl" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="maximumPoolSize" value="10"/> </bean> <!-- MyBatis 会话工厂:mapperLocations 决定 XML 能不能被加载 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.sms.entity"/> </bean> <!-- 扫描 Mapper 接口,生成代理 Bean --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.sms.mapper"/> </bean> <!-- 事务管理器 + 注解驱动 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>参数上重点看三处:mapperLocations写的是classpath:mapper/*.xml,意味着 XML 必须放在resources/mapper下,和接口同包但不同目录;typeAliasesPackage开了别名,XML 里写resultType="Student"就行,不用写全限定名;maximumPoolSize=10是给学生系统这种低并发场景的保守值,连接池太小会排队,太大会把数据库连接数吃满,一般按CPU 核数 * 2 + 磁盘数估。
spring-mvc.xml则只关心 Web 层:
<!-- 只扫 controller 包 --> <context:component-scan base-package="com.example.sms.controller"/> <mvc:annotation-driven/> <!-- 让 DispatcherServlet 放行静态资源 --> <mvc:default-servlet-handler/> <!-- 登录拦截器,排除登录接口和静态资源 --> <mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.sms.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors> <!-- 视图解析器 --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>mvc:default-servlet-handler这行经常被漏掉。DispatcherServlet 映射/之后会接管所有请求,包括/static/css/app.css,没有这行配置的话样式全部 404,页面裸奔,新手常以为是 JSP 写错了。
3.2 登录拦截器实现
登录模块没有用安全框架,就是一个 session + 拦截器,代码量小,逻辑一眼看清:
public class LoginInterceptor implements HandlerInterceptor { // Controller 执行前调用,返回 false 表示中断请求 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); Object user = session == null ? null : session.getAttribute("loginUser"); if (user == null) { // 统一跳登录页,不暴露内页真实 URL response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } // 请求完成后清理 ThreadLocal,防止线程池复用导致的数据串号 @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }getSession(false)的 false 是重点:不主动创建 session。如果用getSession(),每个未登录请求都会生成一个空 session,压测时内存涨得很快。afterCompletion里清理 ThreadLocal 是给后面扩展留的口子——比如把当前用户放到 ThreadLocal 里给 Service 层做数据权限过滤,用完不清理,下一个复用该线程的请求会读到上一个用户的信息,这种问题排查起来非常费劲。
3.3 学生 CRUD 的 Controller 与 Service
Controller 只做参数接收和视图选择,业务判断全部下沉到 Service:
@Controller @RequestMapping("/student") public class StudentController { @Autowired private StudentService studentService; // 分页列表:page 从 1 开始,keyword 为空表示查全部 @RequestMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "") String keyword, Model model) { PageResult<Student> result = studentService.page(page, 10, keyword); model.addAttribute("result", result); model.addAttribute("keyword", keyword); return "student/list"; // 对应 /WEB-INF/views/student/list.jsp } // 新增:POST 后重定向,避免刷新重复提交 @RequestMapping(value = "/add", method = RequestMethod.POST) public String add(Student student, RedirectAttributes ra) { try { studentService.insert(student); ra.addFlashAttribute("msg", "新增成功"); } catch (BizException e) { ra.addFlashAttribute("msg", e.getMessage()); } return "redirect:/student/list"; } // 删除:只接受 POST,防止被 GET 链接误触 @RequestMapping(value = "/delete", method = RequestMethod.POST) public String delete(@RequestParam Long id) { studentService.deleteById(id); return "redirect:/student/list"; } }redirect:/student/list这个写法里,SpringMVC 会自动加上 contextPath,不用手拼。用RedirectAttributes传提示信息而不是塞 session,是因为 flash 属性存两次请求就自动销毁,不会出现「刷新页面提示还在」的尴尬。
Service 层承担校验和事务:
@Service public class StudentServiceImpl implements StudentService { @Autowired private StudentMapper studentMapper; @Override public PageResult<Student> page(int page, int size, String keyword) { // 页码兜底,防止前端传 0 或负数导致 offset 为负 int current = Math.max(page, 1); int offset = (current - 1) * size; List<Student> rows = studentMapper.selectByPage(offset, size, keyword); int total = studentMapper.countByKeyword(keyword); return new PageResult<>(rows, total, current, size); } @Override @Transactional(rollbackFor = Exception.class) public void insert(Student student) { // 先查后插只能挡住大部分重复,最终靠数据库唯一索引兜底 if (studentMapper.countByNo(student.getStuNo()) > 0) { throw new BizException("学号已存在"); } studentMapper.insert(student); } }rollbackFor = Exception.class是必须写的。Spring 默认只对RuntimeException回滚,如果业务里抛的是受检异常(比如某些 IO 操作),不写这行事务不会回滚,数据就脏了。
3.4 分页与条件查询
分页没有引入 PageHelper,手写LIMIT,好处是 SQL 完全可控,坏处是 count 语句要自己维护:
<!-- 分页查询:offset 是偏移量,size 是每页条数 --> <select id="selectByPage" resultMap="StudentMap"> SELECT id, stu_no, name, gender, class_id, phone, create_time FROM student <where> <if test="keyword != null and keyword != ''"> AND (stu_no LIKE CONCAT('%', #{keyword}, '%') OR name LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY id DESC LIMIT #{offset}, #{size} </select> <!-- 总数查询:条件必须和上面完全一致,否则页码会错乱 --> <select id="countByKeyword" resultType="int"> SELECT COUNT(*) FROM student <where> <if test="keyword != null and keyword != ''"> AND (stu_no LIKE CONCAT('%', #{keyword}, '%') OR name LIKE CONCAT('%', #{keyword}, '%')) </if> </where> </select>这里有个隐式约定:Mapper 接口的多个参数必须加@Param,否则 XML 里拿不到名字。
public interface StudentMapper { List<Student> selectByPage(@Param("offset") int offset, @Param("size") int size, @Param("keyword") String keyword); int countByKeyword(@Param("keyword") String keyword); int countByNo(@Param("stuNo") String stuNo); int insert(Student student); }不加@Param时,MyBatis 会把参数命名成arg0、arg1或param1、param2,XML 里写#{offset}直接抛BindingException: Parameter 'offset' not found。单参数方法可以不加,多参数一律加上,养成习惯能省很多事。
4. 数据层与事务:表结构、Mapper 映射和回滚规则
4.1 核心表结构与索引
学生系统最少三张表:用户、班级、学生。用户表管登录,班级表管归属,学生表是主数据:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT '存 MD5 加盐后的值', role TINYINT NOT NULL DEFAULT 2 COMMENT '1管理员 2教师', UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE class_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(50) NOT NULL, grade VARCHAR(20) NOT NULL COMMENT '年级,如 2024', UNIQUE KEY uk_name_grade (class_name, grade) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL COMMENT '学号', name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 0 COMMENT '0未知 1男 2女', class_id BIGINT NOT NULL, phone VARCHAR(20) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_no (stu_no), KEY idx_class_id (class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;utf8mb4而不是utf8,是因为 MySQL 的utf8只支持三字节,存不了 emoji 和部分生僻字,学生姓名里偶尔就有。uk_stu_no是业务上的最后一道防线,Service 里的先查后插在并发下会失效,唯一索引不会。idx_class_id是给「按班级查学生」准备的,没有它数据量上到几万行时列表页会明显卡顿。
4.2 Mapper 接口与 XML 映射
结果集映射建议显式写resultMap,别依赖自动驼峰:
<resultMap id="StudentMap" type="Student"> <id column="id" property="id"/> <result column="stu_no" property="stuNo"/> <result column="name" property="name"/> <result column="gender" property="gender"/> <result column="class_id" property="classId"/> <result column="create_time" property="createTime"/> </resultMap> <!-- 新增:useGeneratedKeys 回填主键,方便后续关联操作 --> <insert id="insert" parameterType="Student" useGeneratedKeys="true" keyProperty="id"> INSERT INTO student (stu_no, name, gender, class_id, phone) VALUES (#{stuNo}, #{name}, #{gender}, #{classId}, #{phone}) </insert> <select id="selectById" resultMap="StudentMap"> SELECT * FROM student WHERE id = #{id} </select>useGeneratedKeys和keyProperty这对参数的作用是:插入完成后把自增主键写回传入的Student对象。别小看这个,如果新增学生后要同时写一条操作日志,日志里需要 studentId,没回填的话只能再查一次。
XML 的namespace必须和 Mapper 接口的全限定名一模一样:
<mapper namespace="com.example.sms.mapper.StudentMapper">写成studentMapper或者少一层包名,启动时不会报错,但调用方法时抛Invalid bound statement (not found),这个报错是 MyBatis 的经典黑匣子,原因通常是 namespace 不对、方法名不对、XML 没被mapperLocations扫到,三者逐一排查。
4.3 声明式事务与回滚
事务配置已经在根容器里开了注解驱动,业务代码只管加注解。真正容易翻车的是失效场景:
@Service public class StudentServiceImpl implements StudentService { // 同类内部方法直接调用,事务不生效,因为没走代理 @Override public void batchImport(List<Student> list) { for (Student s : list) { this.insert(s); // 这里的事务注解形同虚设 } } @Override @Transactional(rollbackFor = Exception.class) public void insert(Student student) { // 真正的事务边界 studentMapper.insert(student); } }上面这段是典型的自调用陷阱。batchImport里this.insert(s)走的是原始对象,不经过 Spring 的事务代理,结果是一条失败其余照常提交。正确做法是把insert抽到另一个 Bean,或者用AopContext.currentProxy()拿代理对象,但后者需要在配置里开exposeProxy,一般不推荐,拆 Bean 更干净。
另一个常见问题是异常被吞:
@Transactional(rollbackFor = Exception.class) public void importData(List<Student> list) { try { for (Student s : list) { studentMapper.insert(s); } } catch (Exception e) { log.error("导入失败", e); // 异常没抛出去,事务管理器认为一切正常 } }catch 之后如果不重新抛出,事务管理器收不到异常信号,前面插入的数据不会回滚。要么在 catch 里throw new BizException(...),要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),前者更符合直觉。
5. 本地跑通与排查避坑:环境、配置清单和五条踩坑记录
5.1 环境准备与建库
跑起来之前先把环境对齐,版本错配是新手最常见的卡点:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 8 | Spring 5.x 最稳的组合 |
| MySQL | 5.7 / 8.0 | 8.0 驱动类名带 cj |
| Tomcat | 8.5 / 9.0 | 不要用 10,包名变了 |
| Maven | 3.6+ | 配好国内镜像 |
建库脚本执行完之后,jdbc.properties里的四要素要对上:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/student_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver,还写老版的com.mysql.jdbc.Driver会报Loading class ... deprecated,虽然不一定启动失败,但连接会慢半拍。serverTimezone不配的话,插入时间会差 8 小时,这个坑很隐蔽,页面上看不出来,导出报表时才发现。
5.2 配置修改清单与 Tomcat 部署
拿到源码后按这份清单改一遍,基本就能跑:
jdbc.properties改数据库地址、账号、密码;pom.xml确认没有写死本机路径的插件配置;- IDEA 里配 Tomcat,Deployment 选
war exploded,Application context 设成/sms; - 启动后先访问
/sms/login,用初始化脚本里的管理员账号登录; - 登录成功后看列表页有没有数据,没有就查
student表是不是空的。
打包部署用 Maven 更省事:
# 跳过测试打包,产物在 target 目录 mvn clean package -DskipTests # 把 war 丢进 Tomcat 的 webapps,启动后访问 /student-management cp target/student-management.war $TOMCAT_HOME/webapps/ $TOMCAT_HOME/bin/startup.sh-DskipTests不是偷懒,是很多老项目的测试类依赖了本机数据库,换台机器必然失败,打包阶段跳过更实际。war 包名就是访问路径,想改成/sms就把 war 重命名。
5.3 五条踩坑记录:现象、原因、解决
现象一:启动报 404,日志里 DispatcherServlet 初始化成功但所有请求都进不去。原因通常是web.xml里contextConfigLocation没配,或者spring-mvc.xml扫包路径写错,导致 Controller 根本没注册。 解决是启动日志里搜Mapped "{[/student/list]}",搜不到就说明映射没注册,回头检查component-scan的包名和@Controller注解是否都在。
现象二:@Transactional加了但数据没回滚。原因有三类:注解打在 Controller 上(Controller 不在事务代理范围)、Service 是new出来的(没被 Spring 管理)、异常被 catch 后没抛出去。 解决办法是先在 Service 方法里打日志确认执行到哪一步,再看当前类是否被@Service标注、是否被根容器扫描到。
现象三:页面中文全是问号,表单提交的中文存进库变乱码。原因可能是三处之一:CharacterEncodingFilter没配或顺序不对、数据库连接串缺characterEncoding=utf8、JSP 页头缺pageEncoding="UTF-8"。 解决按「过滤器 → 连接串 → JSP」的顺序逐一排查,三处都统一成 UTF-8 再重启。
现象四:静态资源全部 404,页面没有样式。原因是 DispatcherServlet 映射/后接管了所有请求,静态资源也被当成 Controller 请求处理。 解决是在spring-mvc.xml加<mvc:default-servlet-handler/>,或者用<mvc:resources mapping="/static/**" location="/static/"/>显式放行。
现象五:调用 Mapper 方法抛Invalid bound statement (not found)。原因是 XML 的 namespace 和接口全限定名不一致、方法名对不上、或者mapperLocations路径没匹配到 XML。 解决是先在target/classes/mapper下确认 XML 被复制过去了,再核对 namespace 和<select id>是否与接口方法名逐字一致。
6. 二次开发:加一个班级管理模块的改造路径
真正接手这类项目,第一件事往往不是重构,而是加一个原项目没有的模块。拿「班级管理」举例,改造路径是固定的五步,按顺序走不会乱。
先改数据库,加class_info表(如果原项目没有),再补Student实体里的className字段用于列表展示。然后写ClassMapper接口和对应 XML,SQL 就三条:分页查、按 ID 查、插入。接着写ClassService,插入时校验「同年级班级名不重复」,这个校验和student表里的学号校验是一个套路。Controller 照抄StudentController的结构,把路径换成/class,视图名换成class/list。最后在 JSP 的菜单里加一个入口,权限判断挂在拦截器上,管理员可见、教师不可见。
改造时我建议先加一个只读的列表页,跑通「菜单点进去能看到数据」这条链路,再去加增删改。原因是菜单和拦截器是最容易出问题的地方,先验证它们,后面写业务代码时心里有底。
验证方法我一般分三层:第一层看日志,请求进来后Mapped和Returning两条日志能确认路由和视图解析正常;第二层看 SQL,把 MyBatis 的logImpl设成STDOUT_LOGGING,执行的语句和参数一目了然;第三层看数据,新增一条记录后直接查库,确认字段和编码都对。
有个技巧是在改造前先给现有模块补一个最小的集成测试,哪怕只是启动容器加调用一次 Service。老项目通常没有测试,改完之后不知道是改坏了还是本来就这样,有个基线能省掉大量扯皮。
代码结构上要克制,别顺手把 Mapper 换成 JPA,也别把 JSP 换成 Thymeleaf。这类项目的价值就在于结构简单、链路透明,大改之后原来的问题没解决,新问题一堆,维护成本反而更高。
我印象最深的一次是改一个类似的学生管理后台,加完班级模块后列表页一直空白,查了两个小时才发现是新 JSP 忘了放WEB-INF/views下,被视图解析器拼成了错误路径,日志里只有一行不起眼的警告。从那以后我每次加新模块,都强制先走一遍「菜单 → 拦截器 → Controller → JSP」这条最小链路再写业务代码。希望帮到你。
本文还有配套的精品资源,点击获取