多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java操作办公文档全攻略:选型、实战与避坑指南

Java操作办公文档全攻略:选型、实战与避坑指南 你没看错这个标题确实有点大。但如果你正被“Java操作办公文档”这个需求砸中比如要做报表导出、合同生成、数据清洗、单据打印你会发现网上的资料通常是“POI能操作Excel”、“iText能做PDF”这种零散答案真正遇到问题时——单元格样式错乱、Word表格宽度失控、PDF中文变方块、CSV乱码——又得折腾半天。这篇文章不打算事无巨细地铺开讲每个API而是把我这些年做Java文档处理攒下的工具选型、实现思路和踩坑记录整理出来。你会看到四大格式各自用什么库最合适、核心流程怎么搭、有哪些“文档上没问题但实际一跑就炸”的坑以及我最终沉淀下来的一套可直接照搬的处理框架。无论你是刚接触还是已经写过一些代码按这个思路走至少能少走一半弯路。1. 先理清需求四大格式到底有什么不一样Java操作文档这件事难不在写代码而在你经常要同时和四套完全不同的数据模型打交道。Excel是行列组成的表格Word是段落和节构成的流式文档PDF是固定布局的页面CSV是纯文本的二维表。它们的操作逻辑完全不同选错工具、用错思路项目进度基本就交代了。我自己经历过一个典型的反面教材早期做导出功能时想用一套代码同时生成Excel和PDF结果为了“统一抽象”硬生生封装了一层适配器最后代码量翻倍、样式全乱还被领导约谈。后来想明白了在文档处理领域工具要按格式分逻辑要按场景合。所谓“合”是数据准备、校验、模板管理这些环节可以共用“分”是每种格式的生成与解析必须走各自最擅长的库。1.1 按业务场景选技术路线在开始写任何代码前先回答三个问题文档是给人看的还是给机器读的如果最终要人工审阅、打印、盖章PDF是首选如果对方要拿去二次编辑、做数据分析Excel/Word更合适如果只是批量数据交换CSV最简单。这个决定直接锁死你的主技术栈。数据量是大是小数据量大比如上万行报表千万避开DOM模型全量加载数据量小但格式要求高反而适合用模板填充。这个问题决定你选API的粒度。是生成还是解析生成通常比解析简单。解析PDF本身就是个深坑文字提取、表格还原、扫描件OCR每一项都能单独写一本书。如果只是“把PDF里的数据拿出来”要考虑是否真有必要。1.2 四大格式的主流Java库选型我把用过的库做了个表格这个选型结论是我在多个生产项目中验证过的直接照抄基本不会出大问题文件格式首选库备选库适合场景主要坑点ExcelApache POI EasyExcelFastExcel复杂样式导出、大数据量读写样式开销大、API繁琐WordApache POI (XWPF)docx4j生成docx、模板填充、表格操作对复杂样式支持有限PDFiText 7 pdfboxApache PDFBox、OpenPDF电子凭证、合同生成、文本抽取中文字体必须显式注册CSV自研或 commons-csvuniVocity数据同步、批量导入导出编码和引号转义问题多每个库我都付过学费下面几章会把核心操作和典型坑掰开讲。先说结论里没体现的一点新手最常犯的错是“一个库干到底”比如想用POI读PDF、用iText写Excel这不是技术问题是方向问题。文档处理的正解是“专业的人干专业的事”。2. Excel操作从POI基础功到EasyExcel的大数据量突围Excel是Java办公文档里出现频率最高的也是坑最多的。Apache POI是老牌选择功能极其强大问题在于它的HSSF.xls和XSSF.xlsx两套API用起来罗嗦而且内存占用高。大数据量导出时容易OOM那感觉跟考试写作文笔卡壳一样憋了半天一个字出不来急得冒汗。所以后来我养成了习惯小数据量用POI做精细控制大数据量用EasyExcel走流式写。2.1 POI创建带样式的工作簿完整可跑先来一个最典型的场景按月导出一份销售报表要求标题行合并单元格、表头加底色、数据区带边框、金额列用两位小数。这在业务里最常用我就直接给你能用的代码import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.ss.util.CellRangeAddress; import java.io.FileOutputStream; import java.math.BigDecimal; import java.util.List; public class ExcelReporter { public void exportSalesReport(String path, String month, ListSalesRecord records) throws Exception { // 工作簿级别必须用XSSFWorkbook对应.xlsx try (Workbook workbook new XSSFWorkbook()) { Sheet sheet workbook.createSheet(month 销售报表); // 标题行合并A1到E1字号16加粗 Row titleRow sheet.createRow(0); titleRow.setHeightInPoints(28); Cell titleCell titleRow.createCell(0); titleCell.setCellValue(month 销售数据汇总); sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 4)); CellStyle titleStyle workbook.createCellStyle(); Font titleFont workbook.createFont(); titleFont.setFontHeightInPoints((short) 16); titleFont.setBold(true); titleStyle.setFont(titleFont); titleStyle.setAlignment(HorizontalAlignment.CENTER); titleStyle.setVerticalAlignment(VerticalAlignment.CENTER); titleCell.setCellStyle(titleStyle); // 表头行灰底、加粗 String[] headers {订单号, 客户, 金额, 日期, 状态}; Row headerRow sheet.createRow(1); CellStyle headerStyle workbook.createCellStyle(); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); headerStyle.setBorderBottom(BorderStyle.THIN); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); } // 数据行金额用BigDecimal转double再写入避免精度缺失 int rowIndex 2; CellStyle moneyStyle workbook.createCellStyle(); moneyStyle.setDataFormat(workbook.createDataFormat().getFormat(0.00)); moneyStyle.setBorderBottom(BorderStyle.THIN); for (SalesRecord record : records) { Row row sheet.createRow(rowIndex); row.createCell(0).setCellValue(record.getOrderId()); row.createCell(1).setCellValue(record.getCustomer()); Cell moneyCell row.createCell(2); moneyCell.setCellValue(record.getAmount().doubleValue()); moneyCell.setCellStyle(moneyStyle); row.createCell(3).setCellValue(record.getDate()); row.createCell(4).setCellValue(record.getStatus()); } // 列宽自适应中文列名容易显示不全必须手动设置 for (int i 0; i headers.length; i) { sheet.autoSizeColumn(i); } sheet.setColumnWidth(1, 20 * 256); // 客户名列适当加宽 try (FileOutputStream fos new FileOutputStream(path)) { workbook.write(fos); } } } }这段代码看着长其实就干了三件事建工作簿、定样式、写数据。值得注意的几个细节CellStyle要复用。POI里样式是对象每个样式都会占内存循环里动不动就createCellStyle()导出5000行你就知道什么叫卡成PPT。所以表头样式、金额样式都提到循环外面全局只建一次。金额必须用BigDecimal算好再setCellValue。虽然POI只认double但你在业务层用double做累加0.10.2的精度问题会让你对不上账。合并单元格要一次性合完再写值先合并后填值否则可能被覆盖。提示autoSizeColumn()在某些字体环境下会计算出奇怪的宽度比如中文变成窄窄一列所以重要列最好手动setColumnWidth。这也是为什么我在代码里先自适应又手动加宽。2.2 EasyExcel大数据量导出不再提心吊胆POI在处理万级数据时XSSF会把整张表加载进内存经常搞出GC压力。EasyExcel阿里开源封装了POI的SAX模式用流式写可以控制到几MB内存跑完几十万行。它的风格是“注解驱动”实体类上标注列名和顺序导出时直接传List。import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.write.style.ColumnWidth; import lombok.Data; Data public class OrderExportModel { ExcelProperty(订单号) private String orderId; ExcelProperty(客户名称) ColumnWidth(20) private String customer; ExcelProperty(订单金额) private BigDecimal amount; }导出代码简洁到怀疑人生import com.alibaba.excel.EasyExcel; public void exportLargeData(String path, ListOrderExportModel data) { EasyExcel.write(path, OrderExportModel.class) .sheet(订单明细) .doWrite(data); }这里不得不提一下网上流传的“EasyExcel导入模板自动校验”功能你可以在ExcelProperty之外配合ExcelValidate这类自定义注解做批量校验但真正生产环境我更建议数据清洗放业务层做别把校验逻辑全堆在Excel层。因为Excel校验错误信息要回写单元格逻辑一复杂代码可读性直线下降属于给后续维护挖坑。大数据量导入的场景EasyExcel还提供了AnalysisEventListener监听器一行一行回调你可以分批入库public class OrderDataListener extends AnalysisEventListenerOrderImportModel { private static final int BATCH_COUNT 1000; private ListOrderImportModel cache new ArrayList(); Override public void invoke(OrderImportModel data, AnalysisContext context) { cache.add(data); if (cache.size() BATCH_COUNT) { saveBatch(cache); // 攒够1000条批量insert cache.clear(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { saveBatch(cache); // 最后剩余不足一批的数据 } }这模式我沿用至今批次大小根据你们的数据库连接池配置微调1000是个均衡值。建议加个计数器在invoke里统计总行数最后用来做“导入完成共N条”的提示。2.3 Excel公式失效和复制粘贴异常的几个排查建议热搜词里有个高频问题Excel公式下拉不生效、CtrlV粘贴没反应。这在Java里也常遇到比如你用POI往单元格写了公式生成的文件打开后公式不计算。大部分情况是因为没有调用workbook.getCreationHelper().createFormulaEvaluator().evaluateAll()或者公式里的引号、逗号在拼接时被转义搞坏了。如果是纯Java生成文件后Excel里提示“安全警告”通常不是代码问题而是Excel的“加载项被禁用”或“受保护的视图”。建议代码里少用外部宏生成的文件尽量在受信任位置测试。遇到粘贴异常先检查Excel选项里“启用填充柄和单元格拖放功能”有没有被关掉这属于“查了两小时代码结果是客户电脑环境问题”的经典剧本。3. Word操作POI的XWPF与模板化实战Word比Excel更麻烦的地方在于它太自由了。段落、节、表格、页眉页脚、图文混排、样式层级没有Excel那种强制二维结构的约束解析和生成都容易“看起来对了一打印就露馅”。Java操作Word选型上基本就是Apache POI的XWPF专门对应.docx。它处理简单文本、表格、页眉页脚足够但真要做复杂的排版、页面级设置docx4j更贴近Open XML规范。不过docx4j的学习曲线陡一般业务用不上。3.1 用XWPF生成合同文档常见需求是生成一份带表格的合同标题、甲乙方信息、金额条款、落款。我用XWPF写过一个典型demo核心操作就几个import org.apache.poi.xwpf.usermodel.*; import java.io.FileOutputStream; import java.math.BigInteger; public class WordContractGenerator { public void generateContract(String path, ContractInfo info) throws Exception { try (XWPFDocument doc new XWPFDocument()) { // 标题段落 XWPFParagraph titlePara doc.createParagraph(); titlePara.setAlignment(ParagraphAlignment.CENTER); XWPFRun titleRun titlePara.createRun(); titleRun.setText(info.getContractName()); titleRun.setBold(true); titleRun.setFontSize(16); titleRun.setFontFamily(微软雅黑); // 普通段落 XWPFParagraph para1 doc.createParagraph(); para1.setFirstLineIndent(480); // 两字符缩进1字符240 twips para1.createRun().setText(甲方委托方 info.getPartyA()); XWPFParagraph para2 doc.createParagraph(); para2.setFirstLineIndent(480); para2.createRun().setText(乙方受托方 info.getPartyB()); // 表格3行2列第一行放项目名称和金额 XWPFTable table doc.createTable(3, 2); setTableCell(table.getRow(0).getCell(0), 项目名称); setTableCell(table.getRow(0).getCell(1), info.getProjectName()); setTableCell(table.getRow(1).getCell(0), 合同金额); setTableCell(table.getRow(1).getCell(1), info.getAmount().toString()); setTableCell(table.getRow(2).getCell(0), 签署日期); setTableCell(table.getRow(2).getCell(1), info.getSignDate()); try (FileOutputStream fos new FileOutputStream(path)) { doc.write(fos); } } } private void setTableCell(XWPFTableCell cell, String text) { XWPFParagraph para cell.getParagraphs().isEmpty() ? cell.addParagraph() : cell.getParagraphs().get(0); para.createRun().setText(text); } }这里有个容易踩的坑table.getRow(0).getCell(0)拿到的单元格默认有一个空段落你直接往里加文字要先取段落或另起一行否则内容会挤到第一段后面或者出现空行。上面代码里做了判空属于我踩过之后的习惯性写法。3.2 Word表格列宽失控的根因与解法评论区经常有人问“POI设置Word表格单元格宽度为什么无效”。这个坑的根源在于docx表格有表格级宽度和单元格级宽度两层属性光设cell.setWidth()没用必须同时把表格的setTableLayout设置为固定布局。import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblLayoutType; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; public void fixWordTableWidth(XWPFTable table) { CTTblPr tblPr table.getCTTbl().getTblPr(); if (tblPr null) { tblPr table.getCTTbl().addNewTblPr(); } // 设置为固定布局不能再拉扯 CTTblLayout layout tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED); // 设置表格总宽度 CTTblWidth tblWidth tblPr.isSetTblW() ? tblPr.getTblW() : tblPr.addNewTblW(); tblWidth.setType(STTblWidth.DXA); tblWidth.setW(BigInteger.valueOf(9600)); // 默认A4可用宽度约9600 twips int[] colWidths {3000, 3000, 3600}; // 三列示例 for (int i 0; i table.getRows().size(); i) { XWPFTableRow row table.getRow(i); // 必须给每一行的每个单元格都设置宽度只设第一行不够 for (int j 0; j row.getTableCells().size(); j) { row.getCell(j).setWidth(String.valueOf(colWidths[j])); } } }写这个的初衷是因为我被客户吐槽过“表格右侧出界”。后来养成的习惯是所有生成Word表格的代码无一例外先设FIXED布局再循环设置每个单元格宽度一步都不能省。表格列宽在Word里的计算方式很霸道你以为设置了就能拖到想要的位置实际上它还会根据内容自动重排不锁布局一切白搭。3.3 Word公式编辑器与宏安全环境问题排查思路热词里有“Mathtype复制到Word卡死”、“同时装了AxMath和Mathtype冲突”、“Word宏安全问题”这些都是Java程序外的工作环境问题但客户报障时会甩给我们概括一下排查思路Word公式插入卡死八成是MathType与AxMath的OLE注册冲突建议只保留一个公式编辑器。宏安全警告生成文档时不要嵌入宏VBA相关功能不要用Java去搞那条路径坑多收益少。“Word公式转LaTeX”的需求核心工具很多Mathpix、Pandoc、TeXsword但这是用户工具链问题和Java无关你不要为了这个改动程序结构。Java侧真正能做的“公式兼容”就是插入OMML公式。POI里已经有了OMath支持但API还不够成熟我建议用“先做成docx模板占位符替换”的方式绕开直接拼公式。如果你一定要用代码生成公式去搜org.apache.poi.xwpf.usermodel.OMath相关示例能跑通但别期望太高。4. PDF操作生成要稳解析要准PDF是四大格式里最“视觉优先”的。生成PDF的主战场是电子发票、合同扫描件、对账单、网页打印解析PDF的主战场是数据抽取、合规审计。Java这边主要就两个阵营iText和Apache PDFBox。4.1 iText生成带中文的PDFiText 7是收费双许可商用要小心AGPL条款但在生成复杂排版方面它最顺手。中文处理是坑点很多新手生成的PDF中文显示成方块不是代码错了是没有注册中文字体。import com.itextpdf.kernel.font.PdfFont; import com.itextpdf.kernel.font.PdfFontFactory; import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfWriter; import com.itextpdf.layout.Document; import com.itextpdf.layout.element.Paragraph; import com.itextpdf.kernel.pdf.PdfPage; import com.itextpdf.kernel.pdf.canvas.PdfCanvas; import com.itextpdf.layout.property.TextAlignment; import java.io.FileOutputStream; public class PdfGenerator { public void generateChinesePdf(String path, String text) throws Exception { // 关键注册系统中文字体文件否则中文全变“豆腐块” PdfFont chineseFont PdfFontFactory.createFont( C:/Windows/Fonts/msyh.ttc,0, // Windows微软雅黑Mac/Linux需换路径 PdfEncodings.IDENTITY_H ); try (PdfWriter writer new PdfWriter(new FileOutputStream(path)); PdfDocument pdfDoc new PdfDocument(writer); Document doc new Document(pdfDoc)) { Paragraph p new Paragraph(text); p.setFont(chineseFont); p.setFontSize(12); p.setTextAlignment(TextAlignment.LEFT); doc.add(p); } } }如果你是在Linux服务器上部署路径就要指向服务器上实际存在的字体文件。很多生产事故就是Windows上开发好好的部署到CentOS发现汉字全没渲染这涉及服务器是否装了中文字体包fontconfig、wqy-microhei等。Linux部署前务必检查fc-list :langzh没有输出就装字体yum install fontconfig wqy-microhei-fonts -y然后代码里指定/usr/share/fonts/wqy-microhei/wqy-microhei.ttc。4.2 PDFBox解析文本与表格还原思路PDFBox用于读取PDF文本内容非常轻量JDK自带里也常配合使用。文本提取代码就几行import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; import java.io.File; public class PdfReader { public String extractText(String path) throws Exception { try (PDDocument document PDDocument.load(new File(path))) { PDFTextStripper stripper new PDFTextStripper(); // 可以按页提取比如只取第2-3页 stripper.setStartPage(2); stripper.setEndPage(3); return stripper.getText(document); } } }但网上很多人把PDF解析想得太美了。PDF本身没有“表格”的概念它只有坐标和图形。想从PDF还原表格常见路线有两条用pdf2json之类的工具先转中间格式再用正则/规则匹配坐标。用Tabula-java直接框选表格区域提取CSV。适合结构化表格但对合并单元格、跨页表格支持不佳。对扫描件要先接OCRTesseract、PaddleOCR否则PDFBox提取出来是空字符串。我的忠告是别指望一条正则吃遍所有PDF。最好的方案是“规则人工兜底”设计一个表格解析器时预留人工校正入口。否则你做3个月后仍会被“这家厂商的PDF排版跟那家不一样”打败。如果真的只是“网页页面PDF打印”场景优先让前端用浏览器自带的打印功能window.print生成Java后端只需输出HTML这是最稳健、最少Bug的路子别自己用iText去拼排版。4.3 PDF转换与合并一次搞定多文件日常也会遇到“PDF文件转换”、“PDF文档合并”的需求。合并用PDFBox的PDFMergerUtilityimport org.apache.pdfbox.multipdf.PDFMergerUtility; public void mergePdfs(ListString sourcePaths, String destPath) throws Exception { PDFMergerUtility merger new PDFMergerUtility(); for (String path : sourcePaths) { merger.addSource(new File(path)); } merger.setDestinationFileName(destPath); merger.mergeDocuments(null); }转换比如Word转PDF、Excel转PDF在Java里没有完美的原生库说自己全能转的库基本是调用了本机Office或LibreOffice。最省事的生产方案是部署LibreOffice headless模式用soffice --headless --convert-to pdf命令行实现转换Java里用ProcessBuilder调用ProcessBuilder pb new ProcessBuilder( soffice, --headless, --convert-to, pdf, --outdir, outputDir, inputFile ); pb.redirectErrorStream(true); Process process pb.start(); int exitCode process.waitFor(); if (exitCode ! 0) throw new RuntimeException(转换失败);这个方案能解决绝大多数“文档转换”需求代价是服务器上要装LibreOffice。你如果被客户要求批量Word转PDF这是成熟可靠的路线比我用POI从零画一个PDF版式快得多。5. CSV处理看着简单坑都在暗处CSV可能是四种格式里Java代码量最少的但坑也是埋得最深的。编码、分隔符、引号转义、换行符任何一个细节没处理好导入的数据就会乱套。5.1 一个健壮的CSV读写工具类我用OpenCSV时间最长后来又结合自研处理过不少怪癖CSV最后沉淀出下面这个版本支持UTF-8 BOM、带引号字段、逗号转义和自定义分隔符import com.opencsv.CSVReader; import com.opencsv.CSVWriter; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.List; public class CsvUtil { public static ListString[] readCsv(InputStream inputStream) throws IOException { // 处理BOM很多Windows导出的CSV带BOM头不处理第一列会多个看不见的字符 PushbackInputStream pb new PushbackInputStream(inputStream, 3); byte[] bom new byte[3]; int len pb.read(bom); if (len 3 (bom[0] 0xFF) 0xEF (bom[1] 0xFF) 0xBB (bom[2] 0xFF) 0xBF) { // 是UTF-8 BOM跳过 } else { pb.unread(bom, 0, len); } try (CSVReader reader new CSVReader(new InputStreamReader(pb, StandardCharsets.UTF_8))) { return reader.readAll(); } } public static void writeCsv(OutputStream outputStream, ListString[] rows) throws IOException { try (CSVWriter writer new CSVWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), CSVWriter.DEFAULT_SEPARATOR, CSVWriter.DEFAULT_QUOTE_CHARACTER, CSVWriter.DEFAULT_ESCAPE_CHARACTER, \n)) { // 统一使用LF换行避免Windows Excel打开的兼容问题 writer.writeAll(rows); } } }5.2 “导入CSV文件”到数据库的标准姿势只读文件不叫导入真正让CSV有生产力的是导入数据库。我们在实际项目中用的流程是先落临时表 → 校验清洗 → 再入业务表。上传CSV到临时目录用上面工具读出来不直接入库因为用户可能多个Sheet、多个版本反复更新。把原始数据批量insert到temp_import表带上一列import_batch_no。写存储过程或Java批量任务逐行校验必填、格式、重复、外键引用。校验通过的数据转入正式表失败数据回写错误原因到temp_import.error_msg。用户通过页面对“有问题数据”修改后再重跑。这套流程看起来很重但它解决了“用户上传一份10万行Excel其中有3000行有问题”的经典争议。没有这个临时表你只能跟着用户的口头抱怨一句一句对数据。另外批量入库建议用JDBC的addBatch或者MyBatis的foreach分页批量插入一条条insert的耗时会在5万行以上给你颜色看。配合rewriteBatchedStatementstrue可以再提速一个量级。5.3 CSV文件分割处理超大文件的老办法热搜里有“CSV文件分割神器2.0”看来不少人对“一个CSV太大打不开”有刚需。其实用Java实现按行数分割很简单import java.io.*; import java.nio.charset.StandardCharsets; public class CsvSplitter { public static void splitByLines(String inputPath, int linesPerFile, String outputPrefix) throws IOException { try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(inputPath), StandardCharsets.UTF_8))) { String line; int fileIndex 1; int lineCount 0; BufferedWriter writer null; try { while ((line reader.readLine()) ! null) { if (writer null || lineCount linesPerFile) { if (writer ! null) writer.close(); writer new BufferedWriter(new OutputStreamWriter( new FileOutputStream(outputPrefix _ fileIndex .csv), StandardCharsets.UTF_8)); lineCount 0; } writer.write(line); writer.newLine(); lineCount; } } finally { if (writer ! null) writer.close(); } } } }注意一个要点分割CSV时要考虑表头要么每段都保留表头方便单独打开要么第一段保留表头其余不保留方便合并回去。实际业务里我建议给用户一个“是否每段保留表头”的选项因为这直接关系到他们后续怎么用这些文件。6. 实战项目一套Java文档处理框架的诞生现在把前面所有零散经验聚合成一个可以直接“抄作业”的框架。我管它叫“文档网关层”核心思路是所有文档操作入口统一按格式分发按业务注入。我不会给你完整源码那够写好几篇文章了但会给出整个骨架和关键代码你照着搭半小时就能落地。6.1 框架分层与核心接口分层就三层Controller层接收请求、Service层处理业务、Handler层做具体格式操作。关键在Handler层public interface DocumentHandlerT { /** 判断能否处理该格式 */ boolean supports(String fileExtension); /** 生成文档data为业务数据templatePath为可选模板 */ File generate(String templatePath, T data, String outputPath) throws Exception; /** 解析文档返回业务对象列表 */ ListT parse(InputStream inputStream) throws Exception; }ExcelHandler、WordHandler、PdfHandler、CsvHandler分别实现。调用时用工厂模式匹配Component public class DocumentHandlerFactory { private final ListDocumentHandler? handlers; public DocumentHandlerFactory(ListDocumentHandler? handlers) { this.handlers handlers; } public DocumentHandler? getHandler(String extension) { return handlers.stream() .filter(h - h.supports(extension.toLowerCase())) .findFirst() .orElseThrow(() - new IllegalArgumentException(Unsupported file type: extension)); } }这样做的好处是以后新增一种格式比如OFD只需要加一个OfdHandler implements DocumentHandler并注入Spring容器其他代码一行都不用动。我在项目里就用这个模式把“导出对账单”从Excel版本扩展到PDF版本只加了一个Handler其他逻辑完全复用。6.2 模板引擎统一管理单据样式上一节提到模板路径这里解释一下为什么模板方案优于纯代码排版。无论是Word合同还是Excel报表代码写死的样式改起来最难——运营说“表头加个日期”你得打开IDE找半天样式对象。模板方案是把样式做进docx或xlsx模板代码只替换占位符。用Apache POI的XWPFDocument替换文本占位符import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import java.io.FileInputStream; import java.io.FileOutputStream; import java.util.Map; public class WordTemplateFiller { public void fillTemplate(String templatePath, String outputPath, MapString, String data) throws Exception { try (XWPFDocument doc new XWPFDocument(new FileInputStream(templatePath))) { // 遍历所有段落替换 for (XWPFParagraph paragraph : doc.getParagraphs()) { replaceInParagraph(paragraph, data); } // 表格里的占位符也要替换 for (var table : doc.getTables()) { for (var row : table.getRows()) { for (var cell : row.getTableCells()) { for (var paragraph : cell.getParagraphs()) { replaceInParagraph(paragraph, data); } } } } try (FileOutputStream fos new FileOutputStream(outputPath)) { doc.write(fos); } } } private void replaceInParagraph(XWPFParagraph paragraph, MapString, String data) { String text paragraph.getText(); boolean changed false; for (Map.EntryString, String entry : data.entrySet()) { if (text.contains(entry.getKey())) { text text.replace(entry.getKey(), entry.getValue()); changed true; } } if (changed) { // POI的run是文本段替换需要先清空run再整体写入 for (int i paragraph.getRuns().size() - 1; i 0; i--) { paragraph.removeRun(i); } paragraph.createRun().setText(text); } } }这段代码的关键点在于直接修改paragraph.getRuns()里的文本可能只改了半个单词因为一行文字可能被拆成多个run所以稳妥的方案是整段重写。模板里占位符建议用${contractNo}这种清晰格式不要跟普通文字混在同一个run里。Excel的模板填充我用POI的Cell.setCellValue替换逻辑类似遇到合并单元格时要注意先把合并区域的单元格都找到否则填充完数据后合并边框会掉。模板的价值不止是省事还能让业务同事自己调整样式而不用找开发所以我强烈推荐从第一天就上模板方案。6.3 模板填充中容易掉的坑做模板填充最容易翻车的几个细节逐个说一下合并单元格和样式被覆盖。如果你用POI往一个合并区域的某个单元格写值合并区域还在但边框可能丢失。解决方案是在填充后重新遍历合并区域把边框样式补回去或者一开始就从模板继承样式。占位符跨run问题。Word在编辑时会把一行存成多个run${name}可能被拆成$、{name、}三段直接替换会失败。解决方法是先合并run文本检测到占位符后整段重建上面代码的做法就属于“以段为单位粗暴重建”方案。日期格式。模板里写${date}业务层传来的是2025-06-01 10:30:00但Excel单元格要显示成自定义格式必须用CellStyle的setDataFormat指定的格式并setCellValue传LocalDateTime类型。这块处理不当就会出现“模板填好了格式全不对”的尴尬。空值为null时报错。Map里value为null替换时直接换成空字符串不要等到String.replace才炸。7. 性能调优与资源管理从“能跑”到“扛得住”最后这部分聊聊生产环境真正关心的性能问题。“能跑”和“能扛住生产并发”差距很大。我自己接过的性能问题主要有四类内存暴涨、导出超时、连接泄漏、文件句柄耗尽。每一个都能在深夜把运维电话打爆。7.1 控制内存流式读写是救命稻草Excel和PDF都是内存大户。POI的XSSFWorkbook一个Sheet几十万行每行几十列再多的堆内存也扛不住。解决办法有三板斧小数据用SXSSFWorkbookPOI的流式版本设置滑动窗口超过窗口的行刷到磁盘。SXSSFWorkbook workbook new SXSSFWorkbook(100); // 保留最近100行在内存 // 用完必须调dispose清理临时文件 workbook.dispose();大数据直接上EasyExcel它的写模式本质就是流式的10万行数据内存占用稳定在几十MB。PDF生成时用流式PdfWriter不要new RandomAccessFile全量读入大文件解析PDF也尽量PDDocument.load(File)而非load(InputStream)PDFBox的load(File)可以映射文件减少堆内拷贝。7.2 资源释放每个Stream都要闭合文档处理最容易忽略的就是InputStream没关。生成Excel、Word、PDF代码里到处是new FileOutputStream一旦异常就泄漏文件句柄。强烈建议所有范本文件、临时文件统一用try-with-resources。Java 7的语法糖别嫌麻烦这是最基本的保命手段。临时文件用完立即删除。比如导出Excel时生成的临时模板文件用File.deleteOnExit()只能兜底最好在finally里显式删除。POI的SXSSFWorkbook用完要dispose这个上面提过它会在临时目录生成几MB的临时文件不清理硬盘会被塞爆。PDFBox的PDDocument实现了Closeable记得close否则最终内存不够后你会看到大量Native Memory相关的报错。7.3 常见性能瓶颈速查表给你们一张我常用的排查表配合Arthas或JVisualVM做热点定位症状常见原因处理对策内存持续上涨XSSFWorkbook全量加载换SXSSFWorkbook或EasyExcelCPU飙升在循环里创建大量CellStyle/Font提取公共样式到循环外导出越来越慢单条写Excel而非批量使用addMergedRegion后一次flushPDF中文缺失Linux服务器缺字体装fontconfig wqy-microhei文件句柄耗尽流未关闭全面用try-with-resources大数据量导入超时逐条insertJDBC addBatch或MyBatis批量其中“循环里创建样式”是最隐蔽的一个循环10万行每行创建一个CellStyle那相当于创建了10万个对象内存和CPU上不去才怪。写代码时多想想“能不能提到外面只建一次”这比任何参数调优都有效。8. 最后的工具箱哪些轮子值得重复造到了收尾的时候分享几个值得长期维护的“重复造轮子”清单。注意我不是劝你所有东西都自己写而是有些小工具每天都要用、写一次能省十年那就有必要在项目里沉淀下来。Excel方面值得封装的是“通用导入解析器”接收任意Excel文件根据配置列名映射、必填校验、类型转换输出标准Map/List同时返回逐行错误信息。这个工具一旦写好所有“上传Excel”的需求都能复用我在两个项目里都靠它节省了大量重复代码。不要每次接到导入需求就重写一个监听器。Word方面值得封装的是“占位符模板填充器”上一节那个替换逻辑不论生成合同、通知书还是介绍信用的都是同一套。建议把模板存放在resources/templates目录支持传参Map再输出到下载路径。PDF方面值得封装的是“统一导出控制器”同一个业务数据传不同的文件扩展名就自动选择对应的Handler生成Excel或PDF。这个我前面介绍的工厂模式就是干这个的项目中接入“一键切换导出格式”特别实用。CSV方面值得封装的是“CSV读写工具类”把BOM处理、编码转换、引号转义都收口到一个类里团队其他人用的时候就不会各自为战、造出各种乱码。这些工具类的价值不在于技术难度而在于把高频需求固化成团队约定。文档处理领域每天都会有新的坑但上面这几个基础工具稳住了你的项目就稳住了大半。以后真要新接一个文档需求你先问自己这个格式的Handler有了吗没有就照前面的框架加一个千万不要把逻辑堆在Controller里不然下一个接手你代码的人会一边改一边骂。
返回列表