EasyExcel - 行合并策略(二级列表)

😼前言:博主在工作中又遇到了新的excel导出挑战:需要导出多条文章及其下联合作者的信息,简单的来说是一个二级列表的数据结构。
🕵️‍♂️思路:excel导出实际上是一行一行的记录,再根据条件对其进行合并。

目录

  • 最终效果图📌
  • 一、数据格式及处理📚
    • 1.文章对象(处理前)
    • 2.文章及作者对象(处理后)
    • 3.未合并的效果图
  • 二、通用行合并策略🔍
    • 1.源码学习
    • 2.通用行合并后的效果图
  • 三、二级通用行合并策略✍
    • 1.源码改造
    • 2.设置excel输出策略
    • 3.延伸
  • 四、问题☔
    • 1.问题描述
    • 2.问题原因
    • 3.解决办法
  • 参考文章📒

最终效果图📌

最终导出格式

一、数据格式及处理📚

首先,需要先将一条文章按联合作者数量,拆分为指定数量的文章及作者导出记录的集合。

以文章《牧区歌与马》为例,一篇文章有三名联合作者,生成三条导出记录。

1.文章对象(处理前)

// 文章记录对象 Acticle.class
[{"contentId": "1","contentTitle": "牧区歌与马","contentCount": 940,"releaseTime": "2025-01-09 11:21:16","readNum": 1,"auditor": "小李","orgName": "办公室","authorList": [{"userName": "小A ","orgName": "单位A"},{"userName": "小B ","orgName": "/"},{"userName": "小C","orgName": "单位C"}]
}]

2.文章及作者对象(处理后)

可以看到记录由一条变为三条,除了作者名称和单位,其余字段内容均一致。

// 文章处理后记录对象 ActicleAuthor.class
[{"contentId": "1","contentTitle": "牧区歌与马","contentCount": 940,"releaseTime": "2025-01-09 11:21:16","readNum": 1,"auditor": "小李","orgName": "办公室","author":"小A""authorUnit":"单位A"
},{"contentId": "1","contentTitle": "牧区歌与马","contentCount": 940,"releaseTime": "2025-01-09 11:21:16","readNum": 1,"auditor": "小李","orgName": "办公室","author":"小B""authorUnit":"/"
},{"contentId": "1","contentTitle": "牧区歌与马","contentCount": 940,"releaseTime": "2025-01-09 11:21:16","readNum": 1,"auditor": "小李","orgName": "办公室","author":"小C""authorUnit":"单位C"
}]

3.未合并的效果图

未设置行合并策略直接导出时,表格的格式内容如下:
未合并前的导出格式
👆图中的E、F列示例错误,应分别为6行记录

二、通用行合并策略🔍

此处学习了csdn博主xiao谢同学分享的通用行合并策略源码
链接:EasyExcel 通用行合并策略实现

1.源码学习

🐱‍👓该策略以列的行数作为主键,每次遍历记录列的最新合并区域信息。将同列且相邻行的单元格内容进行比较:
(1)一致:则仅更新endRow和endCell,继续遍历;
(2)不一致:则将已有区域进行合并,再将MergeRange所有字段进行更新。

MergeRange.class

  • startRow :合并开始行
  • endRow:合并结束行
  • startCell:合并开始单元格
  • endCell:合并结束单元格
  • lastValue:列最新单元格内容
