ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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 type="redirectAction">安全跳转,怎么让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.99),9.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?page=1&rows=10"

检查 Tomcat 控制台日志,应出现类似输出:

[INFO] [CustomerAction] - 开始执行客户列表查询,参数:page=1, rows=10 [DEBUG] [LoggingInterceptor] - 请求耗时:127ms,URI=/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 name="struts.devMode" value="false"/> <constant name="struts.enable.DynamicMethodInvocation" value="false"/> <package name="default" extends="struts-default"> <global-results> <result name="error">/common/error.jsp</result> <result name="login" type="redirectAction">login_input</result> </global-results> </package>

而真正的路由逻辑,在CustomerAction.java里:

@Namespace("/customer") @ParentPackage("default") public class CustomerAction extends ActionSupport implements ModelDriven<Customer> { @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 action="save" method="post"> <s:textfield name="name" label="客户名称" required="true"/> <s:select name="level" list="#{'1':'VIP','2':'普通','3':'潜在'}" label="客户等级"/> <s:textarea name="remark" label="备注"/> <s:submit value="保存"/> </s:form>

关键在CustomerAction如何接收并校验这些参数。它没有用private String name;字段配 getter/setter,而是实现了ModelDriven<Customer>:

public class CustomerAction extends ActionSupport implements ModelDriven<Customer> { 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 在参数注入阶段,会将所有name="xxx"的请求参数,尝试设置到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是一个Map<String, Object>,其中data.get("rows")是订单列表List<Order>,每个Order关联Customer,Customer又关联List<Order>(循环引用)。若不排除,JSON 插件会陷入无限递归,最终StackOverflowError。excludeProperties的正则表达式data\\[\\d+\\]\\.customer\\.orders精确匹配data[0].customer.orders这类路径,阻止序列化。ignoreHierarchy=false则确保只排除指定路径,不连带排除父对象。

3.3 权限拦截器:基于 Session 的粗粒度访问控制实现

系统未集成 Shiro 或 Spring Security,而是用 Struts 2 自定义拦截器实现基础权限控制。AuthInterceptor.java核心逻辑:

public class AuthInterceptor extends AbstractInterceptor { @Override public String intercept(ActionInvocation invocation) throws Exception { Map<String, 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 name="auth" class="com.example.sales.interceptor.AuthInterceptor"/> <interceptor-stack name="defaultStackWithAuth"> <interceptor-ref name="defaultStack"/> <interceptor-ref name="auth"/> </interceptor-stack> </interceptors> <default-interceptor-ref name="defaultStackWithAuth"/>

注意:defaultStack已包含params、prepare、model-driven等核心拦截器,auth必须放在最后,确保model-driven已完成参数注入,session已可用。


4. 避坑指南:Struts 2 销售管理系统上线前必踩的五个深坑

Struts 2 的坑不在文档里,而在你改了某行代码、重启 Tomcat、发现功能莫名失效的凌晨三点。以下是我在三个不同客户现场复现并解决的典型问题,每一条都附带可验证的现象、根本原因和一行命令级解决方案。

4.1 现象:JSP 页面中<s:property value="name"/>显示空值,但System.out.println(name)打印正常

原因:name字段未遵循 JavaBean 规范。Struts 2 的 OGNL 表达式引擎要求:若字段名为name,则必须存在getName()方法,且方法签名必须为public String getName()。如果误写为public String getname()(小写 n),OGNL 就找不到 getter,返回 null。
解决:检查所有Action和Model类的 getter 方法命名,用 IDE 的 “Generate Getter and Setter” 功能重新生成,禁用手动编写。

4.2 现象:表单提交后,addFieldError("name", "错误信息")不显示在页面上

原因:INPUT结果未正确指向原始输入页,或输入页未包含<s:fielderror fieldName="name"/>标签。更隐蔽的情况是:struts.convention.result.path配置的路径与@Result中location的相对路径计算错误,导致 Struts 2 渲染了错误的 JSP,而该 JSP 里没有fielderror标签。
解决:在struts.xml中临时添加<constant name="struts.devMode" value="true"/>,启动后访问http://localhost:8080/sales/struts/查看“Configuration”页,搜索INPUT结果对应的物理路径,确认其与webapp/WEB-INF/content/下文件一致。

4.3 现象:AJAX 请求返回 500,Tomcat 日志报ognl.MethodFailedException,堆栈指向getText()方法

原因:getText("key")在 Action 中被调用时,key对应的资源束(global-messages.properties)中该 key 不存在,且struts.i18n.reload为true(开发模式默认开启),导致 Struts 2 尝试动态重载资源束失败。
解决:在struts.properties中添加struts.i18n.reload=false,并将所有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_EXIT=FALSE;CHARSET=UTF-8

4.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-upload

5. 进阶技巧:用 Struts 2 拦截器链重构导出功能,把 3 秒响应压到 300 毫秒

销售系统最常被吐槽的点,就是“导出 Excel 太慢”。原始实现是:用户点击“导出”,ExportAction.execute()方法内直接调用ExcelUtil.exportToResponse(),遍历 10 万条订单,逐行写入HttpServletResponse.getOutputStream(),期间 Tomcat 线程被独占,用户只能干等。这不是性能问题,是架构问题——它把耗时 IO 操作塞进了 Struts 2 的同步请求处理链。真正的解法,是用拦截器链把“触发导出”和“执行导出”彻底解耦。

5.1 导出任务抽象:从 Action 方法到独立 Service

首先,剥离导出逻辑。新建ExportService.java:

@Service public class ExportService { // 使用 Spring 的 TaskExecutor 实现异步 @Autowired private TaskExecutor taskExecutor; public void exportOrdersAsync(String condition, String userId) { taskExecutor.execute(() -> { try { // 1. 查询数据(此处用 Hibernate 分页避免内存溢出) List<Order> 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,它不处理业务,只做一件事:当检测到请求参数含export=true,就调用ExportService并立即返回轻量 JSON:

public class ExportTriggerInterceptor extends AbstractInterceptor { @Autowired private ExportService exportService; @Override public String intercept(ActionInvocation invocation) throws Exception { Map<String, 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;charset=UTF-8"); response.getWriter().write("{\"status\":\"success\",\"message\":\"导出任务已提交,请稍后查看通知\"}"); return NONE; // 不走后续拦截器和 Action,直接结束请求 } return invocation.invoke(); // 正常流程 } }

5.3 前端协同:轮询 + 通知中心,用户体验质变

前端不再等待导出完成,而是:

  1. 点击“导出”按钮,发送POST /order/list.action?export=true&customerName=ABC;
  2. 收到{status:"success"}后,隐藏按钮,显示“导出任务已提交”;
  3. 启动轮询GET /notify/status.action?userId=123,检查通知表;
  4. 一旦返回{status:"complete", url:"/download/20240520-123456.xlsx"},弹窗提示并提供下载链接。

这个方案把用户感知的“等待时间”从 3 秒降为 300 毫秒(纯网络往返),而真正的耗时操作在后台静默执行。更重要的是,它完全不修改任何原有 Action 代码——OrderAction.list()方法依然只负责查数据、返回 JSON,ExportTriggerInterceptor作为独立拦截器插入到defaultStack之前,实现关注点分离。

从那以后我每次接到“导出慢”的需求,都不再碰Action类,而是先画一张拦截器链图:哪里该切、哪里该异步、哪里该降级。Struts 2 的拦截器不是装饰器,它是请求生命周期的手术刀。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进