JavaPOI分批次读取Excel,彻底避免OOM

POI为我们提供了很方便的文件解析功能,而且使用起来也非常方便。
对于简单的获取Excel全部内容的功能来说,根据POI官网和网上的代码,于是就有了下面的实现:

FileInputStream inputStream = null;
StringBuilder xlsFileContent = new StringBuilder();
try {
    inputStream = new FileInputStream(file);
    Workbook wb;
    if (type.equals(FileTypeUtil.Type.XLS)) {
        wb = new HSSFWorkbook(inputStream);
    } else if (type.equals(FileTypeUtil.Type.XLSX)) {
        wb = new XSSFWorkbook(inputStream);
    } else {
        return "";
    }
    int sheetCount = wb.getNumberOfSheets();
    for (int i = 0; i < sheetCount; i++) {
        ....balabaa
    }            
}

上面的代码不是重点,所以,上面代码中不全的部分我没有给全。满心欢喜的写完了代码,运行发现没有问题。但是,很不幸的是,如果我们要解析的文件比较大,我试过一个50M的Excel文件,结果不出意外的报错了。OutOfMemoryError,没错,内存溢出,其实也没有什么以外的,就算是我们自己写代码解析最普遍的文本文件的时候,在解析大文件时不注意也会出现这个错误,更何况这个比文本文件更复杂的Excel文件。

在网上搜索了一下解决的方案,比较多的是给出的官方的例子代码,官方是给的一个实例代码将Excel的信息转换成CSV文件内容的格式来进行返回的。具体的网上的博客中的能不能实现我不是很清楚,因为我也没试, 因为我的需求是能够解析xls以及xlsx文件,可网上的代码毫无意外的全部都是引用官方的解析xlsx的代码,另外还有应用一个第三方库的,但是也是只能解析xlsx文件,这就很蛋疼了。

没办法只能再去官网碰一碰运气了,知道网上博客里的思路后,在官网的demo列表里面我发现了两个文件。XLSX2CSV, XLS2CSVmra。简答看一下代码,发现正是自己需要的。官方原来不仅给出了XLSX大文件的解析方案,也给出了LSX大文件的解析方案。

查看官方的代码,发现,它是把每一步解析后的信息通过System.out给打印出来了。
就像这样:

/**
     * Creates a new XLS -> CSV converter
     * @param filename The file to process
     * @param minColumns The minimum number of columns to output, or -1 for no minimum
     */
    public XLS2CSVmra(String filename, int minColumns) throws IOException, FileNotFoundException {
        this(
                new POIFSFileSystem(new FileInputStream(filename)),
                System.out, minColumns
        );
    }

上面传给了一个System.out对象,在后面它用这个System.out对象去打印的Excel内容,就像下面的:

// If we got something to print out, do so
        if(thisStr != null) {
            if(thisColumn > 0) {
                output.print(',');
            }
            output.print(thisStr);
        }

那么我们就有了一个思路了,我们可以在官方的类里面添加一个StringBuilder对象,在所有使用output.print(“”)打印的地方,将Excel的内容信息用StringBuilder给接收起来,最后再返回这个StringBuilder就可以了。同理对于XLSX文件就是把官方处理XLSX文件的代码按照上面的思路改一下就可以了。
然而这个也不是重点。。。,所以我也不给代码了,这样做其实是没有问题的,我以这种方式实现后,发现至少读取50M的文件是没有一点问题的,而且估计更大一点的文件也是没有问题的,然而如果,比如说大数据情况下,一个Excel文件可能就要几百M甚至上千M,那么我们在代码里面使用StringBuilder去接受存储Excel中的信息,会有什么问题?,很简单同样会发生OOM,因为它同样吃内存。

那么为了彻底避免OOM,就需要再换一种解析方式。
既然出现OOM是因为内存中的数据太多,那么有什么方式可以让内存中的数据不多?这时候就要想到Java读取文本文件了。读取文本文件怎么防止OOM?很简单,多次读取,每次读取一部分就可以了。那么怎么把Excel文件和文本文件结合起来呢?

