|
| 1 | +--- |
| 2 | +title: Apache POI 以流(Stream)的方式下载Excel |
| 3 | +date: 2025-12-12 17:00:00 +0800 |
| 4 | +categories: [Blogging, Java] |
| 5 | +tags: [java] |
| 6 | +--- |
| 7 | + |
| 8 | +> 文章写于2019年, 可能已过时或有出入 |
| 9 | +{: .prompt-warning } |
| 10 | + |
| 11 | +# Apache POI 以流(Stream)的方式下载Excel |
| 12 | + |
| 13 | +源于一个很BT的需求:从数据库查出大量数据(百万级)导出成Excel(不讨论业务的正确性)。我们时想一边查数据一边以流(Stream)的方式输出到客户端。 |
| 14 | + |
| 15 | +## 前言 |
| 16 | + |
| 17 | +xlsx格式是 office 2007 开始使用的 Office Open XML 标准([WIKI](https://zh.wikipedia.org/wiki/Office_Open_XML)),xlsx 其实是一个压缩包,大家可以解压出来看到里面的内容。 |
| 18 | + |
| 19 | +Java开源导出Excel只有Apache POI这个选择。众所周知POI导出大量的数据会导致OOM。 |
| 20 | +究其原因是从创建 Workbook(org.apache.poi.xssf.usermodel.XSSFWorkbook) 直到调用 Workbook#write() 之前在内存存活着大量的对象。 |
| 21 | +谷歌一番POI官网提供了org.apache.poi.xssf.streaming.SXSSFWorkbook 来解决OOM的问题。官方旧的解决方式([Link](https://svn.apache.org/repos/asf/poi/trunk/src/examples/src/org/apache/poi/xssf/usermodel/examples/BigGridDemo.java))也能使用,不过已经被集合到 SXSSFWorkbook 中。但官网是不提供一边查数据,一边以Stream的方式输出Excel。根据**01定律**,理论上是可以做到,最差也就手写0和1:) |
| 22 | + |
| 23 | +## 例子 |
| 24 | +根据官网文档,可以在看到 SXSSFWorkbook 其实是将数据刷到本地的硬盘上,实现了自动刷入和手动输入, |
| 25 | +最后 write() 的时候安全输出而不会导出OOM。 |
| 26 | + |
| 27 | +我们来看看官方例子怎么样 |
| 28 | +``` |
| 29 | +Workbook workbook = new SXSSFWorkbook();// 1 |
| 30 | +Sheet sheet = workbook.createSheet();//2 |
| 31 | +CellStyle cellStyle = workbook.createCellStyle();//4 |
| 32 | + for (int i = 0; i < 60000; i++) { |
| 33 | + Row newRow = sheet.createRow(i);//3 |
| 34 | + for (int j = 0; j < 100; j++) { |
| 35 | + newRow.createCell(j).setCellValue("test" + Math.random()); |
| 36 | + newRow.setCllStyle(cellStyle);//3 |
| 37 | + } |
| 38 | + } |
| 39 | +ByteArrayOutputStream os = new ByteArrayOutputStream(); |
| 40 | +workbook.write(os);//4 |
| 41 | +``` |
| 42 | + |
| 43 | +### 0x01 Workbook |
| 44 | +``` new SXSSFWorkbook() ``` 是创建一个刷新数据的窗口大小(rowAccessWindowSize)为100的 SXSSFWorkbook。在内存里只允许多少个行对象存在(下面会说明)。如果是由于列太多导致OOM,官方提供的方案是解决不了的^_^ ([技术指标](https://zh.wikipedia.org/wiki/Microsoft_Excel#%E6%8A%80%E6%9C%AF%E6%8C%87%E6%A0%87)) 可以看出SXSSFWorkbook默认是包装了一下XSSFWorkbook,_wb 实际指向的是 XSSFWorkbook。 |
| 45 | +``` |
| 46 | +public static final int DEFAULT_WINDOW_SIZE = 100; |
| 47 | +
|
| 48 | +public SXSSFWorkbook(){ |
| 49 | + this(null /*workbook*/); |
| 50 | +} |
| 51 | +
|
| 52 | +public SXSSFWorkbook(XSSFWorkbook workbook){ |
| 53 | + this(workbook, DEFAULT_WINDOW_SIZE); |
| 54 | +} |
| 55 | +
|
| 56 | +//最终调的是这个方法 |
| 57 | +public SXSSFWorkbook(XSSFWorkbook workbook, int rowAccessWindowSize, boolean compressTmpFiles, boolean useSharedStringsTable){ |
| 58 | + setRandomAccessWindowSize(rowAccessWindowSize); |
| 59 | + setCompressTempFiles(compressTmpFiles); |
| 60 | + if (workbook == null) { |
| 61 | + _wb=new XSSFWorkbook();//实际存的是 XSSFWorkbook |
| 62 | + _sharedStringSource = useSharedStringsTable ? _wb.getSharedStringSource() : null; |
| 63 | + } else { |
| 64 | + _wb=workbook; |
| 65 | + _sharedStringSource = useSharedStringsTable ? _wb.getSharedStringSource() : null; |
| 66 | + for ( Sheet sheet : _wb ) { |
| 67 | + createAndRegisterSXSSFSheet( (XSSFSheet)sheet ); |
| 68 | + } |
| 69 | + } |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +### 0x02 Sheet |
| 74 | +``` workbook.createSheet() ```创建一个 SXSSFSheet 实际也是包装了一个 XSSFSheet,SXSSFSheet 很多方法其实是调用了 XSSFSheet的。 |
| 75 | +``` |
| 76 | +public SXSSFSheet createSheet() |
| 77 | +{ |
| 78 | + return createAndRegisterSXSSFSheet(_wb.createSheet()); |
| 79 | +} |
| 80 | +
|
| 81 | +SXSSFSheet createAndRegisterSXSSFSheet(XSSFSheet xSheet) |
| 82 | +{ |
| 83 | + final SXSSFSheet sxSheet; |
| 84 | + try |
| 85 | + { |
| 86 | + sxSheet=new SXSSFSheet(this,xSheet); |
| 87 | + } |
| 88 | + catch (IOException ioe) |
| 89 | + { |
| 90 | + throw new RuntimeException(ioe); |
| 91 | + } |
| 92 | + registerSheetMapping(sxSheet,xSheet); |
| 93 | + return sxSheet; |
| 94 | +} |
| 95 | +``` |
| 96 | +```new SXSSFSheet(this,xSheet)```会创建一个```SheetDataWriter```对象,从名称可以看到是一个Sheet的数据输出对象,追踪到里面可以得知创建一个```SheetDataWriter```对象实际时创建了一个前缀为poi-sxssf-sheet的xml文件和这文件对应的java.io.Writer。xml文件的路径是在 %java.io.tmpdir%/poifiles 下 |
| 97 | + |
| 98 | +``` |
| 99 | +public SheetDataWriter() throws IOException { |
| 100 | + _fd = createTempFile(); |
| 101 | + _out = createWriter(_fd); |
| 102 | +} |
| 103 | +
|
| 104 | +public File createTempFile() throws IOException { |
| 105 | + return TempFile.createTempFile("poi-sxssf-sheet", ".xml"); |
| 106 | +} |
| 107 | +``` |
| 108 | +### 0x03 Row |
| 109 | +createRow利用上面的sheet来新建行对象保存到内存中,如果在内存的对象超出 _randomAccessWindowSize 就用 SheetDataWriter刷新到硬盘上的临时xml文件里。至于 Cell 也是保存在 Row 对象的 _cells 字段里面。在刷新数据到硬盘上是使用了上面创建的 SheetDataWriter 对象,```writeRow(int,SXSSFRow)```构建成行的xml格式将数据追加到临时文件最后。 |
| 110 | +``` |
| 111 | +@Override |
| 112 | +public SXSSFRow createRow(int rownum) |
| 113 | +{ |
| 114 | + int maxrow = SpreadsheetVersion.EXCEL2007.getLastRowIndex();//判断最大行数 |
| 115 | + if (rownum < 0 || rownum > maxrow) { |
| 116 | + throw new IllegalArgumentException("Invalid row number (" + rownum |
| 117 | + + ") outside allowable range (0.." + maxrow + ")"); |
| 118 | + } |
| 119 | +
|
| 120 | + // attempt to overwrite a row that is already flushed to disk |
| 121 | + // 行数必须大于已经刷到硬盘的行数 |
| 122 | + if(rownum <= _writer.getLastFlushedRow() ) { |
| 123 | + throw new IllegalArgumentException( |
| 124 | + "Attempting to write a row["+rownum+"] " + |
| 125 | + "in the range [0," + _writer.getLastFlushedRow() + "] that is already written to disk."); |
| 126 | + } |
| 127 | +
|
| 128 | + // attempt to overwrite a existing row in the input template |
| 129 | + // 行数必须大于模板的行数 |
| 130 | + if(_sh.getPhysicalNumberOfRows() > 0 && rownum <= _sh.getLastRowNum() ) { |
| 131 | + throw new IllegalArgumentException( |
| 132 | + "Attempting to write a row["+rownum+"] " + |
| 133 | + "in the range [0," + _sh.getLastRowNum() + "] that is already written to disk."); |
| 134 | + } |
| 135 | +
|
| 136 | + SXSSFRow newRow=new SXSSFRow(this);//SXSSFRow 保存当前的 SXSSFSheet 对象 |
| 137 | + _rows.put(rownum,newRow);// 保存行号和行对象,可以看出如何数据量大就会导致OOM |
| 138 | + allFlushed = false; |
| 139 | + // 在内存的行数是否大于刷新的窗口大小,大于就刷到硬盘的临时文件上 |
| 140 | + if(_randomAccessWindowSize>=0&&_rows.size()>_randomAccessWindowSize) |
| 141 | + { |
| 142 | + try |
| 143 | + { |
| 144 | + flushRows(_randomAccessWindowSize); |
| 145 | + } |
| 146 | + catch (IOException ioe) |
| 147 | + { |
| 148 | + throw new RuntimeException(ioe); |
| 149 | + } |
| 150 | + } |
| 151 | + return newRow; |
| 152 | +} |
| 153 | +
|
| 154 | +private void flushOneRow() throws IOException |
| 155 | + { |
| 156 | + Integer firstRowNum = _rows.firstKey(); |
| 157 | + if (firstRowNum!=null) { |
| 158 | + int rowIndex = firstRowNum.intValue(); |
| 159 | + SXSSFRow row = _rows.get(firstRowNum); |
| 160 | + // Update the best fit column widths for auto-sizing just before the rows are flushed |
| 161 | + _autoSizeColumnTracker.updateColumnWidths(row); |
| 162 | + _writer.writeRow(rowIndex, row);// 使用了上面创建的 SheetDataWriter 来写到硬盘上 |
| 163 | + _rows.remove(firstRowNum); |
| 164 | + lastFlushedRowNumber = rowIndex; |
| 165 | + } |
| 166 | + } |
| 167 | +
|
| 168 | +``` |
| 169 | + |
| 170 | +### 0x04 WorkBook#write(OutputStream) 和其他 |
| 171 | +CellStyle等通过 SXSSFWorkbook 创建的对象底层都是通过 XSSFWorkbook 创建。这些对象也是停留在内存中,并不会写到硬盘上。最终输出时 |
| 172 | +先把内存的 row 全部刷到硬盘上,再把Excel模板刷到硬盘上。这个模板其实就是一个包含style等但不包含数据的excel文件,可以看到这excel文件时通过zip格式写到硬盘上,所以又有了开头说的可以解压看excel里面的内容。 |
| 173 | + |
| 174 | +``` |
| 175 | +public void write(OutputStream stream) throws IOException |
| 176 | +{ |
| 177 | + flushSheets();//把内存中的所有数据刷到硬盘上 |
| 178 | +
|
| 179 | + //Save the template |
| 180 | + File tmplFile = TempFile.createTempFile("poi-sxssf-template", ".xlsx"); |
| 181 | + boolean deleted; |
| 182 | + try { |
| 183 | + FileOutputStream os = new FileOutputStream(tmplFile);// 保存 XSSFWorkbook 模板 |
| 184 | + try { |
| 185 | + _wb.write(os); |
| 186 | + } finally { |
| 187 | + os.close(); |
| 188 | + } |
| 189 | +
|
| 190 | + //Substitute the template entries with the generated sheet data files |
| 191 | + final ZipEntrySource source = new ZipFileZipEntrySource(new ZipFile(tmplFile)); |
| 192 | + injectData(source, stream);//往zip文件里面注入数据 |
| 193 | + } finally { |
| 194 | + deleted = tmplFile.delete(); |
| 195 | + } |
| 196 | + ....省略 |
| 197 | +} |
| 198 | +
|
| 199 | +protected void injectData(ZipEntrySource zipEntrySource, OutputStream out) throws IOException { |
| 200 | + ....省略 |
| 201 | + // See bug 56557, we should not inject data into the special ChartSheets |
| 202 | + if(xSheet!=null && !(xSheet instanceof XSSFChartSheet)) {//判断是否 sheet, 是读取xml文件输出,否则直接输出 |
| 203 | + SXSSFSheet sxSheet=getSXSSFSheet(xSheet); |
| 204 | + InputStream xis = sxSheet.getWorksheetXMLInputStream(); |
| 205 | + try { |
| 206 | + copyStreamAndInjectWorksheet(is,zos,xis);// 读取 xml 文件再输出 |
| 207 | + } finally { |
| 208 | + xis.close(); |
| 209 | + } |
| 210 | + } else { |
| 211 | + IOUtils.copy(is, zos); |
| 212 | + } |
| 213 | + ....省略 |
| 214 | +} |
| 215 | +
|
| 216 | +private static void copyStreamAndInjectWorksheet(InputStream in, OutputStream out, InputStream worksheetData) throws IOException { |
| 217 | + ....省略 |
| 218 | + //Copy the worksheet data to "out". |
| 219 | + IOUtils.copy(worksheetData,out);// 将 xml 文件的内容复制到输出流中,从而输出到客户端 |
| 220 | + |
| 221 | + outWriter.write("</sheetData>"); |
| 222 | + outWriter.flush(); |
| 223 | + //Copy the rest of "in" to "out". |
| 224 | + while(((c=inReader.read())!=-1)) { |
| 225 | + outWriter.write(c); |
| 226 | + } |
| 227 | + outWriter.flush(); |
| 228 | +} |
| 229 | +
|
| 230 | +``` |
| 231 | + |
| 232 | +### 0x05 poi 解决 OOM 总结 |
| 233 | +poi 把最终 excel 拆分成模板文件和数据分开保存,数据保存到硬盘上,模板(包含Style、字体等信息)保存在内存上。输出时把模板生成为 xlsx 文件再以 zip 格式读回出来重新输出给客服端,如果读到 sheet 的文件就替换成 xml 数据文件输出。由于 zip 只是一个打包,并没有压缩混乱了整个文件,可以看作一个把一个文件夹的内容输出。 |
| 234 | + |
| 235 | + |
| 236 | +### 0x06 修改 |
| 237 | +其实修改方式有很多,可以由模板到数据把整个输出都处理了,这是最完美的做法。但这方法需要修改的地方太多,需要了解的内容也太多。由于时间的关系,所以我就采用继承 SXSSFWorkbook 在注入数据时不从文件中读取,改为即时读取业务数据即时生成 xml 数据文件。把生成 xml 文件时的 SheetDataWriter#_out 通过反射修改成指 OutputStream 即可解决把输出重定向。 这样可以复用 poi 原生的内容而又不用改动太大。由于```copyStreamAndInjectWorksheet```是私有方法不能重写,那只能复制源码并重写```injectData```方法;```injectData```也调了私有方法,可以用反射来解决。 |
| 238 | + |
| 239 | +``` |
| 240 | +public class StreamSXSSFWorkbook extends SXSSFWorkbook { |
| 241 | +
|
| 242 | + /** |
| 243 | + * 消费数据,生成Row |
| 244 | + */ |
| 245 | + private Consumer<Sheet> sheetConsumer; |
| 246 | + |
| 247 | +
|
| 248 | +
|
| 249 | + private static void copyStreamAndInjectWorksheet(InputStream in, OutputStream out, InputStream worksheetData) throws IOException { |
| 250 | + ....省略 |
| 251 | + //Copy the worksheet data to "out". |
| 252 | + //IOUtils.copy(worksheetData,out);// 将 xml 文件的内容复制到输出流中,从而输出到客户端 |
| 253 | + //将生成 xml 数据文件的输出流改为输出到客户端的输出流 |
| 254 | + try { |
| 255 | + Field writerField = findField(sheet.getClass(), "_writer"); |
| 256 | + writerField.setAccessible(true); |
| 257 | + SheetDataWriter sheetDataWriter = (SheetDataWriter) writerField.get(sheet); |
| 258 | +
|
| 259 | + Field outField = findField(sheetDataWriter.getClass(), "_out"); |
| 260 | + outField.setAccessible(true); |
| 261 | + outField.set(sheetDataWriter, outWriter); |
| 262 | + } catch (IllegalAccessException e) { |
| 263 | + throw new RuntimeException(e); |
| 264 | + } |
| 265 | + consumer.accept(sheet);//生成并输出 xml 数据文件 |
| 266 | + sheet.flushRows();//刷新内存中(rowAccessWindowSize控制的)剩下的数据 |
| 267 | +
|
| 268 | + outWriter.write("</sheetData>"); |
| 269 | + outWriter.flush(); |
| 270 | + //Copy the rest of "in" to "out". |
| 271 | + while (((c = inReader.read()) != -1)) { |
| 272 | + outWriter.write(c); |
| 273 | + } |
| 274 | + outWriter.flush(); |
| 275 | + } |
| 276 | +
|
| 277 | + public void setSheetConsumer(Consumer<Sheet> sheetConsumer) { |
| 278 | + this.sheetConsumer = sheetConsumer; |
| 279 | + } |
| 280 | +} |
| 281 | +``` |
| 282 | +使用方法 |
| 283 | +``` |
| 284 | +StreamSXSSFWorkbook wb = new StreamSXSSFWorkbook(1000); |
| 285 | + List<CellStyle> cellStyles = initCellStyle(wb);//必须先创建 |
| 286 | + wb.setSheetConsumer(sheet -> { |
| 287 | + List<String> ls = data(); |
| 288 | + |
| 289 | + Iterator<String> it = ls.iterator(); |
| 290 | + int i = 0; |
| 291 | + while (it.hasNext()){ |
| 292 | + String val = it.next(); |
| 293 | + |
| 294 | + CellStyle style = cellStyles.get(i); |
| 295 | + Row row = sheet.createRow(i); |
| 296 | + Cell cell = row.createCell(0); |
| 297 | + cell.setCellStyle(style); |
| 298 | + cell.setCellValue(val); |
| 299 | +
|
| 300 | + it.remove(); |
| 301 | + i++; |
| 302 | + } |
| 303 | +}); |
| 304 | +wb.write(out); |
| 305 | +``` |
| 306 | + |
| 307 | +### 0x07 限制、优化、建议 |
| 308 | +1. 我使用的版本时 poi 3.17,修改的```copyStreamAndInjectWorksheet```方法和```injectData```方法依赖于源码,升级版本时源码修改了也需要做相应的修改 |
| 309 | +2. 由于生成 excel 时是先生成输出模板在注入数据,所以CellStyle等通过Workbook创建的对象在setSheetConsumer里生成是没有效果的;但可以在setSheetConsumer之前生成,再传入里面。 |
| 310 | +3. 上面的方法还是会生成临时文件,只不过临时文件是空的,可以通过重写```SheetDataWriter```来优化。同样道理也是可以不用成模板的临时文件再读出输出,但这样子修改成本比较大,不过也是最完美。 |
| 311 | +4. 建议输出大量数据时做到分页查询再输入,并且业务数据以pop的方式读完就删除,让JVM尽快回收。 |
0 commit comments