POI讀取文件的最佳實踐

POI是 Apache 旗下一款讀寫微軟家文檔聲名顯赫的類庫。應該不少人在作報表的導出,或者建立 word 文檔以及讀取之類的都是用過 POI。POI 也的確對於這些操做帶來很大的便利性。我最近作的一個工具就是讀取計算機中的 word 以及 excel 文件。下面我就兩方面講解如下遇到的一些坑:html

word 篇

對於 word 文件,我須要的就是提取文件中正文的文字。因此能夠建立一個方法來讀取 doc 或者 docx 文件:git

private static String readDoc(String filePath, InputStream is) {
        String text= "";
        try {
            if (filePath.endsWith("doc")) {
                WordExtractor ex = new WordExtractor(is);
                text = ex.getText();
                ex.close();
                is.close();
            } else if(filePath.endsWith("docx")) {
                XWPFDocument doc = new XWPFDocument(is);
                XWPFWordExtractor extractor = new XWPFWordExtractor(doc);
                text = extractor.getText();
                extractor.close();
                is.close();
            }
        } catch (Exception e) {
            logger.error(filePath, e);
        } finally {
            if (is != null) {
                is.close();
            }
        }
        return text;
    }

理論上來講,這段代碼應該對於讀取大多數 doc 或者 docx 文件都是有效的。可是!!!!我發現了一個奇怪的問題,就是個人代碼在讀取某些 doc 文件的時候,常常會給出這樣的一個異常:github

org.apache.poi.poifs.filesystem.OfficeXmlFileException: The supplied data appears to be in the Office 2007+ XML. You are calling the part of POI that deals with OLE2 Office Documents.

這個異常的意思是什麼呢,通俗的來說,就是你打開的文件並非一個 doc 文件,你應該使用讀取 docx 的方法去讀取。可是咱們明明打開的就是一個後綴是 doc 的文件啊!apache

其實 doc 和 docx 的本質不一樣的,doc 是 OLE2 類型,而 docx 而是 OOXML 類型。若是你用壓縮文件打開一個 docx 文件,你會發現一些文件夾:c#

clipboard.png

本質上 docx 文件就是一個 zip 文件,裏面包含了一些 xml 文件。因此,一些 docx 文件雖然大小不大,可是其內部的 xml 文件確實比較大的,這也是爲何在讀取某些看起來不是很大的 docx 文件的時候卻耗費了大量的內存。api

而後我使用壓縮文件打開這個 doc 文件,果不其然,其內部正是如上圖,因此本質上咱們能夠認爲它是一個 docx 文件。多是由於它是以某種兼容模式保存從而致使如此坑爹的問題。因此,如今咱們根據後綴名來判斷一個文件是 doc 或者 docx 就是不可靠的了。app

老實說,我以爲這應該不是一個不多見的問題。可是我在谷歌上並無找到任何關於此的信息。how to know whether a file is .docx or .doc format from Apache POI 這個例子是經過 ZipInputStream 來判斷文件是不是 docx 文件:ide

boolean isZip = new ZipInputStream( fileStream ).getNextEntry() != null;

但我並不以爲這是一個很好的方法,由於我得去構建一個ZipInpuStream,這很顯然很差。另外,這個操做貌似會影響到 InputStream,因此你在讀取正常的 doc 文件會有問題。或者你使用 File 對象去判斷是不是一個 zip 文件。但這也不是一個好方法,由於我還須要在壓縮文件中讀取 doc 或者 docx 文件,因此個人輸入必須是 Inputstream,因此這個選項也是不能夠的。 我在 stackoverflow 上和一幫老外扯了大半天,有時候我真的很懷疑這幫老外的理解能力,不過最終仍是有一個大佬給出了一個讓我欣喜若狂的解決方案,FileMagic。這個是一個 POI 3.17新增長的一個特性:工具

