ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java读取Word上传解析入库:POI、批量插入与幂等校验

Java读取Word上传解析入库:POI、批量插入与幂等校验

简介:面向需要把Word文档数据自动录入数据库的Java开发者与学习者,这份项目示例围绕Apache POI解析.docx文本与表格、JDBC连接数据库并批量写入展开,覆盖数据转换、异常处理、事务一致性和多文件并行处理等常见工程要点,适合作为企业数据录入、表单归档类功能的参考实现。压缩包共41个文件、约13.24MB,包含11个jar依赖、9个java源文件与9个class编译文件,另有xml、properties等配置及Eclipse项目描述文件,便于直接导入对照源码与运行结构。已有5138人学习下载。读者可从中获得可运行的POI读取表格、字段映射、PreparedStatement批量插入示例,以及连接配置和编译产物,帮助快速搭建Word到数据库的处理链路,并借鉴异常与事务处理思路减少排错成本。

1. 从一份 Word 到一张表:Java 读取上传链路到底卡在哪

某后台的合同台账要支持运营上传 Word,解析后按段落和表格行入库,还要能按关键词检索。很多人把 Java 读取 Word 文件并上传数据库想成 MultipartFile 接住、POI 读文本、insert 三条语句,真跑起来先翻车在 .doc 与 .docx 分叉,再卡在合并单元格和中文乱码。

这个源码包把上传、解析、结构化、入库、回读校验串成一条能落地的链路,适合做文档导入、合同台账、报表采集的后端。它不是 OCR,扫描件和纯图片 Word 读不出内容;也不是低代码平台,字段映射仍要按业务表改。新手能顺着接口和表结构跑通,熟手重点看解析器边界、批量插入和幂等设计。

2. 选型先定边界:POI、docx4j 还是先转 PDF 再抽文本

2.1 为什么大多数后台系统选 Apache POI

Word 导入这类需求,选型先看两件事:要不要保留表格结构,要不要兼容老 .doc。把 Word 先转 PDF 再抽文本,看起来省事,实际把段落、表格行列、单元格坐标全丢了,后面做字段映射只能靠正则猜。docx4j 功能全,但 JAXB 依赖和 API 心智负担不低,小团队维护成本高。常见做法是 Apache POI 打底:docx 用 XWPF,老 doc 用 HWPF,图片和表格都能拿到结构。

我一般会把依赖拆成两个:poi-ooxml负责 .docx,poi-scratchpad负责 .doc。版本不要散落在各个模块,统一放父 POM 的dependencyManagement,避免 poi 和 poi-ooxml 版本错位导致NoSuchMethodError。

<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>${poi.version}</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-scratchpad</artifactId> <version>${poi.version}</version> </dependency>

${poi.version}由项目依赖管理统一锁定,不要在这个片段里写死。poi-ooxml会传递引入poi与xmlbeans,如果项目里已经有旧版xmlbeans,先用mvn dependency:tree排掉冲突。poi-scratchpad只在需要读 .doc 时加,不读老格式可以省掉。

选 POI 的理由不是它“最强”,而是边界清楚:段落是段落,表格是表格,读不到就抛异常,不会悄悄降级成一段纯文本。对入库场景,结构丢失比解析失败更可怕,失败还能重试,结构错了要洗数据。

2.2 .doc 与 .docx 的解析分叉:XWPF 和 HWPF 不要混用

.docx本质是 zip 包里的 XML,XWPFDocument 能直接按段落、表格、页眉页脚读。.doc是二进制复合文档,HWPF 只能覆盖一部分格式,遇到复杂表格、文本框、域代码,读出来缺行少列很常见。实际项目里我一般把 .doc 当降级通道:能读文本和简单表格就入库,读不了就提示用户另存为 .docx,不做无限兼容。

统一入口先按后缀分叉,再交给两个解析器,不要让 Service 里出现instanceof XWPFDocument。

