
简介面向需要把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 POIWord 导入这类需求选型先看两件事要不要保留表格结构要不要兼容老 .doc。把 Word 先转 PDF 再抽文本看起来省事实际把段落、表格行列、单元格坐标全丢了后面做字段映射只能靠正则猜。docx4j 功能全但 JAXB 依赖和 API 心智负担不低小团队维护成本高。常见做法是 Apache POI 打底docx 用 XWPF老 doc 用 HWPF图片和表格都能拿到结构。我一般会把依赖拆成两个poi-ooxml负责 .docxpoi-scratchpad负责 .doc。版本不要散落在各个模块统一放父 POM 的dependencyManagement避免 poi 和 poi-ooxml 版本错位导致NoSuchMethodError。dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version${poi.version}/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-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 包里的 XMLXWPFDocument 能直接按段落、表格、页眉页脚读。.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-resourcesXWPFDocument 和 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(); ListParagraphDTO 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); ListTableDTO 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); ListListString rows new ArrayList(); for (XWPFTableRow row : table.getRows()) { ListString 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 泄漏到 ServiceDTO 要按入库表设计不要按 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 ListParagraphDTO paragraphs new ArrayList(); private ListTableDTO tables new ArrayList(); // getter/setter 省略 } public class TableDTO { private int tableIndex; private int maxColumns; private ListListString rows new ArrayList(); // getter/setter 省略 }maxColumns不是装饰字段。合并单元格、空单元格会让每行列数不一致入库前用它统一补齐不然前端渲染表格时列错位查起来像玄学。fileSha256在解析前算重复上传直接命中唯一键不用先解析再判重。DTO 里不要放XWPFDocument、XWPFTable这类对象它们持有 XML 树和流序列化、缓存、异步传递都会出问题。3.3 表格行怎么映射成业务字段表头别名与空单元格表格入库有两种做法整行 JSON 存一张子表或者按表头映射成业务字段。前者通用后者好查。源码包里一般两种都留doc_table_row存原始行 JSON业务表按需再抽字段。表头映射不要写死中文用别名表运营改个“姓名/员工姓名/人员姓名”不至于改代码。public MapString, Integer headerIndex(ListString header) { MapString, String alias new HashMap(); alias.put(姓名, name); alias.put(员工姓名, name); alias.put(人员姓名, name); alias.put(部门, dept); alias.put(所属部门, dept); MapString, 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 连接串要开rewriteBatchedStatementstrue否则驱动可能还是一条条发。public void saveParagraphs(long docId, ListParagraphDTO list) { String sql insert into doc_paragraph(doc_id, para_index, content, style) values (?, ?, ?, ?); ListObject[] 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而在数据库连接和表字符集连接串没带useUnicodetruecharacterEncodingUTF-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 ListString normalize(ListString cells, int maxColumns) { ListString 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 解析需求都先拿三份脏样本跑一遍列数比对和回读校验再谈入库。希望帮到你。本文还有配套的精品资源点击获取