phper需要处理excel的时候基本上都会考虑PhpSpreadsheet,但是用过这个库的人都知道,这个库所需要的内存很大,关键是随着文件的增大,所需要的内存不断增大。测试过,导入2M的文excel文件,get_memory_usage显示消耗了128M。那么对于较大文件的excel该如何处理呢,本文试着给出一个解决方案(前提:你的业务允许你分行处理文件,如果不允许,可以不用往下看了)。
为什么PhpSpreadsheet耗内存
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($filename);
跟过库代码的人都知道,在第一句load的时候就将整个文件的内容都load了内存,后面拿数据的时候就直接从内存中取了,这就是为什么随着文件增大,库所耗内存不断增大。但是对于很多业务,其实都允许读一行处理一行,完全没必要把数据一下子放到内存里面。
库文档里面提到了几个topic来解决这个问题的。
1. memory_saving
将数据的内存放入redis/memcache之类的,而不用本机的内存。
这个方法从stackoverflow几个帖子上看,速度会非常慢,实际几乎不可用。
本人没有测试过,主要是穷,买不起大内存的机器,也买不起云redis,所需要的内存还是会随着文件变大而不断增加,所以就算速度不慢,也不会是我考虑的解决方法。
2. reading-files
这里提供了一个思路,重写 readCell 这个函数,可以控制我们只保存其中的某些行和列。
$inputFileType = 'Xls';
$inputFileName = './sampleData/example2.xls';
class ChunkReadFilter implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter
{
private $startRow = 0;
private $endRow = 0;
public function setRows($startRow, $chunkSize) {
$this->startRow = $startRow;
$this->endRow = $startRow + $chunkSize;
}
public function readCell($column, $row, $worksheetName = '') {
if (($row == 1) || ($row >= $this->startRow && $row < $this->endRow)) {
return true;
}
return false;
}
}
$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType);
$chunkSize = 2048;
$chunkFilter = new ChunkReadFilter();
$reader->setReadFilter($chunkFilter);
for ($startRow = 2; $startRow <= 65536; $startRow += $chunkSize) {
$chunkFilter->setRows($startRow,$chunkSize);
$spreadsheet = $reader->load($inputFileName);
}
这段分片读取数据的代码,可以有效降低内存,获取说对内存占用不随着文件增大而增大,已经满足需求,但是这里有一个很大问题的,每一次的文件load都是从文件头读一遍,这将导致读文件时间不断增大。
一行一行读
1. box/spout
use Box\Spout\Reader\Common\Creator\ReaderEntityFactory;
$reader = ReaderEntityFactory::createReaderFromFile('/path/to/file.ext');
$reader->open($filePath);
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
// do stuff with the row
$cells = $row->getCells();
...
}
}
$reader->close();
这个库从提供的函数看,完全可以满足需求,不过它
- php7.0
- 不支持xls
首先强烈建议项目升级到php7+,因为我们是老项目,还没有动手改造,所以只能和这个库说再见。
2. csv格式
从这个库Spreadsheet的代码看,csv格式是非常好处理成一行行读的,自己动手改着了一个:
<?php
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
class Reader_Csv extends PhpOffice\PhpSpreadsheet\Reader\Csv
{
public function checkAndOpenFile($filename){
ini_set('auto_detect_line_endings', true);
if (!$this->canRead($filename)) {
throw new Exception($filename . ' is an Invalid Spreadsheet file.');
}
$this->openFile($filename);
$this->skipBOM();
$this->checkSeparator();
$this->inferSeparator();
}
public function readLine()
{
$rowData = fgetcsv($this->fileHandle, 0, $this->getDelimiter(), $this->getEnclosure(), $this->getEscapeCharacter());
if($rowData === false)
{
return false;
}
$columnLetter = 'A';
$arrLineData = array();
foreach ($rowData as $rowDatum)
{
if ($rowDatum != '') {
if ($this->getInputEncoding() !== 'UTF-8') {
$rowDatum = StringHelper::convertEncoding($rowDatum, 'UTF-8', $this->getInputEncoding());
}
}
$arrLineData[$columnLetter] = $rowDatum;
++$columnLetter;
}
return $arrLineData;
}
public function closeFile(){
fclose($this->fileHandle);
}
}
所以处理CSV格式的文件可以这么处理了:
$read = new Reader_Csv();
$read->checkAndOpenFile($filename);
while(True)
{
$rows ++;
$arrRowData = $read->readLine();
if($arrRowData === false){
break;
}
// to the thing you want
}
$read->closeFile();
此时,针对csv文件,我们可以读一行,处理,再读一行,速度,内存都不受影响。
3. xls/xlsx文件的处理
这两个文件由于格式比较复杂,自己来解析数据会非常困难。因此可以考虑将文件转化成csv格式再进行处理。把xls/xlsx转化成csv格式有非常多的工具
xlsx2csv
基于python的一个库,只支持xlsx格式
“Handles large XLSX files. Fast and easy to use.”
xls2csv
基于perl的库,只支持xls。
需要自己来判断文件格式,本地随便拿一个excel文件测试,并不能正确解析出对应的时间字段
本来如果用这两个库,可以达到降xls/xlsx转化成csv格式的,且安装比较轻量级。但是xls2csv这个库比较难用,所以考虑一个通用的转化库
Gnumeric
这是一个非常强大的软件,支持命令行ssconvert进行文件转化。
这个库安装很痛苦,所需要依赖的包特别多,因为它是一个桌面软件,但是现在都docker化了,这么麻烦的事情显然不用自己来做了。从hub上找到一个镜像 spoonest/gnumeric_docker,dockerfile特别简单,正式所需要的。
测试了一下这个软件,可以正确将xls/xlsx文件转化成csv格式的。但是对于浮点数,由于精度问题,比如0.01会变成0.010000001,这个对我的应用没有影响,因为对于浮点数,我只要小数点后2位,如果对精度有要求的应用,慎用。
本文提供了一个读大excel的处理思路,但是因为Gnumeric代码黑盒,因此不确定在转化过程中机器内存是否会超(这个后面做了大文件测试再来更新结果)。所以如果你只有xlsx文件的话,比较推荐box/spout & xlsx2csv