
做过后台管理系统的人对“Excel导入导出”一定不陌生但大多数项目里处理的都只是单元格里的文本和数字。真正让人头疼的是另一种需求Excel里嵌了图片比如设备台账里有设备照片、员工花名册里有头像、财务回单里有票据扫描件要求把这些图片读出来按行关联到业务数据上再自动传到服务器或对象存储。这种“基于SpringBoot读取Excel图片并自动上传”的需求一旦出现往往会让人懵一会儿——因为数据好读图片的“归属关系”才是真问题。这篇文章我会从需求拆解、技术选型、POI底层原理、完整代码实现到踩坑记录全部过一遍。我用的方案是纯Apache POI SpringBoot不走EasyExcel原因是图片处理这块POI的API更底层、更完整。全文面向的是正在做这类功能的开发同学或者准备接类似需求、想提前评估工作量的朋友。内容不玩虚的代码可以直接抄走改一改就能用。1. 需求拆解与技术选型1.1 这类需求到底在解决什么问题别一上来就急着写代码。先把这个需求的本质拆开你会发现它其实是三个子问题的叠加。第一个子问题是读Excel。这个不需要多解释读取xlsx/xls中的单元格数据是常规操作。难点在于第二个子问题图片在Excel里不是单元格的一部分也不是某一行某一列的属性它是“浮”在表格上面的独立对象——就像你在纸上贴了一张照片照片和格子之间只有视觉上的位置关系没有数据上的绑定关系。所以我们必须通过“图片锚定的单元格坐标”来反推它属于哪条业务记录。这是整个功能的核心难点也是绝大多数人第一次做这类需求时卡住的地方。第三个子问题是传到哪、怎么传。本地磁盘、云存储、OSS、FTP目标位置不同代码结构就应该做成可替换的。这一点很考验抽象能力如果直接把上传逻辑写死在业务代码里换存储方案时就要改业务很被动。另外还有一个容易被忽略的点批量导入里的“失败重试”。导入过程一旦中途失败之前已经上传的图片就成了孤儿文件。所以这个需求天然牵扯到事务边界、幂等性、文件清理这些工程问题不是“读出来传上去”那么简单。1.2 为什么选Apache POI而不是EasyExcel很多人问我EasyExcel用起来那么爽为什么不直接上这里我要给一个明确的结论做纯数据导入EasyExcel确实是首选但一旦涉及图片提取EasyExcel就不够用了。EasyExcel的设计目标是对Excel做高效的数据读写底层虽然也是POI但它把底层的Drawing、Shape、Anchor这一整套对象模型都封装掉了。如果你想从某个工作表里取图片你会发现EasyExcel根本没有暴露获取XSSFPicture、XSSFClientAnchor这些对象的API。至少在我用过的版本里图片这块非常受限只能拿到某种汇总的图片列表拿不到“这张图锚定在第几行第几列”这样的信息。而我们要做的恰恰就是靠锚点坐标来匹配业务行。所以选了Apache POI的完整API直接操作HSSF/XSSF对象模型。代价是代码写起来啰嗦一点换来的是对Excel内部结构的完全掌控。另外POI支持HSSFxls和XSSFxlsx两套API老版本的.xls文件也能覆盖到这在企业场景里非常关键——你永远不知道用户会传一个什么年代的Excel上来。1.3 整体方案的三层设计与边界我把整个功能设计成三层每层职责单一互相之间通过接口和数据结构衔接。第一层是解析层负责把Excel文件读成两类数据单元格值列表和图片信息列表。图片信息里包含图片字节数组、扩展名、所在工作表、锚定的行列坐标。第二层是映射层负责把图片按照行列坐标匹配到对应的业务数据行上。第三层是上传层定义Uploader接口提供本地磁盘和对象存储两种实现业务层只依赖接口。这个设计的核心思想是解析和映射不关心图片最后传到哪上传模块也不关心图片是从哪个Excel里来的。以后需求变了比如从上传本地改成传对象存储只需要新增一个Uploader实现类业务代码一行都不用动。边界上要特别注意一个坑数据库事务只应该包裹“业务数据落库”这一步图片上传必须放在事务外面。原因是文件上传是外部副作用如果事务回滚了但文件已经传上去了磁盘上就会多出一个和业务数据对不上的脏文件。正确的顺序是先落库事务提交后再上传图片或者干脆用异步任务处理。2. Excel图片提取原理把像素翻译成单元格2.1 图片到底存在Excel哪个位置要做到心里有数需要先搞清楚Excel文件的内部结构。xlsx本质上是个zip压缩包里面有各种各样的xml和媒体文件。你插入一张图片这张图片的二进制数据实际存放在xl/media/目录下而它被展示在哪个位置、锚定在哪个单元格记录在xl/drawings/drawing1.xml这样的绘图XML里。POI做的事情就是把这两部分从压缩包里解出来再通过一套对象模型暴露给上层。其中三个关键类XSSFDrawing代表工作表上的绘图层所有浮在表格上的形状、图片、文本框都在这里。XSSFPicture对单张图片的抽象通过getPictureData()可以拿到图片的原始字节和类型。XSSFClientAnchor图片的锚点记录了图片左上角和右下角位于哪个单元格以及在单元格内的偏移量。xls老格式同理对应类是HSSFPatriarch、HSSFPicture和HSSFClientAnchorAPI风格类似区别在于老格式没有Drawing.xml而是二进制记录。理解了这个结构你就明白为什么EasyExcel不提供图片读取能力——它根本不愿意暴露Drawing这一层的细节。2.2 锚点与单元格EMU单位换算锚点对象里有四个核心坐标col1/row1是左上角所在的行列col2/row2是右下角所在的行列同时还有dx1/dy1/dx2/dy2四个偏移量。偏移量的单位是EMUEnglish Metric Unit这是Excel内部用的微长度单位。换算关系记两个数就够了1英寸914400 EMU1像素约等于9525 EMU。也就是说Excel默认单元格宽度对应的EMU值是通过像素换算出来的。但这些偏移量在实际项目中怎么用我用下来的经验是绝大多数业务场景直接使用col1/row1作为图片归属坐标就足够稳定不需要做过细的偏移量换算。原因很简单用户在Excel里调整图片位置时Office会把图片自动“吸附”到最近的单元格网格线上。最终体现在锚点坐标上col1/row1指向的单元格就是用户视觉上认为的图片所在位置。偏移量只有在图片恰好压在两行之间时才需要额外判断这个场景占比很低为它去精确换算列宽和像素成本高、收益低。开发时默认取起点坐标如果业务上遇到错位严重的情况再加偏移判断也不迟。2.3 提取流程与“图片归属行”的判定策略单张图片的提取逻辑分成四步第一步遍历所有工作表每个sheet调用getDrawingPatriarch()获取绘图层对象没有图片时这个方法返回null记得判空。第二步遍历绘图层里的所有Shape对象用instanceof XSSFPicture筛出图片形状。第三步拿到图片形状后分别取出Anchor锚点和PictureData图片数据。第四步把锚点坐标和图片字节组装成自定义对象按行号分组。这里有一个非常关键的工程问题图片归属行的判定。比如Excel第一行是标题第二到第十行是数据中间还可能在某个角落放了一个LOGO。如果简单地把“图片所在行号”直接当作“业务数据的行号来用”就会把标题行的LOGO也当成一条业务的图片数据。我的做法是维护一个“有效数据行映射表”。先通过读取单元格值确定哪几行才是真正的业务数据行记录成一个Set集合。图片提取出来后先看图片锚定的行号在不在这个Set里如果在就匹配如果不在就跳过或者向上/向下查找最近的一个数据行。在实际项目中这个策略能解决百分之八十的图片错位和多余图片干扰问题。3. 核心代码实现从Workbook到自动上传3.1 项目依赖与基础工具类先看Maven依赖POI我用的5.2.x版本这个版本对XSSF和HSSF的支持都很稳定。出于兼容考虑poi-ooxml和poi-ooxml-lite可加可不加但引入后能减少一些运行时依赖冲突的问题。另外用到了commons-codec做MD5也可以换成Spring自带的DigestUtils。dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.5/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId version3.12.0/version /dependency再准备一个文件名校验工具类用来处理图片扩展名。图片的扩展名不能直接拿原始文件名判断因为Excel内部的图片数据可能有自己的类型标记。POI的suggestFileExtension()方法会返回一个可靠的扩展名用它来拼文件名最稳妥。public final class ImageFileUtil { private ImageFileUtil() { } public static String buildFileName(String sheetName, int row, int col, byte[] data, String ext) { String md5 DigestUtils.md5Hex(data).substring(0, 16); return String.format(%s_r%d_c%d_%s.%s, sheetName, row, col, md5, ext); } }3.2 图片提取器完整代码与解释核心类我命名为ExcelImageExtractor对外提供一个统一的入口方法传入Workbook返回所有按行号聚合的图片信息。内部针对XSSFWorkbook和HSSFWorkbook各写一套提取逻辑最终归并到一个数据结构里。先定义返回结果对象。这里我把图片的数据结构设计成“行号列号图片信息”的组合业务层拿到之后可以用行号直接索引到对应的业务数据。public class RowImage { private final int rowIndex; private final int colIndex; private final String sheetName; private final String ext; private final byte[] data; public RowImage(int rowIndex, int colIndex, String sheetName, String ext, byte[] data) { this.rowIndex rowIndex; this.colIndex colIndex; this.sheetName sheetName; this.ext ext; this.data data; } // getter... }然后是提取器的完整实现。这份代码可以直接跑注释里写了几个关键点的意图。public class ExcelImageExtractor { public ListRowImage extract(Workbook workbook) { ListRowImage result new ArrayList(); for (int i 0; i workbook.getNumberOfSheets(); i) { Sheet sheet workbook.getSheetAt(i); if (sheet instanceof XSSFSheet) { extractFromXSSF((XSSFSheet) sheet, result); } else if (sheet instanceof HSSFSheet) { extractFromHSSF((HSSFSheet) sheet, result); } } return result; } private void extractFromXSSF(XSSFSheet sheet, ListRowImage result) { XSSFDrawing drawing sheet.getDrawingPatriarch(); if (drawing null) { return; } for (XSSFShape shape : drawing.getShapes()) { if (!(shape instanceof XSSFPicture)) { continue; } XSSFPicture picture (XSSFPicture) shape; XSSFClientAnchor anchor (XSSFClientAnchor) picture.getAnchor(); // 起点锚点所在单元格就是这张图的归属位置 int row anchor.getRow1(); int col anchor.getCol1(); XSSFPictureData pictureData picture.getPictureData(); String ext pictureData.suggestFileExtension(); result.add(new RowImage(row, col, sheet.getSheetName(), ext, pictureData.getData())); } // 兼容批注里的图片花名册场景经常有人把照片塞进批注 extractFromComments(sheet, result); } private void extractFromComments(XSSFSheet sheet, ListRowImage result) { MapString, XSSFComment comments sheet.getCellComments(); if (comments null || comments.isEmpty()) { return; } for (XSSFComment comment : comments.values()) { XSSFPicture picture comment.getPicture(); if (picture null) { continue; } XSSFClientAnchor anchor (XSSFClientAnchor) picture.getAnchor(); int row anchor.getRow1(); int col anchor.getCol1(); XSSFPictureData pictureData picture.getPictureData(); String ext pictureData.suggestFileExtension(); result.add(new RowImage(row, col, sheet.getSheetName(), ext, pictureData.getData())); } } private void extractFromHSSF(HSSFSheet sheet, ListRowImage result) { HSSFPatriarch patriarch sheet.getDrawingPatriarch(); if (patriarch null) { return; } for (HSSFShape shape : patriarch.getChildren()) { if (!(shape instanceof HSSFPicture)) { continue; } HSSFPicture picture (HSSFPicture) shape; HSSFClientAnchor anchor picture.getClientAnchor(); int row anchor.getRow1(); int col anchor.getCol1(); HSSFPictureData pictureData picture.getPictureData(); String ext pictureData.suggestFileExtension(); result.add(new RowImage(row, col, sheet.getSheetName(), ext, pictureData.getData())); } } }注意一个细节提取图片时只取了anchor.getRow1()和anchor.getCol1()也就是左上角锚点的行列坐标。为什么不用getRow2()/getCol2()或者中心点因为同一张图片只要没有覆盖到下一行的大半部分起点坐标就是用户在视觉上认定的归属位置。这也是我前面说过的“起点单元格归属法”简单稳定项目里实际验证下来效果没问题。3.3 上传模块抽象本地目录与对象存储上传层我定义了一个接口ImageUploader所有上传实现都实现它。接口里两个方法upload负责把图片字节写到目标位置并返回访问相对路径exists用于判断同一内容是否已经上传过配合幂等策略使用。public interface ImageUploader { String upload(byte[] data, String relativePath); boolean exists(String relativePath); }本地磁盘的实现很简单配置一个根目录拼出完整路径写文件即可。对象存储的实现思路也一样只是把写入方式换成SDK调用。这里我用一个模拟对象存储的类来演示实际对接某对象存储时把SDK依赖加进来替换upload方法体里的三行代码就行。Component ConditionalOnProperty(name image.storage.type, havingValue local) public class LocalImageUploader implements ImageUploader { Value(${image.storage.local.dir}) private String baseDir; Override public String upload(byte[] data, String relativePath) { try { Path path Paths.get(baseDir, relativePath); Files.createDirectories(path.getParent()); Files.write(path, data); return relativePath; } catch (IOException e) { throw new RuntimeException(图片写入失败 relativePath, e); } } Override public boolean exists(String relativePath) { return Files.exists(Paths.get(baseDir, relativePath)); } }对象存储的那个实现我在接口不变的情况下只做了个示意重点在于说明抽象的价值业务层根本不知道自己调用的是本地还是云端将来存储扩容、切换供应商都不影响导入逻辑。3.4 导入服务串联Spring事件与事务边界最后把Excel读取、图片提取、行数据映射和上传串起来。这里我特意把上传动作放在事务外执行并且用“先判断再上传”的方式实现幂等。Service public class ExcelImportService { private final ExcelImageExtractor imageExtractor new ExcelImageExtractor(); private final ImageUploader imageUploader; private final JdbcTemplate jdbcTemplate; public ExcelImportService(ImageUploader imageUploader, JdbcTemplate jdbcTemplate) { this.imageUploader imageUploader; this.jdbcTemplate jdbcTemplate; } Transactional(rollbackFor Exception.class) public String importExcel(MultipartFile file) { String batchId UUID.randomUUID().toString().replace(-, ); try (Workbook workbook createWorkbook(file)) { ListRowImage images imageExtractor.extract(workbook); // 这里省略了单元格值的读取逻辑按列读取后组装成ListMap ListMapString, Object rows readCellValues(workbook); ListString validRows getValidRowSet(rows); // 过滤掉不属于数据行的图片 ListRowImage filtered images.stream() .filter(img - validRows.contains(String.valueOf(img.getRowIndex()))) .collect(Collectors.toList()); // 先落库业务数据在事务内 saveRows(rows, batchId); // 事务内只保存图片的“元数据状态为待上传”文件后传 saveImageRecords(filtered, batchId); // 事务提交后立即执行上传 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { uploadImages(filtered); } }); return batchId; } catch (IOException e) { throw new RuntimeException(解析Excel失败, e); } } }afterCommit()回调是Spring事务同步机制提供的钩子事务真正提交成功后才执行完美解决“先传文件后事务回滚导致脏文件”的问题。如果项目里不想用这个机制也可以改成在Service里把上传操作丢给Async方法异步执行两种方式都可行。大文件场景建议用异步因为上传耗时可能拖垮HTTP请求线程。4. 我踩过的坑与排查速查表4.1 五个高频坑位实录第一个坑是share的Drawing为null导致空指针。其实并不复杂POI里EmptyDrawing对象有可能返回null或空实现不同版本表现还不完全一样。我在一个老项目中遇到过某个sheet明明没有图片但第一版代码没判空运行时直接NPE。后来所有获取Drawing的地方一律先判空这个坑才算彻底堵住。第二个坑是xls和xlsx的API差异。HSSF和XSSF的类名完全不同方法名也不一样。HSSF里取锚点用的是picture.getClientAnchor()XSSF里取锚点用的是picture.getAnchor()。如果你只写了一套XSSF的逻辑用户传了个老xls文件上来代码会直接报ClassCastException。我的处理方式是像上面的代码一样在extract入口统一区分workbook类型分两支逻辑写各自的实现。第三个坑是图片扩展名变成了wmf或emf。Excel老版本里插入的某些矢量图、剪贴画实际格式是wmf或emf。POI读取时suggestFileExtension()会老老实实返回这个真实类型。这种图片传到浏览器上根本显示不出来前端报错。解决思路提取后对ext做白名单过滤如果不是png/jpg/jpeg/gif尝试跳过或者提示用户重新截图上传如果业务上必须要就需要引入图片转换库做格式转换。第四个坑是WPS生成的Excel读不到图片。WPS生成的xlsx图片信息可能存储在drawing节点里但锚点坐标写得不规范或者某些情况下根本没有生成Drawing对象只是把图片当背景嵌入。这个从代码层面没有百分百完美的解法因为文件格式本身就不标准。我的建议是两个方向一是加日志解析不出来时提示用户“改用Office另存为再上传”二是在图片提取环节兼容读取其他关系节点但工程量大。实际上绝大多数用户的Excel都是Office/WPS混合环境做一层提示比死磕兼容性更实用。第五个坑是大文件内存溢出。POI的XSSFWorkbook加载xlsx时会把整个工作簿读进内存一个50MB的Excel可能占用500MB堆内存还没算图片字节数组。两个优化方案一是在网关或Controller层限制文件大小超过5MB直接驳回二是对几十MB的超大文件改用SAX模式或XSSFReader逐sheet读取。日常业务如果不是数据分析型应用限制文件大小是最划算的做法。4.2 实用工程建议幂等与性能导入功能一定不要忽略幂等。用户导入失败后通常会立刻重试同一个文件被重复上传图片目录就会积压大量重复文件。我的做法是在拼文件路径时加入图片内容的MD5相同内容的图片得到相同路径上传前先调用exists()判断存在就直接复用路径。这样一来重复导入同一份Excel不会产生重复图片文件还能顺便实现“秒传”效果。性能上还有一个容易被忽略的点图片的存储路径不要按“导入批次”来分目录而应该按“业务ID/行号”来分。按批次分目录虽然代码简单但后续要根据某个员工ID查他的头像路径根本无法推导。我习惯的目录结构是{业务表名}/{业务记录ID}/{内容MD5}.png既满足幂等也方便其他模块引用。另外如果一次导入的行数超过500行建议给图片上传单独开线程池处理避免阻塞导入请求。但要注意异步上传意味着用户请求返回时图片可能还没传完前端展示时要轮询状态。这个取舍很难两头都顾我的经验是小文件同步大文件异步测试环境用同步模式方便联调。4.3 问题排查速查表现象可能原因处理方式提取的图片列表为空sheet没有设置Drawing对象判空处理确认Excel里确实有图片图片读出来了但全是错位图片锚点在批注里不在Drawing里补充读取getCellComments()里的图片图片在Excel正常上传后无法预览图片格式是wmf/emf或老式矢量图用suggestFileExtension()检测并做格式白名单过滤xls文件读取时抛ClassCastExceptionxlsx和xls的API混用按Workbook类型分支处理HSSF和XSSF上传几百张后内存明显飙升XSSFWorkbook全量加载导致限制文件大小及时关闭workbook必要时用SAX模式事务回滚后磁盘上仍有图片文件上传写在事务内用afterCommit()同步回调或把上传移到事务外最后说点实在的我个人在实际项目里折腾过很多次这个功能最大的体会是读图片本身不难难的是把“视觉上的影像”翻译成“业务上的归属”。锚点坐标给出的是一个技术答案但业务通常要的是“这张图属于哪条记录”的逻辑答案。因此做这种功能第一件事不是写代码而是先和业务确认清楚图片一定是贴在数据行旁边吗还是可能出现在任意位置有没有可能在标题或页眉页脚放LOGO这些规则确认得越早你后面写映射逻辑的时候就越轻松否则大概率要经历一次“所有图片都错一行”的返工。另外一个小技巧开发阶段一定要准备几个不同来源的Excel测试文件至少包含一个Office生成的xlsx、一个WPS生成的xlsx、一个旧版xls、一个图片在批注里的特殊样本。这四个文件能覆盖掉大部分用户现场的兼容性问题。等测试文件在你的代码下全部通过再上线也不迟。