后台系统开发里有个场景我遇到不止一次:业务方发来一张Excel,里面是产品信息,每个产品对应一行基础字段,后面还跟着一张产品图片。系统要做批量导入,不只是把单元格里的文字读出来,还要把这些嵌在Excel里的图片提取出来,自动传到对象存储,再把访问地址写回数据库。
这个需求拆开看就是"基于SpringBoot读取Excel图片并自动上传"。听起来只有几个字,真正做起来会涉及文件格式解析、图片坐标定位、上传时序、失败回收一堆问题。这篇文章打算从工程落地的角度,把完整思路、POI关键API用法和我做过的导入功能里踩过的坑都过一遍,适合正在做后台管理系统、需要处理带图片Excel批量导入的开发者参考。
1. 这类需求多半出现在哪,技术链路怎么搭
1.1 典型业务场景
只要后台管理系统存在"从Excel批量录入带图数据"的动作,就绕不开这个需求。最常见的是商品资料导入:运营整理了一张商品清单,每个商品一行,包含名称、编号、分类,最后一列是产品实物图。这些图片不是外链URL,而是直接贴进Excel里的照片,跟着文件一起传上来。
员工信息导入也会遇到类似情况。人事部门维护员工花名册,Excel里每个员工一行,照片就嵌在工号旁边。系统要做入职数据初始化,需要把照片从Excel里剥离出来,单独上传到文件服务,再在员工表里记录一个头像地址。
还有型号图、样品图、设备铭牌照片等场景。共同特点都是:图片数量大、命名混乱、单靠人力整理效率极低,必须让程序自动完成"提取、上传、回填"三个动作。手动方案通常是先把图片一张张导出成单独文件,再按行号人工命名,最后走一遍上传流程,一百行数据就能耗掉半天。自动化的价值就在这,导入时间能从小时级降到分钟级。
1.2 整体技术链路与角色分工
这个需求在SpringBoot工程里的完整链路我一般这样拆:
- 前端把Excel文件上传到后端接口;
- 后端用Apache POI解析工作簿;
- 遍历每个Sheet,读取单元格业务数据的同时,通过Drawing容器拿到底层图片和锚点坐标;
- 根据锚点坐标把图片归属到对应的Excel行;
- 每张图片转成字节流,上传到对象存储服务;
- 上传成功后拿到访问URL,和该行业务数据一起组装成实体对象;
- 最后统一落库,返回导入结果统计。
这里有两个核心角色。一个是POI,负责解析Excel结构;另一个是对象存储客户端,负责把图片字节安全地传上去。我用的是MinIO开源对象存储,不依赖具体云厂商,接口语义和主流对象存储产品基本一致,后面如果要换也只需要替换客户端实现。
1.3 关于EasyExcel,得先泼一盆冷水
很多人第一反应是"用EasyExcel读不就行了"。EasyExcel做数据行的读取确实舒服,性能好、API简单,但它主要定位是单元格值的解析和写入。对图片这类嵌入资源,EasyExcel并没有提供优雅的读取入口,基本还是要回到底层POI的Drawing和PictureData体系里操作。
所以我的做法是:如果Sheet里只有纯数据,用EasyExcel确实省事;一旦牵扯到图片定位,直接用POI解析整个工作簿反而更干净。两者混用会出现行号口径不一致的问题,EasyExcel回调里的行索引和POI的物理行号换算起来容易出错,后面我会专门说这个坑。
2. 想读对图片,先搞懂Excel图片到底存哪了
2.1 xls和xlsx在图片存储上的本质差异
读Excel图片之前,必须分清楚文件格式。xls是老的OLE复合文档格式,xlsx是Office Open XML格式,本质是一个zip压缩包。这两种格式的图片存放方式完全不同。
xlsx结构里,图片二进制通常放在压缩包内部的xl/media/目录下,图片和Sheet的关系通过xl/drawings/目录下的drawing XML来描述,Sheet的XML里再引用对应的drawing编号。只要理解了这一层,你就能明白为什么POI读图片不只是一个接口,而是必须经过"Drawing容器"这个概念。
xls格式则更直接一点,整个文件是一个复合文档流,图片数据存放在工作簿内部的Picture流里,Sheet层级维护了一个Patriarch对象来管理图片。POI里这个对象的类名是HSSFPatriarch。
对应用户来说,这些差异不用记太细,但要记住:POI对xls和xlsx分别提供了HSSF和XSSF两套模型,好在公共接口层已经统一了大部分操作。
2.2 Drawing容器和Picture的关系
POI里读取图片的标准路径是:
- 先通过
sheet.getDrawingPatriarch()拿到这个Sheet的绘图容器; - 遍历容器内的所有Shape;
- 判断Shape是否是
Picture类型; - 从Picture对象里取出
ClientAnchor锚点和PictureData图片数据。
这里有一个容易忽略的事实:Workbook.getAllPictures()虽然能一次性拿到所有图片的字节,但它返回的是纯图片数据,不带任何坐标信息。也就是说,你能拿到五张图,却不知道哪张属于哪一行。真正要做行号绑定,必须走Drawing容器拿锚点。锚点就是Excel里图片左上角和右下角所覆盖的单元格坐标,ClientAnchor.getRow1()返回的是左上角所在行的物理行号,行号从0开始。
2.3 行号定位的唯一依据是锚点,不是视觉位置
图片在Excel里看起来"停在第3行附近",和它在数据模型里真的锚定在第3行,是两个概念。有的图片范围很大,跨越了好几行,你看到的视觉效果是"差不多在中间某行",但POI拿到的锚点可能是第1行到第5行。这时候如果简单用getRow1()去匹配业务行,就很容易错位。
我的经验是,业务上要和Excel模板制定方约定好:图片必须浮在对应的数据行单元格区域内,且代码里优先取row1作为图片归属行,同时校验row2 - row1的跨度。如果跨度大于事先约定的合理范围,就把这条记录标记为异常,人工复查。这个约束比在代码里做各种模糊匹配要可靠得多。
3. 核心实现:POI读取图片、按行分组并传到对象存储
3.1 Maven依赖和SpringBoot基础配置
我用的依赖版本组合是POI 5.x加MinIO 8.x,Java环境JDK8以上都能跑。POI的poi和poi-ooxml两个核心包都要引:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.10</version> </dependency>配置文件里我习惯把对象存储信息单独抽出来,方便不同环境切换:
minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: asset-bucket然后在工程里写一个配置类,把MinIO客户端注册成Spring Bean:
@Configuration public class MinioConfig { @Bean public MinioClient minioClient(MinioProperties props) { return MinioClient.builder() .endpoint(props.getEndpoint()) .credentials(props.getAccessKey(), props.getSecretKey()) .build(); } }MinioProperties就是一个绑定minio.*前缀配置项的普通类,字段名和配置文件一一对应。
3.2 从Sheet提取"行号到图片列表"的通用方法
下面这段是我在导入服务里一直保留的工具方法,核心逻辑就是遍历Drawing容器,按锚点行号分组。它同时兼容xls和xlsx,因为走的是POI公共接口:
public Map<Integer, List<PictureData>> extractImagesByRow(Sheet sheet) { Map<Integer, List<PictureData>> result = new HashMap<>(); Drawing<?> drawing = sheet.getDrawingPatriarch(); if (drawing == null) { return result; } for (Shape shape : drawing) { if (shape instanceof Picture) { Picture picture = (Picture) shape; ClientAnchor anchor = picture.getClientAnchor(); int rowIndex = anchor.getRow1(); result.computeIfAbsent(rowIndex, k -> new ArrayList<>()) .add(picture.getPictureData()); } } return result; }这段代码有几点值得注意。
getDrawingPatriarch()可能返回null,一个空Sheet没有图片时为null,必须先判空。Drawing<?>是公共接口,xls的HSSFPatriarch和xlsx的XSSFDrawing都实现了它,所以不需要再写两套逻辑。
我用PictureData来保存图片字节,后续要用的时候调getData()。不建议在提取阶段就大量调用getData(),因为每个图片字节都常驻内存,后面全部提取完再统一上传,内存峰值会非常吓人。后面有章节专门说内存控制。
3.3 把图片字节上传到对象存储并回填访问地址
有了行号到图片列表的映射,接下来是上传环节。上传服务的核心方法我设计成接收图片字节、对象名和Content-Type,返回可访问URL:
public String upload(byte[] data, String objectName, String contentType) throws Exception { boolean exists = minioClient.bucketExists( BucketExistsArgs.builder().bucket(bucket).build()); if (!exists) { minioClient.makeBucket( MakeBucketArgs.builder().bucket(bucket).build()); } minioClient.putObject( PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(new ByteArrayInputStream(data), data.length, -1) .contentType(contentType) .build()); return String.format("%s/%s/%s", endpoint, bucket, objectName); }对象名不能直接用Excel里的原始文件名。业务方给的图片可能叫"微信图片_20231012123456.jpg",重名率极高,而且可能有中文名、特殊字符,直接当对象名用很容易出问题。我统一用"业务目录/日期/UUID.扩展名"的规则生成:
private String buildObjectName(PictureData pictureData) { String ext = pictureData.suggestFileExtension(); String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); String fileName = UUID.randomUUID().toString().replace("-", ""); return String.format("import/%s/%s.%s", datePath, fileName, ext); }UUID文件名确实没有业务含义,但对象存储本来就是当资源仓库用的,可读性应该由数据库字段承担,而不是文件系统的文件夹。UUID方案最大的好处就是不会重名、不会踩特殊字符的坑、还能天然支持高并发上传,这个取舍我觉得是值得的。
3.4 Controller入口和完整调用链路
Controller入口很简单,接收MultipartFile,调导入Service:
@PostMapping("/import") public Result importWithImages(@RequestParam("file") MultipartFile file) { if (file == null || file.isEmpty()) { return Result.error("文件不能为空"); } String filename = file.getOriginalFilename(); if (!filename.endsWith(".xls") && !filename.endsWith(".xlsx")) { return Result.error("仅支持xls/xlsx格式"); } ImportResult result = importService.importWithImages(file); return Result.success(result); }Service里完整的处理流程大概是:
- 把MultipartFile转成临时File文件;
- 用
WorkbookFactory.create(file)创建Workbook; - 遍历每个Sheet,先解析业务数据行,再提取图片映射;
- 把每个Sheet的行数据模型和图片列表合并,生成带图片URL的完整对象;
- 执行批量落库;
- finally块清理临时文件和Workbook资源。
这里有个细节:WorkbookFactory.create()传入File比传入InputStream对内存压力小一些,因为POI对xlsx可以直接基于文件流读取zip结构,而不是先整体读进内存的byte数组。
4. 图片内容处理:格式识别、命名规则与内存控制
4.1 识别真实图片格式,别只看扩展名
Excel里嵌入的图片来源五花八门,有的是从网页直接复制粘贴,有的是截图软件产物,还有的是从相机导入。虽然Excel内部通常能正确记录图片格式,但POI提供给我们的PictureData.suggestFileExtension()在不同格式下返回值不完全一致,可能返回jpg也可能返回jpeg,甚至还有wmf、emf这类Windows老式矢量格式。
我在代码里做了一个扩展名到Content-Type的映射,统一转成标准MIME类型:
private String resolveContentType(String ext) { if ("jpg".equals(ext) || "jpeg".equals(ext)) { return "image/jpeg"; } if ("png".equals(ext)) { return "image/png"; } if ("gif".equals(ext)) { return "image/gif"; } if ("bmp".equals(ext)) { return "image/bmp"; } return "application/octet-stream"; }wmf和emf这类格式在Web系统里基本没法直接展示。如果导入模板明确要用产品图,我建议在上传前就把这类图片过滤掉,直接报"不支持的图片格式"。否则图片传上去了,前端露出一个大问号图标,业务方体验会非常差。
4.2 上传前的图片合法性校验
只靠POI给的信息还不够。有的Excel文件里面的图片资源被破坏,字节数只有几KB甚至为0,这种图片直接上传没有意义。我一般会在上传前加两层校验。
第一层看文件头magic number。JPEG以FF D8 FF开头,PNG以89 50 4E 47开头,GIF以47 49 46 38开头。读前几个字节就能判断真实格式,不需要等上传完成后再去处理错误。
第二层看尺寸和质量。图片宽高为0或者太大,比如超过5MB的单张产品图,我会在导入结果里标记出来,而不是默默把它传上去。否则一次导五百张图,对象存储空间很快就被无意义的文件塞满。这些校验逻辑写在POI解析完成之后、MinIO上传之前,拦截成本最低。
4.3 大批量图片时的内存控制方法
POI读Excel最让人头疼的就是内存。一张Excel里嵌了五百张图,每张2MB,全量解出来就是1GB起步,再加上工作簿本身的对象结构,堆内存很容易被打爆。
我在实际项目中摸索出几个务实的方向。
第一,限制单次导入文件体积。上传接口里做配置限制,比如文件不能超过20MB,这样从入口控制风险暴露面。业务方如果确实有超大文件,可以拆分成多个文件分批导入。
第二,解析和上传尽量串行化,不要让所有图片字节同时驻留内存。把"提取图片映射"和"上传图片"分成两个阶段后,在第二阶段逐行处理、上传完一张就释放一张的引用。如果只用POI默认方式,PictureData.getData()会把图片字节一次性载入,这个方法调用得越晚,内存里的累积就越少。
第三,如果文件确实巨大,比如超过50MB且有上千张图,走POI的常规模型就很吃力了。这时候我建议跳过POI的XSSFWorkbook模型,直接解析xlsx的zip结构,流式读取xl/media/下的图片文件。这样不需要构建完整工作簿对象,内存占用可以控制在单人可接受范围。这个方法复杂度高一些,正常项目到第二步就够了。
5. 我会在导入功能里踩过的四个真实坑
5.1 别指望EasyExcel顺手把图片也读了
EasyExcel确实让数据解析变得非常舒服,但我在一开始就差点被它误导。EasyExcel对图片的支持几乎没有,官方接口主要面向单元格数据读取。有人在社区里通过反射拿底层POI对象来实现图片读取,那个方案脆得很,POI版本一升级就容易失效。
现在遇到这种需求,我直接用POI解析整个文件。业务数据行也通过POI读取,方式很简单,遍历每行的Cell对象,再按单元格类型取值。这样做的最大好处是:数据行和图片锚点用的是同一套物理行号体系,不需要在两个框架之间做行号换算。这个统一性在后面解决行的错位问题时帮了大忙。
5.2 锚点行号偏移:表头占位和数据起始行的关系
图片行号错位是实际项目里最容易出问题的点。Excel的物理行号从0开始,如果Sheet第0行是表头,那么第一条数据在物理行1,第N条数据在物理行N+1。
ClientAnchor.getRow1()返回的也是物理行号,它记录的是图片左上角锚定的具体行,不受代码里"跳过表头"的影响。所以业务数据解析时,你必须记录每一行真实对应的物理行号,而不是用一个自增序号从0计数。只有按物理行号去匹配图片映射,表头偏移才不会出错。
我见过有人用数据列表的ArrayList下标去匹配图片映射,表头一占位,所有图片整体往前错了整整一行,而且后续数据行越多,错位越严重。排查这条问题的关键,就是打印出每一行数据的物理行号和图片锚点行号做对照。
5.3 浮动图片和嵌入单元格图片的读取差异
传统Excel插图方式是浮动图片,图片浮在单元格上方,POI通过Drawing容器就能读到。但新版Excel出了一个"插入图片到单元格"的功能,这种图片在底层是以单元格内嵌资源的形式存在的,路径完全不同。
POI从5.2.3版本开始提供了XSSFCell.getInlinePictureData()方法来读取这类内嵌图片。如果你的业务方用的是Excel 365,并且图片是"放在单元格内"而不是"浮在单元格上方",就要注意区分。我处理这类需求的方式是:在代码里同时检查Drawing容器和单元格内嵌图片两个来源,取并集后再按行合并。如果只检查其中一个,就会莫名其妙丢图。
5.4 图片上传一半失败后的资源回收
上传过程如果网络抖动、对象存储服务暂时不可用,已经传上去的图片就成了没人引用的脏数据。最直接的兜底方案是:在Service层维护一个已上传对象名列表,任何一步抛异常,就在catch块里调用对象存储的批量删除接口把这些对象清掉。
private void rollbackUploaded(List<String> objectNames) { if (objectNames == null || objectNames.isEmpty()) { return; } List<DeleteObject> objects = objectNames.stream() .map(DeleteObject::new) .collect(Collectors.toList()); minioClient.removeObjects( RemoveObjectsArgs.builder() .bucket(bucket) .objects(objects) .build()); }不过删除操作本身也可能失败,所以更稳妥的做法是给导入任务增加一个状态记录表,把每次导入的批次号、上传成功的对象名、处理进度都记录下来。万一任务中途挂了,可以通过一个补偿任务扫描状态表,把孤儿文件清理掉。这个方案比同步删除更抗故障,也方便之后做导入历史追溯。
6. 从"能跑"到"敢上线":进度反馈、并行处理与收尾建议
6.1 导入进度反馈机制
如果一次导入几千行,HTTP同步请求大概率会超时。前端如果还在傻等接口响应,用户体验会很差。我会把导入拆成异步任务:接口接收文件后立刻返回一个任务ID,导入在后台线程池里跑,前端轮询进度接口。
进度信息用一条导入任务记录维护,包含总行数、已处理行数、成功行数、失败行数、当前状态。线程池建议单独配置,不要直接用Spring默认的@Async执行器,更不能把导入任务丢到Tomcat的工作线程里,否则一个大批量导入就能把服务端请求线程耗尽。
6.2 超大Excel的底层拆解思路
对那种单文件几十MB、图片数量上千的场景,常规POI方案要非常谨慎。我提供的兜底思路是直接解压xlsx的zip结构。
xlsx里的图片二进制在xl/media/下,图片和Sheet的关系在xl/drawings/_rels/drawingN.xml.rels里。你可以用ZipInputStream逐项读取media文件流,边读边传到对象存储,并通过解析drawing的XML拿到锚点的行号信息。这种做法不构建XSSFWorkbook对象,内存占用小一个量级,但XML解析、命名空间处理都要自己来,复杂度明显高,适合项目确实有性能瓶颈时再上。
6.3 我目前交付这类功能的几个落地习惯
代码写完只是第一步,真正能在项目里稳定跑起来,还得看边界处理。文件命名后缀要校验、文件大小要限制、图片格式要做白名单;对象存储客户端要设置连接和读取超时;导入接口要设计好幂等,同一批次文件重复提交不能产生重复数据;还有一点容易被忽视,临时文件用完必须删除,否则临时目录会把磁盘占满。
最后再分享一个很实用的小技巧:正式接入对象存储之前,先用本地磁盘目录实现一个StorageService接口。本地实现就是把字节写到固定目录下,返回一个本地路径。这样开发环境不需要起MinIO服务,单测也能跑。接口设计好了,后续切换到任何对象存储都只是换一个实现,这个抽象带来的收益会在项目后期越来越明显。