Struts2登录注册用户管理系统:从MVC请求链路到拦截器实战 简介用Struts2实现的登录注册及用户信息管理系统定位为Java Web初学者的入门项目适合正在学习MVC分层和常见Web框架的开发者也可作为课程设计或毕业设计的参考。整个rar压缩包大小4.27MB共74个文件包含13个Java源文件、6个JSP页面、9个XML配置、6个依赖jar包以及SQL Server数据库文件ldf/mdf、GIF演示图片和doc图解文档源程序、页面、配置、数据库与说明均齐备可直接运行与对照学习。已有842人浏览学习适合快速复制其整体结构并替换为自己的业务场景。项目覆盖Struts2核心机制Action与Result映射、拦截器实现权限校验、表单验证、异常处理并给出用户注册、登录会话跟踪、用户信息列表与维护等具体功能配合数据库脚本和图解文档能帮助读者理清一个典型Java Web系统从请求到响应的完整流程。 做Java Web课程设计的时候这个题目出现的频率高到什么程度呢——十个人里至少有六个人会选Struts2做的登录注册及用户信息管理系统这种组合。别嫌它朴素这个项目恰好能把Struts2里最有代表性的MVC主链路完整走一遍请求怎么进来、参数怎么封装、Action怎么跳转、结果怎么渲染以及Session和拦截器如何配合做登录状态控制。我最近刚帮人从零搭完一套连带排掉几个比较隐蔽的坑这篇就把整个项目从需求拆解到关键代码实现全捋一遍适合正在做课程设计、毕业设计或者想快速搞懂Struts2核心机制的同学参考。1. 这个系统到底要做什么功能边界与Struts2的角色定位1.1 功能拆解模块边界比想象中清晰一套登录注册及用户信息管理系统剥掉外壳之后核心模块其实非常明确一共四块注册、登录、用户列表管理、用户编辑与删除。注册负责新增用户登录负责验证身份并保持会话状态用户列表负责分页展示与检索编辑和删除负责信息维护。但还有一个容易被忽略的需求不是所有页面都应该被直接访问比如管理后台的列表页、编辑页必须登录后才能进入。这个访问控制的需求正是Struts2拦截器Interceptor发挥作用的典型场景而不是在每个JSP页面里写一堆判断Session是否为空的前端逻辑。1.2 为什么拿Struts2做而不是纯Servlet或者SpringMVC很多人会问这个问题。我实际对比过结论不复杂纯Servlet做的话参数绑定、类型转换、表单校验、页面转发全都要手写request.getParameter()写上一堆代码里大量样板逻辑项目做完能学到的是Servlet Api操作而不是MVC流程组织。SpringMVC在真实公司项目里确实更主流但对一个刚开始接触框架的课程设计项目来说Spring的IOC容器、注解驱动带来的心智负担主要在为什么要引入三层互相注入这件事上反而容易把请求怎么被处理这条主线淹没。Struts2的XML配置方式和 result 命名的跳转逻辑是看得见的流程一个action标签里写清楚class、method、result请求从哪来到哪去一目了然。用一个餐厅类比Struts2的StrutsPrepareAndExecuteFilter是门口的迎宾struts.xml是排班表Action是服务员result是顾客从哪里进、吃到哪一步、最后被带到哪个位置。整个项目做完这个比喻在脑子里会变得非常具象。1.3 项目目录从一个能跑的Maven工程说起我在实际搭建时使用的是Maven结构用maven-war-plugin打包部署到Tomcat。项目目录如下src/main/java com.course.action -- Action类登录、注册、用户管理 com.course.dao -- 数据访问层JDBC操作 com.course.entity -- 实体类User com.course.interceptor -- 自定义拦截器LoginInterceptor com.course.util -- 工具类MD5加密、数据库连接 com.course.service -- 业务层登录校验、注册查重按需拆分 src/main/resources struts.xml -- Struts2核心配置 jdbc.properties -- 数据库连接配置 src/main/webapp WEB-INF/web.xml -- 过滤器注册 jsp/ login.jsp register.jsp list.jsp edit.jsp index.jsp包结构分层我建议不要省掉service层哪怕一开始只写一个简单的Service类。原因很实际Action里如果直接写JDBC逻辑代码短了方便但等你要加注册前查重密码加密插入数据库这三个步骤时Action会肥得很快后面查问题非常难受。分层是给自己减少麻烦。2. 请求穿透Struts2的全过程以登录动作为例走一遍2.1 从Filter到Action一条主链路一个登录表单提交之后请求实际上经过了一条完整的链路。理解这条链路是这个项目最重要的收获。浏览器把POST请求发到Tomcat先被org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter拦住。这个Filter在web.xml里注册核心作用是一看哦是Struts2的请求进入框架内部处理不是放行给容器。Filter内部会根据请求路径去struts.xml里匹配对应的action配置找到之后创建Action实例、构建ActionContext和ValueStack、执行拦截器栈、调用Action方法最终根据返回的字符串找到对应的result把响应交出去。在Struts2框架内部ValueStack值栈是一个很重要的对象。请求参数会被压入值栈Action的属性也暴露在值栈里所以JSP页面上可以用标签库直接读取。这也是为什么struts.xml里的配置会直接影响你在页面上能不能拿到数据。2.2 struts.xml的action映射关键配置逐行解释登录和注册模块的核心配置大概长这样?xml version1.0 encodingUTF-8? !DOCTYPE struts PUBLIC -//Apache Software Foundation//DTD Struts Configuration 2.5//EN http://struts.apache.org/dtds/struts-2.5.dtd struts constant namestruts.i18n.encoding valueUTF-8/ package namedefault namespace/ extendsstruts-default action namelogin classcom.course.action.UserAction methodlogin result namesuccess/jsp/index.jsp/result result nameerror/jsp/login.jsp/result /action action nameregister classcom.course.action.UserAction methodregister result namesuccess/jsp/login.jsp/result result nameinput/jsp/register.jsp/result result nameerror/jsp/register.jsp/result /action /package /struts几个容易被忽略的地方namespace/表示当前包下的action映射根路径。如果写成了别的比如/user那访问地址就变成/user/login.action前后端不一致会直接404。action的name是请求路径去掉.action后缀后的名字method是Action类中要执行的方法名。省略method时默认执行execute()。result里的name是Action方法返回的字符串它必须严格匹配。我见过很多No result defined的报错原因就是大小写不一致或者漏写。constant namestruts.i18n.encoding valueUTF-8/设置请求参数编码这个后面讲中文乱码时还会再提。2.3 参数的两种封装方式属性驱动与模型驱动Action接收页面表单参数的常用方式有两种这个项目里我推荐直接使用模型驱动ModelDriven代码更干净。属性驱动写法public class UserAction extends ActionSupport { private String username; private String password; // getter / setter 省略 }模型驱动写法public class UserAction extends ActionSupport implements ModelDrivenUser { private User user new User(); Override public User getModel() { return user; } }模型驱动的好处是表单里的username、password、email会直接绑定到user对象的对应属性上Action里不用维护一堆散装的String字段。JSP里的form字段名也不需要加user.前缀因为这个对象本身就放在值栈顶部。我实测最舒服的写法就是模型驱动配合validate()校验方法一起用代码量能少三分之一。但要注意一点getModel()返回的对象不能在方法中被整体替换比如不要在里面写user new User()否则参数绑定时会失效。这个坑我踩过细节很隐蔽。3. 注册模块实现别只写能存进去要把校验做完整3.1 注册表单与Action方法设计注册页面的HTML表单字段username、password、confirmPassword、email。其中confirmPassword不是User实体里的字段所以需要在Action里单独声明一个属性用于接收模型驱动和普通属性可以混用。Action的注册方法大致如下public String register() { // 1. 用户名重复检查 User exist userService.findByUsername(user.getUsername()); if (exist ! null) { addFieldError(username, 该用户名已被注册); return INPUT; } // 2. 密码加密 String salt MD5Util.generateSalt(); user.setSalt(salt); user.setPassword(MD5Util.md5(user.getPassword() salt)); user.setRole(user); // 3. 插入数据库 userService.register(user); return SUCCESS; }注意addFieldError的字段名要和JSP里s:fielderror对应的字段名一致否则错误信息不会显示出来。3.2 validate()校验机制与INPUT结果Struts2的输入校验有两种做法写validate()方法或者写校验XML。对于这种单表单场景直接重写validate()更直观Override public void validate() { if (user.getUsername() null || user.getUsername().trim().isEmpty()) { addFieldError(username, 用户名不能为空); } if (user.getPassword() null || user.getPassword().length() 6) { addFieldError(password, 密码长度至少6位); } if (!user.getPassword().equals(confirmPassword)) { addFieldError(confirmPassword, 两次输入的密码不一致); } }这个validate()方法会在register()方法执行前自动被调用。如果fieldErrors非空Struts2就不会执行register()而是直接返回字符串input。所以必须在struts.xml里给register这个action配置result nameinput/jsp/register.jsp/result否则会报找不到result的错误。这个校验先行的机制相比Servlet里自己在方法里判断再转发能让表单校验这个关注点单独成块我觉得这是Struts2对初学者最友好的一点。3.3 密码加密与重名检测密码不能明文存在数据库里。我以前见过直接把密码原样存进表的课程设计还见过存MD5但没加盐的。不加盐的MD5现在用彩虹表查一下基本等于明文。我建议做两步生成一个随机盐值比如UUID去掉横线的前16位存储MD5(密码 盐)的结果同时把盐存入单独字段public static String generateSalt() { return UUID.randomUUID().toString().replace(-, ).substring(0, 16); } public static String md5(String source) { try { MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest(source.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } }登录时候的校验逻辑是根据用户名查出用户记录取出该用户自己的盐再对输入的密码做同样的MD5(密码盐)计算最后比对数据库里的值。这个逻辑写起来多几行但安全效果是实实在在的。重名检测的执行顺序也值得注意先根据用户名去查查到就报已注册查不到就插入。在并发情况下两个请求同时注册同一个用户名可能都会通过查重所以最稳妥的兜底是数据库字段上直接加唯一索引。代码层查重是用户体验层面的提示数据库唯一索引才是最后的防线。4. 登录状态与后台保护拦截器必须这么配4.1 登录判断与Session管理登录Action的处理逻辑不复杂public String login() { User exist userService.findByUsername(user.getUsername()); if (exist null) { addActionError(用户名或密码错误); return ERROR; } String inputPassword MD5Util.md5(user.getPassword() exist.getSalt()); if (!exist.getPassword().equals(inputPassword)) { addActionError(用户名或密码错误); return ERROR; } ActionContext.getContext().getSession().put(loginUser, exist); return SUCCESS; }注意一个细节这里提示信息统一用用户名或密码错误而不是分别提示用户不存在和密码错误。这是为了防止攻击者通过登录接口探测哪些用户名已注册。登录成功后用户对象放进Sessionkey建议用一个常量类统一定义比如SessionKeys.LOGIN_USER。后面所有页面要判断是否登录都从Session里取这个key。4.2 自定义LoginInterceptor拦截未登录访问管理后台的列表页、编辑页、删除操作绝不能依赖每个Action方法开头都手工判断Session。正确做法是自定义一个拦截器把这个横切逻辑单独收起来public class LoginInterceptor extends AbstractInterceptor { Override public String intercept(ActionInvocation invocation) throws Exception { MapString, Object session invocation.getInvocationContext().getSession(); Object loginUser session.get(loginUser); if (loginUser null) { return toLogin; } return invocation.invoke(); } }然后在struts.xml里注册这个拦截器并定义一个新的拦截器栈interceptors interceptor nameloginInterceptor classcom.course.interceptor.LoginInterceptor/ interceptor-stack nameauthStack interceptor-ref namedefaultStack/ interceptor-ref nameloginInterceptor/ /interceptor-stack /interceptors需要做登录保护的管理action就在对应的action标签里声明引用这个栈action namelist classcom.course.action.UserAction methodlist interceptor-ref nameauthStack/ result namesuccess/jsp/list.jsp/result result nametoLogin/jsp/login.jsp/result /action这里有个非常重要、也非常容易踩的坑一旦你给action配置了自定义的拦截器栈默认栈defaultStack就不会自动生效。如果没有在自定义栈里引用defaultStack会导致参数封装、模型驱动、文件上传等基础拦截器全部失效。最常见表现就是Action里的user对象一直为空页面提交了数据但后台收不到。我检查过好几个同学的代码最终都是这个原因。4.3 拦截器顺序的建议defaultStack内部已经处理了参数绑定、模型驱动、校验等逻辑所以自定义的LoginInterceptor必须放在defaultStack之后。如果反过来先做登录判断再做参数绑定就会出现这种情况请求还没有把用户名和密码绑定到Action属性上拦截器先执行了Session里又没有登录信息直接跳回登录页。逻辑上其实没错但那种提交了登录表单却一直被拦截的体验非常迷惑。另外登录、注册、退出这几个Action不应该加LoginInterceptor否则会递归地把自己拦死。所以拦截器一般只加在管理功能对应的action上或者按功能拆分多个package来隔离。5. 用户信息管理分页列表、编辑回显、删除权限5.1 手写一个够用的分页不依赖插件用户列表页是管理后台的主界面分页是必须的。这个项目规模不需要引入分页插件手写一个PageBean足够public class PageBeanT { private int page; // 当前页 private int pageSize; // 每页条数 private int totalCount; // 总条数 private int totalPage; // 总页数 private ListT list; // 当前页数据 }Action里对应的逻辑public String list() { int pageSize 10; int totalCount userService.count(); int totalPage (totalCount pageSize - 1) / pageSize; if (page 1) { page 1; } if (page totalPage) { page totalPage; } PageBeanUser pageBean new PageBean(); pageBean.setPage(page); pageBean.setPageSize(pageSize); pageBean.setTotalCount(totalCount); pageBean.setTotalPage(totalPage); pageBean.setList(userService.findByPage(page, pageSize)); ActionContext.getContext().getValueStack().set(pageBean, pageBean); return SUCCESS; }JSP里用s:iterator遍历pageBean.list用s:property输出属性翻页链接则把page参数拼在链接上。这里有一个重要的参数传递点翻页链接类似list.action?page2因为UserAction实现了ModelDrivenUser模型驱动的对象会放在值栈顶层所以JSP中直接写s:property valuepageBean.page/就能访问到。我实际使用中的经验分页里最容易出的bug是totalPage计算时把除法和取余写反。正确公式是(totalCount pageSize - 1) / pageSize这个式子处理了总条数不能整除每页条数的情况比如11条数据、每页10条结果是(119)/102正好是2页。5.2 编辑数据的回显与提交编辑用户信息的流程是点击列表里的编辑按钮跳转到editUser.action?id3Action根据id查出用户对象传到编辑页面回显用户修改后再提交到updateUser.action。这里较容易搞不清的是编辑页面的表单回显用的是什么数据来源。因为查询出的User对象放到了值栈JSP表单里就可以这样写s:form actionupdateUser methodpost s:hidden nameuser.id/ s:textfield nameuser.username label用户名/ s:password nameuser.password label密码 showPasswordtrue/ s:submit value保存/ /s:form注意updateUser这个action不能只允许用户改用户名却不管密码。如果密码留空就应该保持原密码不变如果填了再做MD5加盐处理后再更新。5.3 删除操作的权限边界删除用户是后台操作中最敏感的功能不能做成谁都能调一下链接就删除。我在这个项目里的做法是判断当前登录用户角色是否为admin非管理员直接返回错误页。不能删除自己。防止管理员把当前登录账号删掉导致后面连后台都进不去。删除前先根据id查一次确认记录存在。删除完成后重定向到列表页避免刷新页面时重复提交删除请求。Struts2的result类型里redirectAction正好适合操作完成后跳回Action的场景action namedeleteUser classcom.course.action.UserAction methoddelete result namesuccess typeredirectActionlist/result /action如果不使用重定向而是直接返回列表页视图浏览器地址栏仍然是deleteUser.action用户按F5刷新时同一个删除请求会被再次发送导致二次删除报错或者数据异常。这个细节我在调试时印象很深。6. 我实测遇到的Struts2报错几类高频问题的排查记录6.1 No result defined for action与404这个报错在课程设计里出现频率极高。经验是分两种情况排查第一种Action方法返回的字符串在struts.xml里没有对应的result name...。比如return SUCCESS用了ActionSupport.SUCCESS常量它的值是successXML里如果写成了result namesucess漏了一个字母c必然报错。这种错误肉眼很难一眼看到建议直接复制返回值去XML里搜索。第二种action映射压根没匹配到。点了按钮URL是/user/list.action但XML里的namespace写的是/或者action的name拼错。这种情况下Tomcat会直接返回404而且不会进入Struts2的日志流程排查方向完全不一样。我的建议是把struts.devMode在开发期设置为trueconstant namestruts.devMode valuetrue/devMode开启后XML文件修改不用重启服务器就能生效且框架会输出更详细的调试信息。等到部署阶段再关掉。6.2 中文乱码别只盯过滤器要全链路检查中文乱码问题是国内做Web项目绕不开的坎。Struts2的乱码根源其实分散在四个地方只改一个没用请求参数编码。struts.i18n.encoding设置为UTF-8后POST请求的参数基本可靠。对于GET请求Tomcat的URI编码机制在8.0以上版本默认就是UTF-8老版本则需要在server.xml的Connector上配置URIEncodingUTF-8。JSP页面本身的编码。需要在pageEncodingUTF-8、contentTypetext/html; charsetUTF-8两处保持一致且JSP文件保存时就用UTF-8编码。数据库连接串。我使用的是MySQL连接串里必须带字符集参数jdbc:mysql://localhost:3306/userdb?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/Shanghai表结构和字段的字符集。建表时统一使用utf8mb4不要用默认的latin1。排查顺序建议从数据库往页面倒着查先看库里存的对不对库里存的如果是乱码说明是写入环节的问题库里是对的而页面显示乱码那就是读取和展示环节的问题。6.3 关于老版本安全漏洞的一点提醒Struts2作为一款历史悠久的框架在早年间出现过几起影响很大的远程代码执行漏洞很多漏洞的利用难度极低网上甚至有现成的扫描工具。如果你的课程设计只是在本地Tomcat里演示给老师看图省事用2.3.x的老版本问题不大但如果这个项目要部署到公网服务器、参加比赛、或者放进开源仓库被人fork我强烈建议直接使用官方修复后的2.5.x版本并且关注Struts2官方安全公告。更稳妥的做法是如果是在校学生直接把这个项目当成理解MVC和Web框架的练习吃透原理后把实现迁移到SpringMVC或Spring Boot上既学到老框架的设计思想又不至于把老框架的安全风险带到新项目里。我在实际维护旧系统的经历中见到过太多仍在生产环境运行的老版本Struts2应用安全团队做扫描时一抓一个准。这是个很现实的教训。另外课程设计里如果加入了文件上传、JSON交互这类功能Struts2的老坑会更多。作为稳妥方案上传模块建议单独用Servlet实现或者直接避免在Struts2里做文件上传把这个复杂度绕开。最后分享一个我在做这个项目后期的小改动把退出登录功能也接到拦截器体系里。点击退出按钮时先session.clear()再返回一个result跳回登录页。就这么一个小功能却能让会话管理的闭环变得完整。建议你做项目时也把扩展点留出来比如给用户表加一个角色字段在列表页根据角色显示不同的操作按钮或者把分页改成带搜索条件的分页。这些增量改动都不难但每加一个你对Struts2这套请求-拦截-处理-跳转机制的理解就更深一层。本文还有配套的精品资源点击获取