“SSM + easyUI 后台管理系统”大概是很多人学习路上的必经一站。这套组合从2015年前后开始流行,一直到今天,Spring Boot + Vue 已经成为新项目的主流,但大量已经上线的企业内部系统仍然跑在 SSM 这套架构上。哪怕你现在的项目已经切换到微服务,SSM 时期积累的东西也一点没浪费:Spring 的 IoC/DI、SpringMVC 的请求分发机制、MyBatis 的 ORM 思维,这些底层逻辑换一层壳还在用。
我最近刚好完整整理了一个基于 Spring + SpringMVC + MyBatis + easyUI 的后台管理系统项目,从环境搭建到权限设计到前端集成,踩了不少坑,也沉淀了一些很实用的经验。这篇文章把这些内容全部摊开讲,适合正在做毕业设计、接手老旧系统维护、或者想通过一个完整项目理解框架协作机制的开发者。不需要你有三年经验,只要你会 Java 基础语法、懂一点 SQL,就能跟得上。
先说一个很多人都纠结过的问题:easyUI 都已经快没人提了,我还有必要学吗?我的答案是:如果目标是理解后台管理系统的完整构建过程,easyUI 反而是最省心的选择。它不像 Vue 那样需要 Node 环境、需要构建打包、需要跨域配置。引入几个 JS 和 CSS 文件,写一个 table 标签,数据表格就出来了。这个特性在当年被称为“拿过来就能用”,在现在依然适合用来快速搭建内部工具和后台界面。更重要的是,easyUI 的 DataGrid、Tree、Tabs 这些组件的交互逻辑,和现在主流前端框架里的表格、树形控件在用户操作层面几乎是一样的。也就是说,我们通过 easyUI 建立的“列表页 + 弹窗表单 + 树形菜单”的交互模型,可以直接迁移到 Vue、React 项目上。
这篇博文我会分五个部分来讲。第一章是整体设计与技术栈解析,讲清楚为什么用这套组合而不是别的,以及每层框架各自承担的职责。第二章是环境准备和项目骨架搭建,包括数据库脚本、Maven 依赖、web.xml 配置这些让人头大的前置工作。第三章是核心配置与后端实现,重点讲 Spring 整合 MyBatis 的细节、Mapper 代理机制的底层原理、以及一个通用的 CRUD 是怎么写出来的。第四章是 easyUI 前端集成和权限控制落地,这也是全篇实操性最强的一部分,我会直接给出代码片段和页面效果描述。第五章是常见问题与性能优化,把我在这个项目里实际踩过的坑全部列出来,比如 Jackson 格式化 LocalDateTime 报错、layui 和 easyUI 混用导致的样式错乱、分页插件 PageHelper 的线程隔离问题,等等。每一章都会有可直接参考的代码、配置和排查逻辑。
这套项目的技术含量其实不在“功能”上,而在“理解”上。当你能把这四个框架串联起来,让一次完整的“浏览器点击 → Controller 接收 → Service 处理 → Mapper 查库 → 数据返回 → DataGrid 渲染”流程在脑海中闭环,你对 Java Web 开发的理解会上一个台阶。这个能力,到今天仍然是值钱的。
1. 整体设计与技术栈解析
1.1 为什么是这四件套
先把技术选型的逻辑讲明白。任何一个后台管理系统,本质上做的事情就是两种:对数据的增删改查,以及对操作的人做权限控制。围绕这两件事,选择什么框架,核心考虑的是开发效率、团队熟悉度和系统维护成本。
Spring 负责解耦。它通过 IoC 容器统一管理对象的创建和依赖关系。在传统写法里,你要用 UserService,就得手动 new 一个。在 Spring 世界里,你只需要声明一个引用,容器会自动把实例化好的对象注入进来。这个机制直接决定了 Service 层和 DAO 层之间可以用接口加注解的方式松散耦合。ServiceImpl 做成什么样子,数据库怎么换,上层代码都可以不动。
SpringMVC 负责接收请求和调度。它是经典 MVC 模式的落地实现。DispatcherServlet 是整个请求链路的核心调度员,它把 URL 映射到 Controller 方法,把 Controller 返回的数据渲染成视图或者 JSON。之所以用 SpringMVC 而不是 Struts2,主要是因为它和 Spring 是无缝集成——本质上是同一个生态,不需要做适配层,而且方法级别的映射粒度更细,一个 URL 对应一个普通 Java 方法,比 Struts2 的 Action 类加配置文件的方式直观得多。
MyBatis 负责数据库访问。它是一个半自动 ORM 框架,SQL 由开发者自己写,映射关系用 XML 或注解声明。相比 Hibernate 的全自动映射,MyBatis 给了你完全的 SQL 控制权,复杂查询、多表联查、动态 SQL 都很好处理。后台管理系统里最多的就是各种报表和查询页面,SQL 的可控性是刚需,这也正是 MyBatis 在互联网公司内部系统里占有率极高的原因。
easyUI 负责页面交互。它是基于 jQuery 的一套 UI 组件库,提供表格、树、下拉框、弹窗、布局等组件。最关键的一点:不需要前端工程化。把静态资源文件放到项目的 static 目录下,页面上用 script 标签引入,就能直接出效果。对于以数据管理为主、交互复杂度不高的后台系统,这种模式比前后端分离的开发速度更快。
1.2 三层架构怎么划分
这个项目的代码分层是标准的 Java Web 三层架构,按包名划分,一目了然:
com.example.shop ├── controller # 控制层,接收请求、参数校验、返回结果 ├── service # 业务层,接口定义 ├── service.impl # 业务实现层,核心业务逻辑 ├── mapper # 数据访问层,接口定义 ├── mapper.xml # 数据访问层,SQL 绑定 ├── entity # 实体类,对应数据库表 ├── common # 公共组件,分页、工具、常量 ├── interceptor # 拦截器,登录校验、权限控制 └── exception # 全局异常处理Controller 层要薄,只做参数接收和数据返回,不写业务逻辑。Service 层是核心,所有的业务规则都放在这里。Mapper 层只做最简单的数据读写,不掺杂业务判断。这个分层不是教条,而是为了“变化隔离”:数据库变了只改 Mapper,业务规则变了只改 Service,URL 变了只改 Controller。我在实际项目中见过把业务逻辑全写在 Controller 里的代码,一个方法六七百行,后来要复用根本没法下手,只能重写。分层的价值,要在项目变大后才会真正体会得到。
1.3 权限模型的设计思路
后台管理系统的权限模型,最常用的是 RBAC,也就是角色-权限控制模型。三个核心表:用户表、角色表、中间关联表。用户被分配角色,角色绑定权限集合,一个用户可能有多个角色,一个角色也能分配给多个用户,典型的多对多关系。
再往下做一个简化。实现层面用“菜单权限”来做,也就是每个权限点对应一个菜单项或一个按钮,后台系统里的权限判断就收敛成“用户能不能看到这个菜单”和“用户能不能执行这个操作”两个维度。菜单存表,URL 也存表,通过角色关联到用户,登录时把该用户的菜单和权限加载进 Session。这种方式简单直接,对于中后台的管理系统足够用。如果是涉及大金额操作或敏感数据、安全要求更高的系统,建议引入更细粒度的数据权限,并增加操作日志审计,但基础骨架依然是 RBAC。
2. 环境准备与项目骨架搭建
2.1 开发环境与版本选择
这一步踩过的坑最多,先给出一份稳定的版本组合:
- JDK 1.8(不用更高,避免和旧版 Tomcat 及 Spring 版本冲突)
- Maven 3.6.3
- Tomcat 8.5
- MySQL 5.7
- IDEA 2020+(任何版本均可)
- easyUI 1.9.x
版本之间会打架,这是我反复试出来的一个结论。Spring 4.x 配 JDK 8 就是比配 JDK 11 顺手,MyBatis 3.4.x 配 MySQL 5.7 就是比配 MySQL 8.0 少遇到时区问题。不是说新版本不行,而是这个项目属于经典技术栈的传承,选一个各方都“磨合过”的组合,能省下很多排查环境问题的功夫。如果你确实要用 MySQL 8.0,记得在 JDBC 连接串后面加上serverTimezone=Asia/Shanghai,否则时间字段的读写会报异常。
2.2 Maven 依赖的完整配置
在pom.xml里,除了常规的 spring-webmvc、spring-jdbc、mybatis 之外,有几个依赖特别容易漏,漏了就会在运行时莫名其妙报错。我把完整依赖列出来,注释也写上:
<properties> <spring.version>5.1.20.RELEASE</spring.version> </properties> <dependencies> <!-- Spring核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis及Spring整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.6</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.6</version> </dependency> <!-- 数据库驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <!-- 连接池:Druid --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.23</version> </dependency> <!-- 分页插件 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.2.13</version> </dependency> <!-- Jackson JSON 处理 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.12.5</version> </dependency> </dependencies>pagehelper 的依赖在这里加的是 starter 形式,如果你在非 Spring Boot 环境使用,应该改为pagehelper核心包,然后手动配置HelperDialect和reasonable参数。避免依赖混乱。
2.3 数据库设计与初始化脚本
以一个订单管理模块来贯穿这个项目,那么数据库至少需要这几张核心表:
sys_user:用户表,字段包括用户ID、用户名、密码(MD5加密存储)、真实姓名、手机号、状态sys_role:角色表,角色ID、角色名称、角色编码、描述sys_user_role:用户角色关联表sys_menu:菜单表,菜单ID、父菜单ID、菜单名称、菜单URL、权限标识、图标sys_role_menu:角色菜单关联表biz_order:业务订单表,订单ID、订单编号、用户ID、商品名称、商品数量、订单金额、订单状态、创建时间
用户、角色、菜单之间的关联表设计,是这套权限系统的核心。我建议把关联表当作独立实体来对待,而不是“顺手加个字段”。加字段的写法看起来简单,但角色和菜单的关系一旦复杂起来(一个角色有多个菜单,一个菜单被多个角色持有),字段法就完全失控了。关联表可以后续加索引、加额外属性(比如创建时间)而不用动主表结构。
初始化 SQL 不需要全部贴出来,但必须要强调一点:字符集统一设置成utf8mb4,排序规则用utf8mb4_general_ci,否则存储中文和表情符号时会出现乱码。我见过一个系统上线半年后用户反馈昵称显示成问号,就是建表时偷懒用了默认字符集导致的。
2.4 web.xml 与 Spring 配置文件加载顺序
这是一个新手必踩的坑区。web.xml 的配置顺序决定了 Spring 容器和 SpringMVC 容器的初始化顺序,也就决定了 Bean 能不能被正确创建。下面是正确的配置逻辑:
<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring/applicationContext-*.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>dispatcherServlet</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring/spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcherServlet</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>这里有个关键的父子容器概念需要理解清楚。ContextLoaderListener 负责创建父容器,加载的是 Spring 配置,包括 Service、Mapper、DataSource 这些 Bean。DispatcherServlet 创建的是子容器,加载的是 spring-mvc.xml,包括 Controller、视图解析器、处理器映射这些 Web 层组件。子容器可以访问父容器的 Bean,反过来不行。
实际操作中,很多人容易把<url-pattern>配成*.do或者/api/*,然后发现静态资源全部 404。我的建议是配成/,让 DispatcherServlet 接管所有请求,然后再用<mvc:resources>来放行静态资源。这样 URL 更干净,Controller 的 RequestMapping 不用带后缀。
2.5 log4j2 日志配置
日志这事,很多人不重视,等出了问题才追悔莫及。mybatis 的 SQL 输出依赖日志框架,如果你没配日志,那么 mapper 里的 SQL 一句都看不懂,排查问题就等于盲人摸象。我自己比较推荐 log4j2,性能比亚军 logback 好,而且是真异步。
<?xml version="1.0" encoding="UTF-8"?> <Configuration status="WARN"> <Appenders> <Console name="Console" target="SYSTEM_OUT"> <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> </Console> </Appenders> <Loggers> <Logger name="com.example.shop.mapper" level="DEBUG"/> <Root level="INFO"> <AppenderRef ref="Console"/> </Root> </Loggers> </Configuration>重点在最后一行:com.example.shop.mapper这个包名要设置成 DEBUG 级别。这样 MyBatis 会在控制台直接打印出每个 Mapper 方法的执行 SQL 和参数。查数据与实际不匹配的问题,扫一眼控制台就能定位到是哪条 SQL、哪个参数的问题。
3. 核心配置与后端实现
3.1 Spring 整合 MyBatis 的两种方式
Spring 整合 MyBatis,业界有两种方式:一种是使用MapperFactoryBean手动配置,另一种是使用MapperScannerConfigurer自动扫描。手动配置只适合 Mapper 接口特别少的项目,一旦接口多起来,每加一个接口都要改一次配置文件,烦死人。于是主流方案是扫描。
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.shop.mapper"/> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> </bean>这个扫描器做的事情,是把你指定包下的所有接口,在 Spring 容器启动时动态生成代理对象,并注册为 Bean。用到的时候直接注入,不需要你手动写实现类。这就是 MyBatis 最核心的 Mapper 代理机制——你写一个接口,配一段 XML,框架帮你生成实现,这也是“半自动”的含义所在。
3.2 事务管理配置
如果项目里有下单、支付这类涉及多张表更新的操作,事务就是必须要开的。Spring 声明式事务最简单的方式就是注解驱动。
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>然后在需要事务的方法上加上@Transactional注解。比如一个订单创建方法,会执行“插入订单表 + 扣减库存 + 写操作日志”三个操作,只要其中一个失败了,全部回滚。这就是@Transactional的意义。
有一个很容易被忽视的细节:@Transactional的默认回滚条件是 RuntimeException 和 Error。如果你的代码抛的是 checked exception,比如Exception,事务不会回滚。这是不少项目里数据不一致的直接原因。踩过坑之后,我现在习惯在注解上明确写一段:
@Transactional(rollbackFor = Exception.class)这是肖复式的最佳实践,值得写进团队规范。
3.3 通用 CRUD 接口设计与实现
后端设计不能只盯着“写出来”,还要考虑“可扩展”。我这个项目的 CRUD 接口按照一个通用模型来设计,很多地方可以复用。
先定义统一返回结果:
public class Result { private int code; private String msg; private long count; private Object data; public static Result success(Object data, long count) { Result r = new Result(); r.setCode(200); r.setMsg("success"); r.setCount(count); r.setData(data); return r; } // getter/setter 略 }count字段特意提供给 easyUI 的 DataGrid 使用,它要求分页数据里带总数。像一个典型的分页查询接口,Controller 是这样写的:
@Controller @RequestMapping("/order") public class OrderController { @Resource private OrderService orderService; @RequestMapping("/page") @ResponseBody public Result page(OrderQuery query) { PageHelper.startPage(query.getPage(), query.getRows()); List<Order> orders = orderService.queryOrderPage(query); PageInfo<Order> pageInfo = new PageInfo<>(orders); return Result.success(pageInfo.getList(), pageInfo.getTotal()); } }这里用到了 PageHelper 分页插件。它的原理很巧:在你调用PageHelper.startPage()之后,执行下一条 SQL 时,插件会自动拦截,生成带limit的查询语句,并额外执行一条select count(*)来拿总数。这一套被封装得极其优雅,也让分页代码异常简洁。有一个实战中最常见的坑在后面第四章里会专门讲。
3.4 SpringMVC 请求链路与参数绑定
很多人会用 SpringMVC,但不清楚一次请求具体发生了什么。在这里展开讲一下,因为理解了链路,后面调 bug 会快很多。
浏览器发送一个请求/order/page?page=1&rows=10后,首先进入 DispatcherServlet。DispatcherServlet 通过 HandlerMapping 找到匹配的 Controller 方法。然后通过 HandlerAdapter 调用方法,方法执行完返回 Result 对象,由于方法上有@ResponseBody,会交给 Jackson 的 HttpMessageConverter,把对象序列化成 JSON 字符串返回给浏览器。
参数绑定是 SpringMVC 的看家本领。请求里的page和rows会自动绑定到 OrderQuery 对象上,不需要手动 getParameter。如果你遇到参数绑不上,大部分原因是参数名不一致,或者缺少无参构造方法。还有个常见坑:如果前端传的是时间字符串,后端实体类里是Date类型,不处理的话会报 400 错误。
解决办法是加一个全局格式化配置。在 spring-mvc.xml 中注册:
<mvc:annotation-driven conversion-service="conversionService"/> <bean id="conversionService" class="org.springframework.format.support.FormattingConversionServiceFactoryBean"> <property name="formatters"> <list> <bean class="org.springframework.format.datetime.DateFormatter"> <property name="pattern" value="yyyy-MM-dd HH:mm:ss"/> </bean> </list> </property> </bean>这个配置可以直接解决前后端时间格式不匹配的问题。这个问题在我经历过的项目里几乎每个新人都必踩。
3.5 拦截器实现登录与权限校验
登录拦截是这个系统的安全底线。用 SpringMVC 拦截器来实现,比在每个 Controller 里重复写判断代码优雅得多。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("loginUser"); if (user == null) { // 是异步请求还是页面跳转,需要区分处理 String header = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(header)) { response.setContentType("application/json;charset=utf-8"); response.getWriter().print("{\"code\":401,\"msg\":\"not login\"}"); } else { response.sendRedirect(request.getContextPath() + "/login"); } return false; } return true; } }注册拦截器时要注意放行登录接口和静态资源:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.shop.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>为什么异步请求要单独判断?因为直接跳转页面会导致前端收到 HTML 而不是 JSON,DataGrid 组件解析时报错。这是拦截器实现中很容易被忽视的细节。而真正的项目里通常不止这一层,还需要再写一个 PermissionInterceptor 来校验菜单权限,判断当前用户的角色是否有访问权限。
4. easyUI 前端集成与权限控制落地
4.1 easyUI 的环境引入
easyUI 在技术层次上走了另一个路线,但特别适合快速见效:将官方压缩包里的资源拷贝到项目的 static 目录,然后在 HTML 页面用 link 和 script 引入 css 和 JS 文件即可。
<link rel="stylesheet" type="text/css" href="${pageContext.request.contextPath}/static/easyui/themes/default/easyui.css"> <link rel="stylesheet" type="text/css" href="${pageContext.request.contextPath}/static/easyui/themes/icon.css"> <script type="text/javascript" src="${pageContext.request.contextPath}/static/easyui/jquery.min.js"></script> <script type="text/javascript" src="${pageContext.request.contextPath}/static/easyui/jquery.easyui.min.js"></script>不做构建、不做打包,整个初始化过程再简单不过。第一行用了${pageContext.request.contextPath}带上项目上下文路径,这在所有 JSP 页面里都应该保持这种写法,否则部署到带路径的服务器上就会发现资源全部 404。
4.2 DataGrid 数据表格的完整用法
DataGrid 是 easyUI 里使用频率最高的组件,对应后台系统最核心的需求——数据列表展示。我用一个订单列表的例子来说明。
<table id="orderTable"> <thead> <tr> <th field="orderNo" width="120">订单编号</th> <th field="productName" width="150">商品名称</th> <th field="orderAmount" width="100">订单金额</th> <th field="orderStatus" width="80" formatter="formatStatus">状态</th> <th field="createTime" width="150">创建时间</th> </tr> </thead> </table>对应的初始化脚本:
$('#orderTable').datagrid({ url: '/order/page', method: 'get', pagination: true, pageSize: 10, pageList: [10, 20, 50], fitColumns: true, singleSelect: true, onLoadError: function(data) { if (data && data.status === 401) { window.location.href = '/login'; } } });url 指向后端的/order/page接口,我把参数page和rows直接通过 query 传过去,这样后端只要按照分页参数的标准命名来接收,前端表格不用再单独写请求逻辑。DataGrid 有一组默认请求参数约定,可以在queryParams里覆盖,所以后端使用@RequestParam接收时,一定注意这个参数约定。
formatter 是一个使用率极高的功能,专门用来做列格式化。比如状态列的数字需要转成文字:
function formatStatus(value, row, index) { var statusMap = {0: '待支付', 1: '已支付', 2: '已发货', 3: '已完成'}; return statusMap[value] || '未知'; }这里有个经验:DataGrid 的列字段必须和后端 JSON 里的 key 一一对应,任何一边拼错,这一列就是空的。比如数据库字段叫order_no,后端如果右是orderNo而实体类里忘了加@JsonProperty,前端永远是 undefined。这个 bug 极其隐蔽,排查了大半天才发现。
4.3 表单弹窗实现新增与编辑
列表页永远搭配一个表单弹窗。easyUI 的 Dialog 组件配合 Form 组件:
function openAddDialog() { $('#orderDialog').dialog({ title: '新增订单', width: 500, height: 350, closed: false, modal: true, buttons: [{ text: '保存', iconCls: 'icon-ok', handler: function() { submitForm(); } },{ text: '取消', iconCls: 'icon-cancel', handler: function() { $('#orderDialog').dialog('close'); } }] }); } function submitForm() { $('#orderForm').form('submit', { url: '/order/save', onSubmit: function() { return $(this).form('validate'); // 校验不通过时不提交 }, success: function(result) { var obj = JSON.parse(result); if (obj.code === 200) { $.messager.alert('提示', '保存成功', 'info'); $('#orderDialog').dialog('close'); $('#orderTable').datagrid('reload'); } else { $.messager.alert('提示', obj.msg, 'error'); } } }); }表单组件有一个“伪 ajax 提交”的行为——实际上是创建了一个隐藏的 iframe,表单提交到 iframe 里。所以 success 回调里拿到的返回体是字符串,要自己 JSON.parse 一下。这个机制很多人刚用时都会踩坑,明明接口返回了 {"code":200,"msg":"success"},前端却拿不到,那是因为没转换。这个细节,是 data 里面最常见的坑之一。
编辑场景复用弹窗时,要注意打开弹窗前要先把要编辑的数据“回显”。
function openEditDialog() { var row = $('#orderTable').datagrid('getSelected'); if (!row) { $.messager.alert('提示', '请先选择一条记录', 'warning'); return; } $('#orderForm').form('load', row); $('#orderDialog').dialog({title: '编辑订单'}); }form('load')会自动按 name 匹配表单字段,把 row 里的数据填进去。前提条件:表单里的输入框 name 属性要和字段名完全一致,否则回显空白。
4.4 权限控制的管理端配置
权限控制的落地,需要一套“菜单管理”界面。用 easyUI 的 Tree 组件来展示菜单树:
$('#menuTree').tree({ url: '/menu/tree', method: 'get', checkbox: true, cascadeCheck: false });后端返回的数据格式是 easyUI 规定的嵌套结构:
[{ "id": 1, "text": "系统管理", "children": [{ "id": 11, "text": "用户管理", "url": "/user/list" }] }]注意字段必须是id、text、children,不是数据库里的menuId、menuName、childMenus。你需要做一个 DTO 转换。这里是 easyUI 这类老式组件和前端工程化框架的最大区别——它定义了一套数据协议,你必须去适配它。
选中某些节点后,点击保存:
var nodes = $('#menuTree').tree('getChecked'); var nodeIds = nodes.map(function(node) { return node.id; }); $.post('/role/saveMenus', {roleId: roleId, menuIds: nodeIds.join(',')}, function(result) { ... });后端接收到 menuIds 字符串之后,按逗号分割并插入sys_role_menu表。这里有个细节:保存角色菜单权限时,要先删掉该角色的所有旧关联,再插入新的。这是权限管理里应该执行的“先删后插”逻辑,否则角色删除某个菜单时会很混乱,极其容易出 bug。
4.5 easyUI 与主流前端框架的核心差异
写完上面这些,把 easyUI 和 Vue 的差异说清楚,就能更明白为什么现在用 easyUI 写后台还有意义。它们的核心区别在于“数据驱动 vs 直接操作 DOM”。
easyUI 是直接操作 DOM 的命令式写法:先有一个<table>标签,然后初始化成 datagrid,再调方法去 reload、去 getSelected。Vue/Element UI 是声明式写法:你只需要维护数据模型,页面会自动根据数据变化刷新 DOM。
这个差异带来的影响是:easyUI 上手快、没有构建链路、直接就能在浏览器里看效果,适合简单系统;Vue 复杂度和灵活性都高,适合大型项目。但我还是建议每个 Java 开发者都亲手写一遍 easyUI 项目,因为它能让你理解页面和数据的互动细节。理解了 jQuery 式的 DOM 操作之后,再去看 Vue 的响应式原理,会有一种“原来框架帮我省了这么多事”的感觉。
5. 常见问题与排查技巧实录
5.1 PageHelper 分页失灵
这是我遇到的最典型的问题。加了startPage之后,查询结果还是全部数据。排查之后发现原因是对 PageHelper 的执行机制理解不够。PageHelper.startPage()之后发出的第一条 SQL 才会被作为物理分页查询处理。如果你在它之前执行了其他查询,那么分页就会落在那次查询上。
更隐蔽的问题是要注意 PageHelper 的线程隔离机制。它的实现原理是使用 ThreadLocal 保存分页参数。如果代码里开了多线程,比如并行查询,子线程里是拿不到分页参数的。这在普通项目里不常遇到,但一旦遇到就很头疼。ALI 的规范也有明确提示,使用 ThreadLocal 必须清理,特别是在配合线程池、异步任务或 Tomcat 线程重用时,分页参数可能被错误地带到下一次请求。所以切忌在开了异步任务的方法里顺手调用 startPage。
5.2 实体类的 Date 字段渲染异常
典型的报错如下:
Caused by: java.lang.IllegalArgumentException: Cannot format given Object as a Date原因是:MyBatis 查出的 java.util.Date 在 Jackson 序列化时格式不对。配置一个全局的 ObjectMapper Bean:
@Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder -> builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); }或者基于 Java 8 的 LocalDateTime 字段做时间序列化。核心原则:实体类统一使用 java.util.Date 或 LocalDateTime,不要混用;全局的时间格式需要统一配置。
5.3 easyUI 和 layui 混用引发的样式错乱
有段时间我想在项目里既用 easyUI 又用 layui,结果页面上一片混乱,弹窗的位置、层级、样式全都对不上。原因是两者都重写了大量基础样式和弹层组件,冲突非常严重。
我的建议是别混用。选一个组件库就全部用它,即使它的一些组件不如另一个好看。实在要引入第三方库来做某些特有功能,比如富文本编辑器,可以用 easyUI 的 dialog 把它纳入管理。核心思想:组件层只能有一个主导框架,其余必须是孤立插件。
5.4 前端请求 404 排查清单
遇到 404,按顺序排查这几项:
- 检查 URL 是否带了项目上下文路径,比如
/order/page还是/shop/order/page - 检查 Controller 的 RequestMapping 是不是匹配上了完整路径
- 检查静态目录是否被拦截器误拦截
- 检查 web.xml 的 servlet mapping 是不是
/,把 HTML、JS、图片全部拦进去了
这里有个项目现场经验:如果静态资源请求被 DispatcherServlet 接管后仍然显示 404,最可能是 spring-mvc.xml 里没配置<mvc:default-servlet-handler/>或者<mvc:resources>。推荐用:
<mvc:resources mapping="/static/**" location="/static/"/>把静态资源统一放在 static 目录下,然后用mvc:resources放行,是最快的解决方案。
5.5 MyBatis 动态 SQL 与 N+1 查询
后台管理系统最容易出现 N+1 查询问题。比如查订单列表后,还要关联查询每个订单的商品名称、用户名称、操作日志。如果在循环里逐条调用 Mapper 方法,系统会重复查数据库很多次,接口变慢。
正确的解法是使用 MyBatis 的关联查询或结果映射。在 XML 里用collection标签一次性把嵌套查询带出来:
<resultMap id="OrderDetailMap" type="com.example.shop.entity.Order"> <id property="orderId" column="order_id"/> <result property="orderNo" column="order_no"/> <collection property="items" ofType="com.example.shop.entity.OrderItem"> <id property="itemId" column="item_id"/> <result property="productName" column="product_name"/> <result property="quantity" column="quantity"/> </collection> </resultMap> <select id="selectOrderDetail" parameterType="long" resultMap="OrderDetailMap"> SELECT o.order_id, o.order_no, oi.item_id, oi.product_name, oi.quantity FROM biz_order o LEFT JOIN biz_order_item oi ON o.order_id = oi.order_id WHERE o.order_id = #{orderId} </select>这个写法保持 SQL 可控可调优,也避免了 N+1 查询。开发后台系统时,这种“一次查出关联数据”的思路要时刻放在心上。
5.6 静态资源缓存与浏览器刷新问题
改完 JS 或 CSS 后,浏览器里怎么刷新都是旧效果。这是前端开发里最烦人的问题之一。easyUI 页面没有构建工具,没办法自动加版本号。
我的做法:给静态资源手动加版本参数。
<script src="/static/js/order.js?v=20250120"></script>每次改完文件,同步把 v 参数改一下。临时性强,但零成本,而且不会误伤。
6. 可扩展方向与维护建议
6.1 从 SSM 向 Spring Boot 迁移的路径
如果你在这个项目上练完手,下一步就是把它改造成 Spring Boot 项目。本质上业务代码几乎不需要动,Controller、Service、Mapper 接口和 XML 全都能复用。真正要处理的只有这么几个点:
- 配置文件合并为 application.yml
- web.xml 的内容替换为配置类或注解
- 用 Spring Boot 的自动装配替换一系列塞满 Bean 的 XML
依赖简化之后,pom.xml 瘦身明显。以 PageHelper 为例,引入pagehelper-spring-boot-starter后,连分页插件的 bean 配置都省了,只要在 application.yml 里写上:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: truereasonable这一项很有用,它能让传入的页码参数自动做边界修正。页码小于 1 就查第 1 页,超过总页数就查最后一页,这个对于前端不传参数时的兜底逻辑很关键。
6.2 引入隐私保护与操作审计
后台系统涉及用户数据时,光做登录拦截不够。实际操作中,我强烈建议至少加三样东西:操作日志、访问审计、密码加密。尤其是密码加密,要坚决杜绝明文存储。用 BCrypt 加盐哈希,比 MD5 加固定 salt 的方式安全得多。安全无小事。
操作日志这一块,可以用 Spring AOP 统一处理。写一个切面,拦截@Log注解标记的方法,自动记录操作人、操作时间、请求参数。这样就不用每个方法手动写日志代码了,这是这个项目可以立即落地的优化点。
@Aspect @Component public class LogAspect { @Around("@annotation(log)") public Object around(ProceedingJoinPoint joinPoint, Log log) throws Throwable { long start = System.currentTimeMillis(); Object result = joinPoint.proceed(); long elapsed = System.currentTimeMillis() - start; // 将操作人、IP、耗时、参数写入日志表 return result; } }6.3 不用刻意断言 “学会了”
最后说点掏心窝子的体会。我见过不少同学,把 Spring、SpringMVC、MyBatis、easyUI 背得滚瓜烂熟,各种注解的作用倒背如流,配置项说得头头是道,但真给他一台服务器、一个空白数据库,让他从头搭一套系统,他就卡住了。
原因在哪?顺序错了。框架是用来解决实际问题的,不是用来背的。你看这篇博文的时候,别只是在看,找个环境把这个项目完整跑起来。从数据库建表开始,到写完第一个分页查询页面,中间每一个报错都是宝贵的经验。那个“浏览器点一下,数据从 MySQL 里查出来,再渲染到表格里”的过程,只有亲手跑通一次,才算真正把这条链路刻在脑海里。
这个项目做完了,后面的路其实还很长。Spring Boot 帮你省掉了配置的繁琐,MyBatis-Plus 让 CRUD 更加简洁,Vue 让前端体验更好,但这些都是在同一个后端思想里长出的一层又一层的新内容。根扎稳了,上面长什么都顺。