简介这是一套面向Java Web学习者与初学者的完整CRUD示例工程主要演示jQuery EasyUI前端组件与SSH框架Struts2、Spring、Hibernate整合后如何一步步完成信息的添加、删除、修改与查询。资源以zip压缩包形式发布共407个文件涵盖128个jar依赖包、47个js脚本、43个css样式、87张gif动态图、45张png静态图同时包含Java源码、Struts2配置文件、Hibernate映射文件、JSP页面以及SQL初始化脚本整包约35.13MB目录结构清晰拷贝到开发环境即可运行查阅。当前页面已有258人浏览学习适合入门阶段对照实践。项目通过DataGrid表格、Dialog对话框、Form表单等EasyUI组件并结合Ajax实现页面局部刷新完整覆盖了新增、删除、修改、查询的核心流程。后端则依照SSH分层思想由Action处理请求、Service封装业务、DAO访问数据库配合Hibernate实体映射完成持久化操作。跟着示例代码走一遍既能熟悉EasyUI组件的真实用法也能理解Struts2的请求流转、Spring的依赖注入与事务管理以及Hibernate的对象关系映射细节是一份可动手、可参考、可复用的Web开发入门资料。1. jquery easyUIssh 增删改查例子初看像古董翻完才发现真能落地如果你在找“jquery easyUIssh 增删改查例子”大概率是两类人一类是还在上学的学生课程设计压着截止日期前端要交一个能看的 UI后端要跑通完整的增删改查另一类是在某个老系统里做维护的开发者系统用的是十年前的技术栈资料却越来越难搜。这个例子正好卡在这个点上前端是 jQuery EasyUI不用深入 Vue 或 React 也能快速搭出表格、弹窗、表单后端是 SSHStruts2 Spring Hibernate虽然新项目不推荐再入坑但对课程设计和传统系统维护来说这套例子能让你少走至少一周弯路。重点不只是把表查出来而是把增删改查的完整链路跑通理解 EasyUI 怎么向后端传参数SSH 又怎么把数据回给前端。2. 先摸前端底牌EasyUI 表格、弹窗与表单的协作套路2.1 datagrid 如何把数据拉到页面例子里最核心的前端组件就是 datagrid它负责列表展示、分页、选中、刷新。打开例子首页的 HTML你会看到类似这样的写法table iddg title用户列表 classeasyui-datagrid stylewidth:100%;height:420px >function formatTime(value, row, index) { if (!value) return ; var date new Date(value); return date.getFullYear() - (date.getMonth() 1) - date.getDate() date.getHours() : date.getMinutes(); }formatter接收三个参数当前列值、整行数据、行索引。我是习惯只取需要的字段重新拼字符串避免引入额外的日期库。这种写法在 EasyUI 的老版本下完全够用也不需要为了一个日期格式化去加载 moment.js文件体积也能压下来。2.2 dialog form 是怎么拼出新增/编辑界面的列表页面只是展示真正的增删改操作藏在 dialog 和 form 的组合里。例子里的新增和编辑共用一个 dialog动态切换标题栏和表单提交地址这个思路可以省掉不少重复代码。打开页面的 JS 部分你会看到这样的结构$(#dlg).dialog({ title: 新增用户, width: 400, closed: true, modal: true, buttons: [{ text: 保存, handler: function() { saveUser(); } }, { text: 取消, handler: function() { $(#dlg).dialog(close); } }] }); function openAddDlg() { $(#dlg).dialog(open).dialog(setTitle, 新增用户); $(#fm).form(clear); $(#fm).form(load, { id: }); } function openEditDlg() { var row $(#dg).datagrid(getSelected); if (!row) { $.messager.alert(提示, 请先选择要编辑的行); return; } $(#dlg).dialog(open).dialog(setTitle, 编辑用户); $(#fm).form(load, row); }这段代码有几个值得说清的逻辑。openEditDlg先调用getSelected没有选中行就弹提示避免用户什么都没选就点了编辑按钮造成空表单提交。form(load, row)会把选中行的数据自动填充到表单控件里控件的name属性要和后端实体字段名对应这部分如果对不上编辑时表单会是一片空白或者填入了错误字段。dialog 的closed:true表示默认隐藏modal:true让弹窗变成模态窗口背景操作被挡住减少误操作的可能。buttons数组里定义了弹窗底部按钮保存时调用自定义的saveUser函数取消时直接关闭弹窗。2.3 删除和刷新工具栏交互的关键细节增删改查的“删”功能写在工具栏里EasyUI 的 toolbar 可以定义多个按钮分别绑定新增、编辑、删除操作$(#dg).datagrid({ toolbar: [{ text: 新增, iconCls: icon-add, handler: openAddDlg }, { text: 编辑, iconCls: icon-edit, handler: openEditDlg }, { text: 删除, iconCls: icon-remove, handler: deleteUser }] }); function deleteUser() { var row $(#dg).datagrid(getSelected); if (!row) { $.messager.alert(提示, 请先选择要删除的行); return; } $.messager.confirm(确认, 确定要删除该记录吗, function(r) { if (r) { $.post(userAction!delete.action, { id: row.id }, function(data) { $.messager.show({ title: 提示, msg: 删除成功 }); $(#dg).datagrid(reload); }); } }); }删除操作先弹出确认框用户确认后再发请求这种交互在老系统里很常见。注意提交的参数是{ id: row.id }这里row.id来自表格选中行的数据所以表格行数据里必须有id字段而且不能隐藏掉对应列或者遗漏 field 映射。还有一个小坑删除成功后必须刷新 datagrid否则界面上还会残留已经删掉的记录用户会误以为删除没生效。这里用reload()重新请求列表接口是最直接的刷新方式。3. SSH 链路四个关键配置从 web.xml 到 Dao 层一次串通3.1 web.xml 过滤器顺序为什么决定了项目死活SSH 项目的入口配置集中在 web.xml 里Struts2 的过滤器、Spring 的监听器、字符编码过滤器全在这里。初学者最容易在这个文件上翻车因为过滤器顺序不对项目可能直接启动失败或者运行时中文乱码、Hibernate 懒加载报错。常见的 web.xml 关键片段长这样context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping filter filter-nameopenSession/filter-name filter-classorg.springframework.orm.hibernate5.support.OpenSessionInViewFilter/filter-class /filter filter-mapping filter-nameopenSession/filter-name url-pattern/*/url-pattern /filter-mapping字符编码过滤器需要放在最前面forceEncoding强制请求和响应都使用 UTF-8这样可以避免表单提交的中文变成乱码。OpenSessionInViewFilter的作用是让 Hibernate 的 Session 在整个请求生命周期内保持打开这样在 JSP 页面里访问懒加载的关联对象时才不会抛LazyInitializationException。filter-mapping没有写具体的 URL 规则直接用/*覆盖所有请求。Struts2 的主过滤器也需要配置在这个文件里通常放在最后并且映射到/*。实际部署的时候注意看 Tomcat 启动日志如果报过滤器类找不到优先检查applicationContext.xml里的包扫描路径和 Spring 版本是否与 Hibernate 版本匹配。3.2 Spring 容器与 Hibernate 的会话管理SSH 例子里Spring 负责管理数据源、SessionFactory 和事务Hibernate 负责具体的 ORM 映射。这一层配置如果不看明白后面 Dao 层的代码可能写成天书。applicationContext.xml的核心配置通常是这样bean iddataSource classorg.apache.commons.dbcp.BasicDataSource property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/demo?useUnicodetrueamp;characterEncodingUTF-8/ property nameusername valueroot/ property namepassword value123456/ /bean bean idsessionFactory classorg.springframework.orm.hibernate5.LocalSessionFactoryBean property namedataSource refdataSource/ property namepackagesToScan valuecom.demo.entity/ property namehibernateProperties props prop keyhibernate.dialectorg.hibernate.dialect.MySQLDialect/prop prop keyhibernate.show_sqltrue/prop prop keyhibernate.hbm2ddl.autoupdate/prop /props /property /bean bean idtransactionManager classorg.springframework.orm.hibernate5.HibernateTransactionManager property namesessionFactory refsessionFactory/ /bean tx:advice idtxAdvice transaction-managertransactionManager tx:attributes tx:method namesave* propagationREQUIRED/ tx:method nameupdate* propagationREQUIRED/ tx:method namedelete* propagationREQUIRED/ tx:method namefind* propagationSUPPORTS read-onlytrue/ tx:method nameget* propagationSUPPORTS read-onlytrue/ tx:method name* propagationREQUIRED/ /tx:attributes /tx:advice这组配置把三件事串起来了数据源负责连接 MySQLSessionFactory 负责初始化 Hibernate事务管理器把 Service 层方法的事务边界划清楚。packagesToScan指向实体类所在包Hibernate 启动时会自动扫描并读取映射注解不需要再逐个配置映射文件。hibernate.hbm2ddl.auto设置为update开发阶段会自动创建或更新表结构非常方便。但它有个隐患实体类字段改名字后旧字段不会自动删除表里会残留无用的列生产环境不建议开着这个参数。接入例子的时候把数据库连接改成自己的地址和账号然后启动项目Hibernate 会自动建表这一步走通说明 Spring 和 Hibernate 的整合没有问题。3.3 Struts2 Action 接收 EasyUI 参数的处理范式列表页用到的page和rows参数在 Action 里是怎么接收的SSH 项目里 Action 通常继承 ActionSupport用成员变量加 getter/setter 的方式接收请求参数。例子里会看到一个处理列表查询的 Action 方法public class UserAction extends ActionSupport { private int page; private int rows; private MapString, Object jsonMap; public String list() { PageResultUser pageResult userService.findByPage(page, rows); jsonMap new HashMapString, Object(); jsonMap.put(total, pageResult.getTotalCount()); jsonMap.put(rows, pageResult.getList()); return SUCCESS; } // 省略其他方法 public int getPage() { return page; } public void setPage(int page) { this.page page; } public int getRows() { return rows; } public void setRows(int rows) { this.rows rows; } public MapString, Object getJsonMap() { return jsonMap; } }这里的核心是 Struts2 的值栈机制。EasyUI 请求地址是userAction!list.actionuserAction是 Spring 管理的 Bean IDlist是要执行的 Action 方法。前端传来的page和rows会被自动注入到同名的成员变量里不需要写任何接收代码。处理完数据后把结果放进jsonMap再通过 struts.xml 里的 JSON 结果类型返回给前端。这里使用的是 Map 而不是直接返回 JSON 字符串方便 Struts2 的json-default包自动序列化。3.4 响应 JSON 的格式约定如果 Action 返回的 JSON 格式跟 EasyUI 期望的不一致表格会一直转圈或直接空白。EasyUI 对分页列表有严格的数据格式要求后端必须返回{ total: 100, rows: [ {id: 1, username: zhangsan, email: zhangsantest.com, createTime: 2024-05-10}, {id: 2, username: lisi, email: lisitest.com, createTime: 2024-05-11} ] }total是总记录数分页条需要用它计算总页数rows是当前页的记录数组数组里的每个对象对应一行字段名和前端表格field严格对应。有些新手会把rows写成list或者在rows里塞了分页对象而不是记录数组结果就是页面报错或者表格显示不出来。配置 struts.xml 让 Action 返回 JSON 时也要把除 JSON 数据外的属性排除掉。比如 Action 里如果还有page、rows这些属性默认会一并序列化到 JSON 里会污染返回结构。常见做法是通过json插件的includeProperties或excludeProperties指定序列化范围我在例子里一般只保留需要的字段避免把 Hibernate 的懒加载代理对象也塞进去。4. 避坑与排查跑不起来时先看这五个老位置4.1 中文乱码最常见也最冤现象EasyUI 表格里中文显示正常但新增或编辑提交后后端查出来的数据在数据库里变成了问号或乱码。 原因字符编码过滤器没配置或位置不对导致请求参数进入 Action 时已经是错误编码。或者数据库连接 URL 没有加useUnicodetruecharacterEncodingUTF-8。 解决先把 web.xml 里的CharacterEncodingFilter放到所有过滤器最前面并且设置forceEncodingtrue。再检查数据源 URL确认连接参数里带了jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingUTF-8这两处都改完后重启服务重新提交测试。还有个容易忽略的点数据库表本身的字符集如果是 latin1即使 Java 和请求都正常存储还是乱码这时需要把表的字符集改成 utf8mb4。4.2 Hibernate 懒加载 No sessionFilter 顺序惹的祸现象页面渲染实体关联对象时控制台抛出LazyInitializationException: could not initialize proxy - no Session。 原因Hibernate 的懒加载机制默认在 Session 关闭后才加载关联数据而 SSH 例子中如果你在 JSP 或 JSON 序列化时访问了关联对象但 Session 已经关闭。 解决确认OpenSessionInViewFilter已经配置并且映射是/*同时要注意它的位置。我在处理类似问题时会把它放在 Struts2 过滤器之前这样整个 Action 执行阶段和 JSP 渲染阶段Session 都保持打开。如果项目里用了事务代理还要确认配置的是同一个 SessionFactory 实例否则过滤器的 Session 和 Dao 操作时用的不是同一个照样报错。4.3 JSON 循环引用实体双向关联时的经典报错现象实体类里有了多对一或一对多关联序列化 JSON 时控制台报StackOverflowError或者前端拿到一串爆炸的数据。 原因比如User对象里有ListOrder的orders属性Order里又有User的user属性。序列化User时递归序列化orders每个Order又序列化user无限循环。 解决在不需要序列化的 getter 上加JsonIgnore注解告诉 JSON 插件这个字段跳过。比如在User的ordersgetter 上加注解或者反过来在Order的usergetter 上加。实际项目里我会把关联关系拆开列表查询时不加载完整关联对象只返回需要的 id 或名称字段既避免循环也减少网络传输量。4.4 前端明明传了 page/rows后端却收不到现象点击分页条后数据不翻页总是一页或者后端 Action 拿到的 page 始终是 0。 原因Struts2 的 Action 类没有提供page、rows的同名属性及 setter 方法值栈没法注入请求参数。 解决确认 Action 里有private int page; private int rows; public void setPage(int page) { this.page page; } public void setRows(int rows) { this.rows rows; }少了 setter 方法参数就不会进入 Action。我用 SSH 项目时有个习惯写完 Action 后先直接打开浏览器访问一次带?page2rows20的地址断点看一下值有没有进来。分页不生效的问题十有八九出在这个地方而不是 EasyUI 配置有问题。4.5 datagrid reload 后选择行不清空现象删除一行数据成功表格也刷新了但再次点击删除时提示框里没有了可选行或者还是选中的旧行。 原因reload()之后选中状态没有清空datagrid 的内部缓存还保留着上一次选中的行。 解决删除请求成功后在reload之前先取消选中$(#dg).datagrid(clearSelections);这个小细节看起来不起眼实际用起来非常影响体验。我在老项目维护中遇到类似交互问题时一般会把所有会改变数据状态的流程统一收口成提交成功后先clearSelections再reload避免出现界面和数据不一致的情况。5. 把例子改成自己的业务表替换思路与最小改动路径5.1 从现有表生成实体类与映射文件例子用的是 User 表但你自己的业务可能是订单、商品、班级、学生等等。拿到例子后最快的方式不是重写框架而是照着 User 的三层结构替换实体类。第一步当然是新建实体类并标注好 Hibernate 注解。Entity Table(name student) public class Student { Id GeneratedValue(strategy GenerationType.IDENTITY) private Integer id; Column(name name, length 50) private String name; Column(name student_no, length 20) private String studentNo; Column(name major, length 100) private String major; Column(name enroll_time) private Date enrollTime; // getter 和 setter 省略 }实体类里几个关键注解要注意Table(name student)指定表名Id标记主键GeneratedValue(strategy GenerationType.IDENTITY)表示数据库自增主键这个在 MySQL 下没问题。Column的length需要配合hbm2ddl.autoupdate使用让 Hibernate 自动建表时生成合适的字段类型。这里有一个容易忽略的点Java 属性名studentNo默认映射成数据库字段名是student_noHibernate 的命名策略会自动做驼峰转下划线。如果你的表字段是自己手工建的命名规则不一致就要在Column里显式声明name属性避免字段找不着。5.2 DAO、Service、Action 的最小改动套路实体替换后DAO 层并不需要大改。SSH 例子的 DAO 一般继承 HibernateDaoSupport里面有现成的getHibernateTemplate()方法关键只是查条件变了。以一个带条件的列表查询为例Repository public class StudentDao extends HibernateDaoSupport { public PageResultStudent findByCondition(Student condition, int page, int rows) { DetachedCriteria criteria DetachedCriteria.forClass(Student.class); if (condition.getName() ! null !.equals(condition.getName())) { criteria.add(Restrictions.like(name, % condition.getName() %)); } if (condition.getMajor() ! null !.equals(condition.getMajor())) { criteria.add(Restrictions.eq(major, condition.getMajor())); } int totalCount ((Long) getHibernateTemplate() .findByCriteria(criteria).size()).intValue(); ListStudent list getHibernateTemplate().findByCriteria(criteria, (page - 1) * rows, rows); return new PageResultStudent(list, totalCount); } }DetachedCriteria是 Hibernate 的离线查询对象不依赖当前 Session 也能先拼条件等到调用findByCriteria时才实际执行。Restrictions.like做模糊查询时百分号要手动拼进参数里Restrictions.eq做精确匹配。分页查询用findByCriteria带(page - 1) * rows和rows注意 page 为 1 时偏移是 0不要写错公式。Service 层和 Action 层的改动更机械替换成自己的 Service 接口和实现类调用就行。如果想要更省事把 DAO 方法统一成findByCondition(Object condition, int page, int rows)后面所有的业务表都可以共用这个方法签名的思路。5.3 页面列定义与查询条件的同步更新后端实体换成 Student 后前端页面也一样要换。表格的field要和 Student 的属性名一致查询表单的name也要对应上input classeasyui-textbox namename stylewidth:150px>function searchStudent() { $(#dg).datagrid(load, { name: $(#s_name).val(), major: $(#s_major).val() }); }datagrid(load, params)会带着额外参数重新请求列表接口这比手动拼 URL 安全得多。需要注意后端 Action 里要提供name、major属性及 setter否则查询条件传不进去搜索功能等于摆设。5.4 多表关联时例子的边界在哪例子的增删改查是基于单表的如果你的业务涉及多表关联比如学生和班级是多对一关系这个例子不会直接给你答案但思路可以平移。实体类里加一个关联对象ManyToOne(fetch FetchType.LAZY) JoinColumn(name class_id) private Clazz clazz;这里FetchType.LAZY表示懒加载查询班级列表时不会带上班级对象需要在特定的查询场景里用 Hibernate 的 fetch 策略或直接查联表视图否则序列化 JSON 时又会出现前面说的懒加载异常。我一般会建议在表单列表里只展示班级名称字段而不是整个关联对象避免打开新坑。6. 让这套代码再顺手一点写一个通用查询封装例子自带的查询功能通常是针对单一实体写的每个 Action 里都拼一次 criteria。改动越多冗余越明显。这里分享一个我常用的通用查询封装思路用一个 Map 接住前端传来的查询条件在 DAO 层统一做条件拼接。protected DetachedCriteria buildCriteria(Class? clazz, MapString, Object params) { DetachedCriteria criteria DetachedCriteria.forClass(clazz); if (params null || params.isEmpty()) { return criteria; } if (params.get(name) ! null !.equals(params.get(name))) { criteria.add(Restrictions.like(name, % params.get(name) %)); } if (params.get(status) ! null !.equals(params.get(status))) { criteria.add(Restrictions.eq(status, params.get(status))); } if (params.get(beginTime) ! null) { criteria.add(Restrictions.ge(createTime, params.get(beginTime))); } if (params.get(endTime) ! null) { criteria.add(Restrictions.le(createTime, params.get(endTime))); } return criteria; }用的时候Action 里先把前端传来的查询参数收进一个 Map然后调用findByCondition(clazz, map, page, rows)返回结果结构和列表接口一样。这个封装只覆盖了模糊查询、等值查询、时间范围查询这三种最常见的场景覆盖了大部分业务列表的搜索需求。有一点要提醒Map 的 key 要跟页面上约定的查询字段名一致否则条件根本不会拼接进去。我在项目里养成的习惯是页面上输入的 name、status、beginTime、endTime后端 Map 的 key 也写同样的名字一眼对得上。多张表直接复用这套逻辑不需要为每个实体单独写一个 DAO 查询方法。从那以后每接一个新表我都是先复制一个实体类再复制一套页面和 Action最后只改字段名和列定义强制走一遍这个流程基本不会再犯漏改查询条件的低级错误。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?