Struts2登录注册用户管理系统:从MVC请求链路到拦截器实战
2026/9/8 23:48:19 网站建设 项目流程

简介:用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 version="1.0" encoding="UTF-8"?> <!DOCTYPE struts PUBLIC "-//Apache Software Foundation//DTD Struts Configuration 2.5//EN" "http://struts.apache.org/dtds/struts-2.5.dtd"> <struts> <constant name="struts.i18n.encoding" value="UTF-8"/> <package name="default" namespace="/" extends="struts-default"> <action name="login" class="com.course.action.UserAction" method="login"> <result name="success">/jsp/index.jsp</result> <result name="error">/jsp/login.jsp</result> </action> <action name="register" class="com.course.action.UserAction" method="register"> <result name="success">/jsp/login.jsp</result> <result name="input">/jsp/register.jsp</result> <result name="error">/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 name="struts.i18n.encoding" value="UTF-8"/>设置请求参数编码,这个后面讲中文乱码时还会再提。

2.3 参数的两种封装方式:属性驱动与模型驱动

Action接收页面表单参数的常用方式有两种,这个项目里我推荐直接使用模型驱动(ModelDriven),代码更干净。

属性驱动写法:

public class UserAction extends ActionSupport { private String username; private String password; // getter / setter 省略 }

模型驱动写法:

public class UserAction extends ActionSupport implements ModelDriven<User> { private User user = new User(); @Override public User getModel() { return user; } }

模型驱动的好处是,表单里的usernamepasswordemail会直接绑定到user对象的对应属性上,Action里不用维护一堆散装的String字段。JSP里的form字段名也不需要加user.前缀,因为这个对象本身就放在值栈顶部。

我实测最舒服的写法就是模型驱动,配合validate()校验方法一起用,代码量能少三分之一。但要注意一点:getModel()返回的对象不能在方法中被整体替换,比如不要在里面写user = new User(),否则参数绑定时会失效。这个坑我踩过,细节很隐蔽。

3. 注册模块实现:别只写"能存进去",要把校验做完整

3.1 注册表单与Action方法设计

注册页面的HTML表单字段:usernamepasswordconfirmPasswordemail。其中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 name="input">/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; }

注意一个细节:这里提示信息统一用"用户名或密码错误",而不是分别提示"用户不存在"和"密码错误"。这是为了防止攻击者通过登录接口探测哪些用户名已注册。

登录成功后,用户对象放进Session,key建议用一个常量类统一定义,比如SessionKeys.LOGIN_USER。后面所有页面要判断是否登录,都从Session里取这个key。

4.2 自定义LoginInterceptor拦截未登录访问

管理后台的列表页、编辑页、删除操作,绝不能依赖每个Action方法开头都手工判断Session。正确做法是自定义一个拦截器,把这个横切逻辑单独收起来:

public class LoginInterceptor extends AbstractInterceptor { @Override public String intercept(ActionInvocation invocation) throws Exception { Map<String, Object> session = invocation.getInvocationContext().getSession(); Object loginUser = session.get("loginUser"); if (loginUser == null) { return "toLogin"; } return invocation.invoke(); } }

然后在struts.xml里注册这个拦截器,并定义一个新的拦截器栈:

<interceptors> <interceptor name="loginInterceptor" class="com.course.interceptor.LoginInterceptor"/> <interceptor-stack name="authStack"> <interceptor-ref name="defaultStack"/> <interceptor-ref name="loginInterceptor"/> </interceptor-stack> </interceptors>

需要做登录保护的管理action,就在对应的action标签里声明引用这个栈:

<action name="list" class="com.course.action.UserAction" method="list"> <interceptor-ref name="authStack"/> <result name="success">/jsp/list.jsp</result> <result name="toLogin">/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 PageBean<T> { private int page; // 当前页 private int pageSize; // 每页条数 private int totalCount; // 总条数 private int totalPage; // 总页数 private List<T> 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; } PageBean<User> 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?page=2,因为UserAction实现了ModelDriven<User>,模型驱动的对象会放在值栈顶层,所以JSP中直接写<s:property value="pageBean.page"/>就能访问到。

我实际使用中的经验:分页里最容易出的bug是totalPage计算时把除法和取余写反。正确公式是(totalCount + pageSize - 1) / pageSize,这个式子处理了"总条数不能整除每页条数"的情况,比如11条数据、每页10条,结果是(11+9)/10=2,正好是2页。

5.2 编辑数据的回显与提交

编辑用户信息的流程是:点击列表里的"编辑"按钮,跳转到editUser.action?id=3,Action根据id查出用户对象,传到编辑页面回显,用户修改后再提交到updateUser.action

这里较容易搞不清的是,编辑页面的表单回显用的是什么数据来源。因为查询出的User对象放到了值栈,JSP表单里就可以这样写:

<s:form action="updateUser" method="post"> <s:hidden name="user.id"/> <s:textfield name="user.username" label="用户名"/> <s:password name="user.password" label="密码" showPassword="true"/> <s:submit value="保存"/> </s:form>

注意updateUser这个action不能只允许用户改用户名,却不管密码。如果密码留空,就应该保持原密码不变;如果填了,再做MD5加盐处理后再更新。

5.3 删除操作的权限边界

删除用户是后台操作中最敏感的功能,不能做成谁都能调一下链接就删除。我在这个项目里的做法是:

  • 判断当前登录用户角色是否为admin,非管理员直接返回错误页。
  • 不能删除自己。防止管理员把当前登录账号删掉,导致后面连后台都进不去。
  • 删除前先根据id查一次,确认记录存在。
  • 删除完成后重定向到列表页,避免刷新页面时重复提交删除请求。

Struts2的result类型里,redirectAction正好适合"操作完成后跳回Action"的场景:

<action name="deleteUser" class="com.course.action.UserAction" method="delete"> <result name="success" type="redirectAction">list</result> </action>

如果不使用重定向,而是直接返回列表页视图,浏览器地址栏仍然是deleteUser.action,用户按F5刷新时同一个删除请求会被再次发送,导致二次删除报错或者数据异常。这个细节我在调试时印象很深。

6. 我实测遇到的Struts2报错:几类高频问题的排查记录

6.1 "No result defined for action"与404

这个报错在课程设计里出现频率极高。经验是分两种情况排查:

第一种,Action方法返回的字符串在struts.xml里没有对应的<result name="...">。比如return SUCCESS用了ActionSupport.SUCCESS常量,它的值是"success",XML里如果写成了<result name="sucess">,漏了一个字母c,必然报错。这种错误肉眼很难一眼看到,建议直接复制返回值去XML里搜索。

第二种,action映射压根没匹配到。点了按钮URL是/user/list.action,但XML里的namespace写的是/,或者action的name拼错。这种情况下Tomcat会直接返回404,而且不会进入Struts2的日志流程,排查方向完全不一样。

我的建议是:把struts.devMode在开发期设置为true

<constant name="struts.devMode" value="true"/>

devMode开启后,XML文件修改不用重启服务器就能生效,且框架会输出更详细的调试信息。等到部署阶段再关掉。

6.2 中文乱码:别只盯过滤器,要全链路检查

中文乱码问题是国内做Web项目绕不开的坎。Struts2的乱码根源其实分散在四个地方,只改一个没用:

  • 请求参数编码。struts.i18n.encoding设置为UTF-8后,POST请求的参数基本可靠。对于GET请求,Tomcat的URI编码机制在8.0以上版本默认就是UTF-8,老版本则需要在server.xml的Connector上配置URIEncoding="UTF-8"
  • JSP页面本身的编码。需要在pageEncoding="UTF-8"contentType="text/html; charset=UTF-8"两处保持一致,且JSP文件保存时就用UTF-8编码。
  • 数据库连接串。我使用的是MySQL,连接串里必须带字符集参数:
jdbc:mysql://localhost:3306/userdb?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/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这套"请求-拦截-处理-跳转"机制的理解就更深一层。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询