下面提供一种思路:
1. 上面已经知道,官方代码里面使用System.out来打印每读取一个单元格的数据的,那么我们将读取过程中的数据存到一个缓存文件里面,而不让它存在在内存里面,显然是可行的
2. 如果每读取一个单元格的话就要进行一次文件的写入,那样写入会太频繁,会严重影响性能,我们可以等积累到一定数量后再进行文件的写入
3. 对于写完的文件,就好办了,我们可以用Java自带的IO流分批次去读取数据,这样,无论多大的文件就都不会出现内存溢出问题了。
4. 当然,对于上面的读取方式,事实上如果我们想在程序里面使用这个数据,那只能是每取出一块数据处理一块数据,这样的处理方式就是为了方式OOM,如果非得想一次性读取完所有的数据,那我想,无论是采用哪种方式,OOM是不可避免的。

明白上面的思路后,我们的第一步就是修改官方的代码,下面以XLS文件为例:
上面已经给出了官方代码的连接了,这里再给一次:。XLSX2CSV(读取XLSX文件), XLS2CSVmra(读取XLS文件)。

那么第一步,我们要定义我们需要的东西:
1. 既然需要将读取过程中的的数据存到一个缓存文件里面,那么我们就得需要一个OutputStream,然后在官方代码里面所有用到PrintStream打印数据的地方要用这个OutputStream去进行数据的存储。
2. 上面说过,为了避免频繁的进行IO读写,我们要等读取的数据达到一定程度后才写缓存文件,因此需要定义两个变量,一个是StringBuilder用来存储读取的数据,一个是int类型的length,用来存储当数据长度达到多少的时候再进行写操作

因此,类中我们重新定义下面的属性:

public class XLS2CSVmra implements XLSFileReader, HSSFListener {
    ...
    private OutputStream outputStream;
    private StringBuilder writeResStr;

    public XLS2CSVmra(POIFSFileSystem fs, OutputStream outputStream) {
        this.writeResStr = new StringBuilder();
        this.outputStream = outputStream;
        ...
    }
    ...
}

第二步,我们应该定义一个方法,用于将程序运行过程中的数据存储到缓存文件里面,并且要在这个方法里面规定什么时候应该写文件:这里我规定了当数据量达到1M的时候才进行文件的写操作。