import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.write.merge.AbstractMergeStrategy;
import org.apache.commons.collections.map.HashedMap;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;import java.util.*;public class MergeStrategy extends AbstractMergeStrategy {// 合并的列编号,从0开始,指定的index或自己按字段顺序数private Set<Integer> mergeCellIndex = new HashSet<>();// 数据集大小,用于区别结束行位置private Integer maxRow = 0;// 禁止无参声明private MergeStrategy() {}public MergeStrategy(Integer maxRow, int... mergeCellIndex) {Arrays.stream(mergeCellIndex).forEach(item -> {this.mergeCellIndex.add(item);});this.maxRow = maxRow;}// 记录上一次合并的信息private Map<Integer, MergeRange> lastRow = new HashedMap();// 每行每列都会进入,绝对不要在这写循环@Overrideprotected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {int currentCellIndex = cell.getColumnIndex();// 判断该列是否需要合并if (mergeCellIndex.contains(currentCellIndex)) {String currentCellValue = cell.getStringCellValue();int currentRowIndex = cell.getRowIndex();if (!lastRow.containsKey(currentCellIndex)) {// 记录首行起始位置lastRow.put(currentCellIndex, new MergeRange(currentCellValue, currentRowIndex, currentRowIndex, currentCellIndex, currentCellIndex));return;}//有上行这列的值了,拿来对比.MergeRange mergeRange = lastRow.get(currentCellIndex);if (!(mergeRange.lastValue != null && mergeRange.lastValue.equals(currentCellValue))) {// 结束的位置触发下合并.// 同行同列不能合并,会抛异常if (mergeRange.startRow != mergeRange.endRow || mergeRange.startCell != mergeRange.endCell) {sheet.addMergedRegionUnsafe(new CellRangeAddress(mergeRange.startRow, mergeRange.endRow, mergeRange.startCell, mergeRange.endCell));}// 更新当前列起始位置lastRow.put(currentCellIndex, new MergeRange(currentCellValue, currentRowIndex, currentRowIndex, currentCellIndex, currentCellIndex));}// 合并行 + 1mergeRange.endRow += 1;// 结束的位置触发下最后一次没完成的合并if (relativeRowIndex.equals(maxRow - 1)) {MergeRange lastMergeRange = lastRow.get(currentCellIndex);// 同行同列不能合并,会抛异常if (lastMergeRange.startRow != lastMergeRange.endRow || lastMergeRange.startCell != lastMergeRange.endCell) {sheet.addMergedRegionUnsafe(new CellRangeAddress(lastMergeRange.startRow, lastMergeRange.endRow, lastMergeRange.startCell, lastMergeRange.endCell));}}}}
}class MergeRange {public int startRow;public int endRow;public int startCell;public int endCell;public String lastValue;public MergeRange(String lastValue, int startRow, int endRow, int startCell, int endCell) {this.startRow = startRow;this.endRow = endRow;this.startCell = startCell;this.endCell = endCell;this.lastValue = lastValue;}
}

2.通用行合并后的效果图

可以发现,这种仅根据相邻行单元格内容进行合并的方式,还未能完全满足博主想要二级列表的效果。
简单行合并的导出格式
👆黄色代表非理想合并的区域

三、二级通用行合并策略✍

🐱‍💻改造思路:列A是文章标题,以列A的内容作为第一层级的标识(tip:不如contentId准确)。即使相邻行单元格内容相等,对应行的A列内容不相等也不能合并。

处理步骤:
(1)在合并区域对象类MergeRange中,增加A列内容的值字段lastValueRowa
(2)遍历单元格构造合并区域对象时,记录A列内容值以此来作为附加的合并条件。
(3)当同一列字段相邻行内容相等且A列内容值相等时,再进行合并。

1.源码改造

