简介这是一份整合jQuery EasyUI与SSHStruts2、Hibernate、Spring框架的增删改查实战示例面向初学Java Web的开发者用于理解从页面组件到后端数据库操作的完整调用链路。资源内涵盖DataGrid、Form、Dialog等EasyUI组件与Struts2、Hibernate、Spring的整合配置并包含Student等实体类、Action、Service、DAO层及对应XML映射和SQL脚本。压缩包共407个文件以jar依赖、js脚本、css样式、png/gif图片为主另有java源码、class字节码和xml配置整体约35.13MB便于对照学习与二次开发。目前已有258人学习浏览适合想在具体例子中快速掌握EasyUI表格分页、增删改查弹窗操作以及SSH事务管理的读者。1. 拿到一个“SSH EasyUI 增删改查”的需求先别急着说技术老很多年前我接手过一个内部管理系统打开代码仓库一看前端是 jQuery EasyUI后端是 Spring Struts2 Hibernate也就是大家常说的 SSH。当时第一反应是“这技术栈也太老了”但真正把需求做完才发现这套组合在内部管理系统、后台管理页面、快速交付类项目里远比想象中常见。EasyUI 的 DataGrid 自带分页、排序、编辑、弹窗表单几乎就是为“增删改查”这四个字设计的SSH 虽然在今天看来笨重但分层清晰Action、Service、Dao 各管一摊出问题只要顺着链路查就能定位。所以这篇笔记我不打算劝你“升级改造”而是把这套组合里最常见的增删改查写法、联调细节和踩过的坑一次性讲清楚。如果你正在维护老项目或者要在一个 SSH 项目里快速交付一个模块照着做能省下不少自己摸索的时间。2. 先把后端骨架立住SSH 整合要点与一张表的完整落地2.1 三个框架的职责划分决定了你写代码的位置SSH 整合里Struts2 负责接收 HTTP 请求并把结果返回给页面Spring 负责管理对象的创建和依赖关系Hibernate 负责把 Java 对象映射成数据库记录。这三者组合在一起请求链路是从 JSP 页面发出 jQuery 请求Struts2 的 Action 接住参数调用 Spring 管理的 Service 层方法Service 里再用 Hibernate 的 Dao 完成数据库操作最后把结果转成 JSON 回传给前端。我见过很多新手在这个链路上翻车根源就是没搞清“哪个类该写在哪一层”。比如有人把 SQL 直接写在 Action 里看起来跑得通但事务、连接释放、复用性全乱套了。记住一句话Action 只做参数接收和结果返回Service 只做业务逻辑和事务边界Dao 只做数据库读写。2.2 建一张“员工表”并完成实体映射所有 CRUD 都从这里开始我以一个员工管理模块为例表结构就四五个字段足够把增删改查的套路讲清楚。先看建表语句CREATE TABLE t_employee ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 姓名, dept varchar(50) DEFAULT NULL COMMENT 部门, salary decimal(10,2) DEFAULT NULL COMMENT 薪资, hire_date date DEFAULT NULL COMMENT 入职日期, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;表结构里我特意把id设计成自增主键这样新增和更新可以共用一套逻辑——有 id 就走 update没有 id 就走 insert。日期字段用 date 而不是 datetime因为 EasyUI 的 datebox 组件默认只处理日期这样前后端对接更省事。如果你项目里用的是 Hibernate 注解方式实体类大致长这样Entity Table(name t_employee) public class Employee implements Serializable { private static final long serialVersionUID 1L; Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; private String dept; Column(precision 10, scale 2) private BigDecimal salary; Temporal(TemporalType.DATE) private Date hireDate; // 省略 getter/setter }这里有几个容易出问题的细节一是实体类必须实现Serializable否则后面用 JSON 序列化时某些容器会报错二是日期字段用Temporal(TemporalType.DATE)标注Hibernate 才能正确读写数据库里的 date 类型三是 salary 用 BigDecimal 而不是 double避免浮点数精度丢失。这些看起来都是小事但在后面编辑回显和列表展示时都会体现出来。2.3 让 Action 被 Spring 托管struts.xml 与 applicationContext.xml 的配合SSH 整合中最容易忽略的一步是“Action 实例到底由谁创建”。默认情况下 Struts2 每次请求都会 new 一个新的 Action但如果你在 Action 里注入了 Service就必须让 Spring 来管理 Action 的生命周期。常见做法是把 struts.xml 里的常量配置成 Spring 托管struts constant namestruts.objectFactory valuespring / constant namestruts.devMode valuefalse / constant namestruts.i18n.encoding valueUTF-8 / package namedefault namespace/ extendsjson-default action nameemployee_* classemployeeAction method{1} result typejson param namerootresult/param /result /action /package /struts注意我这里的写法是employee_*通配符把 Action 的多个方法映射到同一个类上。classemployeeAction这个值不是类名而是 Spring 容器里的 bean id所以在 applicationContext.xml 里要配置对应的 beanbean idemployeeAction classcom.demo.action.EmployeeAction scopeprototype property nameemployeeService refemployeeService/ /bean bean idemployeeService classcom.demo.service.impl.EmployeeServiceImpl property nameemployeeDao refemployeeDao/ /beanscopeprototype是我特别想强调的。因为 Action 是有状态的每次请求都可能携带不同的参数如果用单例模式并发请求下参数会互相污染。这个坑非常隐蔽症状是“有时查出来的数据是别人的查询条件”排查方式就是看 bean 的 scope 配置。3. 前端第一个联调EasyUI DataGrid 怎么把后台数据渲染成表格3.1 引入 EasyUI 资源与页面最小骨架EasyUI 的使用方式很简单在页面里引入 css 和 js 文件就能工作。我习惯把所有资源放到一个公共 JSP 里避免每个页面重复写。页面骨架代码如下link relstylesheet typetext/css href${pageContext.request.contextPath}/js/easyui/themes/default/easyui.css link relstylesheet typetext/css href${pageContext.request.contextPath}/js/easyui/themes/icon.css script typetext/javascript src${pageContext.request.contextPath}/js/jquery.min.js/script script typetext/javascript src${pageContext.request.contextPath}/js/easyui/jquery.easyui.min.js/script引入顺序上jQuery 必须在 EasyUI 之前这个顺序错了页面直接白屏。表格部分我用 HTML 方式定义列比纯 JS 定义列更容易维护尤其当列很多的时候table iddg classeasyui-datagrid title员工列表 stylewidth:100%;height:400px >public class EmployeeAction extends ActionSupport { private EmployeeService employeeService; private int page; // EasyUI 自动传的参数 private int rows; // EasyUI 自动传的参数 private MapString, Object result new HashMapString, Object(); public String list() { PageResultEmployee pageResult employeeService.findByPage(page, rows); result.put(total, pageResult.getTotal()); result.put(rows, pageResult.getList()); return SUCCESS; } // getter/setter 省略 }这里有两个关键点。第一page和rows的属性名是约定死的如果 Action 里没有这两个属性的 setterEasyUI 传过来的分页参数就会被 Struts2 直接丢弃你永远拿不到页码。第二result这个 Map 的 key 可以有多个struts.xml 里已经用rootresult指定了序列化这个 Map所以前端拿到的 JSON 就是{total:100,rows:[...]}的结构。如果你忘了配 json-default 包或者没有把 result type 设成 json前端拿到的会是 JSP 页面源码而不是 JSON 数据。3.3 分页参数 page 和 rows从请求参数到 SQL 的完整链路前端 EasyUI 会发出类似employee_list.action?page2rows10的请求后端最终要把这两个参数翻译成 SQL 的 LIMIT 语句。Service 和 Dao 的写法如下// Service 实现类 public PageResultEmployee findByPage(int page, int rows) { return employeeDao.findByPage(page, rows); } // Dao 实现类 public PageResultEmployee findByPage(int page, int rows) { Criteria criteria getSession().createCriteria(Employee.class); long total (Long) criteria.setProjection(Projections.rowCount()).uniqueResult(); criteria.setProjection(null); criteria.setFirstResult((page - 1) * rows); criteria.setMaxResults(rows); ListEmployee list criteria.list(); return new PageResultEmployee(total, list); }这里建议用 Criteria 而不是 HQL因为 Criteria 的 API 更直观而且拼接动态查询条件时不用拼字符串避免 SQL 注入。setFirstResult的参数是起始行(page - 1) * rows这个公式是分页的通用规则比如第 2 页每页 10 条就是从第 10 条开始取。注意查 total 时用了setProjection(Projections.rowCount())查完之后必须setProjection(null)清掉否则后面取列表数据的 SQL 会被影响这是 Criteria 分页最容易踩的坑。4. 把增删改查四个动作写透从表单弹窗到 Action 接收参数4.1 新增和编辑共用一个弹窗编辑时怎么把选中行带进表单EasyUI 做表单类操作的标准模式是表格上方一个 toolbar里面有“新增”“编辑”“删除”三个按钮点击后弹出一个 DialogDialog 里放一个 form。新增和编辑可以共用一个弹窗区别在于编辑时需要把选中行的数据填充到表单里。前端代码如下// 记录当前选中行 var selectedRow null; $(#dg).datagrid({ onClickRow: function(index, row) { selectedRow row; } }); function openAddDialog() { selectedRow null; $(#dlg).dialog(open).dialog(setTitle, 新增员工); $(#fm).form(clear); // 清空隐藏的 id 字段保证走新增逻辑 $(#fm).form(load, {id: }); } function openEditDialog() { if (!selectedRow) { $.messager.alert(提示, 请先选择一行数据, warning); return; } $(#dlg).dialog(open).dialog(setTitle, 编辑员工); // form 的 load 方法会按 name 匹配填充表单字段 $(#fm).form(load, selectedRow); }用onClickRow记录选中行是 EasyUI 里最常见的做法比用getSelected更稳定因为getSelected在分页切换后有时会拿到旧数据。form(load, selectedRow)是按表单控件的name属性去匹配 JSON 里的 key所以表单里的 input 必须设置name否则不会被填充。新增时先form(clear)再load({id:})是为了确保隐藏的 id 字段为空这样后端就能区分新增和编辑。4.2 表单提交与保存逻辑Struts2 模型驱动的参数绑定弹窗底部是“保存”按钮提交方式用 Ajax这样页面不会整体刷新。保存按钮的点击事件和表单结构如下form idfm methodpost !-- 隐藏 id编辑时由 form load 填充新增时为空 -- input typehidden nameemployee.id / 姓名input classeasyui-textbox nameemployee.name stylewidth:200px / 部门input classeasyui-textbox nameemployee.dept stylewidth:200px / 薪资input classeasyui-numberbox nameemployee.salary precision2 stylewidth:200px / 入职日期input classeasyui-datebox nameemployee.hireDate stylewidth:200px / /formfunction saveEmployee() { $(#fm).form(submit, { url: selectedRow ? employee_update.action : employee_save.action, onSubmit: function() { return $(this).form(validate); // 校验失败则不提交 }, success: function(data) { var json JSON.parse(data); if (json.success) { $(#dlg).dialog(close); $(#dg).datagrid(reload); // 刷新表格 } else { $.messager.alert(提示, json.msg, error); } } }); }这里我用的是nameemployee.id这种写法对应 Struts2 的模型驱动属性嵌套赋值。Action 里要有一个Employee employee属性并提供 getter/setterStruts2 会把表单里所有employee.xxx前缀的参数自动封装到实体里。这样做的好处是表单字段不需要单独声明参数而是直接变成一个实体对象传给 Service。注意日期字段EasyUI datebox 提交上来的格式默认是yyyy-MM-dd比如2024-03-15Hibernate 能自动转换成 Date 类型。但如果页面引入了 EasyUI 的中文语言包格式可能变成/分隔这时要么在 datebox 上设置formatter要么在 Service 里手动转换否则会报类型转换错误。4.3 删除动作前端确认提示与后端批量删除删除按钮不可做得太粗暴一定要有确认提示另外实际项目里往往要支持多选删除所以 DataGrid 的singleSelect在删除场景下要改成false或者单独处理选中集合。我的做法如下function deleteEmployee() { var rows $(#dg).datagrid(getSelections); if (rows.length 0) { $.messager.alert(提示, 请先选择要删除的数据, warning); return; } var ids []; for (var i 0; i rows.length; i) { ids.push(rows[i].id); } $.messager.confirm(确认, 确定删除选中的 ids.length 条记录吗, function(r) { if (r) { $.post(employee_delete.action, {ids: ids.join(,)}, function(data) { if (data.success) { $(#dg).datagrid(reload); } else { $.messager.alert(提示, data.msg, error); } }, json); } }); }getSelections在单选模式下返回的数组最多只有一条在多选模式下才能拿到多条。这里把 ids 数组用join(,)拼成逗号分隔的字符串比直接提交数组更靠谱避免了 EasyUI 和 Struts2 在数组参数格式上的各种兼容问题。后端 Action 对应代码如下public String delete() { try { String idsStr ids; // 形如 1,2,3 employeeService.deleteByIds(idsStr); result.put(success, true); } catch (Exception e) { e.printStackTrace(); result.put(success, false); result.put(msg, 删除失败 e.getMessage()); } return SUCCESS; }Service 层实现批量删除时我一般用 Hibernate 的createQuery执行更新删除语句避免一条条删除频繁触发数据库交互public void deleteByIds(String idsStr) { String hql delete from Employee where id in ( idsStr ); getSession().createQuery(hql).executeUpdate(); }这样写in条件是所有增删改查例子里最直接的方案只要调用方保证 idsStr 是可靠的数字字符串即可。但如果你有洁癖或者要应对更大的数据量可以用setParameterList传 List 参数效果一样且更安全。删除之后必须让前端重新加载表格不然界面上还留着已经删掉的数据会误导用户以为删除没生效。5. SSH EasyUI 增删改查的高频坑报错信息与解决办法5.1 列表加载报 500Hibernate 懒加载实体无法被 JSON 序列化现象页面列表区域一片空白打开浏览器开发者工具看到请求返回 500服务器日志里出现No serializer found for class ...或failed to lazily initialize a collection。原因实体类有关联对象比如员工关联部门而关联对象默认是懒加载JSON 序列化时发现拿不到数据。解决方法是给实体类的关联字段加上JSON(serializefalse)注解或者用 fastjson 序列化时关掉懒加载字段。如果你用的是 Struts2 的 json-default 包最简单的方案是在 Action 里手动构造一个只包含需要字段的 Map而不是直接序列化实体。我碰到这类问题时会先看异常栈里是哪个字段触发再决定是加注解还是改返回结构。全表字段序列化最省事但多对多关系时递归引用会导致栈溢出必须从设计上避开。5.2 EasyUI 表格中文显示正常但新增后数据库里的中文是问号现象列表展示从数据库查出来的中文没问题但新插入的中文记录变成??。原因数据库连接 URL 没加编码参数或者表字符集不是 utf8mb4。SSH 项目里最容易漏掉的是 MySQL 连接串里的characterEncoding参数。解决把 jdbc.url 改成jdbc:mysql://localhost:3306/yourdb?useUnicodetruecharacterEncodingutf8同时确认表结构用的是 utf8mb4。另外 Struts2 的struts.i18n.encoding也要设成 UTF-8这样前端提交的中文参数被 Action 接住时不会乱码。三层编码统一后中文基本不会再出问题。我习惯在排查此类问题时先看字符串到数据库之间哪一段变了用日志打印方式逐层确认而不是直接改配置碰运气。最隐蔽的情况是 Linux 服务器默认 locale 不是 UTF-8导致 Tomcat 启动时读到的默认编码不对这种时候检查JAVA_OPTS里有没有加-Dfile.encodingUTF-8加上就能解决。5.3 翻页时列表数据乱了下一页显示的还是第一页的数据现象点击分页栏的第二页、第三页表格里数据不变化或者后端查询条件失效。原因EasyUI 每次请求都会带page和rows参数但 Action 里没有对应的 setter或者 setter 拼写错误。解决检查 Action 里是否有setPage(int page)和setRows(int rows)两个方法最好在方法里加一行逻辑把参数打出来。分页参数接收是 CRUD 案例里最容易被忽略的环节因为列表第一屏看起来是正常的不翻页就发现不了。我见过一个生产环境问题是 rows 参数被设置成 10但是 Service 层写死了setMaxResults(100)导致每页显示条数不一致用户以为分页坏了。所以前端传什么、后端接什么、SQL 用什么三个地方的数值必须贯通。5.4 删除时提示“提交参数格式错误”或后端拿到的是数组地址串现象删除一条或几条数据时开发者工具里能看到请求参数正常但后端 Action 打印出来的 ids 值是个对象地址串比如[Ljava.lang.String;1a2b3c。原因表单提交时如果 input 的 name 带了下标比如ids[0]1ids[1]2Struts2 会自动组装成数组但在 JSON 插件序列化时数组对象没有正确转成字符串。解决前端把 ids 数组join(,)拼接后作为单个参数提交后端用一个 String 类型接收然后用split(,)转成数组。我通常把这种模式称为“字符串作为弱类型传输”在 SSH 这种前后端类型绑定不够智能的场景下非常实用。5.5 事务不生效saveOrUpdate 执行了但数据库没数据现象保存操作返回成功前端提示成功但数据库查不到新记录。原因Hibernate 的saveOrUpdate只会在事务提交时把数据刷到数据库如果 Service 方法没有被 Spring 的事务拦截器接管操作不会真正持久化。解决检查 applicationContext.xml 里有没有配置HibernateTransactionManager并且tx:annotation-driven是否开启Service 实现类的方法上是否有Transactional注解。另外注意 Spring 的 AOP 代理默认基于接口如果你的 Service 类没有实现接口需要加上proxy-target-classtrue。这类问题排查难度中等因为不报错、不抛异常看起来一切正常但刷新数据库就是没有数据。我自己的习惯是保存成功后立即打印实体的 id——如果 id 不为空说明 Hibernate 已经执行了 insert只是事务没提交如果 id 为空说明根本没走到持久化。5.6 日期和枚举字段编辑回显不对下拉框不选中日期显示毫秒数现象点击编辑时弹窗里的日期显示成一长串数字下拉框没有选中任何选项。原因Hibernate 查询出的 Date 对象被 JSON 序列化后变成毫秒时间戳EasyUI 的 datebox 无法自动转换下拉框值不匹配是因为实体里存的是关联对象的 id而 form load 时 JSON 里没有对应的字符串值。解决在 Action 返回 result 之前手动把实体里的 Date 字段格式化成字符串放到 Map 中下拉框则额外放一个字符串字段用于回显。EasyUI 的formatter函数也可以在列渲染时处理但编辑弹窗里必须保证表单字段对应的是格式化后的结果。这些边界问题在所有增删改查例子里几乎都会遇到提前在封装返回结果时统一处理比在页面上逐行调试省时得多。5.7 请求路径 404Struts2 配置文件改了但容器还用的是旧 class现象新写好的 Action 方法前端调用时报 404检查 struts.xml 里配置没问题重启 Tomcat 还是不行。原因老项目里 Action class 所在的包在 Struts2 的struts.xml里没有定义或者包名写错导致无法加载。解决在 struts.xml 里检查package的namespace属性和 Action 的name值以及extendsjson-default是否正确。另外把项目 clean 后重新部署有时增量发布会导致 class 没被编译进去。这种问题在维护老项目时特别常见因为改动 Action 类后 Tomcat 不会自动重载必须重启容器。我习惯把 SSH 项目配成 devMode开发时改动配置能自动刷新但上线前一定关掉避免生产环境暴露过多信息。6. 把 CRUD 收进一个 BaseAction 和 BaseService新模块开发时间压到半小时做过三五个员工的增删改查之后你会发现代码套路高度重复每个模块就是实体、Dao、Service、Action、前端表格和弹窗这六件套。既然重复就应该把通用逻辑抽出来。我实现了一个泛型 BaseAction把增删改查和分页方法全部定义好新模块只需继承并指定实体类型就能直接获得列表、保存、更新、删除能力。代码核心如下public abstract class BaseActionT extends ActionSupport { protected BaseServiceT baseService; protected MapString, Object result new HashMapString, Object(); private int page; private int rows; public String list() { PageResultT pageResult baseService.findByPage(page, rows); result.put(total, pageResult.getTotal()); result.put(rows, pageResult.getList()); return SUCCESS; } public String save() { try { baseService.save(entity); result.put(success, true); } catch (Exception e) { result.put(success, false); result.put(msg, e.getMessage()); } return SUCCESS; } public String delete() { try { baseService.deleteByIds(ids); result.put(success, true); } catch (Exception e) { result.put(success, false); result.put(msg, e.getMessage()); } return SUCCESS; } // 抽象方法让子类提供具体的实体对象 protected abstract T getEntity(); }这个 BaseAction 关键点在getEntity()抽象方法——每个子类只需要把页面提交的实体传给基类基类就能统一执行 Hibernate 的save或saveOrUpdate。判断新增和更新用的是实体的 id 是否为空这个逻辑放在 BaseService 里public void save(T entity) { // 利用 Hibernate 的 saveOrUpdate 自动判断 getSession().saveOrUpdate(entity); }实战里 BaseAction 加上 BaseService 之后新模块的开发只剩三件事写实体类、调整前端页面字段、在 struts.xml 里加一行 action 配置。前端页面虽然没法完全泛化但复制上一个模块的 JSP 改字段名基本十分钟能完成。有人说 EasyUI 的弹窗和表格写起来繁琐但用熟了之后这套自带皮肤的组件能把后台管理系统的开发效率提升到非常夸张的程度。最后提醒一点BaseAction 里的泛型 Entity 在运行时不能直接 new所以我还是保留了一个getEntity()让子类返回实际对象这个设计细节是避免“泛型黑洞”的关键。我在自己的项目里所有新增模块都走这条模板路线新表建好到页面能增删改查耗时基本控制在一小时内。虽然 SSH 和 EasyUI 都不是新技术但它们的 CRUD 模式极其稳定——DataGrid 的分页请求模型、表单弹窗的编辑回显方式、服务端的分层事务划分这些思路即便换到 Vue 加 Spring Boot 的新项目里一样适用。理解这套组合的套路你实际上就理解了一整类管理系统的数据流骨架。真要遇到更老的 JSF、更小众的 GWT 项目反而会觉得 EasyUI 的组合已经算省心了。希望这篇笔记能帮你少走几个弯路也希望你觉得“哦原来这套东西是这个套路”那我的目的就达到了。本文还有配套的精品资源点击获取