1. 为什么在 SpringBoot 里解析 docx 是个“看起来简单、做起来全是坑”的活
很多做企业级应用的朋友都遇到过这种需求:用户上传一份 Word 文档,系统需要自动把里面的文字提取出来做全文检索,或者把文档里的图片抠出来存到对象存储,再或者把内容结构化后写入数据库。乍一听,这不就是调个库读文件的事吗?我一开始也这么想,结果真正动手才发现,Word 文档的解析远没有想象中那么“老实”。
先说一个最容易被忽略的事实:.doc和.docx完全是两种东西。.doc是微软早期的二进制复合文档格式,结构封闭、解析困难;而.docx本质上是一个 ZIP 压缩包,里面装着一堆 XML 文件和资源文件。你随便把一个.docx后缀改成.zip解压,就能看到word/document.xml、word/media/这些目录。这个认知非常关键,因为它决定了我们后面所有的解析思路——解析 docx,本质上是在解析一套约定好的 XML 结构,外加从压缩包里提取媒体资源。
Apache POI 就是干这件事的成熟工具。它提供了XWPFDocument这个类专门处理 docx,能读段落、读表格、读图片、读页眉页脚。但问题在于,POI 的 API 设计偏向“底层暴露”,很多细节需要你自己处理:图片不是直接给你一个文件,而是给你一个关系 ID,你得自己去package里找对应的数据;文字也不是简单一个getText()就完事,段落、表格、文本框里的文字分散在不同对象里,漏掉一个就丢内容。
这篇内容适合谁看?如果你正在用 SpringBoot 做文档管理系统、合同解析、简历筛选、内容审核这类功能,需要从 Word 里稳定地抽取文字和图片,那这篇就是写给你的。我会把 POI 解析 docx 的完整链路拆开讲,包括依赖怎么选、核心 API 怎么用、图片怎么提取、表格和文本框怎么处理,以及我在实际项目里踩过的那些坑。全程基于真实可复现的代码,你照着抄就能跑起来。
2. 依赖选型与 docx 文件结构的底层认知
2.1 POI 依赖到底该引哪几个
很多人一上来就poi一把梭,结果发现处理 docx 的类根本找不到。Apache POI 是分模块的,处理不同格式需要不同的 artifact。对于 docx,核心是这几个:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency>poi-ooxml会自动带上poi和poi-ooxml-lite(或者完整的ooxml-schemas)。这里有个关键选择:poi-ooxml默认依赖的是精简版的 schema 包,体积小,但如果你要处理一些冷门特性(比如某些复杂的图表、SmartArt),可能会报ClassNotFoundException或者 schema 相关的错。我一般的做法是,如果只是解析文字和图片,精简版完全够用;如果遇到解析异常,再手动引入完整的ooxml-schemas。
版本上,POI 5.x 相比 4.x 在内存占用和安全性上有明显改进,尤其是处理大文档时。但要注意,POI 5.x 要求 JDK 8 以上,如果你项目还在 JDK 7,那只能停在 4.x。另外,POI 依赖xmlbeans,有时候会和项目里其他 XML 处理库冲突,出现NoSuchMethodError,这时候用mvn dependency:tree排查一下版本仲裁就行。
还有一个坑:如果你同时要处理.doc老格式,需要额外引poi-scratchpad,并且用HWPFDocument而不是XWPFDocument。但老格式解析出来的东西经常乱码,图片提取也麻烦,我的建议是能要求用户上传 docx 就尽量要求,实在不行再考虑转换。
2.2 把 docx 当 ZIP 看,一切就通了
前面说了 docx 是 ZIP 包,我们解压一个典型的 docx 会看到这样的结构:
| 路径 | 作用 |
|---|---|
[Content_Types].xml | 声明包内各部分的 MIME 类型 |
word/document.xml | 正文内容,段落、表格、文字都在这里 |
word/media/ | 存放图片等媒体文件,如image1.png |
word/_rels/document.xml.rels | 关系文件,记录 document.xml 里引用的资源 ID 到实际文件的映射 |
word/header1.xml/footer1.xml | 页眉页脚内容 |
word/styles.xml | 样式定义 |
理解这个结构后,POI 的很多行为就说得通了。比如你调用paragraph.getRuns()拿到一个 run,run 里可能有一个图片引用,这个引用其实是一个rId(关系 ID),POI 通过document.getPackage().getPart()之类的方法,结合.rels文件,才能找到word/media/下真正的图片数据。所以图片提取的核心就是:拿到 rId,去关系表里查目标路径,再从包中读出字节流。
这个认知还能帮你排查问题。比如图片提取出来是空的,你可以直接把 docx 解压,看看word/media/里到底有没有文件,document.xml.rels里有没有对应的映射。如果解压后 media 目录是空的,那说明文档本身就没嵌入图片(可能是链接图片),POI 自然也拿不到。
3. 文字内容提取:段落、表格、文本框一个都不能漏
3.1 最基础的段落文字读取
先看最简单的场景:读正文段落。XWPFDocument的getParagraphs()返回所有顶层段落,每个XWPFParagraph又包含若干XWPFRun。文字实际存在于 run 里。
try (InputStream is = new FileInputStream(file); XWPFDocument doc = new XWPFDocument(is)) { StringBuilder sb = new StringBuilder(); for (XWPFParagraph paragraph : doc.getParagraphs()) { String text = paragraph.getText(); if (text != null && !text.trim().isEmpty()) { sb.append(text).append("\n"); } } System.out.println(sb.toString()); }这里有个细节:paragraph.getText()会把该段落所有 run 的文字拼起来,但它不会自动加换行,也不会处理 run 之间的格式差异。如果你需要保留加粗、斜体这些信息,就得遍历 run,用run.isBold()、run.getFontSize()等去判断。另外,getText()对空段落返回空字符串,但有些文档里用空段落做间距,这些你按需过滤就行。
我踩过的一个坑是:有些文档的文字在 run 里被拆得很碎。比如“SpringBoot”这个词,可能被拆成“Spring”和“Boot”两个 run,中间还夹着格式标记。如果你按 run 逐个处理,很容易把词切断。所以做全文检索时,直接用paragraph.getText()拼段落是最稳的,不要自己按 run 拼。
3.2 表格里的文字怎么完整拿出来
getParagraphs()只返回正文顶层段落,表格里的文字它是不管的。这是新手最容易丢内容的地方。表格要用getTables():
for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { // 单元格里可能有多个段落 for (XWPFParagraph p : cell.getParagraphs()) { sb.append(p.getText()).append(" "); } sb.append("|"); // 用竖线分隔单元格 } sb.append("\n"); } }注意单元格里是getParagraphs(),不是直接getText()。一个单元格可能包含多个段落,甚至嵌套表格。嵌套表格需要递归处理,cell.getTables()能拿到单元格内的子表格。我一般会写一个递归方法,把段落和表格统一处理,避免漏掉深层内容。
还有一个隐蔽的点:表格的遍历顺序和文档视觉顺序可能不一致。POI 的getTables()返回的是文档流里的表格,但如果你文档里表格和段落是交错的,单独遍历getParagraphs()再遍历getTables(),会丢失它们的相对位置。要保留顺序,得用doc.getBodyElements(),它返回一个IBodyElement列表,按文档顺序排列,你可以判断每个元素是XWPFParagraph还是XWPFTable,依次处理。这个 API 在做“按原文顺序输出”时非常有用。
3.3 文本框和页眉页脚:最容易被遗忘的角落
文本框(Text Box)在 docx 里是放在 drawing 里的,POI 的getParagraphs()默认拿不到。很多合同、海报类文档喜欢把关键信息放文本框里,如果你只读段落,就会漏掉。处理文本框需要遍历 run 里的CTR对象,判断是否有CTTextbox,然后从里面取段落。代码相对繁琐:
for (XWPFParagraph paragraph : doc.getParagraphs()) { for (XWPFRun run : paragraph.getRuns()) { for (CTR ctr : run.getCTR().getRList()) { // 处理 drawing 里的 textbox // 具体通过 ctr.getDrawingList() 等逐层深入 } } }说实话,文本框的解析用 POI 原生 API 写起来很痛苦,因为要操作底层 XMLBeans。我的经验是,如果业务对文本框内容有强需求,可以考虑用docx4j作为补充,它对文本框的支持更友好。但如果只是偶尔处理,用 POI 硬啃也能出来,只是代码丑一点。
页眉页脚同理,doc.getHeaderList()和doc.getFooterList()能拿到,但要注意一个文档可能有多种页眉(首页不同、奇偶页不同),需要遍历所有 header/footer 的段落和表格。我一般会把页眉页脚的文字单独存一个字段,不和正文混在一起,因为它们的语义不同。
4. 图片提取:从 rId 到字节流的完整链路
4.1 图片在 docx 里到底是怎么存的
再强调一遍:图片不是以二进制形式直接嵌在document.xml里的,而是存在word/media/目录下,document.xml里只放一个引用。这个引用通过关系 ID(rId)关联到document.xml.rels,rels 文件里写着rId对应media/image1.png。所以提取图片分三步:找到 run 里的图片引用,拿到 rId,通过 rId 找到图片数据。
POI 里,图片引用藏在 run 的getEmbeddedPictures()里。每个XWPFPicture有一个getPictureData(),返回XWPFPictureData,这个对象直接提供了getData()字节数组和getPictureType()类型。所以其实 POI 已经帮你把 rId 到数据的链路封装好了,你不需要手动解析 rels。
for (XWPFParagraph paragraph : doc.getParagraphs()) { for (XWPFRun run : paragraph.getRuns()) { for (XWPFPicture picture : run.getEmbeddedPictures()) { XWPFPictureData data = picture.getPictureData(); byte[] bytes = data.getData(); String ext = data.suggestFileExtension(); // png, jpeg 等 // 保存 bytes 到文件或上传对象存储 } } }这段代码看起来简单,但有几个坑必须说。
4.2 图片提取的三个实战坑
第一个坑:图片可能不在段落里,而在表格单元格或页眉里。上面的代码只遍历了doc.getParagraphs(),如果图片在表格单元格里,就漏了。正确做法是把遍历逻辑抽成一个方法,对段落、表格单元格、页眉页脚都调用一遍。我一般写一个extractImagesFromParagraphs(List<XWPFParagraph>),然后各处复用。
第二个坑:同一张图片可能被引用多次。比如文档里同一个 logo 出现三次,word/media/里其实只有一份image1.png,但三个 run 都引用它。如果你按 run 提取,会得到三份相同的字节。做去重的话,可以用XWPFPictureData的getChecksum()或者直接对字节做 MD5,相同就跳过。这个在批量处理时能省不少存储。
第三个坑:图片格式的扩展名不一定准。suggestFileExtension()返回的是根据内容推断的扩展名,但有些文档里图片实际是 EMF、WMF 这种矢量格式,POI 可能返回emf,你保存成.emf后前端不一定能显示。我的做法是,保存时用getPictureType()拿到 POI 的枚举值,再映射到常见扩展名,同时记录原始 MIME 类型,方便后续转换。
另外,如果图片是“链接式”插入的(不是嵌入),getEmbeddedPictures()是拿不到的,因为数据根本不在文档包里。这种情况只能拿到链接地址,需要额外处理。判断方法就是解压 docx 看 media 目录是否为空。
4.3 大文档图片提取的内存控制
如果文档很大,图片很多,一次性把所有字节读进内存再处理,容易 OOM。我的建议是边遍历边处理,拿到一个XWPFPictureData就立刻写盘或上传,不要攒在 List 里。如果确实需要先收集元信息再统一处理,那也只存 rId 和类型,字节流用的时候再读。
POI 本身在解析大文档时也吃内存,XWPFDocument会把整个文档模型加载到内存。对于几十兆的文档,可以调大 JVM 堆,或者考虑用 POI 的 event model(XSSF 那套是给 Excel 的,Word 没有对应的流式 API),所以 Word 解析基本只能全量加载。实际项目里,我会限制上传文档大小,比如 20MB 以内,超过就提示用户拆分。
5. 把解析逻辑封装成 SpringBoot 可复用的服务
5.1 设计一个解析结果对象
在 SpringBoot 里,我们不会把解析代码散落在 Controller 里,而是封装成一个 Service。首先定义一个结果对象,把文字和图片分开存:
public class DocxParseResult { private String fullText; // 全文文字 private List<ImageInfo> images; // 图片列表 private Map<String, String> metadata; // 可选:页眉页脚等 public static class ImageInfo { private String rId; private String extension; private byte[] data; private String checksum; // getter/setter 省略 } }fullText里我会按文档顺序拼接段落和表格文字,表格用|分隔单元格,用换行分隔行。这样前端展示或全文检索都方便。图片单独存,因为字节流不适合塞进大字符串。
5.2 Service 层的完整实现思路
Service 的核心方法接收MultipartFile,返回DocxParseResult。流程是:打开流、创建XWPFDocument、遍历 body elements 提取文字、遍历所有位置提取图片、关闭资源。用 try-with-resources 确保流关闭。
@Service public class DocxParseService { public DocxParseResult parse(MultipartFile file) throws IOException { DocxParseResult result = new DocxParseResult(); StringBuilder textBuilder = new StringBuilder(); List<DocxParseResult.ImageInfo> images = new ArrayList<>(); Set<String> seenChecksums = new HashSet<>(); try (InputStream is = file.getInputStream(); XWPFDocument doc = new XWPFDocument(is)) { // 按顺序处理正文元素 for (IBodyElement element : doc.getBodyElements()) { if (element instanceof XWPFParagraph) { appendParagraphText((XWPFParagraph) element, textBuilder); extractImagesFromParagraph((XWPFParagraph) element, images, seenChecksums); } else if (element instanceof XWPFTable) { appendTableText((XWPFTable) element, textBuilder); extractImagesFromTable((XWPFTable) element, images, seenChecksums); } } // 处理页眉页脚 for (XWPFHeader header : doc.getHeaderList()) { for (XWPFParagraph p : header.getParagraphs()) { appendParagraphText(p, textBuilder); extractImagesFromParagraph(p, images, seenChecksums); } } // footer 同理 } result.setFullText(textBuilder.toString()); result.setImages(images); return result; } }这里用getBodyElements()而不是分别getParagraphs()和getTables(),就是为了保留文档顺序。appendParagraphText里我会判断段落是否为空,空段落跳过,避免全文里出现大量空行。
5.3 图片去重与存储策略
去重用 checksum。XWPFPictureData没有直接的 checksum 方法,我用DigestUtils.md5Hex(data.getData())算 MD5。如果 checksum 已存在,就跳过,不重复添加。这样同一张图在文档里出现多次,最终只存一份。
存储上,实际项目里我不会把字节流放在返回结果里传给前端,而是解析时直接上传到对象存储(如 MinIO、OSS),返回 URL 列表。这样 Service 的职责更清晰:解析 + 上传。如果只是本地测试,写到一个临时目录也行。关键是不要让大字节流在内存里长时间驻留,上传完就释放引用。
还有一个细节:图片的命名。用UUID + "." + extension避免重名,同时把原始 rId 和 checksum 存到数据库,方便追溯。如果业务需要按文档内顺序展示图片,那在提取时记录一个序号,上传后按序号排序。
6. 那些让我加班到深夜的异常与排查过程
6.1OfficeXmlFileException:文件根本不是 docx
这个异常信息是“The supplied data appears to be in the OLE2 Format”,意思是 POI 认为你给的是老式.doc二进制格式,但你用的是XWPFDocument。原因通常是用户把.doc文件改了后缀成.docx上传,或者文件本身损坏。排查方法很简单:读文件头几个字节,docx 是PK(ZIP 标志),doc 是D0 CF 11 E0。如果文件头是后者,就得用HWPFDocument处理,或者提示用户另存为 docx。
我在 Controller 层加了一个校验:读取文件前 4 个字节,不是PK开头就直接返回友好错误,避免异常堆栈暴露给前端。这个校验成本极低,但能挡掉大部分脏数据。
6.2 图片提取为空:解压文档一看便知
有次测试反馈说某文档图片提不出来,我第一反应是代码问题,排查半天没结果。后来把 docx 解压,发现word/media/目录是空的,图片全是链接式的。这种文档在 Word 里显示正常,但数据不在包里。解决办法只能是拿到链接地址后单独下载,或者要求用户重新插入为嵌入图片。所以遇到图片为空,先解压看 media 目录,这一步能省你半小时。
6.3 中文乱码:编码问题的几种可能
POI 读 docx 一般不会乱码,因为 XML 里是 UTF-8。但如果文档里嵌入了老式字体或者特殊符号,可能出现方块。还有一种情况是你把提取的文字写到文件时用了平台默认编码(比如 Windows 的 GBK),而内容里有生僻字。我的做法是,所有输出统一用 UTF-8,写文件时显式指定StandardCharsets.UTF_8。另外,paragraph.getText()返回的字符串本身是没问题的,乱码往往出在后续的 IO 环节。
6.4 内存溢出:大文档的应对
前面提过,XWPFDocument全量加载。我遇到过一个 50MB 的文档,里面几百张高清图,解析时直接 OOM。后来做了两件事:一是限制上传大小到 20MB;二是解析时用-Xmx调大堆,并且解析完立刻doc.close()释放。如果业务必须处理超大文档,可以考虑拆分,或者用异步任务 + 独立 JVM 进程处理,避免拖垮主服务。
还有一个隐蔽的内存问题:XWPFPictureData.getData()每次调用都会返回字节数组的副本吗?实际上 POI 内部有缓存,但如果你反复调用,可能产生多份。我的做法是拿到一次就存下来,不要重复调。
7. 几个让解析更稳的工程化习惯
7.1 解析前先做文件类型和大小校验
不要信任前端传来的文件。在 Service 入口做三件事:检查文件扩展名是否为.docx,检查文件头是否为PK,检查文件大小是否超限。这三步能挡掉 90% 的异常。校验失败返回明确的错误码,而不是抛异常。
7.2 把解析做成异步任务
文档解析是 CPU 和 IO 密集型操作,同步接口容易超时。我的做法是上传后立即返回一个任务 ID,后台用线程池异步解析,前端轮询进度。解析结果存数据库或缓存,图片上传对象存储。这样用户体验好,服务也稳定。线程池大小根据文档平均大小调整,一般 4 到 8 个线程够用。
7.3 记录解析日志,方便回溯
每次解析记录:文件名、大小、解析耗时、提取文字长度、图片数量、是否成功、失败原因。这些日志在排查问题时非常有用。比如某类文档总是图片为 0,你一看日志就能发现是链接式图片的问题。日志里不要打全文内容,避免敏感信息泄露,打长度和摘要即可。
7.4 单元测试要覆盖边界情况
我写测试时会准备几类文档:纯文字、纯图片、文字加表格、带页眉页脚、带文本框、空文档、损坏文档。每类都跑一遍,断言文字长度和图片数量。这样重构时心里有底。测试资源放在src/test/resources下,用ClassPathResource加载。
8. 关于 docx 解析这件事,我最后想说的
POI 解析 docx 不是什么高深技术,但它的坑都藏在细节里:表格里的文字、文本框里的内容、图片的 rId 链路、页眉页脚的遍历、大文档的内存。这些细节文档里不会重点讲,只有真正做过一遍、被测试提过 bug、被线上报警叫醒过,才会刻在脑子里。
我的建议是,第一次实现时不要追求大而全,先把正文文字和嵌入图片跑通,再逐步加表格、页眉、文本框。每加一个特性,就用对应的测试文档验证。遇到解析异常,先解压 docx 看结构,大部分问题都能定位。代码里做好资源关闭和异常兜底,别让一个坏文档拖垮整个服务。
如果你正在做类似的功能,希望这篇内容能帮你少走点弯路。解析出来的文字和图片只是第一步,后面怎么做检索、怎么做结构化、怎么和业务结合,那又是另一个话题了。先把数据稳稳当当地拿出来,后面的路才好走。