import cn.hutool.json.JSONUtil;
import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.write.merge.AbstractMergeStrategy;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.map.HashedMap;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;import java.util.*;/*** 行合并策略*/
@Slf4j
public class MergeStrategy extends AbstractMergeStrategy {// 合并的列编号,从0开始,指定的index或自己按字段顺序数private Set<Integer> mergeCellIndex = new HashSet<>();// 数据集大小,用于区别结束行位置private Integer maxRow = 0;// 禁止无参声明private MergeStrategy() {}public MergeStrategy(Integer maxRow, int... mergeCellIndex) {Arrays.stream(mergeCellIndex).forEach(item -> {this.mergeCellIndex.add(item);});this.maxRow = maxRow;}// 记录上一次合并的信息private Map<Integer, MergeRange> lastRow = new HashedMap();// 每行每列都会进入,绝对不要在这写循环@Overrideprotected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {// 获取单元格当前列int currentCellIndex = cell.getColumnIndex();log.info("遍历单元格:{}行,{}列 >>>>>>>>>>>>>>>>>", cell.getRowIndex(), currentCellIndex);// 判断该列是否需要合并if (mergeCellIndex.contains(currentCellIndex)) {// 获取当前单元格内容值String currentCellValue = new DataFormatter().formatCellValue(cell);// 获取当前单元格的行rowint currentRowIndex = cell.getRowIndex();// 获取当前行A列单元格内容 newString currentValueRowa = "";// 如果最后合并行的map,不包括当前列if (!lastRow.containsKey(currentCellIndex)) {log.info("lastRow添加第{}列【前】,当前lastRow={}", currentCellIndex, JSONUtil.toJsonStr(lastRow));// 获取当前行的列A内容 newif (currentCellIndex == 0) {currentValueRowa = currentCellValue;} else {currentValueRowa = getRowaValue();}// 记录首行起始位置? 记录当前列及合并范围lastRow.put(currentCellIndex, new MergeRange(currentCellValue, currentRowIndex, currentRowIndex, currentCellIndex, currentCellIndex, currentValueRowa));log.info("lastRow添加第{}列【后】,当前行列A内容={},新lastRow={}", currentCellIndex, currentValueRowa, JSONUtil.toJsonStr(lastRow));return;} else {// 该列已存在lastRow中,则取最新的列A内容值 newcurrentValueRowa = getRowaValue();}//有上行这列的值了,拿来对比.MergeRange mergeRange = lastRow.get(currentCellIndex);// 判断条件:增加A列内容判断 newlog.info("合并比对1>>>>>>>>>:第{}列最新内容lastValue = {},当前内容currentCellValue={},", currentCellIndex, mergeRange.lastValue, currentCellValue);log.info("合并比对2>>>>>>>>>第{}列最新列A内容lastValueRowa={}, 当前列A内容currentValueRowa={}", currentCellIndex, mergeRange.lastValueRowa, currentValueRowa);if (!(mergeRange.lastValue != null&& mergeRange.lastValue.equals(currentCellValue) && mergeRange.lastValueRowa.equals(currentValueRowa))) {// 结束的位置触发下合并.// 同行同列不能合并,会抛异常if (mergeRange.startRow != mergeRange.endRow || mergeRange.startCell != mergeRange.endCell) {sheet.addMergedRegionUnsafe(new CellRangeAddress(mergeRange.startRow, mergeRange.endRow, mergeRange.startCell, mergeRange.endCell));}// 更新当前列起始位置lastRow.put(currentCellIndex, new MergeRange(currentCellValue, currentRowIndex, currentRowIndex, currentCellIndex, currentCellIndex, currentValueRowa));log.info("比对不一致,确认合并!!>>>>>>>>>:第{}列最新列A内容={},最新lastRow = {}", currentCellIndex, currentValueRowa, JSONUtil.toJsonStr(lastRow));}// 合并行 + 1mergeRange.endRow += 1;// 结束的位置触发下最后一次没完成的合并if (relativeRowIndex.equals(maxRow - 1)) {MergeRange lastMergeRange = lastRow.get(currentCellIndex);// 同行同列不能合并,会抛异常if (lastMergeRange.startRow != lastMergeRange.endRow || lastMergeRange.startCell != lastMergeRange.endCell) {sheet.addMergedRegionUnsafe(new CellRangeAddress(lastMergeRange.startRow, lastMergeRange.endRow, lastMergeRange.startCell, lastMergeRange.endCell));}}}}/*** 获取列A最新一行的内容值 new*/private String getRowaValue() {// 获取当前行A列单元格内容String currentValueRowa = "";if (lastRow.get(0) != null) {currentValueRowa = lastRow.get(0).lastValue;}return currentValueRowa;}
}class MergeRange {public int startRow;public int endRow;public int startCell;public int endCell;public String lastValue;// 最后一个合并值得A列值 newpublic String lastValueRowa;public MergeRange(String lastValue, int startRow, int endRow, int startCell, int endCell, String lastValueRowa) {this.startRow = startRow;this.endRow = endRow;this.startCell = startCell;this.endCell = endCell;this.lastValue = lastValue;this.lastValueRowa = lastValueRowa;}
}

2.设置excel输出策略

  // 设置excel输出策略EasyExcel.write(fileName, ActicleAuthor.class)// 0,1 表示 对1,2列启用合并策略.registerWriteHandler(new MergeStrategy(dataList.size(),0,1)) .sheet(0).doWrite(dataList);