public DocumentDTO parse(MultipartFile file) throws IOException { String original = Optional.ofNullable(file.getOriginalFilename()).orElse(""); String lower = original.toLowerCase(Locale.ROOT); if (lower.endsWith(".docx")) { return parseDocx(file.getInputStream()); } if (lower.endsWith(".doc")) { return parseDoc(file.getInputStream()); } throw new IllegalArgumentException("only .doc and .docx are supported"); } private DocumentDTO parseDocx(InputStream in) throws IOException { try (XWPFDocument doc = new XWPFDocument(in)) { return DocxReader.read(doc); } } private DocumentDTO parseDoc(InputStream in) throws IOException { try (HWPFDocument doc = new HWPFDocument(in)) { return DocReader.read(doc); } }

这段代码的关键在try-with-resources:XWPFDocument 和 HWPFDocument 都持有底层流,忘记关会在批量导入时把文件句柄耗光。parseDocx和parseDoc返回同一个DocumentDTO,上层不感知 POI 类型。后缀判断用toLowerCase(Locale.ROOT),避免土耳其语环境下的I/i问题,这种小坑在容器里排查很费时间。

2.3 把“读取”拆成段落、表格、图片三条管线

Word 入库最怕一锅炖。段落、表格、图片的生命周期不一样:段落要保留顺序和样式,表格要保留行列坐标,图片一般只存路径或文件 ID,不把二进制塞进业务表。解析器里拆成三条管线,DTO 里各放各的集合,后面入库按表拆。

public final class DocxReader { public static DocumentDTO read(XWPFDocument doc) { DocumentDTO dto = new DocumentDTO(); List<ParagraphDTO> paragraphs = new ArrayList<>(); for (int i = 0; i < doc.getParagraphs().size(); i++) { XWPFParagraph p = doc.getParagraphs().get(i); ParagraphDTO pd = new ParagraphDTO(); pd.setIndex(i); pd.setText(p.getText()); pd.setStyle(p.getStyle()); paragraphs.add(pd); } dto.setParagraphs(paragraphs); List<TableDTO> tables = new ArrayList<>(); for (int t = 0; t < doc.getTables().size(); t++) { XWPFTable table = doc.getTables().get(t); TableDTO td = new TableDTO(); td.setTableIndex(t); List<List<String>> rows = new ArrayList<>(); for (XWPFTableRow row : table.getRows()) { List<String> cells = new ArrayList<>(); for (XWPFTableCell cell : row.getTableCells()) { cells.add(cell.getText()); } rows.add(cells); } td.setRows(rows); tables.add(td); } dto.setTables(tables); return dto; } }

doc.getParagraphs()只拿顶层段落,表格单元格里的段落不会出现在这里,这是很多人统计段落数对不上的原因。表格行用getTableCells()拿物理单元格,合并列会少列,后面入库前要按最大列数补齐。cell.getText()只拿文本,单元格里的换行、制表符会被压平,需要保留格式就继续读cell.getParagraphs()。图片管线不在这个骨架里展开,常见做法是遍历doc.getAllPictures()存到文件服务,业务表只留media_id和顺序号。

3. 上传接口与解析服务:从 MultipartFile 到结构化文档对象

3.1 控制器只做三件事:校验后缀、限流大小、转交解析

上传接口最容易被写成万能入口:接文件、解析、入库、返回结果全在 Controller 里。后面加异步、加幂等、加回读校验,改一处崩三处。我一般让 Controller 只做三件事:判空和后缀、卡文件大小、把 MultipartFile 交给导入服务。业务异常统一抛,返回体只带docId、段落数、表格数和耗时,方便前端提示和排查。

@PostMapping("/api/docs/import") public ImportResult importDoc(@RequestParam("file") MultipartFile file) { if (file == null || file.isEmpty()) { throw new BizException("EMPTY_FILE"); } String name = Optional.ofNullable(file.getOriginalFilename()).orElse(""); String lower = name.toLowerCase(Locale.ROOT); if (!lower.endsWith(".doc") && !lower.endsWith(".docx")) { throw new BizException("ONLY_DOC_OR_DOCX"); } if (file.getSize() > 20L * 1024 * 1024) { throw new BizException("FILE_TOO_LARGE"); } return importService.importOne(file); }

@RequestParam("file")要和前端表单字段名一致,不一致会直接 400,不是解析失败。20MB 是业务侧兜底,Spring 的spring.servlet.multipart.max-file-size和max-request-size也要同步设,两个地方一个放宽一个收紧,最后表现就是“偶尔能传偶尔不能”。后缀校验只防误传,不防伪造,真正解析时 POI 抛异常再兜一层。