public enum FileMagic {
    /** OLE2 / BIFF8+ stream used for Office 97 and higher documents */
    OLE2(HeaderBlockConstants._signature),
    /** OOXML / ZIP stream */
    OOXML(OOXML_FILE_HEADER),
    /** XML file */
    XML(RAW_XML_FILE_HEADER),
    /** BIFF2 raw stream - for Excel 2 */
    BIFF2(new byte[]{
            0x09, 0x00, // sid=0x0009
            0x04, 0x00, // size=0x0004
            0x00, 0x00, // unused
            0x70, 0x00  // 0x70 = multiple values
    }),
    /** BIFF3 raw stream - for Excel 3 */
    BIFF3(new byte[]{
            0x09, 0x02, // sid=0x0209
            0x06, 0x00, // size=0x0006
            0x00, 0x00, // unused
            0x70, 0x00  // 0x70 = multiple values
    }),
    /** BIFF4 raw stream - for Excel 4 */
    BIFF4(new byte[]{
            0x09, 0x04, // sid=0x0409
            0x06, 0x00, // size=0x0006
            0x00, 0x00, // unused
            0x70, 0x00  // 0x70 = multiple values
    },new byte[]{
            0x09, 0x04, // sid=0x0409
            0x06, 0x00, // size=0x0006
            0x00, 0x00, // unused
            0x00, 0x01
    }),
    /** Old MS Write raw stream */
    MSWRITE(
            new byte[]{0x31, (byte)0xbe, 0x00, 0x00 },
            new byte[]{0x32, (byte)0xbe, 0x00, 0x00 }),
    /** RTF document */
    RTF("{\\rtf"),
    /** PDF document */
    PDF("%PDF"),
    // keep UNKNOWN always as last enum!
    /** UNKNOWN magic */
    UNKNOWN(new byte[0]);

    final byte[][] magic;

    FileMagic(long magic) {
        this.magic = new byte[1][8];
        LittleEndian.putLong(this.magic[0], 0, magic);
    }

    FileMagic(byte[]... magic) {
        this.magic = magic;
    }

    FileMagic(String magic) {
        this(magic.getBytes(LocaleUtil.CHARSET_1252));
    }

    public static FileMagic valueOf(byte[] magic) {
        for (FileMagic fm : values()) {
            int i=0;
            boolean found = true;
            for (byte[] ma : fm.magic) {
                for (byte m : ma) {
                    byte d = magic[i++];
                    if (!(d == m || (m == 0x70 && (d == 0x10 || d == 0x20 || d == 0x40)))) {
                        found = false;
                        break;
                    }
                }
                if (found) {
                    return fm;
                }
            }
        }
        return UNKNOWN;
    }

    /**
     * Get the file magic of the supplied InputStream (which MUST
     *  support mark and reset).<p>
     *
     * If unsure if your InputStream does support mark / reset,
     *  use {@link #prepareToCheckMagic(InputStream)} to wrap it and make
     *  sure to always use that, and not the original!<p>
     *
     * Even if this method returns {@link FileMagic#UNKNOWN} it could potentially mean,
     *  that the ZIP stream has leading junk bytes
     *
     * @param inp An InputStream which supports either mark/reset
     */
    public static FileMagic valueOf(InputStream inp) throws IOException {
        if (!inp.markSupported()) {
            throw new IOException("getFileMagic() only operates on streams which support mark(int)");
        }

        // Grab the first 8 bytes
        byte[] data = IOUtils.peekFirst8Bytes(inp);

        return FileMagic.valueOf(data);
    }


    /**
     * Checks if an {@link InputStream} can be reseted (i.e. used for checking the header magic) and wraps it if not
     *
     * @param stream stream to be checked for wrapping
     * @return a mark enabled stream
     */
    public static InputStream prepareToCheckMagic(InputStream stream) {
        if (stream.markSupported()) {
            return stream;
        }
        // we used to process the data via a PushbackInputStream, but user code could provide a too small one
        // so we use a BufferedInputStream instead now
        return new BufferedInputStream(stream);
    }
}

在這給出主要的代碼,其主要就是根據 InputStream 前 8 個字節來判斷文件的類型,毫無覺得這就是最優雅的解決方式。一開始,其實我也是在想對於壓縮文件的前幾個字節彷佛是由不一樣的定義的,magicmumber。由於 FileMagic 的依賴和3.16 版本是兼容的,因此我只須要加入這個類就能夠了,所以咱們如今讀取 word 文件的正確作法是:優化

