简介:基于Spring与SpringMVC整合MyBatis的SSM学生信息管理系统资源,面向Java Web初学者、课程设计或毕业设计开发者,重点解决学生信息增删改查、数据持久化及基础权限控制等典型需求。压缩包共2个文件:项目源码压缩包与数据库初始化脚本。源码采用经典分层架构,涵盖Controller、Service、DAO及前端页面,完整演示依赖注入、事务管理、HTTP请求处理与SQL动态执行的链路;SQL脚本预建学生、课程、成绩等关联表结构,导入后即可直接运行。资源整体约16.32MB,结构紧凑、上手门槛低,适合对照学习SSM框架整合流程与实际业务开发规范。已有2392人学习下载,可用于个人练习或团队培训参考,帮助快速掌握从数据库设计到页面交互的完整实现思路。
1. 学生信息管理系统(Spring+SpringMVC):一套能串起全链路的最小实践
用Spring和SpringMVC把学生信息管理系统完整搭一遍,几乎是很多开发者第一次真正理解后端容器与Web层协作的起点。这个系统功能不复杂,无非是学生档案的增删改查、登录和角色权限,但正因为足够简单,才有机会把数据源配置、事务边界、组件扫描、视图解析这些平时被框架藏起来的机制逐一摸透。下面我会按“表设计、容器配置、控制器实现、故障排查”的顺序给出可复现的方案,顺带把静态资源被拦截、中文乱码这类高频事故一起说清楚。适合正在做课程设计,或者第一次完整搭建Java Web项目的读者照着敲。
2. 建表先于写码:学生信息管理系统的需求边界与数据落点
2.1 收窄业务边界:三个角色决定三块功能集
学生信息管理系统虽然叫“系统”,但原始需求本质上是一套档案管理后台。最常见的翻车做法是一开始就把成绩、考勤、宿舍全并进来,设计出来的表二十多个字段,查询条件互相打架,课设做完了自己也说不清每张表在系统中担当什么。我的习惯是先和需求方明确系统的最小功能集,三个模块就足够打底:登录与鉴权、学生档案的增删改查、简单的统计展示。如果评审要求加成绩或选课模块,也应当是独立的子模块,而不是让学生表越改越肿。
角色设计上,大多数课设项目用一张user表加一个role字段就能解决。管理员管账号和全量数据,辅导员管本院学生档案,学生只能查看本人信息。这个层级放在Service层做数据过滤就够了,不必一开始就上权限框架。如果以后要扩展成更细的权限点,再把它改造成用户-角色-权限多表关联也不迟。数据库层面最怕的不是角色少,而是把角色做成游离在业务表之外的“假表”。
动手建表之前,先把页面原型或接口清单列出来:列表查询需要哪些过滤条件,新增表单有哪些必填项,删除需不需要回收站。这个清单就是建表的输入。我见过不少人跳过这一步,结果做到一半发现缺联系电话字段,或者要在历史数据上补专业字段,这种就叫“表结构返工”,后面所有mapper和页面都要跟着动。越早意识到“表是系统的地基”,后面返工越少。
2.2 三张核心表与字段选型:主键、唯一约束与软删除
下面这套DDL是学生信息管理系统里比较精简的版本,以MySQL为例,表本身不追求大而全,但字段命名的习惯值得保留。主键用bigint自增,学号单独加唯一约束,属于业务编号,能挡住重复数据的写入;如果只靠业务代码里查一遍再判断,并发下必然漏。
CREATE TABLE `student` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `student_no` VARCHAR(20) NOT NULL COMMENT '学号', `name` VARCHAR(32) NOT NULL COMMENT '姓名', `gender` TINYINT NOT NULL DEFAULT 1 COMMENT '性别:1男 2女', `birthday` DATE DEFAULT NULL COMMENT '出生日期', `major` VARCHAR(64) DEFAULT NULL COMMENT '专业', `class_name` VARCHAR(64) DEFAULT NULL COMMENT '班级', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '学籍状态:1在读 0离校', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删 1已删', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_no` (`student_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL, `password` VARCHAR(64) NOT NULL COMMENT 'BCrypt密文', `role` TINYINT NOT NULL DEFAULT 3 COMMENT '1管理员 2辅导员 3学生', `student_no` VARCHAR(20) DEFAULT NULL COMMENT '学生关联学号', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;deleted字段是逻辑删除标记,用来避免物理删除丢掉学生历史档案。代价是每一条查询都要带上AND deleted = 0,这个条件建议写死在Mapper XML里,而不是在每个调用处手工拼接。update_time列依赖ON UPDATE CURRENT_TIMESTAMP,你写update语句时可以完全不管这个字段,数据库会自动刷新。password字段放的是密文而不是明文,即使是课程设计,只要演示时会被问到,就提前用摘要算法处理,不要让评审看到明文口令。
两张表之外,如果系统里要加班级、专业,也别急着建表。学生表里已经有class_name和major两个冗余列,对“档案查询”这种场景足够。真正需要单独建表的是那些需要“被维护”的字典数据,比如专业列表要在管理端增删改,否则写在业务表里反而多一次关联查询。
2.3 Mapper落法:XML映射还是注解
Spring+SpringMVC这套组合背后,最常见的持久层配套是MyBatis,合起来就是我们常说的SSM。MyBatis的Mapper有两种写法:注解和XML。学生信息管理系统里查询条件多变,列表页经常要加关键字过滤、状态过滤,我一般会选XML这一侧,理由只有一个:动态SQL在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"> <mapper namespace="com.example.smis.mapper.StudentMapper"> <sql id="baseColumn"> id, student_no, name, gender, birthday, major, class_name, phone, email, status </sql> <select id="selectPage" resultType="com.example.smis.entity.Student"> SELECT <include refid="baseColumn"/> FROM student WHERE deleted = 0 <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR student_no LIKE CONCAT('%', #{keyword}, '%')) </if> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select> <insert id="insert" parameterType="com.example.smis.entity.Student" useGeneratedKeys="true" keyProperty="id"> INSERT INTO student (student_no, name, gender, birthday, major, class_name, phone, email) VALUES (#{studentNo}, #{name}, #{gender}, #{birthday}, #{major}, #{className}, #{phone}, #{email}) </insert> <update id="updateById" parameterType="com.example.smis.entity.Student"> UPDATE student <set> <if test="name != null">name = #{name},</if> <if test="phone != null">phone = #{phone},</if> <if test="email != null">email = #{email},</if> </set> WHERE id = #{id} AND deleted = 0 </update> <update id="logicDeleteById"> UPDATE student SET deleted = 1 WHERE id = #{id} </update> </mapper>selectPage里的#{keyword}走的是占位符绑定,不是字符串拼SQL,能规避掉一部分注入风险;你还得注意LIKE '%#{keyword}%'这么写是错的,MyBatis会把#{}当成占位符处理,正确写法是上面这种CONCAT拼起来。<set>标签会自动去掉最后一个逗号,updateById只更新传入的非空字段,这是故意为之,能避免前端表单未填的字段把已有数据覆盖成null。
接口边界定义到这儿,数据层基本就完工。对应Java侧只需要写一个StudentMapper接口,方法签名和XML里的id一一对应,不能用重载名——MyBatis按名字绑定的机制很死板,重名方法会直接启动报错。下一章把这些接口接进Spring容器,整套工程才真正被“激活”。
3. Spring+SpringMVC集成配置:从依赖清单到容器装配
3.1 工程骨架与Maven依赖:哪些依赖必须进pom
项目结构我建议按官方Maven Web骨架来组织:src/main/java放Java代码,src/main/resources放配置文件,src/main/webapp/WEB-INF放JSP和web.xml。我第一次搭这类系统时喜欢把配置文件全扔在src目录底下,结果ClassLoader经常找不到,后来统一归到resources下就再没出过这种问题。
依赖选择上有几个容易漏的地方。spring-webmvc本身已经传递依赖了spring-context,但spring-jdbc、spring-tx这两个用于事务和数据库接入的包经常被漏掉;MyBatis的Java驱动包和spring整合包缺一不可;数据库连接池我建议选HikariCP或Druid其中一个,不要在项目里同时出现两个,避免配置混乱。
<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <!-- 版本号按自己本地Maven仓库能拉到的最新稳定版换 --> <spring.version>5.3.x</spring.version> <mybatis.version>3.5.x</mybatis.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</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.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.x</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.x</version> </dependency> <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>版本号这里我不建议无脑照抄某个最新版本。Spring 5.3.x和MyBatis 3.5.x是当前大多数老项目在用的稳定组合,javax.servlet-api的scope是provided,意思是容器里已经有Servlet的实现,打包时不要重复带进去,否则碰上一台严格一点的Tomcat会直接类冲突报错。
3.2 Spring容器装配:数据源、SqlSessionFactory与事务管理器
Spring容器负责管理Service、Mapper这些业务对象。applicationContext.xml是Spring上下文的核心。这里最关键的配置有三个:组件扫描的范围、数据源指向、事务管理器,顺序错了经常报出各种奇怪的Bean找不到。
<!-- 注意:扫描Service/Mapper,但不扫Controller --> <context:component-scan base-package="com.example.smis"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="jdbcUrl" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <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.smis.entity"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.smis.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>applicationContext.xml里我特意用<context:exclude-filter>把Controller排除,是因为SpringMVC的子容器还要单独扫描Controller一遍。如果重复扫描,事务切面很容易因为代理对象被覆盖而失效,这是后面“事务怎么都不生效”问题的高发区。dataSource的四个属性从jdbc.properties里读取,文件里注意jdbc.url不要手滑多打了空格,HikariCP校验失败时会打印一堆连不上的错误,但第一眼很难看出是配置空格问题。
sqlSessionFactory的mapperLocations指向classpath:mapper/*.xml,也就是上一章那个StudentMapper.xml的位置。typeAliasesPackage配置了entity包,这样MyBatis的resultType可以直接写Student,属性自动映射驼峰和下划线,省掉一堆resultMap手工映射代码。
3.3 SpringMVC配置:DispatcherServlet、视图解析器与静态资源放行
Web层配置涉及两个文件,web.xml里声明DispatcherServlet和编码过滤器,spring-mvc.xml里声明RequestMapping处理规则、视图解析器和静态资源放行。这两个文件容易被混淆:web.xml只有Servlet容器的生命周期概念,spring-mvc.xml才是真正的SpringMVC“大脑”。
<!-- web.xml --> <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> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <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>CharacterEncodingFilter的forceEncoding设为true,强制请求和响应的编码都走UTF-8。这个filter必须注册在所有过滤器的最前面,如果有别的过滤器先读取了请求参数,这里再转编码就来不及了,中文乱码的第一现场往往就发生在这个步骤被别人抢了先。
<!-- spring-mvc.xml --> <context:component-scan base-package="com.example.smis.controller"/> <mvc:annotation-driven/> <mvc:default-servlet-handler/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>mvc:annotation-driven/ 是Controller里那些@GetMapping、@RequestParam能用的前提,没有它,SpringMVC不会帮你做参数绑定。 mvc:default-servlet-handler/ 解决的是静态资源被DispatcherServlet一把抓的问题;DispatcherServlet把/路径全接管后,CSS、JS、图片会全部404,加了这个配置后,凡是Controller没映射到的URL都会交给Tomcat默认Servlet处理。视图解析器把Controller返回的逻辑视图名student/list拼成/WEB-INF/views/student/list.jsp,这样外部直接访问WEB-INF目录是行不通的,页面只能通过后端渲染,这在教学项目里是很好的一道安全边界。
到这里,Spring和SpringMVC两个容器的边界就清晰了:applicationContext.xml管Service和Mapper,spring-mvc.xml只管Controller和视图。下一章把Controller、Service和JSP串起来,你会看到请求从浏览器一路走到数据库再回到页面的完整链路。
4. 核心功能代码:Service、Controller与视图回显一条链路
4.1 Service层与事务边界:事务注解为什么放在这里
Service层夹在Controller和Mapper之间,是业务规则的地盘。常见错误有两种:一是Controller里直接把Mapper当Repository用,事务注解满天飞;二是把 @Transactional 放在Controller上,以为这样能包住整个请求。事务的本质是数据库连接上的边界,MyBatis的SqlSession和Spring事务绑定后,只有经过Spring代理的Service方法上的事务注解才起作用。
@Service public class StudentServiceImpl implements StudentService { private final StudentMapper studentMapper; public StudentServiceImpl(StudentMapper studentMapper) { this.studentMapper = studentMapper; } @Override @Transactional(readOnly = true) public PageResult<Student> pageByCondition(String keyword, int pageNum, int pageSize) { int offset = (pageNum - 1) * pageSize; List<Student> list = studentMapper.selectPage(keyword, offset, pageSize); long total = studentMapper.countByCondition(keyword); return new PageResult<>(list, total, pageNum, pageSize); } @Override @Transactional(rollbackFor = Exception.class) public void save(Student student) { studentMapper.insert(student); } @Override @Transactional(rollbackFor = Exception.class) public void update(Student student) { studentMapper.updateById(student); } @Override @Transactional(rollbackFor = Exception.class) public void deleteById(Long id) { studentMapper.logicDeleteById(id); } }pageByCondition方法上用了readOnly = true,只是一个优化提示,不是强约束,尝试在里面写insert也不会被数据库拦截,但对MySQL的InnoDB来说,它会走只读事务的连接优化路径。写操作全部用rollbackFor = Exception.class,不然Spring默认只在RuntimeException上回滚,Service里抛一个检查异常就变成了“事务提交了,数据写了一半”的诡异状态。offset的计算放在Service里而不是JSP里,是为了让Controller看到的参数更干净,分页逻辑只存在一个地方,避免前端传一个page值导致查询越界。
4.2 Controller层:参数绑定、Model传值、重定向刷新
Controller是最容易写“胖”的一层。很多系统里,校验、分页、组装页面全部写在Controller里,一个方法上百行。学生信息管理系统里Controller应该保持薄:接收参数,调Service,把结果放进Model,返回视图名,仅此而已。
@Controller @RequestMapping("/student") public class StudentController { private final StudentService studentService; public StudentController(StudentService studentService) { this.studentService = studentService; } @GetMapping("/list") public String list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword, Model model) { PageResult<Student> result = studentService.pageByCondition(keyword, page, pageSize); model.addAttribute("result", result); model.addAttribute("keyword", keyword); return "student/list"; } @GetMapping("/add") public String addForm() { return "student/add"; } @PostMapping("/save") public String save(Student student, RedirectAttributes redirectAttributes) { studentService.save(student); redirectAttributes.addFlashAttribute("msg", "新增成功"); return "redirect:/student/list"; } @GetMapping("/edit") public String edit(@RequestParam Long id, Model model) { Student student = studentService.getById(id); model.addAttribute("student", student); return "student/edit"; } @PostMapping("/update") public String update(Student student, RedirectAttributes redirectAttributes) { studentService.update(student); redirectAttributes.addFlashAttribute("msg", "修改成功"); return "redirect:/student/list"; } @GetMapping("/delete") public String delete(@RequestParam Long id, RedirectAttributes redirectAttributes) { studentService.deleteById(id); redirectAttributes.addFlashAttribute("msg", "已删除"); return "redirect:/student/list"; } }注意保存和更新返回的字符串是redirect:/student/list。用了重定向而不是直接返回JSP路径,是为了防止浏览器刷新时把上一次的POST表单又提交一遍,这在管理后台是一个很重要的交互习惯,学名叫PRG模式,很多新手第一次做删除操作时被“刷新一次就删一条”坑过,原因就是没做重定向。RedirectAttributes是跨重定向传值的一种办法,它会存进Session然后立刻被消费,页面刷新就不会再看到那条消息了。
edit方法里用Model回显Student对象,JSP里各个input用EL表达式取属性,就能把编辑前的数据自动填进表单。这里有个细节,Controller方法参数里的Student对象是SpringMVC直接做的表单参数绑定,要求JSP表单的name属性与Student类的字段名一致,多一个不一致字段就会绑定失败甚至报400错误。
4.3 视图层:列表遍历、表单回显、EL与JSTL
视图层我用JSP加JSTL,这是SpringMVC时代最常见的一种页面方案。列表页的核心就是一个<c:forEach>遍历再加一个分页条,表单页用EL表达式做回显,不需要引入任何前端框架。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <table> <thead> <tr> <th>学号</th> <th>姓名</th> <th>性别</th> <th>班级</th> <th>联系电话</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach var="s" items="${result.list}"> <tr> <td>${s.studentNo}</td> <td>${s.name}</td> <td>${s.gender == 2 ? '女' : '男'}</td> <td>${s.className}</td> <td>${s.phone}</td> <td> <a href="${ctx}/student/edit?id=${s.id}">编辑</a> <a href="${ctx}/student/delete?id=${s.id}" onclick="return confirm('确定删除该学生档案吗?')">删除</a> </td> </tr> </c:forEach> </tbody> </table> <c:if test="${result.totalPage > 1}"> <div> <a href="${ctx}/student/list?page=${result.currentPage - 1}&keyword=${keyword}">上一页</a> 第 ${result.currentPage} / ${result.totalPage} 页 <a href="${ctx}/student/list?page=${result.currentPage + 1}&keyword=${keyword}">下一页</a> </div> </c:if>${ctx} 里的ctx是我在SpringMVC拦截器里封装好的上下文路径,页面里用它拼接URL,这样部署到不同端口或目录下都不会写死路径。JSP页面里直接写${s.studentNo}会调用JavaBean的getStudentNo()方法,所以实体类必须写标准命名规范的getter/setter,少一个都会导致EL表达式取不到值。分页链接用keyword回显搜索条件,是为了翻页时不会丢掉上一次的关键字过滤。
到这里,登录和增删改查的功能链路已经完整。剩下的部分不是新功能多不多,而是会不会踩到配置和环境的坑。下一章我把这几年收集到的高频问题按“现象、原因、解决”的格式逐个拆解,很多问题看起来是代码的问题,根因往往在配置上。
5. 高频报错与排查:学生信息管理系统里让人翻车的五个坑
5.1 启动即报Bean找不到:双容器扫描打架
现象是Tomcat启动后报错NoSuchBeanDefinitionException,明确提示找不到studentService,但Service实现类上明明写了@Service。原因出在Spring和SpringMVC两个容器重复扫描,Controller所在的子容器把Service也扫描进去了,但子容器里又没有完整的事务代理配置,导致某些场景下Service的实例根本没被父容器创建。解决办法就是第3章里那样:applicationContext.xml扫描Service,spring-mvc.xml只扫描Controller,互不重叠。以后凡是遇到“Bean明明标记了却找不到”的第一反应,先检查扫描路径是不是重叠了。
5.2 静态资源全部404:DispatcherServlet把CSS/JS一网打尽
页面能打开,点击能跳转,但CSS全丢,控制台一堆静态资源404。DispatcherServlet配置成url-pattern=/之后,所有请求都先进SpringMVC,HandlerMapping没找到对应Controller就直接报404,不会自动转发给Tomcat的DefaultServlet。解决方式是在spring-mvc.xml里加一行<mvc:default-servlet-handler/>,它的意思是:所有没匹配到@Controller的请求交给Servlet容器自己处理。另一种做法是用<mvc:resources mapping="/static/**" location="/static/"/>把静态资源目录显式映射出来,两者二选一即可,同时配置也不冲突。
5.3 新增记录成功后数据库里中文变成问号或乱码
数据库里中文全是???,这属于编码问题里最典型的一类。原因有三段可能:Java代码和编译环境不是UTF-8;请求参数进Controller时被容器按ISO-8859-1解码;MySQL连接串没指定characterEncoding。排查顺序我习惯从数据库开始反向查。先看库表和连接字符集,再检查web.xml里CharacterEncodingFilter是否放在最前面,最后检查pom里有没有把project.build.sourceEncoding写死成UTF-8。编码问题之所以玄学,是因为它虽然发生在“一个环节”,但触发点往往是你上一次手工改过的某一个地方。
5.4 @Transactional怎么都不生效:内部调用与代理覆盖
UPDATE语句执行了,但只改了一条,事务没包住后续操作,回滚也回滚不掉。常见根因有两个。第一,在同一个Service类里调自己的另一个方法,比如saveAndAudit方法里直接调this.save(),Spring的事务代理只在外部调用时生效,这个问题本质上是自调用绕过代理。第二,Controller被Spring容器和SpringMVC容器重复扫描,后加载的容器覆盖了前面生成的代理对象。解决方式上面已经提到,方案就是把事务边界外的操作拆到另一个Service类,或者完整重构扫描配置,别让两个容器管理同类组件。
5.5 一个排查方法论:从第一行异常而不是从页面表现入手
学生信息管理系统这类教学项目里,最常见的排查错误是看Tomcat控制台最后几行,或者盯着页面上500的状态码猜。我的习惯是先看异常栈的第一行,完整看懂那行英文里包含的类和行号。比如Caused by: java.sql.SQLSyntaxErrorException后面往往就是SQL拼接错误,这类信息比你想的更有指向性。控制台的日志级别也建议调试期开成DEBUG,尤其是SpringMVC的DispatcherServlet日志,打开之后能直观看到每个请求被哪个Handler处理、参数绑定到哪个字段,信息量比对着代码猜大得多。日志不是配好就一劳永逸的,每次排查新问题都可能需要临时调整某一个包的日志级别,用完再改回来。
6. 让旧系统往前走一步:给项目加一个统一返回对象和JSON接口
整套系统跑通之后,你多半会想着它能不能更贴近真实项目。我建议从下面这个点切入:给Controller加一个统一的JSON返回入口,不推翻现有的JSP渲染,只是新增能力。做法是定义一个Result<T>包装类,字段就三个:code、message、data。Controller里凡是需要给前端接口用的方法,返回值从String改成Result<T>,方法上加@ResponseBody,SpringMVC会借助Jackson自动把对象转成JSON。
public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.code = 500; r.message = message; return r; } // getter/setter 略 }这个入口做出来后,你可以在保留旧页面的同时,加一个/api/student/list的JSON接口,前端用简单的Ajax甚至Postman就能拉数据。不要小看这一步,它把你从“只能写JSP页面”的思维里拉出来,开始自然想到“接口是给调用方用的,页面只是调用方之一”。这也是以后拆前后端分离项目最稳妥的一步过渡:页面不变,接口先长出来,再慢慢把页面迁走。
我对这类项目的习惯是,每次接到类似模拟项目交付,先检查它有没有统一的返回结构。没有的话,哪怕功能全对,我也一定先把这一步补上。因为后续所有扩展功能,登录状态校验、操作日志、权限校验,都依赖这样一个公共出口。如果你在给某个历史项目做维护,别急着改Java版本、换数据库,把返回结构统一了,后面每一步都顺得多。希望你也能在这套组合里建立起自己的方法论。
本文还有配套的精品资源,点击获取