ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java数据库导出Word文档:JDBC+POI完整实现方案

Java数据库导出Word文档:JDBC+POI完整实现方案 简介一个面向.NET开发者的C#数据库转Word文档完整工程演示如何通过ADO.NET连接SQL Server等数据库读取表结构与字段信息并借助Word对象模型将数据以表格形式导出为文档。适用于有C#基础、需要实现报表自动生成或数据导出功能的开发人员。压缩包共230个文件约2.62MB以51个.cs源码文件为核心另含46个dll库、32个pdb调试文件、9个csproj工程文件及6个config配置文件并附3个sql脚本与说明文档目录结构完整可直接在Visual Studio中打开运行。目前已有429人学习使用。借助该项目可掌握数据库连接、SQL查询构造、Word表格填充与样式设置的完整流程同时了解Office Interop与NPOI/OpenXML等替代方案的选型思路对实现自动化报表与数据导出工作流有直接参考价值。1. 程序实现数据库生成 word 文档这不是导出 PDF是一套能直接改的导出接口很多朋友拿到“程序实现数据库生成 word 文档”这个需求时第一反应是打开一篇 POI 教程抄几行代码结果导出的文档要么中文乱码要么表格多出一行要么打开时提示需要修复。这套资源拆解下来其实是一条完整的导出链路程序从数据库查出记录按配置好的字段映射和样式规则生成可编辑的 docx 文档。它适合后端开发接报表导出、课程设计做管理系统以及每天要从业务库手工复制数据到 Word 里的运维同事。它不是一次性脚本而是一个能跑通的工程改改数据库连接和 SQL 就能用在多个项目里。2. 选型不能拍脑袋JDBC 读库 POI 拼文档先看对比再动手数据库生成 word 文档的程序网上能找到很多版本但大多数是用 HTML 改后缀名冒充 docx。真正的导出程序要能控制标题级别、表格宽度、分页位置还要能应对中文。我选型时对比过三条路JDBC 直连 Apache POI、ORM docx4j、Word 模板 XML 占位符。这套程序最终走的是 JDBC POI原因很直接依赖少导出逻辑肉眼可见出问题能直接定位到是哪一步写操作出了问题。下面把选型理由和工程结构讲清楚。2.1 数据读出JDBC 直连负责“读得干净”连接池负责“扛得住”数据读出的关键不只是把 SQL 跑一遍。业务系统里的数据库通常有多个库、多种字符集导出程序一旦连错库或者忽略编码后面生成的 Word 全是乱码。这里选择 JDBC 直连而不引入 MyBatis 或 JPA是因为导出程序的查询逻辑往往只有几条固定 SQL用 ORM 反而要维护实体类和 XML 映射多一层没必要的转换。程序里把连接信息放在 jdbc.properties方便换环境时直接改配置。jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/report_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrue jdbc.usernamereport_user jdbc.passwordreport_pwd jdbc.maxActive10 jdbc.initialSize2这段配置里最容易被忽略的是 URL 后面的四个参数。useUnicode 和 characterEncoding 必须成对出现utf8 决定了数据库返回的字符串进入 Java 后不会变成乱码serverTimezone 指定数据库服务器的时区否则新版 MySQL 驱动会在连接阶段直接报 timezone 异常rewriteBatchedStatements 在批量写入场景下有效这里虽然只是查询但保留它对同一条连接执行多次预编译有好处。maxActive 表示连接池最大活跃连接数导出操作如果同时被多个用户触发设得太小会互相等待设得太大又会让数据库连接数爆掉。程序里一般会再封装一个 JdbcManager用 HikariCP 做连接池避免每次导出都创建新连接。public class JdbcManager { private static HikariDataSource dataSource; static { Properties props new Properties(); try (InputStream in JdbcManager.class.getClassLoader().getResourceAsStream(jdbc.properties)) { props.load(in); } catch (IOException e) { throw new ExceptionInInitializerError(e); } HikariConfig config new HikariConfig(); config.setJdbcUrl(props.getProperty(jdbc.url)); config.setUsername(props.getProperty(jdbc.username)); config.setPassword(props.getProperty(jdbc.password)); config.setDriverClassName(props.getProperty(jdbc.driver)); config.setMaximumPoolSize(Integer.parseInt(props.getProperty(jdbc.maxActive))); config.setMinimumIdle(Integer.parseInt(props.getProperty(jdbc.initialSize))); dataSource new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }代码的逻辑是在类加载阶段读取 jdbc.properties初始化 HikariCP 数据源之后所有操作都通过 getConnection 获取连接。HikariCP 的两个参数这里要额外解释maximumPoolSize 是对外能同时借出的连接数minimumIdle 是空闲时保留的最小连接数。导出 Word 是“查数据 写文档”交替执行的长任务连接如果每次都新建数据库端会出现大量 TIME_WAIT 连接微信公众号后台那种几分钟一次的导出任务看不出来但到了批量生成几十份合同的时候就非常明显。2.2 文档生成Apache POI 的 XWPF 才是改 Word 的正路Word 生成方案我见过三类第一类是直接用 Java 拼 HTML再把 HTML 后缀改成 doc 或 docx这种文件打开时 Word 会提示格式与扩展名不匹配而且不能设置大纲级别导航窗格完全废掉。第二类是 FreeMarker 结合 Word 转 XML 的模板适合格式完全固定的合同书但遇到动态行数的表格模板里要写大量循环标签维护成本不低。第三类就是用 Apache POI 的 XWPF 组件直接操作 docx这也是这套程序采用的方案。POI 把 docx 文档看成一段 XML 的树形结构文档对应 XWPFDocument段落对应 XWPFParagraph段落里的文字块对应 XWPFRun表格对应 XWPFTable。最小单位是 Run同一个段落里如果字体不一样就必须拆成多个 Run。下面是最简单的一段生成代码XWPFDocument document new XWPFDocument(); XWPFParagraph paragraph document.createParagraph(); XWPFRun run paragraph.createRun(); run.setText(示例从数据库读出来的订单号 AB123); run.setFontSize(12);这段代码展示了写入 Word 的基本动作先建文档对象再建段落然后在段落里建 Run 并写入文字。setFontSize 设置的是字号单位是磅。这里要注意中文字体不能只靠 setFontSize 保证还需要设置一个中文字体族比如 setFontFamily(宋体)否则 Word 在 Windows 下打开时会用默认字体替代实际显示效果和程序里预期的段落格式不一致。这套程序里专门封装了 FontUtil统一处理中文段落字体、加粗和颜色后面第三章会讲具体写法。2.3 源码包目录结构拿到程序后先找这几个文件刚拿到这套程序时不要急着跑先把目录结构看一遍。工程是一个标准的 Maven 项目入口是一个 main 方法核心代码集中在 export 包下。目录结构如下word-export/ ├── pom.xml ├── sql/ │ └── report_query.sql └── src/main/ ├── java/cn/example/wordexporter/ │ ├── Main.java │ ├── config/ │ │ ├── JdbcManager.java │ │ └── ExportConfig.java │ ├── dao/ │ │ └── ReportDao.java │ ├── service/ │ │ └── WordExportService.java │ ├── util/ │ │ ├── FontUtil.java │ │ └── TableUtil.java │ └── export/ │ └── DocxExporter.java └── resources/ ├── jdbc.properties └── field_mapping.properties这里每个文件职责都很单一ReportDao 负责执行 SQL 并返回 List 数据DocxExporter 负责把数据写成 WordWordExportService 负责协调整个流程。pom.xml 里只放两个核心依赖poi-ooxml 负责读写 docxmysql-connector-j 负责数据库驱动版本号都写在 Maven 仓库里直接用中央仓库默认的最新稳定版即可。文件作用改动频率jdbc.properties数据库连接参数换环境时改field_mapping.properties数据库字段与 Word 标题的对应关系每次接新报表时改report_query.sql导出的数据查询 SQL每次接新报表时改Main.java程序入口读取参数并调 service基本不动DocxExporter.javaPOI 写 Word 的核心实现调样式时改这套结构的好处是数据查询和文档生成分离。改 SQL 不用碰 Java 代码改 Word 排版不用碰 SQL两个人并行也互相干扰。有些课程设计里会把 SQL 直接写在 main 方法里改一次需求就要重新编译一次而这里只需要替换 properties 和 sql 文件。下一章就顺着这个结构把核心实现拆开看。3. 核心实现查数据、写标题、建表格、插分页完整程序骨架这一章是复现的关键。整套导出流程可以拆成四步先把数据库结果集变成 Java 对象再把查询结果写成标题段接着把明细数据写成表格最后按规则插入分页符和页眉页脚。每一步都对应源码包里的一个类下面按顺序过一遍。3.1 查询数据把 ResultSet 映射成 ListMapString, Object数据库查询的结果是一个 ResultSet直接把它传给 Word 写入逻辑会非常别扭因为 ResultSet 的光标只能向前移动一次。程序里先在 ReportDao 中把结果转成 List每个 Map 代表一行记录Map 的 key 是 SQL 里的列名。这里用 LinkedHashMap 而不是 HashMap是为了保持列顺序和 SELECT 语句中的顺序一致写表格时就不用额外排序。public ListMapString, Object queryForList(Connection conn, String sql, Object... params) throws SQLException { ListMapString, Object rows new ArrayList(); try (PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { ResultSetMetaData metaData rs.getMetaData(); int columnCount metaData.getColumnCount(); while (rs.next()) { MapString, Object row new LinkedHashMap(); for (int i 1; i columnCount; i) { row.put(metaData.getColumnLabel(i), rs.getObject(i)); } rows.add(row); } } } return rows; }这段代码用了 try-with-resourcesPreparedStatement 和 ResultSet 都会在方法结束后自动关闭。使用 PreparedStatement 是为了防止 SQL 注入导出程序要接收外部传入的查询条件不能把字符串直接拼进 SQL。getColumnLabel 取的是 SQL 别名如果查询里写的是select order_no as orderNo from t_order那么 Map 的 key 就是 orderNo后面配置字段映射时会用这个 key 去取值。getObject 返回的是 Java 对象的原始类型日期、BigDecimal 在这一步先不做转换等写入 Word 时统一转字符串避免出现格式不一致。3.2 写标题动态生成带大纲级别的章节标题很多导出程序生成的 Word 没有大纲级别导航窗格里什么也看不到打印目录也找不到标题。这个问题在选型章节提过现在看实现。POI 中设置大纲级别需要操作底层 XML通过 setStyle 设置 Heading1 等内置样式同时还要给段落设置 outlineLvl。private void addHeading(XWPFDocument document, String text, int level) { XWPFParagraph paragraph document.createParagraph(); paragraph.setAlignment(ParagraphAlignment.LEFT); paragraph.setStyle(Heading level); paragraph.getCTP().getPPr().addNewOutlineLvl().setVal(level - 1); XWPFRun run paragraph.createRun(); run.setText(text); run.setBold(true); run.setFontSize(level 1 ? 16 : 14); run.setFontFamily(微软雅黑); }这里有两个细节容易翻车。一是 setStyle(Heading1) 只是使用了内置样式Word 会根据当前主题决定字体颜色这不代表你想要的格式真正决定它在导航窗格中层级的是 outlineLvl 的值level 1 对应值 0level 2 对应值 1依次类推。二是同一个段落里同时设置了 setBold 和 setFontSize但这两个属性都作用在 Run 上如果后面还要追加文字并且希望新文字不加粗就必须另建一个 Run而不是接着在同一个 Run 上 setText。标题的数据来源通常是主表里的某个字段比如“某某公司的 2024 年 12 月报表”。程序从 Map 中取出这个字段值再拼上固定前缀。这个拼接动作放在 WordExportService 里而不是写在 DocxExporter 里因为标题规则是业务层面的换一个项目可能就变了。3.3 建表格列头、数据行、空值兜底一个方法搞定明细区明细数据是 Word 里最常见的内容。POI 创建表格有两种方式先 createTable 再逐行写内容或者直接传入行列数创建空表格然后填充。第二种方式更适合程序里的动态数据因为行数可以直接用 list.size() 1 算出来。private void addDataTable(XWPFDocument document, String[] headers, ListMapString, Object rows, String[] fields) { XWPFTable table document.createTable(rows.size() 1, fields.length); XWPFTableRow headerRow table.getRow(0); for (int i 0; i headers.length; i) { headerRow.getCell(i).setText(headers[i]); } for (int r 0; r rows.size(); r) { XWPFTableRow tableRow table.getRow(r 1); MapString, Object row rows.get(r); for (int c 0; c fields.length; c) { Object value row.get(fields[c]); tableRow.getCell(c).setText(value null ? / : value.toString()); } } }这段代码最值得说的是空值兜底。数据库里的 NULL 如果直接转字符串写进 Word 后单元格会变成空白看起来像漏了数据。这里统一把 NULL 替换成“/”既保留了列结构也方便事后核对。fields 数组的顺序决定了每行单元格的数据来源程序本身不知道哪一列是订单号、哪一列是金额它只按数组下标去 Map 里取值所以这一层必须和 field_mapping.properties 一一对应。表格创建后列宽默认是平均分配的如果某一列是超长文本比如备注字段Word 会自动换行这一点后续在第四章讲列宽调整。3.4 分页符和页眉页脚让多页文档不再像流水账数据量超过一页时Word 会按页面大小自动分页但自动分页不会照顾章节逻辑。比如第一张表结束、第二张表开始时可能正好从表格中间断掉。程序里的做法是在每组数据结束之后手动插入分页符让下一个章节从新的一页开始。XWPFParagraph pageBreak document.createParagraph(); XWPFRun run pageBreak.createRun(); run.addBreak(BreakType.PAGE);addBreak(BreakType.PAGE) 会在当前段落后插入一个分页标记。需要注意的是这句话不要把分页符加在表格内部否则会破坏表格结构。正确位置是上一张表格写完之后下一章标题写入之前。页眉页脚用 XWPFHeaderFooterPolicy 处理页眉通常放公司名称页脚放页码。页码需要插入域代码不能直接写死文本否则每页都是同一个页码。POI 对域代码的支持比较底层程序里封装了 createField 方法生成PAGE域。这个功能属于锦上添花如果只是内部使用可以暂时不接页码但页眉建议还是加上因为正式文档没有页眉会显得很不完整。4. 参数怎么调连接、字体、分页、空值五组最关键的配置程序跑通之后大家问得最多的问题就是“我改了字段名怎么没效果”“为什么表格这么挤”“页码怎么还是 1”。这些问题基本都能在前面的参数配置里找到答案。这一章把整套程序里最关键的五组参数单拎出来讲改之前先对照检查一遍。4.1 JDBC 与连接池参数超时、编码、最大连接数数据库连接相关的参数在 jdbc.properties 里集中管理前面已经看过基础配置。这里补三个容易低估的参数参数建议值作用connectionTimeout30000连接池获取连接的超时时间单位毫秒validationTimeout5000连接申请校验的超时时间maxLifetime1800000连接最长存活时间避免被防火墙切断这三个参数在长任务导出时尤其重要。如果数据库端设置了 wait_timeout而连接池里某个连接空闲时间超过这个阈值下次使用时会拿到一个已经失效的连接。HikariCP 默认会自动检测但检测需要额外发送心跳包在高峰导出时段会明显拖慢第一次查询速度。设置 maxLifetime 略小于数据库 wait_timeout可以让连接在真正失效前被主动重建。很多同学在自己电脑上跑程序没问题部署到服务器后第一次导出要等十几秒基本都是连接池参数没调。4.2 中文字体和页边距宋体、行距、表格字号POI 默认字体是 Calibri如果不设置中文字体Windows 环境下打开会看到宋体但不同操作系统上打开可能变成其他字体导致段落行数变化、表格变高。程序里 FontUtil 的核心逻辑是同时设置 ascii 字体和 eastAsia 字体光 setFontFamily 一个方法只能设置西文字体。public static void setChineseFont(XWPFRun run, String fontName, int fontSize, boolean bold) { run.setFontFamily(fontName); run.setFontSize(fontSize); run.setBold(bold); run.getCTR().getRPr().getRFontsArray(0).setEastAsia(fontName); }run.getCTR().getRPr() 操作的是底层 XML 对象需要在创建 Run 之后立即调用否则 rPr 可能为 null。getRFontsArray(0) 取得当前 Run 的字体数组setEastAsia 就是设置中文字体。页边距也在文档初始化阶段设置包括上下左右边距、页面宽度和高度。程序里有两个选择正文用宋体五号表格内容用小五号标题用微软雅黑这样打印和屏幕阅读都能兼顾。4.3 分页与章节什么时候插分页符什么时候靠样式分页符不是越多越好。如果每组数据固定从新页开始而每组数据内容不足一页文档就会有很多半空白页如果不分页表格在页中断开阅读体验又不好。程序里默认按前几章的规则一级标题出现前强制分页明细表格和下一个一级标题之间不强制分页。这个开关放在 ExportConfig.java 中字段叫 forcePageBeforeLevel1默认 true。如果导出的是 A4 纸打印用的流程图手动分页符位置要人工确定如果导出的是屏幕阅读的在线报告可以让 Word 自动分页只保留标题段落的“段前分页”属性。POI 通过设置段落属性 pageBreakBefore 实现paragraph.getCTP().getPPr().addNewPageBreakBefore().setVal(true);4.4 字段映射与空值策略数据库字段到 Word 标题的对照关系field_mapping.properties 是接新报表时改动最频繁的文件。它的作用是告诉程序数据库查询结果的哪个列名对应 Word 表格里的哪个表头以及该列数据为空时显示什么。order.orderNo订单编号|/ order.orderDate下单日期|/ order.customerName客户名称|未填写 order.totalAmount订单金额|0.00竖线前面的部分是数据库列名竖线后面是 Word 表头名称再后面是空值默认显示。解析时按竖线拆分成两个或三个字段列名和表头名顺序必须一一对应。这里有个很容易弄反的点properties 文件里“”右边如果也包含竖线解析时要先 split(\|)然后把值的第一段作为表头第二段作为空值策略不要把整个字符串直接拿去做表头。程序里专门写了 FieldMappingConfig 类把配置文件解析成 Map在写表格时直接查这个 Map。5. 避坑指南乱码、空行、列宽和内存溢出的排错记录这一章把我在真实项目里踩过的坑集中写出来每一条都按“现象、原因、解决”的顺序记录。你复现这套程序时如果遇到同样问题直接对照这一章排错比重新翻 POI 文档快得多。5.1 一导出就乱码中文变成问号或者 Word 提示文件损坏现象程序跑完没报错打开 docx 发现所有中文都变成了“?”英文和数字正常有些情况是整个文件打不开提示“文件已损坏是否尝试修复”。原因中文变问号多半是 JDBC 连接串里没有 characterEncodingutf8数据库返回的 UTF-8 字节被当成默认字符集解码。文件打不开常见于手动拼接 XML 时没有处理转义字符比如把数据库里的“”直接写进票据而 Word 底层 XML 里必须写成amp;。POI 的 setText 方法会自动转义但如果你为了修改底层标记而直接操作 XML 对象就容易漏掉这一点。解决连接的 URL 上把 useUnicodetruecharacterEncodingutf8 写成固定模板程序启动时校验一次没有这两个参数直接抛异常。所有文本写入都走 setText不要自己拼 XML 字符串。如果已经生成了打不开的文件用文本编辑器打开 docx 的 document.xml 片段检查特殊字符。5.2 表格多出一行每个表格最后都有一个空行怎么删都删不掉现象生成的 Word 文档里每一张表格下面都多出一个空白段落打印时每张表后面都空一块。原因这是 POI createTable 的默认行为。XWPFDocument 在创建表格之后会保留一个段落标记这个段落不属于表格但 Word 打开时会在表格后面显示成一个空行。如果你用 createTable(rows, cols) 后来又在同一文档顺序里穿插其他元素表格后的空段落会自动补位。解决在创建表格之前先记录文档当前段落数表格写完后手动删除多余段落或者更简单的做法是接受这个空行把它当作表格和下一个标题之间的唯一间隔。如果一定要删需要通过 getDocument().getBodyElements() 找到新创建的段落对象调用 XMLObject.remove() 移除。删除时要先判断下一个 bodyElement 是不是段落否则会把表格误删。5.3 列宽设置不生效表格看起来还是等宽的设了宽度没有变化现象代码里通过 table.setWidth(5000) 设置表格宽度又对每个表格单元格 setWidth导出后仍然全部列等宽长文本列没有变宽。原因Word 表格的实际宽度由三层决定表格级宽度、单元格级宽度、表格布局类型。POI 只设置表格宽度而不设置每个单元格的 tcWWord 会按内容自动分配列宽更隐蔽的是表格的 tblLayout 默认可能是 autofitautofit 模式下 word 会自动忽略手动设置的固定值。解决先把表格布局设置为 fixed再设置表格总宽度然后再逐列设置列宽。列宽的计算方式是 页面宽度减页边距 除以期望比例。程序里 TableUtil 的 setColumnWidths 方法专门处理这个最后还要给每个单元格单独调用 setWidth因为 Word 的列宽以单元格 tcW 为准。5.4 大数据量导出时内存溢出导出三万行明细程序直接 OOM现象查询结果只有三万条记录不算特别大但导出过程中堆内存持续上涨最后发生 OutOfMemoryError连日志都没打完整。原因一是查询方法把全部数据先放进 List三万行每个字段都存一份 Map占用已经不小二是 POI 默认把所有段落、表格、Runs 都保存在内存里一次性写出时会维持整篇文档对象模型。这两个叠加内存自然不够。解决先分页查询比如每 5000 行刷一批每批写完就刷新到磁盘。POI 的 XWPFDocument 不像 SXSSFWorkbook 那样有专门流式版本但可以通过临时文件的方式分批写入最后合并。更实用的做法是按业务拆分明细数据超过预设行数时不再生成一个巨型表格而是按分组拆成多个小表格并插入分页符这样每一张表格只在内存里存活很短时间。程序里 ExportConfig 中有一个 pageSize 参数默认 5000超过就拆表这是防止 OOM 的第一道防线。6. 进阶用法把导出程序打包成命令行工具再做一次自动验证程序在 IDE 里跑通只是第一步真正落地要能脱离 IDE 运行。源码包里的工程可以打成可执行 jar然后用命令行参数控制数据库配置、SQL 文件和输出路径。Maven 打包命令和调用方式如下mvn clean package -DskipTests java -jar word-export.jar --config /etc/wordexport/jdbc.properties --sql /tmp/report_query.sql --out /tmp/report.docxmain 方法里会解析这三个参数先从 config 初始化连接池然后读取 sql把查询结果交给 service。打包时要注意把依赖一起打进去用 maven-assembly-plugin 打 fat jar否则在服务器上会报 ClassNotFound。我一般习惯把 jar 放在单独目录和配置文件、SQL 文件分开这样以后换报表只需要改配置和 SQL不用重新打 jar。程序写完后要回归验证。我的验证方式不是肉眼打开 Word 看一遍而是再写一个几十行的校验程序用 POI 把生成好的 docx 重新读一遍检查标题数量和表格数量是否符合预期。核心校验代码很简单try (XWPFDocument checker new XWPFDocument(new FileInputStream(/tmp/report.docx))) { if (checker.getTables().size() ! expectedTables) { throw new IllegalStateException(表格数量不对导出失败); } }用 getTables().size() 可以精确统计文档里的表格数再对比 SQL 查询结果的分组数量能快速发现漏数据、重复分页的问题。这个校验脚本不算优雅但它能在没有打开 Word 的情况下完成大部分回归验证。从那以后我每次改完字段映射都强制走一遍部署、命令行导出、再读取校验两个动作确认这份程序不是只在 IDE 里能跑。希望帮到你。本文还有配套的精品资源点击获取
返回列表