ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java读取Word并入库:Apache POI解析、大文件与事务实践

Java读取Word并入库:Apache POI解析、大文件与事务实践

简介:面向需要把办公文档数据自动导入业务系统的Java开发者,这份素材包聚焦Word文档解析与数据库写入的完整实现路径:借助Apache POI读取docx正文与表格内容,经类型转换后通过JDBC与PreparedStatement批量入库,并覆盖异常捕获、事务管理与多文件并行处理等工程细节,适合具备Java基础、正在处理数据录入自动化的初中级开发者参考。压缩包共41个文件、约13.24MB,以11个依赖jar、9个java源码与9个class编译产物为主体,配合2个xml配置、2个properties数据库连接参数及Eclipse工程配置项,构成可直接导入IDE运行的示例工程结构;同包还附带Excel读取相关内容,便于横向对照两种Office格式的解析差异。已有5138人学习,读者可获得可调试的读取、转换、入库链路代码与依赖清单、工程配置模板,并据此理解解析层与持久层的衔接方式,快速改造为自身项目的导入模块。

1. Java 读取 Word 并入库:一个“上传按钮”背后的四层工程

后台管理系统里加一个上传 Word 的功能,看起来只需要一个MultipartFile参数,实际项目里真正麻烦的地方在解析层和存储层。常见需求是:用户上传.doc或.docx,系统读出正文、标题、段落、表格,做校验后把内容连同元信息写进数据库。新手容易卡在依赖版本和文件流处理,有经验的人则更关心格式兼容、大文件内存占用和入库字段怎么设计。这篇用一份完整的 Java 示例把链路拆开,从 Apache POI 选型讲到入库事务边界,能直接照着复现。

2. 选型与解析原理:POI、流式读取和文档模型

2.1 为什么 Word 读取首选 POI,而不是转换工具

Java 生态里读写 Office 文档绕不开 Apache POI。XWPF负责.docx(OOXML),HWPF负责.doc(OLE2 二进制)。有人图省事先用 LibreOffice 或某些转换服务转成纯文本再解析,这在测试环境能跑,上线后问题集中爆发:转换进程需要额外部署、并发低、字体缺失会导致内容错乱,还有安全审计风险。POI 是纯 Java 库,不用外部进程,字段级控制力强,段落、表格、样式都能取到。

必须先认清一个事实:.doc和.docx是两种完全不同的格式,不是改后缀的关系。.docx本质是 zip 包加 XML,POI 的XWPFDocument基于 XML 模型,能力成熟;.doc是 OLE2 复合文档,HWPFDocument只覆盖读取场景的一部分,复杂嵌套对象容易抛异常,而且 HWPF 写回能力很弱。所以工程上常见的做法是:.docx走完整解析,.doc只做有限的文本提取,或者明确要求上传时转存.docx。

依赖上要成组引入,版本必须一致。下面这份是常用的 Maven 组合,poi-ooxml会自动带上poi和poi-ooxml-lite,但很多老项目踩坑就踩在只引入了poi,结果XWPFDocument找不到类。

<properties> <poi.version>5.2.5</poi.version> </properties> <dependencies> <!-- .docx 解析核心,必须同时引入 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>${poi.version}</version> </dependency> <!-- 大文件流式读取,避免整份文档进内存 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>${poi.version}</version> <classifier>tests</classifier> <scope>test</scope> </dependency> <dependency> <groupId>org.apache.xmlbeans</groupId> <artifactId>xmlbeans</artifactId> <version>5.2.0</version> </dependency> </dependencies>

提示:POI 5.x 需要 JDK 8 以上,且内部依赖log4j-api与commons-compress。如果你的项目里存在旧版xmlbeans(2.x),启动时会报NoSuchMethodError,这是版本冲突最常见的一种表现,用mvn dependency:tree排掉旧版本即可。

2.2 文档对象模型和可提取的内容粒度

XWPFDocument暴露的模型自上而下分四层:文档 → 正文(document body)→ 段落 / 表格 → 段落内的 run。run 是格式一致的最小文本单位,粗体、斜体、不同字号都会切一个 run。这也解释了为什么直接拿getText()有时字符串中间会出现奇怪的拼接——多 run 拼接是正常的,但如果你要做富文本渲染,就得遍历 run 而不是取整段文本。