3.延伸

🐱‍🚀如果需要做三级、四级等列表,可以将指定多个字段的拼接值当作列A来处理。可修改getRowaValue()方法实现逻辑。

四、问题☔

在开发的过程中,不可避免地碰到了一些问题……

1.问题描述

💁‍♀️在获取列A内容值时,曾尝试从Sheet对象中获取。
因为存在Sheet.getRow(0)获取第一行的row对象是null的问题,所以用从lastRow中获取列A内容的方法进行替代。

/*** 从sheet.getRow(0)中获取列A内容值*/private String getRowaValue(Sheet sheet, int rowId) {// 获取当前行A列单元格内容String currentValueRowa = "";Row row = sheet.getRow(rowId);if (row != null) {Cell cell = row.getCell(0);if (cell != null) {currentValueRowa = cell.getStringCellValue();}}return currentValueRowa;}

2.问题原因

👩‍💻经过面向百度查询,从csdn博主吾乃南华老仙分享的文章sheet.getRow(0)获取的row为null?中得知:

new SXSSFWorkbook(new XSSFWorkbook(inputStream)) 创建Workbook的时候,
SXSSFWorkbook对象内部会维护一个HashMap(反编译后的名称为_xFromSxHash)。

而当使用workBook.getSheetAt(0)的时候,其实是从_xFromSxHash中获取新创建的Sheet对象,从而导致sheet.getRow(0)获取的row为null。

😸文中提供的解决方法:

将获取首行代码:
Workbook workBook = new SXSSFWorkbook(new XSSFWorkbook(inputStream));
Sheet sheet = workBook.getSheetAt(0);
Row row = sheet.getRow(0);修改为👇:Workbook workBook = new SXSSFWorkbook(new XSSFWorkbook(inputStream));
Sheet sheet;
if (workBook instanceof SXSSFWorkbook) {SXSSFWorkbook sxssfWorkbook = (SXSSFWorkbook) workBook;sheet = sxssfWorkbook.getXSSFWorkbook().getSheetAt(sheetIndex);
} else {sheet = workBook.getSheetAt(sheetIndex);
}
Row row = sheet.getRow(0);

😧而我们使用的导出是基于EasyExcel的,并没有单独的使用流去创建对象,应该怎么办呢?

👇可以看到debug过程中,显示sheet对象类型是SXSSFSheet。
在这里插入图片描述

3.解决办法

😾再次经过一番查询,在EasyExcel语雀文档的QA:EasyExcel 我想在导出excel文件的时候添加水印,要怎么做,请给出代码示例和解释中找到了解释:

  • inMemory(true):
    EasyExcel默认使用SXSSFWorkbook以减少内存消耗,但它不支持复杂的样式设置(如水印)。通过设置inMemory(true),我们改用XSSFWorkbook,它提供了更全面的样式支持。

在设置excel输出策略时加上inMemory(true)设置:

  // 设置excel输出策略EasyExcel.write(fileName, ActicleAuthor.class)// 必须设置,以便使用XSSFWorkbook而非SXSSFWorkbook new.inMemory(true)// 0,1 表示 对1,2列启用合并策略.registerWriteHandler(new MergeStrategy(dataList.size(),0,1)) .sheet(0).doWrite(dataList);

🙆‍♀️执行debug后,发现可以获取到sheet.getRow(0)的第一行对象了,切换类型的问题成功解决。
在这里插入图片描述

参考文章📒

EasyExcel 通用行合并策略实现-xiao谢同学
sheet.getRow(0)获取的row为null?
EasyExcel 我想在导出excel文件的时候添加水印,要怎么做,请给出代码示例和解释

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/68468.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

第9章:基于Vision Transformer(ViT)网络实现的迁移学习图像分类任务:早期秧苗图像识别

目录 1. ViT 模型 2. 早期秧苗分类 2.1 数据集 2.2 训练 2.3 训练结果 2.4 可视化网页推理 3. 下载 1. ViT 模型 视觉变换器&#xff08;ViT&#xff09;是一种神经网络架构&#xff0c;它将变换器架构的原理应用于视觉数据。最初&#xff0c;Transformers主要用于自然…

ros2-7.5 做一个自动巡检机器人

7.5.1 需求及设计 又到了小鱼老师带着做最佳实践项目了。需求&#xff1a;做一个在各个房间不断巡逻并记录图像的机器人。 到达目标点后首先通过语音播放到达目标点信息&#xff0c; 再通过摄像头拍摄一张图片保存到本地。 7.5.2 编写巡检控制节点 在chapt7_ws/src下新建功…

【React】新建React项目

目录 create-react-app基础运用React核心依赖React 核心思想&#xff1a;数据驱动React 采用 MVC体系package.jsonindex.html好书推荐 官方提供了快速构建React 项目的脚手架&#xff1a; create-react-app &#xff0c;目前使用它安装默认是19版本&#xff0c;我们这里降为18…

Linux手写FrameBuffer任意引脚驱动spi屏幕

一、硬件设备 开发板&#xff1a;香橙派 5Plus&#xff0c;cpu&#xff1a;RK3588&#xff0c;带有 40pin 外接引脚。 屏幕&#xff1a;SPI 协议 0.96 寸 OLED。 二、需求 主要是想给板子增加一个可视化的监视器&#xff0c;并且主页面可调。 平时跑个模型或者服务&#xff0c;…

网络安全构成要素

一、防火墙 组织机构内部的网络与互联网相连时&#xff0c;为了避免域内受到非法访问的威胁&#xff0c;往往会设置防火墙。 使用NAT&#xff08;NAPT&#xff09;的情况下&#xff0c;由于限定了可以从外部访问的地址&#xff0c;因此也能起到防火墙的作用。 二、IDS入侵检…

React Native的现状与未来:从发展到展望

每周跟踪AI热点新闻动向和震撼发展 想要探索生成式人工智能的前沿进展吗&#xff1f;订阅我们的简报&#xff0c;深入解析最新的技术突破、实际应用案例和未来的趋势。与全球数同行一同&#xff0c;从行业内部的深度分析和实用指南中受益。不要错过这个机会&#xff0c;成为AI领…

数据结构——链表(概念,类型,java实现、增删、优缺点)

我是一个计算机专业研0的学生卡蒙Camel&#x1f42b;&#x1f42b;&#x1f42b;&#xff08;刚保研&#xff09; 记录每天学习过程&#xff08;主要学习Java、python、人工智能&#xff09;&#xff0c;总结知识点&#xff08;内容来自&#xff1a;自我总结网上借鉴&#xff0…

在 macOS 上,用命令行连接 MySQL(/usr/local/mysql/bin/mysql -u root -p)

根据你提供的文件内容&#xff0c;MySQL 的安装路径是 /usr/local/mysql。要直接使用 mysql 命令&#xff0c;你需要找到 mysql 可执行文件的路径。 在 macOS 上&#xff0c;mysql 客户端通常位于 MySQL 安装目录的 bin 子目录中。因此&#xff0c;完整的路径应该是&#xff1…

【QT】: 初识 QWidget 控件 | QWidget 核心属性(API) | qrc 文件

&#x1f525; 目录 1. 控件概述 控件体系的发展阶段 2. QWidget 核心属性 2.1 核心属性概览2.2 用件可用&#xff08;Enabled&#xff09; 2.3 坐标系&#xff08;Geometry&#xff09; **实例 1: 控制按钮的位置**实例 2: 表白 程序 2.4 窗口标题&#xff08;windowTiltle&a…

git操作(Windows中GitHub)

使用git控制GitHub中的仓库版本&#xff0c;并在Windows桌面中创建与修改代码&#xff0c;与GitHub仓库进行同步。 创建自己的GitHub仓库 创建一个gen_code实验性仓库用来学习和验证git在Windows下的使用方法&#xff1a; gen_code仓库 注意&#xff0c;创建仓库时不要设置…

python爬虫爬取淘宝商品比价||淘宝商品详情API接口

最近在学习北京理工大学的爬虫课程&#xff0c;其中一个实例是讲如何爬取淘宝商品信息&#xff0c;现整理如下&#xff1a; 功能描述&#xff1a;获取淘宝搜索页面的信息&#xff0c;提取其中的商品名称和价格 探讨&#xff1a;淘宝的搜索接口 翻页的处理 技术路线:requests…

【Vim Masterclass 笔记13】第 7 章:Vim 核心操作之——文本对象与宏操作 + S07L28:Vim 文本对象

文章目录 Section 7&#xff1a;Text Objects and MacrosS07L28 Text Objects1 文本对象的含义2 操作文本对象的基本语法3 操作光标所在的整个单词4 删除光标所在的整个句子5 操作光标所在的整个段落6 删除光标所在的中括号内的文本7 删除光标所在的小括号内的文本8 操作尖括号…

强推未发表!3D图!Transformer-LSTM+NSGAII工艺参数优化、工程设计优化!

目录 效果一览基本介绍程序设计参考资料 效果一览 基本介绍 1.Transformer-LSTMNSGAII多目标优化算法&#xff0c;工艺参数优化、工程设计优化&#xff01;&#xff08;Matlab完整源码和数据&#xff09; Transformer-LSTM模型的架构&#xff1a;输入层&#xff1a;多个变量作…

SpringCloud系列教程:微服务的未来(十一)服务注册、服务发现、OpenFeign快速入门

本篇博客将通过实例演示如何在 Spring Cloud 中使用 Nacos 实现服务注册与发现&#xff0c;并使用 OpenFeign 进行服务间调用。你将学到如何搭建一个完整的微服务通信框架&#xff0c;帮助你快速开发可扩展、高效的分布式系统。 目录 前言 服务注册和发现 服务注册 ​编辑 …

跨境电商使用云手机用来做什么呢?

随着跨境电商的发展&#xff0c;越来越多的卖家开始尝试使用云手机来协助他们的业务&#xff0c;这是因为云手机具有许多优势。那么&#xff0c;具体来说&#xff0c;跨境电商使用云手机可以做哪些事情呢&#xff1f; &#xff08;一&#xff09;实现多账号登录和管理 跨境电商…

一体机cell服务器更换内存步骤

一体机cell服务器更换内存步骤&#xff1a; #1、确认grdidisk状态 cellcli -e list griddisk attribute name,asmmodestatus,asmdeactivationoutcome #2、offline griddisk cellcli -e alter griddisk all inactive #3、确认全部offline后进行关机操作 shutdown -h now #4、开…

“AI开放式目标检测系统:开启智能识别新时代

嘿&#xff0c;朋友们&#xff01;今天咱们来聊聊一个超酷炫的技术——AI开放式目标检测系统。这可不是什么高大上、遥不可及的玩意儿&#xff0c;它已经悄悄地走进了我们的生活&#xff0c;改变着我们对世界的认知和互动方式呢。 先来说说&#xff0c;什么是AI开放式目标检测系…

【鱼皮大佬API开放平台项目】Spring Cloud Gateway HTTPS 配置问题解决方案总结

问题背景 项目架构为前后端分离的微服务架构&#xff1a; 前端部署在 8000 端口API 网关部署在 9000 端口后端服务包括&#xff1a; api-backend (9001端口)api-interface (9002端口) 初始状态&#xff1a; 前端已配置 HTTPS&#xff08;端口 8000&#xff09;后端服务未配…

【游戏设计原理】68 - 玩家错误

一、错误类型 玩家错误类型 行为错误&#xff08;performance errors&#xff09;和运动控制错误&#xff08;motor control errors&#xff09;是玩家在游戏中常犯的错误。 运动控制错误 错误发生在玩家协调或掌握输入设备时&#xff0c;可能包括不小心按错键或未能及时把握战…

2.使用Spring BootSpring AI快速构建AI应用程序

Spring AI 是基于 Spring Boot3.x 框架构建&#xff0c;Spring Boot官方提供了非常便捷的工具Spring Initializr帮助开发者快速的搭建Spring Boot应用程序,IDEA也集成了此工具。本文使用的开发工具IDEASpring Boot 3.4Spring AI 1.0.0-SNAPSHOTMaven。 1.创建Spring Boot项目 …