3.2 解析器返回统一 DTO,别把 XWPFDocument 泄漏到 Service

DTO 要按入库表设计,不要按 POI 的类设计。段落 DTO 至少留index、text、style;表格 DTO 留tableIndex、rows、maxColumns;文档 DTO 再挂fileName、fileSha256、paragraphs、tables。这样解析器换实现,Service 和 DAO 不用动。

public class DocumentDTO { private String fileName; private String fileSha256; private List<ParagraphDTO> paragraphs = new ArrayList<>(); private List<TableDTO> tables = new ArrayList<>(); // getter/setter 省略 } public class TableDTO { private int tableIndex; private int maxColumns; private List<List<String>> rows = new ArrayList<>(); // getter/setter 省略 }

maxColumns不是装饰字段。合并单元格、空单元格会让每行列数不一致,入库前用它统一补齐,不然前端渲染表格时列错位,查起来像玄学。fileSha256在解析前算,重复上传直接命中唯一键,不用先解析再判重。DTO 里不要放XWPFDocument、XWPFTable这类对象,它们持有 XML 树和流,序列化、缓存、异步传递都会出问题。

3.3 表格行怎么映射成业务字段:表头别名与空单元格

表格入库有两种做法:整行 JSON 存一张子表,或者按表头映射成业务字段。前者通用,后者好查。源码包里一般两种都留:doc_table_row存原始行 JSON,业务表按需再抽字段。表头映射不要写死中文,用别名表,运营改个“姓名/员工姓名/人员姓名”不至于改代码。

public Map<String, Integer> headerIndex(List<String> header) { Map<String, String> alias = new HashMap<>(); alias.put("姓名", "name"); alias.put("员工姓名", "name"); alias.put("人员姓名", "name"); alias.put("部门", "dept"); alias.put("所属部门", "dept"); Map<String, Integer> index = new HashMap<>(); for (int i = 0; i < header.size(); i++) { String key = alias.get(header.get(i).trim()); if (key != null) { index.putIfAbsent(key, i); } } return index; }

trim()不能省,Word 表头里常有前后空格和不间断空格。putIfAbsent保证重复别名只取第一列,后面出现同名列不会覆盖。映射不到的业务字段要记录到unmapped_headers,不要静默丢列。空单元格按空字符串入库,不要转 null,否则前端回显和后续concat查询会多一层判空。

4. 入库不是 save 一下:批量插入、事务与字段长度

4.1 表结构设计:主表存元数据,子表存段落与表格行

Word 导入的表结构不要只建一张大表。主表存文件元数据和解析状态,段落表按顺序存文本,表格行表存原始行 JSON,需要检索的字段再抽到业务表。这样重导入时只删子表,不污染业务数据。

表关键字段用途
doc_mainid、file_name、file_sha256、status、created_at一次导入一条,file_sha256唯一
doc_paragraphdoc_id、para_index、content、style段落顺序和文本
doc_table_rowdoc_id、table_index、row_index、row_json表格原始行,保留列坐标
doc_import_logdoc_id、step、cost_ms、error_msg排查解析和入库耗时

file_sha256用char(64),不要用varchar(255),唯一索引长度可控。content用text或mediumtext,按单段落长度选,别一上来就longtext。row_json用 JSON 类型,MySQL 5.7 以上支持,查询时可以用json_extract,但高频检索字段还是抽列,别让业务查询全靠 JSON 扫描。

4.2 JdbcTemplate 批量插入与批大小

段落和表格行动辄几百上千条,逐条insert在开发环境看不出,生产上一万行能跑几十秒。用JdbcTemplate.batchUpdate按批提交,批大小我一般设 500,太小网络往返多,太大事务日志和内存压力上来。MySQL 连接串要开rewriteBatchedStatements=true,否则驱动可能还是一条条发。

public void saveParagraphs(long docId, List<ParagraphDTO> list) { String sql = "insert into doc_paragraph(doc_id, para_index, content, style) values (?, ?, ?, ?)"; List<Object[]> args = new ArrayList<>(list.size()); for (ParagraphDTO p : list) { args.add(new Object[]{docId, p.getIndex(), p.getText(), p.getStyle()}); } int batchSize = 500; for (int from = 0; from < args.size(); from += batchSize) { int to = Math.min(from + batchSize, args.size()); jdbcTemplate.batchUpdate(sql, args.subList(from, to)); } }

args.subList是视图,不要在循环里再改原列表。batchSize按数据库和网络调,500 是保守值。content入库前做长度截断或校验,超长直接抛业务异常,别让数据库报Data too long后整批回滚。style可能为 null,允许空就留空,不允许就在 DTO 层给默认值。

4.3 事务边界:解析失败不留半截数据

事务最容易翻车的点是把解析也包进去。POI 解析大文件耗时几秒到几十秒,事务一直开着,数据库连接被占住,并发一上来连接池就满。我一般把流程拆成三段:事务外解析并算哈希,事务内先查重再写主表和子表,事务外做回读校验和日志。

@Transactional(rollbackFor = Exception.class) public long saveDocument(DocumentDTO dto) { Long exists = jdbcTemplate.queryForObject( "select id from doc_main where file_sha256 = ?", Long.class, dto.getFileSha256()); if (exists != null) { return exists; } KeyHolder kh = new GeneratedKeyHolder(); jdbcTemplate.update(con -> { PreparedStatement ps = con.prepareStatement( "insert into doc_main(file_name, file_sha256, status, created_at) values (?, ?, ?, now())", Statement.RETURN_GENERATED_KEYS); ps.setString(1, dto.getFileName()); ps.setString(2, dto.getFileSha256()); ps.setString(3, "PARSED"); return ps; }, kh); long docId = kh.getKey().longValue(); saveParagraphs(docId, dto.getParagraphs()); saveTableRows(docId, dto.getTables()); return docId; }

rollbackFor = Exception.class要显式写,默认只回滚运行时异常。查重和插入放在同一事务里仍有并发窗口,所以file_sha256上必须建唯一索引,捕获DuplicateKeyException后回查已有 ID。先删后插只适合重导入明确覆盖的场景,正常导入用唯一键幂等更稳。

5. 避坑排查:中文乱码、合并单元格、OOM 与重复上传

5.1 现象:中文变成问号或方块

现象是入库后段落文本正常,表格里的中文变成???,或者前端展示成方块。原因通常不在 POI,而在数据库连接和表字符集:连接串没带useUnicode=true&characterEncoding=UTF-8,表用latin1,或者上传文件名按 ISO-8859-1 解了一遍。先查库和连接,再怀疑文件。

show variables like 'character_set%'; show create table doc_paragraph;

表统一utf8mb4,连接串显式指定编码。老 .doc 里的文本如果是本地编码,HWPF 读出来可能已经是错码,这种要在解析入口按文件来源做编码探测,别指望入库时再救。文件名乱码则用new String(name.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8)前先确认容器编码,不要无脑转。

5.2 现象:表格列数对不上

现象是同一张表第一行 6 列,第三行 4 列,前端渲染错位。原因多半是合并单元格:POI 的getTableCells()返回物理单元格,跨列合并不会重复占位;空单元格也可能被跳过。解决是按maxColumns补齐,同时保留原始tableIndex和rowIndex,别在解析阶段就把坐标丢了。

private List<String> normalize(List<String> cells, int maxColumns) { List<String> row = new ArrayList<>(cells); while (row.size() < maxColumns) { row.add(""); } return row.size() > maxColumns ? row.subList(0, maxColumns) : row; }

补齐只解决渲染,业务映射还要知道哪一列是合并出来的。需要精确坐标就继续读CTTcPr的gridSpan,把跨列数记到 JSON 里。不要用“第一列一定是姓名”这种假设,表头别名映射比位置映射抗改。

5.3 现象:大文件解析时堆内存飙升

现象是 30MB 的 Word 一上传,服务堆内存直线上升,频繁 Full GC,最后 OOM。XWPFDocument 会把整个文档加载进内存,图片越多越明显。先卡上传大小,再把解析放异步线程池,别在 Web 线程里硬扛。图片不要全部读成byte[]再塞 DTO,边读边写临时文件或对象存储。

if (file.getSize() > maxFileSize) { throw new BizException("FILE_TOO_LARGE"); }

maxFileSize按业务定,合同类 20MB 够用,带大量扫描图的 Word 要单独走图片压缩或人工拆分。异步解析要配独立线程池和队列上限,队列满了直接拒绝,别让任务堆在内存里。解析完及时关流,XWPFDocument的close()不能漏。

5.4 现象:同一文件重复入库

现象是运营点两次上传,主表出现两条一样的数据,子表翻倍。原因可能是前端重复提交、网关重试、用户改文件名再传。解决用文件内容哈希做幂等键,不要用文件名。SHA-256 在解析前算,命中唯一键直接返回已有docId。

public static String sha256(InputStream in) throws IOException { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { md.update(buf, 0, len); } StringBuilder sb = new StringBuilder(); for (byte b : md.digest()) { sb.append(String.format("%02x", b)); } return sb.toString(); }

流读完要重新打开文件再解析,MultipartFile 的getInputStream()每次调用一般给新流,但不要假设所有实现都支持重复读。唯一索引冲突捕获后回查,返回同一docId,前端提示“已存在”而不是报错。

5.5 现象:.doc 文件一读就抛异常

现象是 .docx 正常,.doc 一上传就抛OfficeXmlFileException或OldWordFileFormatException,或者读出来只有几行。原因常见两类:后缀是 .doc 实际是 .docx 改名的,或者 HWPF 不支持该 .doc 的复杂结构。先做文件头校验,再看异常类型,不要一律当解析失败。

if (lower.endsWith(".doc")) { try (InputStream in = file.getInputStream()) { new HWPFDocument(in); } catch (Exception e) { throw new BizException("DOC_PARSE_FAILED_SAVE_AS_DOCX"); } }

文件头校验能挡住改名文件。HWPF 读不了的就提示另存为 .docx,不要在生产环境做二进制格式转换,依赖重、效果不稳。导入日志里记下原始文件名、大小、异常类名,后面运营反馈“这份文件传不上”时能直接定位,不用靠猜。

6. 进阶验证:用回读比对和影子表把上传链路跑稳

6.1 回读比对:段落数、表格行列数、文本哈希

解析入库后别只看接口返回成功。我一般做一次回读比对:按doc_id查段落数、表格行数,和 DTO 里的数量比;再抽前 20 段拼文本算哈希,和解析结果比。数量对不上,先查合并单元格和空行;哈希对不上,查字符集和trim。这三项跑通,基本能挡住大部分“看起来成功、查出来是空”的问题。

public void verify(long docId, DocumentDTO dto) { Integer paraCount = jdbcTemplate.queryForObject( "select count(*) from doc_paragraph where doc_id = ?", Integer.class, docId); if (paraCount == null || paraCount != dto.getParagraphs().size()) { throw new IllegalStateException("paragraph count mismatch"); } Integer tableRows = jdbcTemplate.queryForObject( "select count(*) from doc_table_row where doc_id = ?", Integer.class, docId); int expected = dto.getTables().stream().mapToInt(t -> t.getRows().size()).sum(); if (tableRows == null || tableRows != expected) { throw new IllegalStateException("table row count mismatch"); } }

verify放在事务提交后跑,失败只记日志和告警,不回滚已入库数据,避免大文件反复重试把库压垮。回读比对用的连接要和业务连接分开,别让校验把连接池占满。

6.2 影子表先跑批再切主表

新解析器上线前,我会建一张doc_shadow影子表,拿历史 Word 跑批,把段落数、表格行数、文本哈希、耗时四个指标记下来,和旧链路对比。差异超过阈值的样本单独捞出来看,是合并单元格还是编码问题。影子表跑一周,再切主表,比直接上线后靠用户反馈稳得多。

指标采集方式关注点
段落数doc_paragraphcount顶层段落是否漏读
表格行数doc_table_rowcount合并单元格是否少列
文本哈希前 20 段拼接 SHA-256编码和空白字符
解析耗时日志cost_ms大文件是否拖垮线程池

从那以后我每次接 Word 解析需求,都先拿三份脏样本跑一遍列数比对和回读校验,再谈入库。希望帮到你。

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

RELATED READING

延伸阅读

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