☰
SSH与EasyUI增删改查实战:从后台到前端完整实现与踩坑指南
2026/10/12 3:02:58 网站建设 项目流程

简介:这是一份整合jQuery EasyUI与SSH(Struts2、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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

表结构里我特意把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 name="struts.objectFactory" value="spring" /> <constant name="struts.devMode" value="false" /> <constant name="struts.i18n.encoding" value="UTF-8" /> <package name="default" namespace="/" extends="json-default"> <action name="employee_*" class="employeeAction" method="{1}"> <result type="json"> <param name="root">result</param> </result> </action> </package> </struts>

注意我这里的写法是employee_*通配符,把 Action 的多个方法映射到同一个类上。class="employeeAction"这个值不是类名而是 Spring 容器里的 bean id,所以在 applicationContext.xml 里要配置对应的 bean:

<bean id="employeeAction" class="com.demo.action.EmployeeAction" scope="prototype"> <property name="employeeService" ref="employeeService"/> </bean> <bean id="employeeService" class="com.demo.service.impl.EmployeeServiceImpl"> <property name="employeeDao" ref="employeeDao"/> </bean>

scope="prototype"是我特别想强调的。因为 Action 是有状态的,每次请求都可能携带不同的参数,如果用单例模式,并发请求下参数会互相污染。这个坑非常隐蔽,症状是“有时查出来的数据是别人的查询条件”,排查方式就是看 bean 的 scope 配置。

3. 前端第一个联调:EasyUI DataGrid 怎么把后台数据渲染成表格

3.1 引入 EasyUI 资源与页面最小骨架

EasyUI 的使用方式很简单,在页面里引入 css 和 js 文件就能工作。我习惯把所有资源放到一个公共 JSP 里,避免每个页面重复写。页面骨架代码如下:

<link rel="stylesheet" type="text/css" href="${pageContext.request.contextPath}/js/easyui/themes/default/easyui.css"> <link rel="stylesheet" type="text/css" href="${pageContext.request.contextPath}/js/easyui/themes/icon.css"> <script type="text/javascript" src="${pageContext.request.contextPath}/js/jquery.min.js"></script> <script type="text/javascript" src="${pageContext.request.contextPath}/js/easyui/jquery.easyui.min.js"></script>

引入顺序上,jQuery 必须在 EasyUI 之前,这个顺序错了页面直接白屏。表格部分我用 HTML 方式定义列,比纯 JS 定义列更容易维护,尤其当列很多的时候:

<table id="dg" class="easyui-datagrid" title="员工列表" style="width:100%;height:400px" >public class EmployeeAction extends ActionSupport { private EmployeeService employeeService; private int page; // EasyUI 自动传的参数 private int rows; // EasyUI 自动传的参数 private Map<String, Object> result = new HashMap<String, Object>(); public String list() { PageResult<Employee> pageResult = employeeService.findByPage(page, rows); result.put("total", pageResult.getTotal()); result.put("rows", pageResult.getList()); return SUCCESS; } // getter/setter 省略 }

这里有两个关键点。第一,page和rows的属性名是约定死的,如果 Action 里没有这两个属性的 setter,EasyUI 传过来的分页参数就会被 Struts2 直接丢弃,你永远拿不到页码。第二,result这个 Map 的 key 可以有多个,struts.xml 里已经用root=result指定了序列化这个 Map,所以前端拿到的 JSON 就是{"total":100,"rows":[...]}的结构。如果你忘了配 json-default 包或者没有把 result type 设成 json,前端拿到的会是 JSP 页面源码而不是 JSON 数据。

3.3 分页参数 page 和 rows:从请求参数到 SQL 的完整链路

前端 EasyUI 会发出类似employee_list.action?page=2&rows=10的请求,后端最终要把这两个参数翻译成 SQL 的 LIMIT 语句。Service 和 Dao 的写法如下:

// Service 实现类 public PageResult<Employee> findByPage(int page, int rows) { return employeeDao.findByPage(page, rows); } // Dao 实现类 public PageResult<Employee> 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); List<Employee> list = criteria.list(); return new PageResult<Employee>(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,里面有“新增”“编辑”“删除”三个按钮,点击后弹出一个 Dialog,Dialog 里放一个 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 id="fm" method="post"> <!-- 隐藏 id,编辑时由 form load 填充,新增时为空 --> <input type="hidden" name="employee.id" /> 姓名:<input class="easyui-textbox" name="employee.name" style="width:200px" /> 部门:<input class="easyui-textbox" name="employee.dept" style="width:200px" /> 薪资:<input class="easyui-numberbox" name="employee.salary" precision="2" style="width:200px" /> 入职日期:<input class="easyui-datebox" name="employee.hireDate" style="width:200px" /> </form>
function 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'); } } }); }

这里我用的是name="employee.id"这种写法,对应 Struts2 的模型驱动属性嵌套赋值。Action 里要有一个Employee employee属性并提供 getter/setter,Struts2 会把表单里所有employee.xxx前缀的参数自动封装到实体里。这样做的好处是表单字段不需要单独声明参数,而是直接变成一个实体对象传给 Service。

注意日期字段,EasyUI datebox 提交上来的格式默认是yyyy-MM-dd,比如2024-03-15,Hibernate 能自动转换成 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 列表加载报 500:Hibernate 懒加载实体无法被 JSON 序列化

现象:页面列表区域一片空白,打开浏览器开发者工具看到请求返回 500,服务器日志里出现No serializer found for class ...或failed to lazily initialize a collection。原因:实体类有关联对象,比如员工关联部门,而关联对象默认是懒加载,JSON 序列化时发现拿不到数据。解决方法是给实体类的关联字段加上@JSON(serialize=false)注解,或者用 fastjson 序列化时关掉懒加载字段。如果你用的是 Struts2 的 json-default 包,最简单的方案是在 Action 里手动构造一个只包含需要字段的 Map,而不是直接序列化实体。我碰到这类问题时会先看异常栈里是哪个字段触发,再决定是加注解还是改返回结构。全表字段序列化最省事,但多对多关系时递归引用会导致栈溢出,必须从设计上避开。

5.2 EasyUI 表格中文显示正常,但新增后数据库里的中文是问号

现象:列表展示从数据库查出来的中文没问题,但新插入的中文记录变成??。原因:数据库连接 URL 没加编码参数,或者表字符集不是 utf8mb4。SSH 项目里最容易漏掉的是 MySQL 连接串里的characterEncoding参数。解决:把 jdbc.url 改成jdbc:mysql://localhost:3306/yourdb?useUnicode=true&characterEncoding=utf8,同时确认表结构用的是 utf8mb4。另外 Struts2 的struts.i18n.encoding也要设成 UTF-8,这样前端提交的中文参数被 Action 接住时不会乱码。三层编码统一后中文基本不会再出问题。我习惯在排查此类问题时先看字符串到数据库之间哪一段变了,用日志打印方式逐层确认,而不是直接改配置碰运气。最隐蔽的情况是 Linux 服务器默认 locale 不是 UTF-8,导致 Tomcat 启动时读到的默认编码不对,这种时候检查JAVA_OPTS里有没有加-Dfile.encoding=UTF-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]=1&ids[1]=2,Struts2 会自动组装成数组,但在 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-class="true"。这类问题排查难度中等,因为不报错、不抛异常,看起来一切正常,但刷新数据库就是没有数据。我自己的习惯是保存成功后立即打印实体的 id——如果 id 不为空说明 Hibernate 已经执行了 insert,只是事务没提交;如果 id 为空说明根本没走到持久化。

5.6 日期和枚举字段编辑回显不对:下拉框不选中,日期显示毫秒数

现象:点击编辑时弹窗里的日期显示成一长串数字,下拉框没有选中任何选项。原因:Hibernate 查询出的 Date 对象被 JSON 序列化后变成毫秒时间戳,EasyUI 的 datebox 无法自动转换;下拉框值不匹配是因为实体里存的是关联对象的 id,而 form load 时 JSON 里没有对应的字符串值。解决:在 Action 返回 result 之前,手动把实体里的 Date 字段格式化成字符串放到 Map 中,下拉框则额外放一个字符串字段用于回显。EasyUI 的formatter函数也可以在列渲染时处理,但编辑弹窗里必须保证表单字段对应的是格式化后的结果。这些边界问题在所有增删改查例子里几乎都会遇到,提前在封装返回结果时统一处理,比在页面上逐行调试省时得多。

5.7 请求路径 404:Struts2 配置文件改了但容器还用的是旧 class

现象:新写好的 Action 方法,前端调用时报 404,检查 struts.xml 里配置没问题,重启 Tomcat 还是不行。原因:老项目里 Action class 所在的包在 Struts2 的struts.xml里没有定义,或者包名写错导致无法加载。解决:在 struts.xml 里检查<package>的namespace属性和 Action 的name值,以及extends="json-default"是否正确。另外把项目 clean 后重新部署,有时增量发布会导致 class 没被编译进去。这种问题在维护老项目时特别常见,因为改动 Action 类后 Tomcat 不会自动重载,必须重启容器。我习惯把 SSH 项目配成 devMode,开发时改动配置能自动刷新,但上线前一定关掉,避免生产环境暴露过多信息。

6. 把 CRUD 收进一个 BaseAction 和 BaseService:新模块开发时间压到半小时

做过三五个员工的增删改查之后,你会发现代码套路高度重复:每个模块就是实体、Dao、Service、Action、前端表格和弹窗这六件套。既然重复,就应该把通用逻辑抽出来。我实现了一个泛型 BaseAction,把增删改查和分页方法全部定义好,新模块只需继承并指定实体类型,就能直接获得列表、保存、更新、删除能力。代码核心如下:

public abstract class BaseAction<T> extends ActionSupport { protected BaseService<T> baseService; protected Map<String, Object> result = new HashMap<String, Object>(); private int page; private int rows; public String list() { PageResult<T> 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 的组合已经算省心了。希望这篇笔记能帮你少走几个弯路,也希望你觉得“哦原来这套东西是这个套路”,那我的目的就达到了。

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

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

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

立即咨询