简介一套基于SSM框架的学生信息管理系统完整源码与数据库初始化脚本适用对象包括Java Web初学者、毕业设计学生以及想快速搭建后台管理模块的开发者。资源包共2个文件整体大小约16.32MB包含一个SQL脚本用于创建students学生信息表及相关数据结构和一个ZIP源码包内附Spring配置、Controller控制器、Service业务层、DAO数据访问层与前端页面模板可直接导入IDE运行。源码包内目录结构清晰分层模块便于逐层学习与二次开发。通过实际操作和阅读代码能掌握SSM三框架的整合步骤、请求参数绑定、依赖注入、MyBatis动态SQL及事务管理等关键技能同时也能借鉴系统的分层设计为扩展课程管理、成绩统计等功能提供清晰的参考。目前已有2392人学习下载适合作为课程设计、毕业设计或入门级企业项目的蓝本。1. Spring crm 与三层架构为什么这套学生系统是最好入手的骨架Spring crm 这类业务系统拆到最底层骨架就是同一套Spring 管理对象SpringMVC 接收请求数据库负责存取。这套学生信息管理系统正好把这个骨架完整地串了一遍从登录、列表到新增、编辑、删除把 Spring、SpringMVC 和数据库操作的 CRUD 闭环串在一起顺带覆盖了事务、拦截器、参数绑定这些 CRM 业务里高频出现的能力。适合两类人一类是刚学完 JavaWeb、想看看真实工程结构怎么落地的新手另一类是需要在老项目里快速定位问题的熟手。核心不是它“多复杂”而是它把三层架构该有的分工摆得很清楚。SpringMVC 项目看起来不如 Spring Boot 新潮但把它的容器关系和请求链路看透再回头看任何框架都轻松得多。2. 先看懂工程骨架Spring 容器与 SpringMVC 请求链路2.1 三层分包controller、service、dao 各管什么先看目录结构。这套资源的主代码在 src/main/java 下按 controller、service、dao、entity 四个包分开resources 下放配置文件webapp/WEB-INF/views 下放 JSP。结构如下src/main/java/com/demo/student ├── controller │ └── StudentController.java ├── service │ ├── StudentService.java │ └── impl │ └── StudentServiceImpl.java ├── dao │ └── StudentDao.java ├── entity │ └── Student.java ├── interceptor │ └── LoginInterceptor.java src/main/resources ├── applicationContext.xml ├── springmvc.xml └── jdbc.properties webapp ├── static │ ├── css │ └── js └── WEB-INF ├── web.xml └── views ├── login.jsp └── student ├── list.jsp └── form.jsp分包的核心逻辑controller 只做参数接收和视图跳转不写业务逻辑service 层负责事务边界和业务规则dao 层只写 SQL 和数据映射。把 controller 里的业务代码往下赶是这套工程能长期维护的关键。很多新手把查询和保存逻辑全写在 controller 里项目小的时候没问题一旦加权限判断或事务要求controller 就变成一坨改不动的代码。这套资源在这一点上做得规范每个类的职责边界清楚照着这个分包习惯写任何新模块都不会跑偏。2.2 web.xml 启动顺序Listener 先建容器Servlet 再接收请求SpringMVC 工程的入口不是 controller而是 web.xml。这里有两个组件需要重点关注ContextLoaderListener 和 DispatcherServlet。典型配置如下context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namespringmvc/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:springmvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringmvc/servlet-name url-pattern//url-pattern /servlet-mapping启动顺序是容器先创建根容器加载 applicationContext.xml把 service、dao、数据源这些对象放进去随后 DispatcherServlet 创建子容器加载 springmvc.xml只负责 controller 和视图解析。子容器能访问父容器的 Bean但父容器看不到子容器里的 controller。这就是为什么项目里两份 XML 分工明确如果混在一个容器里事务代理经常出问题。这里有个参数容易忽略 / 表示拦截所有请求但不拦截 .jsp所以 JSP 由容器自己处理。还有一个默认路径规则如果删掉 contextConfigLocationListener 默认去 WEB-INF/applicationContext.xml 找配置DispatcherServlet 不写 init-param默认找 /WEB-INF/springmvc-servlet.xml。这套资源是显式指定 classpath 路径好处是配置文件放在 resources 下也能被加载。新手改配置时最容易把 resources 下的文件和 WEB-INF 下的文件搞混报找不到文件时先看 classpath 前缀和实际目录位置。2.3 容器配置文件applicationContext.xml 与 springmvc.xml 的职责边界先看 applicationContext.xml 的内容要点context:component-scan base-packagecom.demo.student context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idjdbcTemplate classorg.springframework.jdbc.core.JdbcTemplate property namedataSource refdataSource/ /bean这个配置做了三件事扫描 service、dao 所在包排除 Controller读取 jdbc.properties 四个参数把数据源注入 JdbcTemplate。这里的 Bean 定义用了 Spring 内置的 DriverManagerDataSource是为了让初学者不用一上来就面对连接池的依赖配置实际部署时换成 Druid 或 HikariCP 只需要改这个 Bean 的实现类依赖注入的代码不用动。再看 springmvc.xml 的对应部分context:component-scan base-packagecom.demo.student use-default-filtersfalse context:include-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan mvc:annotation-driven/ mvc:resources mapping/static/** location/static// bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean注意 use-default-filtersfalse它和 include-filter 配合保证子容器只扫 Controller不去碰 service 和 dao。这个写法很多教程不会强调如果用默认扫描service 就被装进子容器一份父容器的 Transactional 代理管不住它事务失效的坑就是这么埋下的。InternalResourceViewResolver 的两个参数好理解controller 返回 student/list视图解析器拼出 /WEB-INF/views/student/list.jsp。把 JSP 放在 WEB-INF 下还有一个好处用户不能通过 URL 直接访问 JSP 源码所有页面都必须走 controller 转发。提示判断 Bean 归属有个笨办法启动时把日志级别调到 DEBUG看哪些类被“Root WebApplicationContext”初始化哪些被“DispatcherServlet”初始化一清二楚。3. 把 CRUD 跑通学生列表、新增、编辑、删除的完整请求链路3.1 数据层JdbcTemplate 的 SQL 参数绑定先看 dao 层。StudentDao 用 Repository 标记由 Spring 容器管理JdbcTemplate 在容器里已经配置好了。列表查询和数据插入的写法Repository public class StudentDao { Autowired private JdbcTemplate jdbcTemplate; public ListStudent findAll() { String sql select id, name, gender, birthday, phone, email from t_student order by id desc; return jdbcTemplate.query(sql, new BeanPropertyRowMapper(Student.class)); } public void insert(Student student) { String sql insert into t_student(name, gender, birthday, phone, email) values(?, ?, ?, ?, ?); jdbcTemplate.update(sql, student.getName(), student.getGender(), student.getBirthday(), student.getPhone(), student.getEmail()); } }BeanPropertyRowMapper 会把查询结果按列名自动映射到实体属性但有一个前提查询列名要么和属性名完全一致比如 id、name、gender要么用 SQL 别名把下划线转驼峰。如果表字段是 created_time 而实体属性是 createdAt查询语句里必须写 created_time as createdAt。这个细节在老项目里很常见很多表十年前就是下划线风格接手时才发现 RowMapper 映射后全是 null。参数绑定用问号占位符按顺序传参避免字符串拼接带来的 SQL 注入问题。如果写成 select * from t_student where name name 别人在表单里输入一个单引号就能截断条件SQL 执行结果完全不可控。JdbcTemplate 的参数绑定解决了注入问题但要求传入参数的顺序和 SQL 里的问号一一对应。后续改表结构时这里最容易出错新增字段后SQL 语句和参数列表要同步改少传一个参数就会报参数个数不匹配。3.2 事务与参数绑定Service 层和 Controller 层的协作Service 层负责事务边界Controller 层负责参数接收。ServiceImpl 的核心实现Service(studentService) Transactional public class StudentServiceImpl implements StudentService { Autowired private StudentDao studentDao; Transactional(readOnly true) public ListStudent findAll() { return studentDao.findAll(); } Transactional public void save(Student student) { studentDao.insert(student); } }Transactional 标注在类上表示所有 public 方法默认走事务方法级注解再覆盖 readOnly 属性。readOnly true 是给查询方法用的暗示底层这条连接上不会执行写操作。这个注解全路径是 org.springframework.transaction.annotation.Transactional实际项目里真有人导成 javax.transaction.Transactional事务完全不生效这个问题放到第 5 章展开。Controller 层通过 Autowired 注入 service 接口而不是实现类这样后续替换实现类时不需要动 controller。新增和编辑共用 form.jsp表单提交到 save 方法Controller RequestMapping(/student) public class StudentController { Autowired private StudentService studentService; RequestMapping(/list) public String list(Model model) { model.addAttribute(students, studentService.findAll()); return student/list; } RequestMapping(value /save, method RequestMethod.POST) public String save(Student student, Model model) { studentService.save(student); return redirect:/student/list; } RequestMapping(/edit/{id}) public String edit(PathVariable(id) Integer id, Model model) { model.addAttribute(student, studentService.findById(id)); return student/form; } RequestMapping(/delete/{id}) public String delete(PathVariable(id) Integer id) { studentService.delete(id); return redirect:/student/list; } }这里有两个关键设计。第一save 方法直接接收 Student 对象SpringMVC 会把表单里的 name、gender、birthday 等字段自动绑定到实体属性上这叫参数绑定。但绑定有前提表单 input 的 name 必须和实体属性名一致否则字段值就是 null。第二新增成功后返回 redirect:/student/list而不是视图名。这就是 PRG 模式Post/Redirect/Get如果返回视图用户按 F5 刷新会把上一次 POST 请求再发一遍数据重复插入重定向到列表页后刷新只是重新 GET不会造成重复提交。还有 PathVariable(id) 绑定 URL 路径上的 {id}和 RequestParam 不同后者取 ?id1 这种查询参数。用 RESTful 风格时编辑和删除都走路径参数经过 URL 编码比查询参数更干净。3.3 日期字段和表单回显类型转换的默认行为实体类 Student 里 birthday 是 Date 类型。表单提交的是 2024-03-05 这种字符串SpringMVC 不会自动把字符串转成 java.util.Date必须指定格式。常见做法是在字段上加注解public class Student { private Integer id; private String name; private String gender; DateTimeFormat(pattern yyyy-MM-dd) private Date birthday; private String phone; private String email; }DateTimeFormat 依赖 mvc:annotation-driven 自动注册的转换器不用再单独配置。但这里有个隐藏问题如果前端用的是带时分秒的日期时间控件提交的值是 2024-03-05 14:30:00而 pattern 只写了 yyyy-MM-dd转换直接失败返回 400。所以 pattern 必须和前端实际的展示格式严格一致。列表页回显时也有一个类似的问题。JSP 里直接通过 el 表达式输出 Date 对象得到的是英文格式的一长串时间比如 Sun Mar 05 14:30:00 CST 2024。解决方法是 JSP 里引入 fmt 标签库用 fmt:formatDate 统一格式化或者更省事在实体类里额外加一个字符串类型的 birthdayStr 字段只用于表单回显不映射数据库列很多老项目都愿意这么干改动最小。提示前端只传日期不传时间就用 yyyy-MM-dd传了时间就写 yyyy-MM-dd HH:mm:ss。两种 pattern 不匹配报错都在提交那一瞬间浏览器直接给 400。4. 拦截器与登录校验给系统加上统一的访问控制4.1 HandlerInterceptor 实现登录拦截学生管理系统一般要求登录后才能操作拦截器是实现这个需求的首选。SpringMVC 里自定义拦截器是实现 HandlerInterceptor 接口核心方法是 preHandlepublic class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser request.getSession().getAttribute(loginUser); if (loginUser null) { String contextPath request.getContextPath(); response.sendRedirect(contextPath /login); return false; } return true; } }preHandle 的返回值决定请求是否继续true 进入 controllerfalse 请求到此为止由拦截器自己决定跳转方向。这里用 Session 里有没有 loginUser 判断登录状态登录成功后把用户信息塞进 Session。相关代码在 LoginController 里Controller public class LoginController { RequestMapping(value /login, method RequestMethod.GET) public String loginPage() { return login; } RequestMapping(value /login, method RequestMethod.POST) public String doLogin(String username, String password, HttpSession session, Model model) { if (admin.equals(username) 123456.equals(password)) { session.setAttribute(loginUser, username); return redirect:/student/list; } model.addAttribute(error, 用户名或密码错误); return login; } }这段登录逻辑是写死的演示账号真实项目里至少要从用户表查密码。但拦截器关心的不是账号密码而是 Session 里有没有登录标记。登出时调用 session.invalidate() 把整个会话失效比单独 removeAttribute 彻底。preHandle 返回 false 后要注意响应是否被提交。如果直接 return false 却不给客户端任何响应浏览器会一直停在那个请求上直到超时。所以拦截器里要么 sendRedirect要么 forward要么给前端返回一段 JSON。老项目里还有一种常见情况页面发 AJAX 请求被拦截服务端 sendRedirect 到登录页前端拿到的是登录页 HTML 而不是 JSON解析直接报错。处理办法是判断请求头 X-Requested-With如果是 AJAX直接返回 401 状态码由前端统一跳转登录页。4.2 拦截路径和放行规则配置顺序是有讲究的在 springmvc.xml 里注册拦截器mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/static/**/ bean classcom.demo.student.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorsmvc:mapping path/ 表示拦截所有路径exclude-mapping 用来排除不需要登录的路径。实际项目里至少还要排除登录的 POST 提交地址比如 /loginSubmit、/captcha。如果忘了排除 /static/页面刷新后 CSS 和 JS 会被拦下来样式全丢但页面 HTML 正常出来这种半残现象最容易让人摸不着头脑。路径匹配用的是 Ant 风格几个通配符的差别列在下面配置匹配范围常见用途/login精确匹配 /login登录页 GET 请求/login/**/login 下的所有子路径登录相关的多个接口/**所有请求全局拦截/static/**/static 下的静态资源放行 CSS、JS、图片配置顺序上有个经验mvc:interceptors 和 mvc:resources 没有严格的先后要求但拦截器的 exclude 一定要写全。否则即使 Spring 把静态资源交给默认处理器HandlerInterceptor 依然会在进入处理器之前执行loginUser 为 null 时静态资源请求也会被重定向到登录页。拦截器工作在前端控制器处理链上它按请求路径匹配并不关心这个请求最终是不是真的交给 controller。4.3 一个容易忽略的边界POST 登录请求的放行这里单独提一个实际维护中见过多次的情况。很多人配置了 /login 放行但登录表单的 action 提交到了 /loginCheck这个路径没有加进 exclude-mapping结果是点了登录按钮直接跳到登录页还以为是账号密码错了。排查方法很简单在浏览器开发者工具里看请求路径再把 exclude 列表和实际请求地址对着查一遍。这套资源的登录提交路径是 /login 的 POST 方法所以只排除 /login 就够了但如果你接手的是别人的老项目注意确认表单 action 的实际值。另一个边界是登出路径。登出操作本身需要登录态所以 /logout 不应该放行否则登出按钮点击后先被拦截器接到没走到登出方法就直接被重定向到登录页。从用户角度看好像也“登出”了但 Session 里的 loginUser 并没有被清除如果用户按浏览器后退键还能回到登录后的页面。这种细节看需求怎么定义如果要求严格登出路径应该留在拦截范围内让登出方法去调 session.invalidate()。5. 避坑排查从跑起来到真正上线五个常见坑的复盘5.1 容器装配类Bean 找不到与重复扫描坑一启动报 NoSuchBeanDefinitionException提示 studentService 或 studentDao 找不到。现象是 Tomcat 起来后一访问 /student/list 就报 500控制台堆栈写着“No qualifying bean of type StudentService”。原因无非两种第一种是包扫描路径不对component-scan 的 base-package 没有覆盖到 service 实现类所在的包第二种是 Service、Repository 注解漏写Spring 不认这个类。解决方法是先看注解再看包路径最后把启动日志调到 DEBUG确认容器到底注册了哪些 Bean。坑二同一个 Bean 被扫描两遍注入点报“expected single matching bean but found 2”。现象是启动过程不报错运行到某个注入点才报类型冲突。原因是 applicationContext.xml 和 springmvc.xml 都写了全包扫描service 在父子容器里各有一份。解决方法是父容器扫描时排除 Controller子容器关闭默认过滤器只包含 Controller。这个点在第 2 章就强调过实际操作里多数“事务不生效”的问题就是这里埋下的。5.2 请求处理类静态资源、日期格式、中文乱码坑三页面能打开但 CSS、JS 全部 404。现象是控制台不报错浏览器 Network 面板里 .css 和 .js 都是红色状态码 404。原因是 DispatcherServlet 映射了 /把静态资源请求拦截后当成 controller 处理而项目里没有对应的处理器。解决方法是配 mvc:resources并确认 JSP 里引用的路径带了项目上下文mvc:resources mapping/static/** location/static//配完还是 404就检查 webapp 下是不是真的存在 static 目录以及 JSP 里的路径是不是写成了 /static/css/style.css 而少了上下文前缀。最保险的写法是统一用 c:url 标签或者 ${pageContext.request.contextPath}/static/...。坑四日期字段提交报 400 Bad Request。现象是表单里填入日期保存时浏览器直接返回 400控制台提示“Failed to convert from type [java.lang.String] to type [java.util.Date]”。原因是实体类没有加转换规则SpringMVC 的默认转换器不认识 yyyy-MM-dd 这种字符串。解决方法是加 DateTimeFormat(pattern yyyy-MM-dd)并保证 pattern 和前端实际提交格式完全一致。如果前端日期控件带时间pattern 就要写成 yyyy-MM-dd HH:mm:ss时间部分解析不了同样报 400。坑五中文乱码入库的数据变成问号。现象是表单提交的中文在列表页显示正常但数据库里是 ??或者列表页直接乱码。原因是 web.xml 没有配置 CharacterEncodingFilter或者配置了但 forceEncoding 没设 true另一种可能是 jdbc.url 没带 characterEncoding 参数。解决方法是先配过滤器再检查连接串。配置如下filter filter-nameencoding/filter-name filter-classorg.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-nameencoding/filter-name url-pattern/*/url-pattern /filter-mapping这个过滤器要放在 web.xml 过滤器链的第一个位置如果后面还有其他过滤器编码过滤器不靠前别的过滤器可能已经按默认编码读了请求体。jdbc.url 里要追加 useUnicodetruecharacterEncodingutf8注意 XML 里 符号要转义成 。5.3 数据一致性类事务不回滚与重复提交坑六方法里抛了 RuntimeException前面的 insert 没有回滚。这个坑在旧项目里出现频率极高现象是故意造一个运行期异常数据库里数据照样多了。顺着三个方向排查基本能定位排查点检查内容常见结论注解导入的包是否是 spring 的 Transactional导错包时注解不生效容器service 是否在父容器扫描范围内子容器扫到 service 时事务代理失效调用方式是否同类内部 this.xxx() 自调用自调用不经过代理对象同类自调用这个问题比较隐蔽AdminService 里 saveStudent 方法调了 this.sendNotice()sendNotice 上标了 Transactional但因为走的是 this 对象没有经过 Spring 代理事务不起作用。解决是把需要事务的方法放到另一个 Bean 里或者用 AopContext.currentProxy() 显式获取代理对象。注意 AopContext 需要 expose-proxytrue 才能用配置里加一行 aop:aspectj-autoproxy expose-proxytrue/。这个开关老项目不一定开着所以建议优先用拆分 Bean 的方式。表单重复提交也常归到这一类。现象是新增成功后刷新一次列表里多出两条相同记录。原因是新增操作走了 POST返回的是视图而不是重定向。解决是在新增、修改、删除成功后统一走 redirect也就是 PRG 模式。这套资源里的保存和删除都是 redirect直接照抄就不会踩这个坑。如果你是改造老代码重点检查 controller 返回的字符串是不是以 redirect: 开头。6. 再进一步把列表查询封装成可复用的条件查询对象列表页只做 findAll 够用但一旦要按姓名、性别筛选或者做分页最怕的就是在每个方法里拼 SQL。常见做法是定义一个查询条件对象 StudentQuery把筛选参数和分页参数收拢在一起public class StudentQuery { private String name; private String gender; private Integer pageNo 1; private Integer pageSize 10; public String getName() { return name; } public void setName(String name) { this.name name; } // 其余 getter/setter 省略 }DAO 里根据条件动态拼 where参数用 List 收集最后统一转数组public ListStudent findByQuery(StudentQuery query) { StringBuilder sql new StringBuilder( select id, name, gender, birthday, phone, email from t_student where 11 ); ListObject args new ArrayList(); if (StringUtils.hasText(query.getName())) { sql.append(and name like ? ); args.add(% query.getName() %); } if (StringUtils.hasText(query.getGender())) { sql.append(and gender ? ); args.add(query.getGender()); } sql.append(order by id desc limit ?, ?); args.add((query.getPageNo() - 1) * query.getPageSize()); args.add(query.getPageSize()); return jdbcTemplate.query(sql.toString(), args.toArray(), new BeanPropertyRowMapper(Student.class)); }这段代码有两个边界值得注意。第一条件全部通过占位符传入不拼接字符串杜绝了注入第二分页用的 limit 是 MySQL 方言如果底层换成 Oracle分页语句要改成 rownum 写法这套资源的 SQL 是 MySQL 风格迁移数据库时这里一定要单独处理。我维护过一段带 CRM 模块的老代码原来的查询是每个方法单独写一遍 SQL加了三个筛选条件后代码里出现六七段大同小异的 where 拼接。后来统一收敛成查询对象结构Controller 接收查询参数组装成查询对象DAO 只根据对象是否有值决定加不加条件。从那以后我每次接到 SpringMVC 老项目都会先翻一遍 DAO 层有没有堆砌的字符串拼接看到就建议改成这种写法最大的好处是以后加筛选条件时只改 Query 字段和 DAO 里的一个 if 分支不去动接口签名。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?