
简介Struts2 登录注册与用户信息管理项目面向正在学习 Java Web、准备课程设计或希望入门 MVC 开发的读者以一套可运行的登录、注册和用户资料修改流程串联控制层、视图层与数据库访问帮助理解 Struts2 在真实小项目中的组织方式。压缩包共 74 个文件包含 java 源码、class 编译文件、xml 配置、jsp 页面、gif/jpg 界面素材以及 jar 依赖等整体约 4.27MB适合对照源码梳理 Action 与 Result 的跳转关系、拦截器带来的登录校验与日志思路、表单验证、异常处理以及 SQL Server 数据表的读写流程。已有 845 人学习项目规模不大但模块相对完整可用于拆解用户认证、session 会话跟踪、页面跳转和数据库交互的实现细节也便于作为课程实验、毕业设计或 Struts2 入门练习的基础模板进行二次修改。1. 从一份跑不动的老项目说起接手一个 Struts2 项目最先让人犯难的不是代码量而是它明明能启动点一下登录却报 “No result defined for action”翻遍struts.xml也看不出哪里配错了。这份Struts2 做的登陆注册及用户信息管理系统就是针对这个场景把 Struts 登陆注册这条主线从表单提交、参数注入、拦截器鉴权一直到 JSP 回显完整串起来再挂上一个能翻页、能改能删的用户管理模块。它适合三类人课程设计阶段要交一个逻辑自洽、能讲清请求链路的完整项目刚接手维护老系统、需要一块可对照的参照代码以及想真正搞明白 Struts2 拦截器栈、值栈和 Session 管理如何配合的开发者。完整走一遍你手上会是一个能登录、注册、查用户、改资料、删账号的后台雏形。2. 请求链路拆解一次登录从表单到 Action 走了哪几步很多人在 Struts2 上遇到的问题本质上都是没有把链路骨架弄清楚尤其是遇到“参数没进来”“跳转不生效”“拦截器没触发”这类问题时只能靠猜。与其到处试不如先沿着一条登录请求走一遍记住几个关键节点后面排查就有坐标了。2.1 从浏览器按下登录按钮开始一个POST /auth/login.action发出去服务端经历的固定动作是这样的顺序节点关键动作1StrutsPrepareAndExecuteFilter拦截/*判断是否.action后缀创建ActionProxy2ConfigurationManager按 namespace action name 查ActionConfig找不到就抛无映射异常3ActionInvocation初始化装载拦截器链、Action 实例、结果映射4拦截器栈顺序执行servletConfig→modelDriven→params→validation→workflow5Action 方法业务逻辑返回SUCCESS/INPUT/ERROR等逻辑结果名6Result 渲染dispatcher转发 JSP或redirectAction跳另一个 Action7响应写回视图输出或 302 重定向这条链上第 4 步和第 6 步是出问题最多的地方前者决定请求参数有没有进到 Action、校验有没有跑后者决定页面跳到哪、Session 里的登录态还带不带得过去。把这两步理解清楚Struts2 的调试效率就明显提升了。2.2 web.xml 与 struts.xml两个配置各管什么先给一份对照用的最小骨架登录和用户管理分两个包namespace 隔开后面加拦截器也有地方挂。!-- web.xmlStruts2 的总入口只有一个 Filter -- filter filter-namestruts2/filter-name filter-classorg.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter/filter-class /filter filter-mapping filter-namestruts2/filter-name url-pattern/*/url-pattern /filter-mapping!-- struts.xml按业务域切包auth 管登录注册user 管信息维护 -- struts constant namestruts.devMode valuetrue/ constant namestruts.i18n.encoding valueUTF-8/ constant namestruts.ui.theme valuesimple/ package nameauth namespace/auth extendsstruts-default action namelogin classcom.example.web.action.LoginAction methoddoLogin result namesuccess typeredirectAction param namenamespace/user/param param nameactionNamelist/param /result result nameinput/WEB-INF/views/login.jsp/result /action /package package nameuser namespace/user extendsstruts-default action namelist classcom.example.web.action.UserAction methodpage result/WEB-INF/views/user_list.jsp/result /action action nameedit classcom.example.web.action.UserAction methodedit result namesuccess/WEB-INF/views/user_edit.jsp/result result nameinput/WEB-INF/views/user_edit.jsp/result /action /package /struts参数上要说清楚三件事。struts.devMode打开后异常堆栈会直接打到页面上调试期非常有价值上线前必须关掉否则等于把内部路径结构暴露给访问者。struts.i18n.encoding是 Struts 自己对请求体做的编码设置它管得住 POST但管不住某些容器下的 GET 查询串后面避坑章节会专门说这个。struts.ui.theme设成simple是为了让s:textfield这类标签只输出 input不额外套一层表格和样式不然页面结构会被框架默认主题打乱。包名和 namespace 分开收益是结果跳转可以写绝对路径。redirectAction里同时给namespace和actionName跳转目标就是/user/list.action不会因为当前请求在/auth下而找不到。这个细节在同一个包里跳转时感觉不到一旦跨模块就是必错项。2.3 拦截器顺序params为什么必须排在modelDriven后面默认栈defaultStack的顺序是固定的其中几环的先后不是随便排的exception → alias → servletConfig → prepare → i18n → modelDriven → fileUpload → params → conversionError → validation → workflowmodelDriven先把getModel()返回的对象压到值栈栈顶params再把请求参数往栈顶对象上塞所以参数最终落到 model 上。如果你为了加个自定义拦截器把整个栈手写了一遍顺序写成params在前、modelDriven在后现象就是页面表单提交了Action 里 model 的字段全是 null日志里却看不到任何异常排查起来很麻烦。这也是不少人说自己“配置了但没生效”的真实原因。补自定义拦截器时稳妥写法是引用默认栈再追加不要从零拼interceptors interceptor nameloginCheck classcom.example.web.interceptor.LoginInterceptor/ interceptor-stack namesecureStack interceptor-ref namedefaultStack/ interceptor-ref nameloginCheck/ /interceptor-stack /interceptors自定义的放在defaultStack之后意味着参数注入、类型转换、校验都跑完了才做登录判断拦截器里能直接拿到已经填好的数据不需要自己去读HttpServletRequest。如果把loginCheck放在最前面虽然也能拦但省下的那点性能远不如排错成本高。3. 登录注册落地Action 写法、会话保持与密码存储链路清楚之后落到具体代码。这一块最容易出现的分歧是“参数到底怎么接”“登录态放哪”“密码能不能明文”三个问题各有明确的取舍选错了后面改起来要动一大片。3.1 属性驱动还是 ModelDriven两种接参方式团队里经常混着用结果是维护的人看不懂数据从哪来。属性驱动是在 Action 上直接写字段和 setter简单直接适合字段少的登录ModelDriven是让 Action 实现接口返回一个实体对象参数自动注入到实体适合注册、编辑这种字段多的场景。登录用属性驱动代码短一眼能看出用了哪几个参数public class LoginAction extends ActionSupport implements SessionAware { private String username; private String password; private final UserService userService new UserService(); private MapString, Object session; public String doLogin() { User user userService.findByUsername(username); // 用户不存在与密码错误返回同一句提示避免被用来枚举账号 if (user null || !userService.matches(password, user.getPassword(), user.getSalt())) { addActionError(用户名或密码不正确); return INPUT; } session.put(LOGIN_USER, user); return SUCCESS; } Override public void setSession(MapString, Object session) { this.session session; } // username / password 的 getter、setter 必须写全params 拦截器只认 setter }逻辑说明params拦截器反射调用 setter 完成注入缺 setter 字段就是 null这是第一类翻车点。SessionAware让 Struts 把包装后的 Session Map 注入进来比直接拿HttpServletRequest.getSession()更利于单元测试。密码比对交给userService.matches()方法内部做加盐摘要再比较Action 层不碰明文。注册用ModelDriven表单字段十几个的时候省事很多public class RegisterAction extends ActionSupport implements ModelDrivenUser { private final User model new User(); private final UserService userService new UserService(); Override public User getModel() { return model; } public String doRegister() { // 盐值随机生成跟着用户记录一起存 String salt userService.randomSalt(); model.setSalt(salt); model.setPassword(userService.digest(model.getPassword(), salt)); userService.save(model); return SUCCESS; } }注意getModel()返回的对象必须是同一个实例不能每次 new否则params注入到一个对象、业务方法读到另一个对象字段自然是空的。3.2 登录态保持SessionAware 与自定义拦截器登录成功把用户对象塞进 Session 只是第一步真正的鉴权要靠拦截器。拦截器判断 Session 里有没有登录标记没有就返回一个全局结果统一跳回登录页。public class LoginInterceptor extends AbstractInterceptor { Override public String intercept(ActionInvocation invocation) throws Exception { MapString, Object session invocation.getInvocationContext().getSession(); if (session.get(LOGIN_USER) null) { return toLogin; // 对应 struts.xml 里的全局结果 } return invocation.invoke(); // 放行继续执行后面的拦截器和 Action } }配套的全局结果写在包里global-results result nametoLogin typeredirectAction param namenamespace/auth/param param nameactionNameloginPage/param /result /global-results用redirectAction而不是dispatcher是因为转发会保留原始请求路径用户刷新时会重复提交原请求重定向则让地址栏变成一个干净的登录页 URL行为符合直觉。另外这个拦截器必须把登录、注册、登录页这三个 action 排除掉否则用户永远进不去登录页形成无限重定向浏览器会直接报重定向次数过多。3.3 注册校验validate()还是校验文件字段校验有两种写法。validate()方法写在 Action 里适合规则少、需要查数据库的场景XxxAction-validation.xml适合纯格式规则改规则不用重新编译。Override public void validate() { if (model.getUsername() null || model.getUsername().trim().length() 4) { addFieldError(username, 用户名至少 4 个字符); } if (model.getPassword() ! null model.getPassword().length() 8) { addFieldError(password, 密码至少 8 位); } // 唯一性必须放到这里格式校验通过的才去查库 if (!hasFieldErrors() userService.exists(model.getUsername())) { addFieldError(username, 该用户名已被占用); } }校验方法会在validation拦截器阶段被调用只要出现了 field errorworkflow拦截器就让它返回INPUT不再进入doRegister()。这里有个前提struts.xml里必须为这个 action 配置input结果否则校验失败会直接抛 “No result defined for action”。查库的校验放在最后避免每次格式不合法都打一次数据库。3.4 密码不能明文入库加盐摘要的落地写法不加盐的 MD5 早已不够用彩虹表一扫就出来。最低限度的做法是每个用户一条随机盐存库时把摘要和盐分两列存。public String digest(String raw, String salt) throws Exception { MessageDigest md MessageDigest.getInstance(SHA-256); md.update(salt.getBytes(StandardCharsets.UTF_8)); // 先混入盐 byte[] bytes md.digest(raw.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); // 转成定长十六进制 } return sb.toString(); }参数说明salt建议 16 位以上随机串用SecureRandom生成不要用时间戳或用户名那些都可预测。摘要算法选 SHA-256 是因为 JDK 自带、不引第三方依赖如果项目允许加依赖慢哈希迭代次数可调的算法抗暴力破解能力更强但要注意每次登录的计算耗时别把登录接口拖到几百毫秒。校验时用同样的盐重新算一遍再比对比对过程用定长比较避免时序差异。4. 用户信息管理分页、防重提交与 OGNL 取值管理模块的代码量比登录注册大得多但技术点反而集中一条分页查询、一套增删改、一层视图绑定。写顺手之后就是重复劳动真正需要留心的是几个边界。4.1 分页的两条 SQL 与 PageBean分页不要用“查全表再内存截取”用户量一上来就把内存吃干。标准做法是两条 SQL一条 count一条带 limit 的列表查询。-- 条件与列表查询共用同一段 where避免 count 和 list 结果对不上 SELECT COUNT(*) FROM t_user WHERE username LIKE ? AND status ?; SELECT id, username, real_name, email, status, create_time FROM t_user WHERE username LIKE ? AND status ? ORDER BY id DESC LIMIT ?, ?;LIMIT的两个参数是偏移量和条数偏移量按(pageNo - 1) * pageSize算。ORDER BY必须写而且最好带上唯一列这里是 id否则翻页时同一条记录可能在两页都出现用户会觉得数据在飘。承载分页数据的对象public class PageBeanT { private int pageNo 1; private int pageSize 10; private long total; private ListT rows; public int getOffset() { return (pageNo - 1) * pageSize; } public int getTotalPages() { return (int) ((total pageSize - 1) / pageSize); // 向上取整别用 total/pageSize } // getter / setter 省略 }getTotalPages()用向上取整能避开“最后一页只有一条数据却算不出一页”的经典错误。pageNo和pageSize都要做上限保护比如pageNo小于 1 归 1、pageSize超过 100 归 100不然有人把pageSize100000拼到 URL 上数据库会承受不必要的压力。4.2 编辑与删除token 拦截器挡重复提交删除按钮被连点两下、编辑页刷新一次就多提交一遍这类问题在管理后台很常见。Struts2 自带的 token 机制能挡进入编辑页时生成令牌放进 Session提交时比对并移除第二次提交对不上就判为重复。action namedelete classcom.example.web.action.UserAction methodremove interceptor-ref namedefaultStack/ interceptor-ref nametoken param nameincludeMethodsremove,update/param /interceptor-ref result namesuccess typeredirectAction param nameactionNamelist/param /result result nameinvalid.token/WEB-INF/views/error_repeat.jsp/result /actionincludeMethods限定只对删除和更新生效列表查询不受影响。invalid.token这个结果名是固定写法写成别的名字令牌失效时会找不到结果。页面端要配合s:token/标签它负责把令牌值输出成隐藏域少了这一步服务端永远拿不到令牌。4.3 JSP 与 OGNL值栈取值的三种写法视图层最容易出的不是语法错误而是“取到了但不是我想要的那个对象”。值栈是有顺序的#取非根对象Session、request 属性不加前缀取栈顶对象的属性加#var取迭代时声明的临时变量。s:iterator valuepageBean.rows varu statusst tr tds:property value#u.id//td %-- 用户名是用户自己填的必须转义否则等于给自己开了个存储型 XSS 的口子 --% tds:property value#u.username escapeHtmltrue//td tds:property value#st.index 1 (pageBean.pageNo - 1) * pageBean.pageSize//td /tr /s:iterator逻辑说明varu把当前迭代对象放进 ActionContext 并命名之后必须用#u.xxx取值如果省掉var对象直接被压到栈顶s:property valueusername/也能取到但一旦外面还有一层迭代就会取错对象。escapeHtmltrue在输出用户输入的内容时务必加上默认值是false。序号那行把行号换算成全局序号比直接写#st.index更符合使用者预期。5. 避坑与排查五个高频故障的现象、原因与解决下面五条基本覆盖了这套系统上线前后最容易反复出现的问题按“现象 → 原因 → 解决”整理遇到时对照着看比自己一行行 debug 快。第一校验失败直接 500提示 “No result defined for action xxx and result input”。现象是表单填错一个字段页面没回显错误反而跳到异常页。原因是 Action 里写了validate()但struts.xml里这个 action 只配了success没有input。workflow拦截器返回INPUT时找不到结果只能抛异常。解决是给每个可能触发校验的 action 都补上result nameinput指向它自己的表单页。第二注册表单提交中文用户名入库变成乱码。现象是英文正常、中文全变问号。原因多半是容器对 GET 查询串的解码用了默认字符集而struts.i18n.encoding只管请求体。解决是在web.xml里加一个编码过滤器并且放在 Struts 过滤器之前顺序反了不生效filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-paramparam-nameencoding/param-nameparam-valueUTF-8/param-value/init-param init-paramparam-nameforceEncoding/param-nameparam-valuetrue/param-value/init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mapping同时确认数据库连接串带了characterEncodingutf8JSP 页面的pageEncoding也是 UTF-8三处任一漏了都会复现。第三表单提交了Action 里的字段全是 null。原因有三种常见情况字段没写 setterparams拦截器只认 setter手写拦截器栈时把params拿掉了或者用了ModelDriven但getModel()每次返回新对象。排查顺序是先在 Action 里打断点看getModel()返回的对象是不是同一个引用再看struts.xml里这个 action 有没有interceptor-ref覆盖掉默认栈。第四打开登录页就变成无限重定向浏览器报重定向次数过多。原因是登录校验拦截器配在了包级别把登录 action 自己也拦了未登录 → 跳登录页 → 又被拦 → 再跳。解决是把登录页、登录提交、注册这几个 action 从拦截器里排除或者干脆把它们放进另一个不带登录拦截器的 package。第五删除一条记录后按 F5 刷新提示重复提交或者数据已被删除。原因是列表页用dispatcher转发到删除 action 的结果刷新等于重发删除请求。解决是删除成功后一律redirectAction回列表页同时把 token 拦截器的invalid.token结果配好双层保险。6. 进阶把权限和日志织进拦截器链基础功能跑通之后通常会冒出两个新需求不同角色能看的菜单不一样出问题时能查到谁在什么时候做了什么。这两件事都不该写进业务 Action正确的位置是拦截器链。先定义一个方法级注解标在 Action 方法上Retention(RetentionPolicy.RUNTIME) Target({ElementType.METHOD, ElementType.TYPE}) public interface RequireRole { String[] value() default {}; }再写一个读注解的拦截器反射拿到当前执行的方法看有没有这个注解、注解里的角色和 Session 里的角色是否匹配public class RoleInterceptor extends AbstractInterceptor { Override public String intercept(ActionInvocation invocation) throws Exception { Object action invocation.getAction(); String methodName invocation.getProxy().getMethod(); Method method action.getClass().getMethod(methodName); RequireRole role method.getAnnotation(RequireRole.class); if (role ! null) { MapString, Object session invocation.getInvocationContext().getSession(); User current (User) session.get(LOGIN_USER); if (current null || !Arrays.asList(role.value()).contains(current.getRole())) { return noAuth; // 全局结果统一跳无权限页 } } return invocation.invoke(); } }invocation.getProxy().getMethod()拿到的是struts.xml里method属性指定的名字正好对应真实方法名不需要自己解析请求路径。注解加在类上则对整个 Action 生效方法上的注解优先级更高这个判断顺序要在代码里显式写出来别依赖注解继承的默认行为。日志拦截器更简单在invoke()前后各取一次时间戳把 Action 名、方法名、耗时、登录用户写进日志long start System.currentTimeMillis(); String result invocation.invoke(); // 一定要接住返回值再原样返回 long cost System.currentTimeMillis() - start; logger.info(action{} method{} user{} cost{}ms result{}, invocation.getProxy().getActionName(), invocation.getProxy().getMethod(), session.get(LOGIN_USER) null ? anonymous : ((User) session.get(LOGIN_USER)).getUsername(), cost, result); return result;这里有个必须注意的地方invoke()的返回值是逻辑结果名不能丢掉。早期的写法里有人写成调完invoke()直接return SUCCESS表面上看页面正常实际上后续拦截器返回的INPUT、ERROR全被吞了校验直接失效。改完之后建议按下表做一轮回归每一项都能手工验证不用写自动化脚本也能覆盖主干验证项操作预期未登录访问直接访问/user/list.action302 到登录页无异常栈错误密码输入错误密码提交停留在登录页并提示Session 无用户对象重复用户名注册用已存在的用户名注册字段级错误回显不入库权限越界普通账号访问管理员方法进入无权限页不执行 Action重复提交删除后立即刷新提示重复提交数据只删一次分页边界手动改pageNo0和超大值被规整到合法范围SQL 不报错血泪经验是拦截器顺序一旦改动上面六项至少要重跑一遍。拦截器之间是有隐含依赖的比如RoleInterceptor必须排在LoginInterceptor之后否则 Session 里根本没有用户对象角色判断永远走“未登录”分支。我现在的习惯是只要动了struts.xml里任何一个interceptor-ref就先跑这张表再提交哪怕只改了一个参数。这套系统的代码包按这个思路组织登录注册、用户管理、拦截器三块是分开的你可以只取需要的那部分对接到自己项目里。希望帮到你。本文还有配套的精品资源点击获取