private void writeStr(String string) {
        // 将要写进缓存文件的数据现在程序中缓存
        writeResStr.append(string);
        // 定义数据达到多少时进行写操作
        int writeResLength = 1024 * 1024;
        if (writeResStr.length() >= writeResLength) {
            try {
                if (outputStream != null) {
                    outputStream.write(writeResStr.toString().getBytes());
                }
                // 进行写操作后,清空程序中的数据
                writeResStr.delete(0, writeResStr.length());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

另外,因为对数据大小什么时候写进行了限制,可能最后仍有一部分数据会存在writeResStr里面,最后的时候需要在把他里面的数据检查一遍,因此,我定义了一个close方法,用于做后续工作和资源的销毁,当然,为了使用方便,可以让类继承Closeable接口。

@Override
public void close() throws IOException {
    if (outputStream != null) {
        outputStream.write(writeResStr.toString().getBytes());
        outputStream.flush();
    }
    writeResStr.delete(0, writeResStr.length());
    fs.close();
}

第三步,就是把我们上面定义的方法,把官方代码中的output.println(..)全部替换成我们的writeStr(..),这样把解析文件的数据存储到缓存文件里面就完成了。

第四步,根据上面的几个步骤,将XLSX文件解析的也进行更改。

那么,对于改好的文件,我们怎么使用?
其实使用起来就是Java读取最简单的文本文件了。Java文本文件怎么分批次读取?
这个就简单了,我们定义一个变量,当读取的文件达到这个变量值的时候就返回一次性读取的数据。
下面是简单的代码:
为了使用方便,我定义了一个接口。以及一个返回数据的类。

/**
 * 读取文档的解析类
 * 在设置单次解析文本的大小后
 * 可以调用readNext进行下一次的文本读取
 */
public interface ReadFileParser extends Closeable {

    void setReadSize(int readSize);

    ReadResult readNext();

}


public class ReadResult {

    ReadResult(String content, boolean isEnd) {
        this.content = content;
        this.isEnd = isEnd;
    }

    private String content;// 读取的内容
    private boolean isEnd;// 是否读到了文件结尾

    public String getContent() {
        return content;
    }

    public boolean isEnd() {
        return isEnd;
    }
}

下面就定义文本类文档解析的类

import cn.beimingkun.searchengine.Utils.FileUtils;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

public class ReadDefaultFileParser implements ReadFileParser {
    private static Log log = LogFactory.getLog(ReadDefaultFileParser.class);
    private int readSize = 1024 * 1024;
    private FileInputStream fileInputStream;
    private BufferedReader bufferedReader;

    public ReadDefaultFileParser(String filePath) throws IOException {
        this.fileInputStream = new FileInputStream(filePath);
        this.bufferedReader = new BufferedReader(
                new InputStreamReader(fileInputStream, FileUtils.resolveCode(filePath)),
                10 * 1024 // Default Read Once Size is 1M.
        );
    }

    @Override
    public void setReadSize(int readSize) {
        this.readSize = readSize;
    }

    @Override
    public ReadResult readNext() {
        StringBuilder builder = new StringBuilder();
        boolean isEnd = false;
        char[] c = new char[10 * 1024];
        while (true) {
            int length;
            try {
                length = bufferedReader.read(c);
                if (length == -1) {
                    isEnd = true;
                    break;
                } else {
                    builder.append(c, 0, length);
                    // 只读取设定大小的数据
                    if (builder.length() >= readSize) {
                        break;
                    }
                }
            } catch (IOException e) {
                log.error(e);
            }
        }
        return new ReadResult(builder.toString(), isEnd);
    }

    @Override
    public void close() {
        FileUtils.closeAllStream(bufferedReader, fileInputStream);
    }
}

最后,我们首先使用修改过的官方代码将读取的Excel数据存到一个文本文件里面,然后使用上面定义的读取文本文件的类进行分批次读取。因此,下面也定义一个读取Excel数据的类,和读取文本文件的类实现同一个接口。


public class ReadXLSFileParser implements ReadFileParser {

    private ReadFileParser parser;
    private File tmpFile;

    public ReadXLSFileParser(String filePath, String tmpFileDir) throws Exception {
        // 预处理Tmp文件
        FileTypeUtil.Type fileType = FileTypeUtil.getFileType(filePath);
        XLSFileReader reader = null;
        FileOutputStream fileOutputStream = null;
        // 获取处理结果的中间缓存文件
        tmpFile = new File(tmpFileDir,
                FileUtils.getNameWithoutExtension(filePath)
                        + String.valueOf(System.currentTimeMillis()).substring(5)
                        + ".txt");
        try {
            if (FileUtils.createFile(tmpFile, true)) {
                fileOutputStream = new FileOutputStream(tmpFile);
                if (fileType.equals(FileTypeUtil.Type.XLS)) {
                    reader = new XLS2CSVmra(filePath, fileOutputStream);
                } else if (fileType.equals(FileTypeUtil.Type.XLSX)) {
                    reader = new XLSX2CSV(filePath, fileOutputStream);
                } else {
                    throw new FileSystemException("File Type not match:" + filePath);
                }
                reader.process();
            } else {
                throw new FileSystemException("Create TmpFile Error");
            }
        } finally {
            FileUtils.closeAllStream(reader, fileOutputStream);
        }

        // 解析tmp文件,分批次读取
        parser = new ReadDefaultFileParser(tmpFile.getPath());
    }

    @Override
    public void setReadSize(int readSize) {
        parser.setReadSize(readSize);
    }

    @Override
    public ReadResult readNext() {
        return parser.readNext();
    }

    @Override
    public void close() throws IOException {
        parser.close();
        //noinspection ResultOfMethodCallIgnored
        tmpFile.delete();
    }
}

上面的代码基本上分为这几个步骤;
1. 使用修改过的官方代码将获取Excel文件内容,并保存到本地文本文件
2. 根据这个保存后的文本文件创建读取文本文件的读取类
3. 每次调用ReadNext的时候其实读取的就是本地的文本文件
4. 释放资源的时候就把本地文件给删除掉。

基本上的代码就是这样了,代码比较多,也没有办法一一说明,只好将主要思想表达一下了,查看具体的代码逻辑的话,可以下载代码来进行查看。

https://download.csdn.net/download/wangpengfei_p/10629834

你可能感兴趣的:(日常记录)