可提取的粒度大致如下:

内容类型提取方式典型用途
正文纯文本paragraph.getText()全文检索、摘要
段落样式paragraph.getStyle()判断标题层级
表格单元格table.getRow(i).getCell(j).getText()结构化数据导入
页眉页脚document.getHeaderArray()合同编号、公司名
内嵌图片paragraph.getRuns()里的 picture附件转存

工程上通常不追求“把 Word 完整还原成 HTML”,那是一类独立需求,成本很高。多数入库场景只需要“标题 + 正文纯文本 + 表格结构 + 基础元信息(文件名、大小、段落数、上传时间)”。把提取范围先限定死,后面的表结构和事务设计才能稳定。

2.3 大文件场景:XWPF 与“事件模型”该怎么选

XWPFDocument会把整份文档的 XML 树加载进内存。经验阈值是这样的:几 MB、几百页以内的文档完全没问题;一旦超过 50MB 或者并发几十个上传,堆内存会明显吃紧,严重时 OOM。这时应该换成XWPFWordExtractor配合流式读取,或者用opc包下的OPCPackage打开后按需读取。另一种是 POI 的事件模型(XSSF 那一套流式接口),Word 侧对应XWPFDocument之外的读取方式,API 更绕,只在确实需要处理超大文档时才值得投入。

平时我一般会加一个前置门槛:文件大小上限 20MB、扩展名白名单.docx/.doc、解析前后各打一次耗时日志。超过阈值的文件直接拒掉并提示用户拆分,这比在解析过程中做各种兜底要省事得多。真正要处理超大文档,就单独开一条异步解析链路,不放在同步上传接口里。

3. 从上传到入库:接口、解析与持久化实现

3.1 接口层:上传参数校验先于解析

很多线上故障不是解析写错,而是校验放得太晚。文件类型如果单纯看后缀,很容易被改名的文件绕过。比较稳的做法是后缀 + MIME 双校验,再结合 POI 打开时的异常兜底。下面的 Controller 代码用 Spring Boot 风格,入参是MultipartFile,校验失败按统一响应返回。

