Excel作为一种常用的数据存储格式,在很多项目中都会有相应的导入导出的功能。这篇文章会介绍如何使用Java操作Excel,以及如何解决大文件读写时内存溢出的问题。
1、OpenXML标准
Word、Excel、PPT是Office办公套件中最常用的三个组件。早期的Office套件使用二进制格式,这里面包括以.doc
、.xls
、.ppt
为后缀的文件;直到07这个划时代的版本将基于XML的压缩格式作为默认文件格式,也就是相应以.docx
、.xlsx
、.pptx
为后缀的文件。
这个结合了XML与Zip压缩技术的新文件格式使用的是OpenXML标准。微软从2000年开始酝酿这项技术标准,到2006年申请成为ECMA-376,然后在Office2007中用作默认的文件格式,再到08年成为了ISO / IEC 29500国际标准,后续每两三年就会发布一个新版本。Office的一路凯歌无不彰显微软雄厚的实力。
所以说三流公司做产品,二流公司做平台,一流公司定标准。
微软的官方文档中详细介绍了WordprocessingML(Word)、SpreadsheetML(Excel)、PresentationML(PPT)三个标准,这里主要介绍Excel的部分内容。
首先Excel几个最基础的概念:
- 一个Excel就是一个工作簿(Workbook)
- 一个Sheet就是一张表格
- 一个Workbook可以包含多个Sheet
- 每一行Row的每一列就是一个单元格(Cell)
因为07版后的.xlsx
本质上就是一个压缩包,我们完全可以用解压工具打开它。
一个基础的Excel解压之后,目录结构大致如下:
更典型的Excel还包括:数字、文本、公式、图表(Chart)、普通列表(Table)、数据透视表(Pivot Table)等内容。
Excel远比我们想象的复杂
2、使用POI操作Excel
Java领域最常见的两个操作Excel的工具库分别是JXL(Java Excel API)和Apache的POI。JXL有个严重的缺点就是只支持07版本之前的二进制格式Excel,而POI除了能操作Excel,还可以操作Word和PPT以及Office套装中其他的组件,高下立现。
POI全称是Poor Obfuscation Implementation,简洁模糊实现,也有人翻译成糟糕的模糊实现。
POI目前最新版本是4.0,可以将相应maven依赖添加到pom.xml文件中:
1 2 3 4 5 6 7 8 9 10 11 12
| <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>4.0.0</version> </dependency>
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>4.0.0</version> </dependency>
|
POI提供了三种读写Excel的方式:
1、HSSF支持.xls
为后缀的二进制格式,并提供了流解析模式的HSSFListener
相关API以及基于内存模型的HSSFWorkbook
相关API。
2、XSSF支持.xlsx
为后缀的OpenXML格式。因为是底层文件是XML所以可以使用SAX解析,POI提供了XSSFReader
用来获取压缩包中的各个XML文件相应的输入流;另外提供了基于DOM解析模式的XSSFWorkbook
相关API。
3、POI3.8后提供了SXSSF API,它是基于XSSF构建的低内存占用版本(使用滑动窗口机制来实现低内存访问)。但是需要注意的是**SXSSFWorkbook默认使用内联字符串而不是共享字符串表(SharedStringsTable)**,这样可以让保存在内存中的数据尽可能更少(SharedStringsTable需要常驻内存),所以如果是自己写SAX解析要注意兼容性。
POI滑动窗口只窗口范围内的单元格数据加载到内存中,窗口外的数据读写内容会以临时文件的形式保存到磁盘上,同时还支持临时文件的压缩。SXSSF可以通过构造函数中的rowAccessWindowSize
参数指定窗口大小,compressTmpFiles
指定是否压缩临时文件,useSharedStringsTable
指定是否使用共享字符表。
2.1、使用Workbook API
上面说的三种方式都有一个Workbook实现类,用法上基本一致。唯一不同的是SXSSFWorkbook最后需要调用dispose()
方法处理磁盘上的临时文件。
下面是使用XSSFWorkbook读取.xlsx
文件的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| FileInputStream file = new FileInputStream(new File(fileLocation)); Workbook workbook = new XSSFWorkbook(file);
Sheet sheet = workbook.getSheetAt(0);
Map<Integer, List<String>> data = new HashMap<>(); int i = 0; for (Row row : sheet) { data.put(i, new ArrayList<String>()); for (Cell cell : row) { switch (cell.getCellType()) { case STRING: ... break; case NUMERIC: ... break; case BOOLEAN: ... break; case FORMULA: ... break; case BLANK: ... break; } } i++; }
|
POI有不同的方法来读取每种类型的数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| switch(cell.getCellType()) { case CellType.STRING: data.get(i).add(cell.getRichStringCellValue().getString()); break; case CellType.NUMERIC: if(DateUtil.isCellDateFormatted(cell)) { data.get(i).add(cell.getDateCellValue)); } else { data.get(i).add(cell.getNumericCellValue()); } break; case CellType.BOOLEAN: data.get(i).add(cell.getBooleanCellValue()); break; case CellType.FORMULA: data.get(i).add(cell.getCellFormula()); break; case CellType.BLANK: data.get(i).add("") break; }
|
Workbook API也支持Excel的写入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| Workbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("Persons"); sheet.setColumnWidth(1, 4000);
Row header = sheet.createRow(0);
CellStyle headerStyle = workbook.createCellStyle(); headerStyle.setFillForegroundColor(IndexedColors.LIGHT_BLUE.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); XSSFFont font = ((XSSFWorkbook) workbook).createFont(); font.setFontName("Arial"); font.setFontHeightInPoints((short) 16); font.setBold(true); headerStyle.setFont(font);
Cell headerCell = header.createCell(0); headerCell.setCellValue("Name"); headerCell.setCellStyle(headerStyle);
headerCell = header.createCell(1); headerCell.setCellValue("Age"); headerCell.setCellStyle(headerStyle);
CellStyle style = workbook.createCellStyle(); style.setWrapText(true);
Row row = sheet.createRow(2); Cell cell = row.createCell(0); cell.setCellValue("John Smith"); cell.setCellStyle(style);
cell = row.createCell(1); cell.setCellValue(20); cell.setCellStyle(style);
FileOutputStream outputStream = Files.newOutputStream("/path/to/excel"); workbook.write(outputStream); workbook.close();
|
POI还支持插入图片、形状、数据透视表以及更多的样式,更多详细代码可以参考官方的快速入门指南
2.2、HSSFListener实现流式解析
虽然SXSSFWorkbook通过滑动窗口有效地降低了内存消耗,但是并不支持读的功能,而且写功能也只支持OpenXML格式。而HSSFWorkbook和XSSFWorkbook需要将Excel内容全部读取到内存才能操作,对于二进制Excel大文件的读取必须使用HSSFListener。
不过03版二进制Excel能支持的最大行数为65536
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| public class EventExample implements HSSFListener { private SSTRecord sstrec;
public void processRecord(Record record) { switch (record.getSid()) { case BOFRecord.sid: BOFRecord bof = (BOFRecord) record; if (bof.getType() == bof.TYPE_WORKBOOK) { System.out.println("Encountered workbook"); } else if (bof.getType() == bof.TYPE_WORKSHEET) { System.out.println("Encountered sheet reference"); } break; case BoundSheetRecord.sid: BoundSheetRecord bsr = (BoundSheetRecord) record; System.out.println("New sheet named:" + bsr.getSheetname()); break; case RowRecord.sid: RowRecord rowrec = (RowRecord) record; System.out.println("first column:" + rowrec.getFirstCol() + "," "last column:" + rowrec.getLastCol()); break; case NumberRecord.sid: NumberRecord numrec = (NumberRecord) record; System.out.println("Row:"+numrec.getRow() + "," "Column:" + numrec.getColumn() + "," "Number value:" + numrec.getValue()); break; case SSTRecord.sid: sstrec = (SSTRecord) record; System.out.println("String table value:"); for (int k = 0; k < sstrec.getNumUniqueStrings(); k++) { System.out.println(k + " = " + sstrec.getString(k)); } break; case LabelSSTRecord.sid: LabelSSTRecord lrec = (LabelSSTRecord) record; System.out.println("String cell value:" + sstrec.getString(lrec.getSSTIndex())); break; } }
public static void main(String[] args) throws IOException { FileInputStream fin = new FileInputStream("/path/to/file"); POIFSFileSystem poifs = new POIFSFileSystem(fin); InputStream din = poifs.createDocumentInputStream("Workbook"); HSSFRequest req = new HSSFRequest(); req.addListenerForAllRecords(new EventExample()); HSSFEventFactory factory = new HSSFEventFactory(); factory.processEvents(req, din); fin.close(); din.close(); System.out.println("done."); } }
|
2.3、SXSSF API
SXSSF(org.apache.poi.xssf.streaming)是兼容XSSF API的流式扩展,用于生成数据量较大的Excel文件。SXSSF通过限制滑动窗口内行的访问来实现低内存占用,而XSSF API允许访问文档中的所有行。不在窗口中的旧行将不可访问,因为它们已经被写入磁盘。
您可以通过new SXSSFWorkbook(int windowSize)
指定窗口大小, 也可以通过SXSSFSheet.setRandomAccessWindowSize(int windowSize)
设置每个sheet的窗口大小
当通过createRow()
创建新行时,且尚未flush()
的数据总量超过指定的窗口大小时,将flush()
内存中最前面的行,并且不能再通过getRow()
访问该行。
默认窗口大小为100,由SXSSFWorkbook.DEFAULT_WINDOW_SIZE
定义。
windowSize为-1表示无限制访问。在这种情况下,所有未调用flushRows()
强制刷新的记录都可访问。
请注意,SXSSF会创建临时文件,所以最后必须通过调用dispose()
方法来显式清理临时文件。
SXSSFWorkbook默认使用内联字符串而不是共享字符串表。因为不需要在内存中保存文档字符内容,所以这种方式能有效地减低内存消耗,但是也导致了生成与某些客户端不兼容的文档。启用共享字符串后,文档中所有的唯一字符串都必须保留在内存中,所以这可能会比禁用共享字符串消耗更多的资源。
另外还需要注意,根据你使用的功能,仍然可能消耗大量内存,例如合并区域,超链接,注释……,这些内容只存储在内存中。
在决定是否启用共享字符串之前,请仔细检查内存预算和兼容性需求。
SXSSF在将sheet的数据刷新到临时文件中(每个sheet一个临时文件),这些临时文件可能会变得非常大。比如,对于20 MB的csv数据,临时xml文件将超过GB字节。如果临时文件的大小是个问题,可以通过setCompressTempFiles(true)
让SXSSF使用gzip压缩。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| SXSSFWorkbook wb = new SXSSFWorkbook(100); Sheet sh = wb.createSheet();
for(int rownum = 0; rownum < 1000; rownum++){ Row row = sh.createRow(rownum); for(int cellnum = 0; cellnum < 10; cellnum++){ Cell cell = row.createCell(cellnum); String address = new CellReference(cell).formatAsString(); cell.setCellValue(address); }
}
for(int rownum = 0; rownum < 900; rownum++){ Assert.assertNull(sh.getRow(rownum)); }
for(int rownum = 900; rownum < 1000; rownum++){ Assert.assertNotNull(sh.getRow(rownum)); }
FileOutputStream out = new FileOutputStream("/temp/sxssf.xlsx"); wb.write(out); out.close();
wb.dispose();
|
我们还可以将windowSize置为-1,通过调用SXSSFSheet.flushRows()
手动刷新到磁盘
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| SXSSFWorkbook wb = new SXSSFWorkbook(-1); Sheet sh = wb.createSheet();
for(int rownum = 0; rownum < 1000; rownum++){ Row row = sh.createRow(rownum); for(int cellnum = 0; cellnum < 10; cellnum++){ Cell cell = row.createCell(cellnum); String address = new CellReference(cell).formatAsString(); cell.setCellValue(address); }
if(rownum % 100 == 0) { ((SXSSFSheet)sh).flushRows(100); }
}
FileOutputStream out = new FileOutputStream("/temp/sxssf.xlsx"); wb.write(out); out.close();
wb.dispose();
|
2.4、使用SAX解析xlsx文件
虽然大文件的写入有SXSSF的支持,但是读取暂时没有更好的解决方案。POI目前推荐的做法是直接使用SAX API手动解析XML。这要求开发者对Excel的接口有清楚的认识。
POI也对SAX解析提供了一些支持——XSSFReader。
XSSFReader能帮我们轻松地获取.xlsx
压缩包中各个部分的输入流,
XSSFReader有一个子类XSSFBReader用于读取.xlsb
文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
| public class ExampleEventUserModel { public void processOneSheet(String filename) throws Exception { OPCPackage pkg = OPCPackage.open(filename); XSSFReader r = new XSSFReader(pkg); SharedStringsTable sst = r.getSharedStringsTable();
XMLReader parser = fetchSheetParser(sst);
InputStream sheet2 = r.getSheet("rId2"); InputSource sheetSource = new InputSource(sheet2); parser.parse(sheetSource); sheet2.close(); }
public void processAllSheets(String filename) throws Exception { OPCPackage pkg = OPCPackage.open(filename); XSSFReader r = new XSSFReader(pkg); SharedStringsTable sst = r.getSharedStringsTable();
XMLReader parser = fetchSheetParser(sst);
Iterator<InputStream> sheets = r.getSheetsData(); while(sheets.hasNext()) { System.out.println("Processing new sheet:\n"); InputStream sheet = sheets.next(); InputSource sheetSource = new InputSource(sheet); parser.parse(sheetSource); sheet.close(); System.out.println(""); } }
public XMLReader fetchSheetParser(SharedStringsTable sst) throws SAXException { XMLReader parser = XMLReaderFactory.createXMLReader( "org.apache.xerces.parsers.SAXParser" ); ContentHandler handler = new SheetHandler(sst); parser.setContentHandler(handler); return parser; } private static class SheetHandler extends DefaultHandler { private SharedStringsTable sst; private String lastContents; private boolean nextIsString;
private SheetHandler(SharedStringsTable sst) { this.sst = sst; }
public void startElement(String uri, String localName, String name, Attributes attributes) throws SAXException { if(name.equals("c")) { System.out.print(attributes.getValue("r") + " - "); String cellType = attributes.getValue("t"); if(cellType != null && cellType.equals("s")) { nextIsString = true; } else { nextIsString = false; } } lastContents = ""; }
public void endElement(String uri, String localName, String name) throws SAXException { if(nextIsString) { int idx = Integer.parseInt(lastContents); lastContents = new XSSFRichTextString(sst.getEntryAt(idx)).toString(); nextIsString = false; }
if(name.equals("v")) { System.out.println(lastContents); } }
public void characters(char[] ch, int start, int length) throws SAXException { lastContents += new String(ch, start, length); } }
public static void main(String[] args) throws Exception { ExampleEventUserModel example = new ExampleEventUserModel(); example.processOneSheet(args[0]); example.processAllSheets(args[0]); } }
|
上面的代码是使用原生SAX API进行XML处理的例子,它要求我们知道sheet.xml文件的内容结构。POI已经将这部分逻辑封装在了XSSFSheetXMLHandler
中,我们只要实现它暴露的SheetContentsHandler
接口即可。
使用SheetContentsHandler的例子可以参考官方的XLSX2CVS
3、写在最后
Excel本身有很多已知的限制,如最大行数和最大列数(这些限制可以参考SpreadsheetVersion),理论上只要你有足够大的内存,你就能使用Workbook API对任意Excel进行读写。
很多场景需要我们克服内存限制,总结下来有以下方案:
1、大文解析使用SAX
2、大文件写入使用SXSSFWorkbook
还有一种妥协的方案是将数据分别写入到多个Excel中,最后对这些Excel打包。
最近在Github上看到阿里的一位大佬开发的EasyExcel,不过还没实际使用过,处于观望阶段…
参考链接:
https://en.wikipedia.org/wiki/Office_Open_XML
http://poi.apache.org/components/spreadsheet/how-to.html