简介:这是一套基于Spring Boot开发的超市信息管理系统完整源码资源,面向Java初学者与Web开发入门者,解决中小型商超日常经营中商品采购、销售管理、库存盘点及财务分析等核心业务场景的信息化需求。资源包大小为7.92MB,为RAR压缩格式,包含可直接运行的后端工程、配套数据库脚本及基础配置文件,支持远程调试与本地部署,结构清晰、注释规范,便于理解MVC分层设计与RESTful接口实践。已有918人学习下载,适合用于课程设计、毕业设计或Spring Boot实战训练。读者可获得完整可运行系统(含登录、商品录入、库存管理、销售模块等核心功能)、MySQL建表语句与初始化数据、前后端交互逻辑说明,以及论文中对应的技术实现思路梳理,有助于快速掌握企业级Java Web项目开发全流程。
1. 一个能当天跑通、次日就敢往里填真实商品数据的超市后台:SpringBoot + MyBatis + Thymeleaf 三件套落地实录
上周帮某高校实验室搭实训环境,导师甩来一个「超市信息管理系统」压缩包,说“学生交作业用,别整太复杂”。我点开一看:没 README.md,没 application.yml 示例,连数据库建表语句都藏在 resources/sql/ 下面一个叫 init.sql 的文件里——但双击 jar 包居然真弹出了登录页。输入 admin/123456,货架、员工、进货单全在眼前。更关键的是,它没用任何云服务或外部依赖:H2 内存库开箱即用,MySQL 切换只要改两行配置;Thymeleaf 模板全在 templates/ 下,改个商品列表页不用重启;连远程调试参数都写死在 pom.xml 里——-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005。这不是教学 Demo,是能直接塞进小超市老板手机相册里、让他指着屏幕说“这个价格栏我要加个红色警示”的生产级骨架。适合 Java 入门者练手 SpringBoot 全流程,也适合老手拿它当基座,三天内接入扫码枪或微信支付。
2. 项目结构与技术栈解剖:从 pom.xml 到 templates 目录的每一层意图
2.1 为什么选 SpringBoot 而不是 SpringMVC 原生?——pom.xml 里的 5 个关键依赖真相
打开pom.xml,最核心的不是<spring-boot-starter-web>,而是这 5 行:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>提示:
spring-boot-starter-jdbc是关键伏笔——它让 MyBatis 不需要 XML 配置就能自动绑定 DataSource;而 H2 和 MySQL 同时存在,意味着项目默认走 H2(内存库,启动快),但只要把application.yml里spring.datasource.url换成 MySQL 地址,再删掉h2依赖,整个系统立刻切到真实数据库,零 SQL 重写。
常见误区是以为mybatis-spring-boot-starter会自动扫描 Mapper 接口——其实它只负责注入 SqlSessionFactory,真正的扫描靠@MapperScan("com.example.supermarket.mapper")注解,这个注解在SupermarketApplication.java主类上写着。如果你删了它,启动时会报Could not find mapper interface,但错误日志里根本不会提“扫描路径”,只会说BeanCreationException——这是新手翻车第一现场。
2.2 目录结构即设计语言:templates/ 与 static/ 的分工铁律
项目前端部分严格遵循 SpringBoot 默认约定:
src/main/resources/ ├── application.yml # 主配置,含数据库、服务器端口、Thymeleaf 开关 ├── sql/ │ └── init.sql # H2 初始化脚本:建库、建表、插 3 条测试数据 └── static/ ├── css/ │ └── style.css # 全局样式,仅覆盖基础排版(无 Bootstrap) └── js/ └── main.js # 商品搜索的 debounce 实现,防连续请求 src/main/resources/templates/ ├── index.html # 首页:商品总览卡片 + 最近 5 条进货记录 ├── goods/ │ ├── list.html # 商品列表页:分页 + 搜索框 + “新增”按钮 │ └── edit.html # 商品编辑页:form 表单,method="post",action="/goods/save" ├── employees/ │ └── list.html # 员工列表:带“启用/禁用”开关(AJAX PATCH) └── layout.html # Thymeleaf 布局模板:定义 header/footer,用 th:fragment="content" 插入子页注意static/和templates/的本质区别:
static/css/style.css是纯静态资源,浏览器直取,不经过 Spring MVC 拦截器,所以不能用@Value("${app.version}")注入变量;templates/goods/list.html是 Thymeleaf 模板,渲染时可调用@serviceBean、读取@Controller传来的 Model 数据,比如${goods}就是 Controller 里model.addAttribute("goods", goodsList)放进去的 List。
layout.html的写法值得抄作业:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title th:text="${title} ?: '超市管理系统'">默认标题</title> </head> <body> <header th:fragment="header"> <h1>超市信息管理系统 v1.0</h1> <nav><a href="/">首页</a> | <a href="/goods/list">商品管理</a></nav> </header> <main th:fragment="content"> <!-- 子页面通过 th:replace="~{::content}" 注入此处 --> </main> <footer th:fragment="footer"> <p>© 2023 超市后台 · 仅供学习交流</p> </footer> </body> </html>子页goods/list.html只需写:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout}"> <head> <title>商品列表</title> </head> <body> <div layout:fragment="content"> <h2>商品管理</h2> <table> <tr th:each="g : ${goods}"> <td th:text="${g.name}">商品名</td> <td th:text="${g.price} + '元'">价格</td> <td><a th:href="@{/goods/edit(id=${g.id})}">编辑</a></td> </tr> </table> </div> </body> </html>这种结构让 UI 修改和业务逻辑彻底解耦:改样式动static/css/,改页面结构动templates/,改数据逻辑动controller/mapper/service——三者互不影响。
2.3 数据库设计:init.sql 里藏着的 4 张表与 2 个设计权衡
resources/sql/init.sql文件虽小,但暴露了作者对小型超市业务的理解深度。它建了 4 张表:
-- 商品表:price 用 DECIMAL(10,2) 而非 FLOAT,避免 0.1+0.2=0.30000000000000004 CREATE TABLE goods ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, category VARCHAR(50), created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 员工表:status 字段用 TINYINT(1) 代替 ENUM,兼容 MySQL 5.7 且方便 JDBC 映射 CREATE TABLE employees ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, phone VARCHAR(20), status TINYINT(1) DEFAULT 1 -- 1=启用, 0=禁用 ); -- 进货单主表:关联员工,记录谁在什么时候进了多少货 CREATE TABLE purchase_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, employee_id BIGINT NOT NULL, order_date DATE NOT NULL, total_amount DECIMAL(12,2) DEFAULT 0.00 ); -- 进货单明细表:一对多,解决“一单进多种商品”问题 CREATE TABLE purchase_order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, quantity INT NOT NULL, unit_price DECIMAL(10,2) NOT NULL );两个关键设计权衡点:
- 没做库存扣减事务:
purchase_order_item插入后,并不自动更新goods.stock。这是故意为之——作者在PurchaseOrderService.java里留了 TODO 注释:“TODO: 库存同步需加事务控制,当前为简化演示”。这意味着你如果直接改数据库,库存数可能和实际不符,但代码层面没锁表,适合教学场景快速验证流程,不适合高并发入库。 - 员工状态用 TINYINT 而非布尔:Java 里映射为
Integer,避免某些 MySQL 驱动把TINYINT(1)当 Boolean 导致true/false误判(尤其在旧版驱动中)。这是血泪经验:某次升级 MySQL 驱动后,所有禁用员工突然变启用,查了 3 小时才发现是类型映射 bug。
3. 启动与调试全流程:从双击 jar 到远程断点的 7 步实操
3.1 本地一键启动:H2 内存库模式的 3 个隐藏开关
项目默认使用 H2 内存数据库,无需安装 MySQL 即可运行。启动只需一步:
java -jar supermarket-system-1.0.jar但必须确保application.yml中这 3 项配置处于激活状态:
spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true # 关键!开启 H2 控制台 path: /h2-console # 访问 http://localhost:8080/h2-console 即可查库 thymeleaf: cache: false # 关键!关闭模板缓存,改 HTML 不用重启注意:
DB_CLOSE_DELAY=-1表示 JVM 进程不退出时 H2 不关闭连接;DB_CLOSE_ON_EXIT=FALSE防止应用关闭时清空内存库——否则每次重启商品数据全丢。这两个参数是 H2 内存库能“持久化”到本次运行周期的关键。
访问http://localhost:8080/h2-console后,填入:
- JDBC URL:
jdbc:h2:mem:testdb - User Name:
sa - Password: 空
点 Connect,就能看到GOODS,EMPLOYEES等表,甚至执行UPDATE GOODS SET PRICE = 9.9 SET NAME = '矿泉水' WHERE ID = 1实时改价——这是比改 Java 代码更快的调试方式。
3.2 切换 MySQL 真实库:5 行配置 + 1 个 SQL 文件的最小改动
要换成 MySQL,只需 5 步:
- 在 MySQL 中新建数据库:
CREATE DATABASE supermarket DEFAULT CHARSET utf8mb4; - 修改
application.yml:
spring: datasource: url: jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_mysql_password # 替换为你的真实密码 driver-class-name: com.mysql.cj.jdbc.Driver # 删除或注释掉 h2.console 配置 # thymeleaf.cache: true # 生产环境建议开启删除
pom.xml中 H2 依赖(保留 MySQL 依赖):<!-- <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> -->把
resources/sql/init.sql中的 H2 语法转为 MySQL:AUTO_INCREMENT保持不变(MySQL 原生支持)TIMESTAMP DEFAULT CURRENT_TIMESTAMP改为TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(MySQL 5.7+ 兼容)- 执行该 SQL 到 MySQL 数据库中
重启应用:
java -jar supermarket-system-1.0.jar,此时所有数据将持久化到 MySQL。
提示:
serverTimezone=Asia/Shanghai是必加参数,否则 MySQL 8.0+ 会报The server time zone value 'XXX' is unrecognized错误——这是国内开发者踩坑率最高的 JDBC 连接参数。
3.3 远程调试实战:IDEA 里 3 分钟配好 5005 端口断点
项目pom.xml已预埋远程调试参数,但需手动触发:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <jvmArguments> -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 </jvmArguments> </configuration> </plugin>在 IDEA 中配置 Remote JVM Debug:
- 点击右上角
Add Configuration→+→Remote JVM Debug - 填写:
- Host:
localhost - Port:
5005 - Module classpath: 选择本项目模块
- Host:
- 启动 jar 时加
-agentlib参数(或直接用 Maven 命令):mvn spring-boot:run -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" - 在
GoodsController.java的list()方法第一行打断点 - 点击 Debug 按钮,访问
http://localhost:8080/goods/list,IDEA 自动停在断点
此时你能看到Model model对象里goodsList 的真实 size,甚至右键goods.get(0)→Evaluate Expression查看商品详情——这才是调试的正确姿势,比 System.out.println() 快 10 倍。
4. 核心模块代码逐行解析:从商品列表到库存扣减的完整链路
4.1 GoodsController:RESTful 风格路由与 Model 传递的 4 个关键点
com.example.supermarket.controller.GoodsController.java是流量入口,其设计体现 SpringBoot 最佳实践:
@Controller @RequestMapping("/goods") public class GoodsController { @Autowired private GoodsService goodsService; // 1. GET /goods/list → 返回商品列表页(Thymeleaf 渲染) @GetMapping("/list") public String list(Model model) { List<Goods> goodsList = goodsService.findAll(); // 调用 service 层 model.addAttribute("goods", goodsList); // 绑定到模板变量 return "goods/list"; // 返回模板名(不带 .html) } // 2. GET /goods/edit?id=1 → 返回编辑页,查单条商品 @GetMapping("/edit") public String edit(@RequestParam Long id, Model model) { Goods goods = goodsService.findById(id); model.addAttribute("goods", goods); return "goods/edit"; } // 3. POST /goods/save → 处理表单提交,@Valid 启用校验 @PostMapping("/save") public String save(@Valid @ModelAttribute Goods goods, BindingResult result, RedirectAttributes redirectAttributes) { if (result.hasErrors()) { return "goods/edit"; // 校验失败返回编辑页,错误信息自动注入 } goodsService.save(goods); redirectAttributes.addFlashAttribute("message", "保存成功"); return "redirect:/goods/list"; // 重定向防重复提交 } // 4. POST /goods/delete?id=1 → 删除商品,返回 JSON(前后端分离预备) @PostMapping("/delete") @ResponseBody public Map<String, Object> delete(@RequestParam Long id) { goodsService.delete(id); Map<String, Object> response = new HashMap<>(); response.put("success", true); return response; } }关键点解析:
@ModelAttribute的妙用:save()方法参数Goods goods自动绑定表单字段(name, price, stock),无需@RequestParam逐个写——前提是表单 input 的name属性与 Goods 类字段名一致(如<input name="name">→goods.setName())。BindingResult必须紧跟@Valid参数之后:Spring 规定,校验结果对象必须紧挨被校验对象,否则result.hasErrors()永远为 false。这是新手写校验时 90% 的失败原因。RedirectAttributes.addFlashAttribute():让message在重定向后仍能被下个请求读取(Thymeleaf 中用${message}取出),这是实现“操作成功提示”的标准方案。@ResponseBody与@Controller共存:同一 Controller 既服务页面(Thymeleaf),又提供 API(JSON),为后续接入 Vue/React 预留接口——不需要新建@RestController。
4.2 GoodsMapper 与 MyBatis XML:动态 SQL 与 resultMap 的精准控制
MyBatis 配置分散在两处:接口GoodsMapper.java和 XMLmapper/GoodsMapper.xml。这种分离让 SQL 与 Java 逻辑解耦:
GoodsMapper.java:
@Mapper public interface GoodsMapper { List<Goods> findAll(); Goods findById(@Param("id") Long id); void insert(Goods goods); void update(Goods goods); void delete(@Param("id") Long id); }对应mapper/GoodsMapper.xml:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.supermarket.mapper.GoodsMapper"> <!-- 1. resultMap 定义字段映射,解决数据库下划线转驼峰 --> <resultMap id="GoodsResultMap" type="com.example.supermarket.entity.Goods"> <id property="id" column="id"/> <result property="name" column="name"/> <result property="price" column="price"/> <result property="stock" column="stock"/> <result property="category" column="category"/> <result property="createdTime" column="created_time"/> <!-- 数据库字段 created_time → Java 字段 createdTime --> </resultMap> <!-- 2. findAll 使用 resultMap,避免手写 AS 别名 --> <select id="findAll" resultMap="GoodsResultMap"> SELECT * FROM goods ORDER BY created_time DESC </select> <!-- 3. 动态 SQL:update 语句只更新非 null 字段 --> <update id="update" parameterType="com.example.supermarket.entity.Goods"> UPDATE goods <set> <if test="name != null and name != ''">name = #{name},</if> <if test="price != null">price = #{price},</if> <if test="stock != null">stock = #{stock},</if> <if test="category != null and category != ''">category = #{category},</if> </set> WHERE id = #{id} </update> </mapper>提示:
<set>标签会自动删除末尾逗号,<if>标签实现“有值才更新”,避免把空字符串写进数据库。这是 MyBatis 动态 SQL 的核心价值——比手拼 SQL 字符串安全 10 倍。
4.3 GoodsService:事务边界与库存扣减的伪原子性实现
GoodsService.java是业务逻辑中枢,其@Transactional注解定义了事务范围:
@Service @Transactional public class GoodsService { @Autowired private GoodsMapper goodsMapper; @Autowired private PurchaseOrderItemMapper orderItemMapper; public List<Goods> findAll() { return goodsMapper.findAll(); } public Goods findById(Long id) { return goodsMapper.findById(id); } public void save(Goods goods) { if (goods.getId() == null) { goodsMapper.insert(goods); } else { goodsMapper.update(goods); } } // 伪原子性扣减:先查库存,再判断,再更新 public boolean reduceStock(Long goodsId, Integer quantity) { Goods goods = goodsMapper.findById(goodsId); if (goods == null || goods.getStock() < quantity) { return false; // 库存不足 } goods.setStock(goods.getStock() - quantity); goodsMapper.update(goods); return true; } }这里有个重要事实:reduceStock()方法没有加@Transactional(它继承了类级别的注解),但它的“查-判-改”三步并非真正原子——在高并发下,两个线程同时查到stock=10,都判断10>=5成立,然后都执行stock=5,最终库存变成5而非0。这是典型的“超卖”漏洞。
修复方案(不改架构的前提下):
// 在 GoodsMapper.xml 中加一条乐观锁更新 <update id="reduceStockWithLock" parameterType="map"> UPDATE goods SET stock = stock - #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity} </update>然后在 Service 中调用:
public boolean reduceStock(Long goodsId, Integer quantity) { int updated = goodsMapper.reduceStockWithLock(goodsId, quantity); return updated > 0; // updated=1 表示扣减成功,0 表示库存不足 }这种基于 WHERE 条件的更新,由数据库保证原子性,是中小型系统最经济的库存控制方案。
5. 避坑指南:上线前必须验证的 4 类典型故障
5.1 现象:启动时报Failed to configure a DataSource
原因:application.yml中spring.datasource配置缺失或格式错误,例如:
- 忘了缩进(YAML 对空格敏感):
url:前多了空格 driver-class-name写成driverClassName(Spring Boot 2.4+ 强制要求带连字符)- H2 的 JDBC URL 写成
jdbc:h2:mem:testdb但没加DB_CLOSE_DELAY=-1,导致连接被提前关闭
解决:
- 用 YAML 校验网站(如 https://yamlchecker.com/)粘贴配置检查语法
- 确保
spring.datasource.url以jdbc:开头,且驱动类名与依赖版本匹配(H2 用org.h2.Driver,MySQL 8.0+ 用com.mysql.cj.jdbc.Driver) - 在
application.yml顶部加spring.profiles.active: dev,并在application-dev.yml中单独写数据库配置,避免主配置混乱
5.2 现象:Thymeleaf 页面显示${goods.name}而非真实值
原因:Thymeleaf 模板未被正确解析,常见于:
templates/目录不在src/main/resources/下(应为src/main/resources/templates/)application.yml中spring.thymeleaf.enabled设为false(默认 true,但有人会手动关)- Controller 返回
"goods/list"时,实际文件是list.html但放在templates/goods/外层
解决:
- 检查项目结构是否符合 SpringBoot 默认约定(IDEA 中
src/main/resources必须标为 Resources Root) - 在
application.yml中显式开启:spring: thymeleaf: enabled: true cache: false - 在 Controller 中临时加日志:
System.out.println("goods size: " + goodsList.size());确认数据已查出,排除 service 层问题
5.3 现象:MySQL 中文乱码,商品名显示???
原因:MySQL 服务端、数据库、表、连接四层编码不统一。即使建库时指定了utf8mb4,JDBC 连接参数也可能覆盖它。
解决:
- 登录 MySQL 执行:
确保SHOW VARIABLES LIKE 'character_set%'; SHOW VARIABLES LIKE 'collation%';character_set_server和collation_server为utf8mb4 - 修改
application.yml的 JDBC URL,强制指定编码:spring: datasource: url: jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai - 对已有表执行:
ALTER TABLE goods CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
5.4 现象:远程调试连不上 5005 端口,提示Connection refused
原因:
pom.xml中spring-boot-maven-plugin的jvmArguments未生效(Maven 版本过低或插件配置错位)- 防火墙拦截了 5005 端口(Windows Defender 或公司网络策略)
- IDEA 的 Remote JVM Debug 配置中 Host 写成了
127.0.0.1,但 jar 是用address=*:5005启动的(*表示监听所有网卡,127.0.0.1只监听本地回环)
解决:
- 改用命令行启动并确认参数生效:
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar supermarket-system-1.0.jar - Windows 下临时关闭防火墙,或添加入站规则放行 TCP 5005
- IDEA 中 Remote JVM Debug 的 Host 改为
localhost(与address=*:5005兼容)
6. 二次开发实战:给商品列表页加 Excel 导出功能的 3 种实现路径
6.1 路径一:Apache POI —— 零依赖、纯 Java、适合小数据量
这是最轻量的方案,不引入新依赖,直接用 JDK 写.xls(Excel 97-2003 格式):
在
GoodsController.java中新增导出方法:@GetMapping("/export") public void export(HttpServletResponse response) throws IOException { List<Goods> goodsList = goodsService.findAll(); // 创建工作簿 HSSFWorkbook workbook = new HSSFWorkbook(); HSSFSheet sheet = workbook.createSheet("商品列表"); // 表头 HSSFRow headerRow = sheet.createRow(0); String[] headers = {"ID", "名称", "价格", "库存", "分类", "创建时间"}; for (int i = 0; i < headers.length; i++) { headerRow.createCell(i).setCellValue(headers[i]); } // 数据行 int rowNum = 1; for (Goods g : goodsList) { HSSFRow row = sheet.createRow(rowNum++); row.createCell(0).setCellValue(g.getId()); row.createCell(1).setCellValue(g.getName()); row.createCell(2).setCellValue(g.getPrice().doubleValue()); row.createCell(3).setCellValue(g.getStock()); row.createCell(4).setCellValue(g.getCategory() != null ? g.getCategory() : ""); row.createCell(5).setCellValue(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(g.getCreatedTime())); } // 设置响应头 response.setContentType("application/vnd.ms-excel"); response.setHeader("Content-Disposition", "attachment; filename=goods_export.xls"); workbook.write(response.getOutputStream()); }在
goods/list.html中加导出按钮:<a href="/goods/export" class="btn btn-primary">导出 Excel</a>
注意:POI 3.17 对应
HSSFWorkbook(.xls),若要支持.xlsx(Excel 2007+),需升级 POI 到 4.x 并用XSSFWorkbook,但会增大 jar 包体积。对于超市系统,.xls完全够用,且兼容性更好。
6.2 路径二:EasyExcel —— 自动映射、支持大文件、一行代码搞定
如果数据量超过 1 万行,POI 内存占用会飙升。EasyExcel 是阿里巴巴开源的流式 Excel 处理库,内存友好:
加依赖(替换
pom.xml中的 POI):<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> </dependency>新建 Excel 实体类
GoodsExportData.java:@Data public class GoodsExportData { @ExcelProperty("商品ID") private Long id; @ExcelProperty("商品名称") private String name; @ExcelProperty("价格(元)") private BigDecimal price; @ExcelProperty("库存数量") private Integer stock; @ExcelProperty("商品分类") private String category; @ExcelProperty("创建时间") @DateTimeFormat("yyyy-MM-dd HH:mm:ss") private Date createdTime; }Controller 中改写导出方法:
@GetMapping("/export-easy") public void exportEasy(HttpServletResponse response) throws IOException { List<Goods> goodsList = goodsService.findAll(); List<GoodsExportData> exportList = goodsList.stream() .map(g -> { GoodsExportData data = new GoodsExportData(); data.setId(g.getId()); data.setName(g.getName()); data.setPrice(g.getPrice()); data.setStock(g.getStock()); data.setCategory(g.getCategory()); data.setCreatedTime(g.getCreatedTime()); return data; }) .collect(Collectors.toList()); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("商品导出", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), GoodsExportData.class) .sheet("商品列表") .doWrite(exportList); }
6.3 路径三:前端 SheetJS —— 浏览器内生成,不走后端,零服务器压力
如果只是导出当前页数据(非全部),用前端 JS 更高效:
在
templates/layout.html的<head>中引入 SheetJS:<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>在
goods/list.html底部加 JS:<script> function exportToExcel() { // 构造二维数组:第一行是表头,后面是数据 const data = [['ID', '名称', '价格', '库存', '分类', '创建时间']]; document.querySelectorAll('#goods-table tbody tr').forEach(row => { const rowData = []; row.querySelectorAll('td:not(:last-child), th:not(:last-child)').forEach(cell => { rowData.push(cell.textContent.trim()); }); data.push(rowData); }); // 创建工作簿并导出 const ws = XLSX.utils.aoa_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "商品列表"); XLSX.writeFile(wb, "goods_current_page.xlsx"); } </script> <button onclick="exportToExcel()" class="btn btn-success">导出当前页</button>
三种路径对比:
方案 适用场景 优点 缺点 Apache POI 数据量 < 5000 行,无额外依赖需求 纯 Java,学习成本低 内存占用高,不支持大文件 EasyExcel 数据量 > 1 万行,需导出全量 内存友好,自动映射,社区活跃 需引入新依赖 SheetJS 只导出当前页,服务器零压力 前端完成,响应极快 无法导出分页后的全部数据
从那以后我每次加导出功能,都强制走一遍这三步验证:先用 POI 快速验证流程,再用 EasyExcel 压测大数据,最后用 SheetJS 给用户加个“当前页快捷导出”按钮——三个方案不是替代关系,而是互补组合。希望帮到你。
本文还有配套的精品资源,点击获取