private static String readDoc (String filePath, InputStream is) {
        String text= "";
        is = FileMagic.prepareToCheckMagic(is);
        try {
            if (FileMagic.valueOf(is) == FileMagic.OLE2) {
                WordExtractor ex = new WordExtractor(is);
                text = ex.getText();
                ex.close();
            } else if(FileMagic.valueOf(is) == FileMagic.OOXML) {
                XWPFDocument doc = new XWPFDocument(is);
                XWPFWordExtractor extractor = new XWPFWordExtractor(doc);
                text = extractor.getText();
                extractor.close();
            }
        } catch (Exception e) {
            logger.error("for file " + filePath, e);
        } finally {
            if (is != null) {
                is.close();
            }
        }
        return text;
    }

excel 篇

對於 excel 篇,我也就不去找以前的方案和如今的方案的對比了。就給出我如今的最佳作法了:

@SuppressWarnings("deprecation" )
    private static String readExcel(String filePath, InputStream inp) throws Exception {
        Workbook wb;
        StringBuilder sb = new StringBuilder();
        try {
            if (filePath.endsWith(".xls")) {
                wb = new HSSFWorkbook(inp);
            } else {
                wb = StreamingReader.builder()
                        .rowCacheSize(1000)    // number of rows to keep in memory (defaults to 10)
                        .bufferSize(4096)     // buffer size to use when reading InputStream to file (defaults to 1024)
                        .open(inp);            // InputStream or File for XLSX file (required)
            }
            sb = readSheet(wb, sb, filePath.endsWith(".xls"));
            wb.close();
        } catch (OLE2NotOfficeXmlFileException e) {
            logger.error(filePath, e);
        } finally {
            if (inp != null) {
                inp.close();
            }
        }
        return sb.toString();
    }

    private static String readExcelByFile(String filepath, File file) {
        Workbook wb;
        StringBuilder sb = new StringBuilder();
        try {
            if (filepath.endsWith(".xls")) {
                wb = WorkbookFactory.create(file);
            } else {
                wb = StreamingReader.builder()
                        .rowCacheSize(1000)    // number of rows to keep in memory (defaults to 10)
                        .bufferSize(4096)     // buffer size to use when reading InputStream to file (defaults to 1024)
                        .open(file);            // InputStream or File for XLSX file (required)
            }
            sb = readSheet(wb, sb, filepath.endsWith(".xls"));
            wb.close();
        } catch (Exception e) {
            logger.error(filepath, e);
        }
        return sb.toString();
    }

    private static StringBuilder readSheet(Workbook wb, StringBuilder sb, boolean isXls) throws Exception {
        for (Sheet sheet: wb) {
            for (Row r: sheet) {
                for (Cell cell: r) {
                    if (cell.getCellType() == Cell.CELL_TYPE_STRING) {
                        sb.append(cell.getStringCellValue());
                        sb.append(" ");
                    } else if (cell.getCellType() == Cell.CELL_TYPE_NUMERIC) {
                        if (isXls) {
                            DataFormatter formatter = new DataFormatter();
                            sb.append(formatter.formatCellValue(cell));
                        } else {
                            sb.append(cell.getStringCellValue());
                        }
                        sb.append(" ");
                    }
                }
            }
        }
        return sb;
    }

其實,對於 excel 讀取,個人工具面臨的最大問題就是內存溢出。常常在讀取某些特別大的 excel 文件的時候都會帶來一個內存溢出的問題。後來我終於找到一個優秀的工具 excel-streaming-reader,它能夠流式的讀取 xlsx 文件,將一些特別大的文件拆分紅小的文件去讀。

另一個作的優化就是,對於可使用 File 對象的場景下,我是去使用 File 對象去讀取文件而不是使用 InputStream 去讀取,由於使用 InputStream 須要把它所有加載到內存中,因此這樣是很是佔用內存的。

最後,個人一點小技巧就是使用 cell.getCellType 去減小一些數據量,由於我只須要獲取一些文字以及數字的字符串內容就能夠了。

以上,就是我在使用 POI 讀取文件的一些探索和發現,但願對你能有所幫助。上面的這些例子也是在個人一款工具 everywhere 中的應用(這款工具主要是能夠幫助你在電腦中進行內容的全文搜索),感興趣的能夠看看,歡迎 star 或者 pr。

m1jSLd.jpg

相關文章
相關標籤/搜索