@RestController @RequestMapping("/api/doc") public class WordUploadController { private final WordParseService wordParseService; public WordUploadController(WordParseService wordParseService) { this.wordParseService = wordParseService; } @PostMapping("/upload") public Result<Long> upload(@RequestParam("file") MultipartFile file) { // 1. 空文件快速失败 if (file == null || file.isEmpty()) { return Result.fail("文件为空"); } // 2. 后缀白名单 String name = Objects.requireNonNull(file.getOriginalFilename()); String lower = name.toLowerCase(Locale.ROOT); if (!lower.endsWith(".docx") && !lower.endsWith(".doc")) { return Result.fail("仅支持 .doc/.docx"); } // 3. 大小上限 20MB,避免整份文档进堆 if (file.getSize() > 20L * 1024 * 1024) { return Result.fail("文件超过 20MB,请拆分后再传"); } // 4. 进入解析与入库,异常统一在 service 内包装 Long docId = wordParseService.parseAndSave(file); return Result.ok(docId); } }

这里的关键逻辑是“先挡后解”:空文件、后缀、大小三项都在 Controller 里拦掉,不进解析逻辑。getOriginalFilename()在个别容器里可能返回 null,所以要判空。大小限制用字节数写死而不是靠配置中心,是为了让这条链路的边界足够确定,需要调整时改这一处即可。

参数说明:MultipartFile.getSize()返回字节数,20MB 的阈值适合一般管理系统;lower转换后判断避免.DOCX这类大写后缀被误拒。这里不校验 MIME,是因为不同浏览器对.doc的 MIME 不一致(application/msword和application/octet-stream都常见),强校验反而误伤,真正的兜底放在解析层的异常捕获上。

3.2 解析层:.docx 与 .doc 分开处理

解析服务要解决两个问题:按扩展名走不同 API,以及把段落和表格抽成统一结构。下面的实现返回一个ParsedDoc,包含标题、纯文本、段落列表和表格数量,入库时按这个结构落库。

@Service public class WordParseService { public ParsedDoc parse(InputStream in, String filename) throws IOException { String lower = filename.toLowerCase(Locale.ROOT); if (lower.endsWith(".docx")) { // .docx:XWPF 完整解析 try (XWPFDocument doc = new XWPFDocument(in)) { return parseDocx(doc); } } // .doc:HWPF 只做文本提取,结构能力有限 try (HWPFDocument doc = new HWPFDocument(in)) { String text = doc.getText().toString(); ParsedDoc r = new ParsedDoc(); r.setTitle(filename); r.setPlainText(text); r.setParagraphCount(0); // .doc 不保证段落信息 return r; } } private ParsedDoc parseDocx(XWPFDocument doc) { List<String> paragraphs = new ArrayList<>(); StringBuilder plain = new StringBuilder(); String title = null; for (XWPFParagraph p : doc.getParagraphs()) { String t = p.getText(); // 用样式名识别一级标题,常见样式是 Heading1 if (title == null && p.getStyle() != null && p.getStyle().toLowerCase().startsWith("heading1")) { title = t; } if (t != null && !t.isBlank()) { paragraphs.add(t.trim()); plain.append(t.trim()).append('\n'); } } ParsedDoc r = new ParsedDoc(); r.setTitle(title == null ? "未命名文档" : title); r.setPlainText(plain.toString()); r.setParagraphs(paragraphs); r.setParagraphCount(paragraphs.size()); r.setTableCount(doc.getTables().size()); return r; } }

逻辑说明:.docx用XWPFDocument打开后遍历getParagraphs(),空段落直接过滤,避免正文里出现大量空行。标题识别靠样式名而不是“第一个非空段落”,因为很多文档开头是封面信息。getStyle()可能返回 null,必须先判断。.doc分支用HWPFDocument.getText()拿全量文本,明确不承诺段落粒度,这比强行解析后抛异常要诚实。

参数上要注意几个点:XWPFDocument和HWPFDocument都实现了Closeable,必须 try-with-resources,否则文件句柄泄漏在高并发下会很快耗尽;InputStream由调用方管理,解析层不要关闭它两次;tableCount只统计顶层表格,嵌套表格需要用getTables()的单元格内表格再递归,一般入库场景不要求这个粒度。

3.3 持久化:表结构和事务边界怎么定

入库设计有两派:一派把整份纯文本塞进一个CLOB字段,另一派把段落拆成子表。前者简单,适合全文检索以外的一般展示;后者能做段落级定位和编辑,但表量和写入成本都高。我一般选混合方案:主表存文档元信息和纯文本,子表存段落,表格只存数量摘要,需要时再延迟解析。

CREATE TABLE doc_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, title VARCHAR(300), plain_text LONGTEXT, paragraph_cnt INT DEFAULT 0, table_cnt INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE doc_paragraph ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id BIGINT NOT NULL, seq INT NOT NULL, content TEXT, KEY idx_doc_seq (doc_id, seq) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意:字符集必须用utf8mb4。Word 里常见的方块符号、特殊标点、表情符号在utf8三字节编码下会直接报错,这个坑几乎每个项目都要踩一次。

事务上要把“插主表 + 批量插段落”包在同一个事务里。段落数量可能上千,逐条插入会明显拖慢,用 MyBatis 的foreach批量或在 JDBC 里addBatch。批大小经验值是 500 条一提交,太大反而增加回滚开销。入库完成后返回主表 id,前端跳转到详情页,整个链路就闭合了。

4. 踩坑与排查:这几类故障占了大半

4.1 现象:解析报OfficeXmlFileException,原因是伪装成 docx 的 doc

现象很典型:前端把.doc文件改名成.docx上传,后端按后缀走XWPFDocument,抛The supplied data appears to be in the OLE2 Format。原因就是扩展名不可信,OOXML 和 OLE2 的魔数头完全不同。解决方式是解析前读前 8 个字节判断格式,或者直接 try-catch 这个异常后回退到HWPFDocument。我一般是在 service 里做一次回退,日志里记一条“后缀与实际格式不符”,顺便统计这类文件的比例。

4.2 现象:生产环境偶发 OOM,原因是整份文档和并发上传叠加

现象是上线一段时间后堆内存持续上涨,full GC 后不回落,dump 出来全是XWPFDocument相关的 XML 对象。原因是单份文档全量加载,加上上传接口是同步的,并发一高内存就顶不住。解决分三步:接口层加上大小上限和并发限流;解析后立刻关闭文档对象;如果业务确实需要处理大文件,改成先落临时文件、异步解析,接口只返回任务 id。临时文件用完必须删,否则磁盘也会被慢慢吃掉。

4.3 现象:中文乱码或特殊字符插入失败,原因是连接与字符集不一致

现象是入库后详情页显示问号,或者插入时报Incorrect string value。原因通常有两层:数据库或表用了utf8而不是utf8mb4;JDBC URL 没带characterEncoding=utf8。解决顺序是先改表字符集和字段,再检查连接串,最后确认解析时没有用错编码读取流。这三步里漏掉任何一步都会继续乱码,排查时按从存储到读取的顺序逐一确认。

4.4 现象:依赖冲突导致启动失败,原因是 POI 与旧版 xmlbeans 并存

现象是应用启动或首次解析时抛NoSuchMethodError、NoClassDefFoundError,堆栈指向org.apache.xmlbeans或org.apache.commons.compress。原因是项目里其他组件传递依赖了旧版本。解决办法是mvn dependency:tree -Dincludes=org.apache.xmlbeans定位来源,用exclusion排掉旧版本并在dependencyManagement里锁定 POI 官方对应的版本。血泪经验是:POI 升级不要只升一个包,poi、poi-ooxml、xmlbeans、commons-compress要一起看。

4.5 现象:表格内容丢失,原因是只取了段落没遍历表格

现象是纯文本里表格数据完全不见,用户反馈“导入后表格没了”。原因是getParagraphs()只返回正文段落,表格是getTables()单独的对象,二者不重叠。解决是按文档 body 的顺序遍历,先处理段落再处理表格,或者用bodyElements按真实顺序取。如果需要表格结构入库,就单独建一张明细表,把行、列、单元格值序列化存 JSON,比强行拍平成文本更可靠。

5. 进阶技巧:把解析做成可验证、可回滚的链路

上面几步跑通之后,真正决定这套东西能不能长期用的,是“出问题时能不能快速定位”。我在项目里一般会加三层可验证手段。第一层是解析结果的摘要日志:文件名、大小、段落数、表格数、纯文本长度、解析耗时,五个字段打一行,出问题先看这一行就能判断是解析失败还是入库失败。第二层是入库前的内容指纹,对纯文本做一次 MD5,主表存这个字段,重复上传同一份文档时可以直接命中,避免重复解析和重复入库。

public String fingerprint(String plainText) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(plainText.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("MD5 不可用", e); } }

这段代码没什么花哨的地方,但用处很大:指纹相同直接返回已有 id,前端体验是“秒传”,后端省掉一次解析。注意指纹只对纯文本计算,不包含文件名和上传时间,否则永远对不上。如果业务要求同一份内容允许不同标题,就在指纹里拼上标题再算。

第三层是失败回滚。解析和入库放在同一个事务里还不够,因为文件本身可能已经写到了临时目录或对象存储。我的习惯是:解析阶段完全不碰持久化存储,只产出ParsedDoc对象;入库成功后才把原始文件转存,转存失败就补偿删除主表记录。这个顺序能保证“数据库里有记录,但文件没了”这种脏数据不会出现。

还有一个容易被忽略的点是线程安全。XWPFDocument不是线程安全的,绝对不能做成单例复用或者放进缓存。每次请求新建、用完关闭,这是最稳的用法。如果确实要做解析结果缓存,缓存的是ParsedDoc这种纯数据对象,不是文档对象本身。

验证方法上,我一般准备一个小型回归集:一份带标题的正常文档、一份只有表格的文档、一份.doc老格式、一份改名的伪.docx、一份 15MB 左右的大文件。这五份跑一遍,基本能覆盖八成线上问题。每次改解析逻辑或升 POI 版本,这五份都要过一遍。从那以后我每次动 Word 解析相关代码,都强制先把这份回归集跑完再提交,比事后翻日志省太多时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进