2026/10/15 0:45:24

Struts 2 实战:老系统改造、拦截器链与避坑指南

Struts 2 实战:老系统改造、拦截器链与避坑指南 简介这是一套基于Struts框架开发的企业级销售管理系统源码面向Java Web初学者与中级开发者聚焦MVC架构实践与销售业务建模能力提升。系统完整实现商品进出货管理含入库、出库、库存查询与盘点和客户管理含信息维护、分类评级、服务记录与关系维护两大核心模块可直接部署学习或二次开发。资源共298个文件涵盖94个JSP页面视图层、48个Java源码Action与DAO逻辑、48个Class字节码编译产物、35个JS脚本前端交互、20个JPG/PNG图片及18个CSS样式文件界面资源另有XML配置、字体与项目元数据文件整体压缩包仅2.05MB轻量易解压。已有258人下载学习适合通过真实业务场景理解Struts控制器调度、标签库应用、国际化支持及前后端协同机制尤其利于掌握商品-客户双主线的数据流设计与分层编码规范。1. 这不是又一个 CRUD 演示项目Struts 2 驱动的销售管理系统专治「改个字段要重发整包」的交付焦虑你手头正压着一个客户现场刚提的需求把“客户等级”从下拉单选改成带搜索的远程异步选择器后端要支持模糊匹配、分页、缓存穿透防护同时销售订单导出 Excel 的模板得加一列“最近一次回访时间”数据来自另一个微服务。你打开当前系统——一个用 Struts 2.3.x 搭建的老销售管理系统JSP 页面里混着 Java 脚本、Action 类里塞着 DAO 和业务逻辑、struts.xml 配置文件长达 800 行……改改完测试环境跑通UAT 环境报NullPointerException在getCustomerLevelList()的第 47 行不改客户下周就要验收。这不是教学 Demo这是某公司实际交付中反复翻车的真实战场。它用 Struts 2 作为核心 MVC 框架完整覆盖客户管理、商品维护、销售订单、库存查询、基础报表五大模块所有 Action 均通过 Convention 插件自动映射拦截器链明确分离权限校验、日志记录与事务控制。适合正在维护同类老系统的 Java 工程师、需要快速理解 Struts 2 实战边界的学生以及被“升级 Spring Boot”口号吓退、想先吃透经典分层设计的架构补习者。它不教你怎么写 Hello World它教你怎么在struts.xml里用result typeredirectAction安全跳转怎么让ModelDriven接口真正驱动页面绑定怎么用Validation注解替代 XML 验证配置——全是能立刻抄进自己项目里的硬货。2. 从源码结构到运行闭环Struts 2 销售管理系统的四层落地路径Struts 2 不是黑匣子它是一套有明确职责边界的分层契约。这个销售管理系统把这种分层具象化为可触摸的文件结构和可验证的调用链。我拆包后第一件事就是确认它是否真按 Struts 2 最佳实践组织——答案是肯定的src/main/java下清晰划分action、service、dao、model四包webapp/WEB-INF里struts.xml仅保留全局常量与包继承定义具体 Action 映射全部交给Namespace和Action注解驱动。这意味着你改一个订单查询逻辑不需要碰 XML 配置只要改OrderAction.java里的execute()方法和对应order.jsp视图即可。但前提是你得先让这个结构跑起来。2.1 环境准备JDK 8 Tomcat 8.5 是唯一经过验证的组合这个系统对 JDK 版本极其敏感。我试过用 JDK 11 启动Tomcat 日志第一行就报java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter——不是缺依赖是 Struts 2.3.34 内部反射调用 JAXB API而 JDK 11 已移除java.xml.bind模块。血泪经验必须用 JDK 8u202 或更高更新版但低于 8u301因后者引入了新的安全限制。Tomcat 必须是 8.5.x 系列推荐 8.5.999.x 会因 Servlet 4.0 的HttpServletResponse#sendError(int, String)签名变更导致StreamResult输出异常7.x 则因缺少AsyncContext支持使后台导出任务无法异步化。Maven 依赖里struts2-core版本锁死为2.3.34这是最后一个兼容 JDK 8 且修复了 OGNL 表达式沙箱绕过的稳定版。其他关键依赖版本如下依赖坐标版本作用说明org.apache.struts:struts2-core2.3.34核心 MVC 框架含拦截器、OGNL、结果类型org.apache.struts:struts2-convention-plugin2.3.34实现注解驱动 Action 映射省去 XML 配置org.apache.struts:struts2-json-plugin2.3.34提供json结果类型支撑 AJAX 异步交互org.hibernate:hibernate-core4.3.11.FinalORM 层与 Struts 2.3 兼容性最佳com.h2database:h21.4.199内置 H2 数据库开箱即用避免 MySQL 配置干扰提示不要试图升级struts2-core到 2.5.x。2.5 引入的SecurityMemberAccess机制会彻底禁用#context访问导致所有 JSP 中s:property value#session.user.name/全部失效且无平滑迁移方案。2.2 启动与验证三步确认 Struts 2 拦截器链已生效启动不是目的验证拦截器是否真实介入才是关键。很多开发者以为看到登录页就代表成功其实 Struts 2 的核心价值在请求处理管道里。我们用最朴素的方式验证# 第一步启动 Tomcat访问根路径 curl -I http://localhost:8080/sales/响应头中必须包含X-Struts-Executed: true—— 这是系统在struts.properties中配置的自定义响应头由ExecuteInterceptor添加证明 Struts 2 的执行拦截器已挂载。# 第二步触发一个带参数的 Action 请求如客户列表 curl http://localhost:8080/sales/customer/list.action?page1rows10检查 Tomcat 控制台日志应出现类似输出[INFO] [CustomerAction] - 开始执行客户列表查询参数page1, rows10 [DEBUG] [LoggingInterceptor] - 请求耗时127msURI/sales/customer/list.action [INFO] [TransactionInterceptor] - 事务已提交无异常这三行日志分别来自CustomerAction自身的log.info()、自定义LoggingInterceptor、以及TransactionInterceptor—— 证明拦截器链完整执行。2.3 关键配置解析struts.xml 与 struts.properties 的分工铁律这个系统把配置拆成两份是理解 Struts 2 设计哲学的钥匙。struts.xml只做三件事定义常量、声明包继承关系、配置全局结果。所有具体 Action 映射全部交给注解!-- struts.xml 片段 -- constant namestruts.devMode valuefalse/ constant namestruts.enable.DynamicMethodInvocation valuefalse/ package namedefault extendsstruts-default global-results result nameerror/common/error.jsp/result result namelogin typeredirectActionlogin_input/result /global-results /package而真正的路由逻辑在CustomerAction.java里Namespace(/customer) ParentPackage(default) public class CustomerAction extends ActionSupport implements ModelDrivenCustomer { Action(value list, results { Result(name success, location /customer/list.jsp), Result(name error, type chain, params {actionName, error_handler}) }) public String list() { // 查询逻辑 customerList customerService.findPage(page, rows); return SUCCESS; } }struts.properties则专注运行时行为# struts.properties 片段 struts.objectFactory spring struts.convention.action.packages com.example.sales.action struts.convention.result.path /WEB-INF/content/ struts.custom.i18n.resources global-messages这里struts.objectFactory spring是关键——它告诉 Struts 2所有 Action 实例交由 Spring 容器管理因此CustomerAction里可以直接Autowired private CustomerService customerService;无需手动new。而struts.convention.result.path指定了 JSP 视图的根路径所以Result(location /customer/list.jsp)实际解析为/WEB-INF/content/customer/list.jsp实现视图层隔离。3. 核心模块实战客户管理与销售订单的 Struts 2 编码范式Struts 2 的威力不在语法糖而在它如何把 HTTP 请求生命周期、Java 对象生命周期、Web 视图生命周期三者用一套拦截器契约粘合。客户管理模块是整个系统的入口销售订单模块则是业务复杂度峰值。我们以这两个模块为切口看 Struts 2 如何落地。3.1 客户管理ModelDriven 接口如何真正驱动双向绑定客户新增页面 (/customer/input.jsp) 使用标准 Struts 2 表单标签s:form actionsave methodpost s:textfield namename label客户名称 requiredtrue/ s:select namelevel list#{1:VIP,2:普通,3:潜在} label客户等级/ s:textarea nameremark label备注/ s:submit value保存/ /s:form关键在CustomerAction如何接收并校验这些参数。它没有用private String name;字段配 getter/setter而是实现了ModelDrivenCustomerpublic class CustomerAction extends ActionSupport implements ModelDrivenCustomer { private Customer model new Customer(); // 必须初始化 Override public Customer getModel() { return model; } Action(save) public String save() { // 此时 model.name, model.level, model.remark 已被自动注入 if (model.getName() null || model.getName().trim().isEmpty()) { this.addFieldError(name, 客户名称不能为空); return INPUT; // 返回 INPUT 结果重新渲染 input.jsp } customerService.save(model); return SUCCESS; } }ModelDriven的本质是Struts 2 在参数注入阶段会将所有namexxx的请求参数尝试设置到getModel()返回的对象属性上而非 Action 自身属性。这带来两个硬性要求第一model字段必须在 Action 构造时就初始化否则getModel()返回 null注入失败第二INPUT结果必须指向原始输入页这里是input.jsp因为 Struts 2 会自动将fieldErrors绑定到该页面的s:fielderror/标签上。如果你把INPUT指向其他页面错误信息就丢失了。3.2 销售订单JSON 插件与 AJAX 交互的防坑配置订单列表页需支持分页、排序、条件筛选全部通过 AJAX 加载。前端使用 jQuery$.post(/sales/order/list.action, { page: 1, rows: 20, sort: createTime, order: desc, customerName: $(#searchName).val() }, function(data) { renderTable(data.rows); // data 来自 JSON 插件序列化 });后端OrderAction的list()方法返回JSON结果类型Action(value list, results { Result(name success, type json, params { root, data, excludeProperties, data\\[\\d\\]\\.customer\\.orders|data\\[\\d\\]\\.items\\[\\d\\]\\.order, ignoreHierarchy, false }) }) public String list() { data orderService.findPage(page, rows, sort, order, customerName); return SUCCESS; }这里excludeProperties是生死线。data是一个MapString, Object其中data.get(rows)是订单列表ListOrder每个Order关联CustomerCustomer又关联ListOrder循环引用。若不排除JSON 插件会陷入无限递归最终StackOverflowError。excludeProperties的正则表达式data\\[\\d\\]\\.customer\\.orders精确匹配data[0].customer.orders这类路径阻止序列化。ignoreHierarchyfalse则确保只排除指定路径不连带排除父对象。3.3 权限拦截器基于 Session 的粗粒度访问控制实现系统未集成 Shiro 或 Spring Security而是用 Struts 2 自定义拦截器实现基础权限控制。AuthInterceptor.java核心逻辑public class AuthInterceptor extends AbstractInterceptor { Override public String intercept(ActionInvocation invocation) throws Exception { MapString, Object session invocation.getInvocationContext().getSession(); User user (User) session.get(user); if (user null) { // 未登录跳转至登录页但保留原请求 URL 用于登录后重定向 String requestUrl getRequestUrl(invocation); session.put(redirectAfterLogin, requestUrl); return login; // 全局 result name } // 检查角色简单字符串匹配生产环境应查 DB String actionName invocation.getProxy().getActionName(); if (actionName.startsWith(admin_) !ADMIN.equals(user.getRole())) { return forbidden; } return invocation.invoke(); // 放行 } }此拦截器在struts.xml中注册为默认拦截器栈interceptors interceptor nameauth classcom.example.sales.interceptor.AuthInterceptor/ interceptor-stack namedefaultStackWithAuth interceptor-ref namedefaultStack/ interceptor-ref nameauth/ /interceptor-stack /interceptors default-interceptor-ref namedefaultStackWithAuth/注意defaultStack已包含params、prepare、model-driven等核心拦截器auth必须放在最后确保model-driven已完成参数注入session已可用。4. 避坑指南Struts 2 销售管理系统上线前必踩的五个深坑Struts 2 的坑不在文档里而在你改了某行代码、重启 Tomcat、发现功能莫名失效的凌晨三点。以下是我在三个不同客户现场复现并解决的典型问题每一条都附带可验证的现象、根本原因和一行命令级解决方案。4.1 现象JSP 页面中s:property valuename/显示空值但System.out.println(name)打印正常原因name字段未遵循 JavaBean 规范。Struts 2 的 OGNL 表达式引擎要求若字段名为name则必须存在getName()方法且方法签名必须为public String getName()。如果误写为public String getname()小写 nOGNL 就找不到 getter返回 null。解决检查所有Action和Model类的 getter 方法命名用 IDE 的 “Generate Getter and Setter” 功能重新生成禁用手动编写。4.2 现象表单提交后addFieldError(name, 错误信息)不显示在页面上原因INPUT结果未正确指向原始输入页或输入页未包含s:fielderror fieldNamename/标签。更隐蔽的情况是struts.convention.result.path配置的路径与Result中location的相对路径计算错误导致 Struts 2 渲染了错误的 JSP而该 JSP 里没有fielderror标签。解决在struts.xml中临时添加constant namestruts.devMode valuetrue/启动后访问http://localhost:8080/sales/struts/查看“Configuration”页搜索INPUT结果对应的物理路径确认其与webapp/WEB-INF/content/下文件一致。4.3 现象AJAX 请求返回 500Tomcat 日志报ognl.MethodFailedException堆栈指向getText()方法原因getText(key)在 Action 中被调用时key对应的资源束global-messages.properties中该 key 不存在且struts.i18n.reload为true开发模式默认开启导致 Struts 2 尝试动态重载资源束失败。解决在struts.properties中添加struts.i18n.reloadfalse并将所有getText()调用包裹在 try-catch 中或改用getText(key, 默认文本)提供 fallback。4.4 现象H2 数据库中中文显示为乱码如“张三”存为“??”原因H2 连接 URL 缺少字符集参数。默认 JDBC URLjdbc:h2:~/salesdb不指定编码H2 使用平台默认编码Windows 通常是 GBK而 Struts 2 请求默认 UTF-8。解决修改hibernate.cfg.xml中的连接 URL 为jdbc:h2:~/salesdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE;CHARSETUTF-84.5 现象部署到 Linux 服务器后s:file上传控件始终返回null本地 Windows 正常原因Linux 文件系统区分大小写而 Struts 2 的FileUploadInterceptor默认使用的临时目录java.io.tmpdir在某些 Linux 发行版中指向/tmp但struts.multipart.saveDir配置为upload相对路径导致 Struts 2 尝试创建/tmp/upload目录失败权限不足或路径不存在。解决在struts.properties中显式指定绝对路径struts.multipart.saveDir /var/tmp/sales-upload并确保该目录存在且 Tomcat 用户有读写权限sudo mkdir -p /var/tmp/sales-upload sudo chown tomcat:tomcat /var/tmp/sales-upload5. 进阶技巧用 Struts 2 拦截器链重构导出功能把 3 秒响应压到 300 毫秒销售系统最常被吐槽的点就是“导出 Excel 太慢”。原始实现是用户点击“导出”ExportAction.execute()方法内直接调用ExcelUtil.exportToResponse()遍历 10 万条订单逐行写入HttpServletResponse.getOutputStream()期间 Tomcat 线程被独占用户只能干等。这不是性能问题是架构问题——它把耗时 IO 操作塞进了 Struts 2 的同步请求处理链。真正的解法是用拦截器链把“触发导出”和“执行导出”彻底解耦。5.1 导出任务抽象从 Action 方法到独立 Service首先剥离导出逻辑。新建ExportService.javaService public class ExportService { // 使用 Spring 的 TaskExecutor 实现异步 Autowired private TaskExecutor taskExecutor; public void exportOrdersAsync(String condition, String userId) { taskExecutor.execute(() - { try { // 1. 查询数据此处用 Hibernate 分页避免内存溢出 ListOrder orders orderDao.findForExport(condition, 0, 100000); // 2. 生成 Excel 文件用 Apache POI SXSSFWorkbook 流式写入 File tempFile generateExcel(orders); // 3. 保存文件到共享存储如 NFS 或 MinIO生成下载 URL String downloadUrl saveToStorage(tempFile, userId); // 4. 更新用户通知表标记任务完成 notifyService.sendComplete(userId, downloadUrl); } catch (Exception e) { notifyService.sendFail(userId, e.getMessage()); } }); } }5.2 拦截器接管ExportTriggerInterceptor 实现零侵入触发新建拦截器ExportTriggerInterceptor.java它不处理业务只做一件事当检测到请求参数含exporttrue就调用ExportService并立即返回轻量 JSONpublic class ExportTriggerInterceptor extends AbstractInterceptor { Autowired private ExportService exportService; Override public String intercept(ActionInvocation invocation) throws Exception { MapString, Object params invocation.getInvocationContext().getParameters(); if (params.containsKey(export) true.equals(params.get(export).toString())) { // 提取导出条件从 request 参数或 session 中获取 String condition extractCondition(invocation); String userId getCurrentUserId(invocation); // 异步触发导出 exportService.exportOrdersAsync(condition, userId); // 立即返回成功响应前端轮询状态 HttpServletResponse response ServletActionContext.getResponse(); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\status\:\success\,\message\:\导出任务已提交请稍后查看通知\}); return NONE; // 不走后续拦截器和 Action直接结束请求 } return invocation.invoke(); // 正常流程 } }5.3 前端协同轮询 通知中心用户体验质变前端不再等待导出完成而是点击“导出”按钮发送POST /order/list.action?exporttruecustomerNameABC收到{status:success}后隐藏按钮显示“导出任务已提交”启动轮询GET /notify/status.action?userId123检查通知表一旦返回{status:complete, url:/download/20240520-123456.xlsx}弹窗提示并提供下载链接。这个方案把用户感知的“等待时间”从 3 秒降为 300 毫秒纯网络往返而真正的耗时操作在后台静默执行。更重要的是它完全不修改任何原有 Action 代码——OrderAction.list()方法依然只负责查数据、返回 JSONExportTriggerInterceptor作为独立拦截器插入到defaultStack之前实现关注点分离。从那以后我每次接到“导出慢”的需求都不再碰Action类而是先画一张拦截器链图哪里该切、哪里该异步、哪里该降级。Struts 2 的拦截器不是装饰器它是请求生命周期的手术刀。希望帮到你。本文还有配套的精品资源点击获取