PHP 导出 Excel 问题

我们在工作中经常会遇到 Excel 导出的问题,比如:

  1. 表格内容的填充
  2. 下载数据过大执行超时、内存溢出
  3. 异步导出

那么,这些问题应该怎样解决呢?

1. 表格内容的填充

我们知道,在 PHP 中,ord()可将字符转换为 ASCII 码,chr()则可以将 ASCII 码转换为对应字符,二者为互补函数。

通过查看 Excel,我们知道一个 sheet 中,列序号是 A1, B1, C1 ...递增的,而行序号是A2, A3, A4...递增的。下面,就让我们用代码来生成一下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
$rawData = [
[
'name' => '张三',
'gender' => ' 男',
'age' => 19,
'birthday' => '2000-01-01'
],
[
'name' => '李四',
'gender' => '男',
'age' => 29,
'birthday' => '1990-01-01'
]
];

//拼装导出数据
$sheetData = [
'姓名' => array_column($rawData, 'name'),
'性别' => array_column($rawData, 'gender'),
'年龄' => array_column($rawData, 'age'),
'生日' => array_column($rawData, 'birthday'),
];

$excelObj = new \PHPExcel();
$sheet = $excelObj->getActiveSheet();

//生成导出表格
$i = 65;//A 在 ASCII 中的十进制为 65
foreach ($sheetData as $title => $datum) {
//填充表头
$row = chr($i++);
$sheet->setCellValue($row . '1', $title);

//填充内容
$j = 2;
foreach ($datum as $item) {
$sheet->setCellValueExplicit($row . $j++, $item);
}
}

优点:可随导出内容自适应,扩展性强

缺点:表头只能兼容到A1~Z1,超过Z1则会触发 bug 而无法导出

方案二

针对方案一的缺点,我们来扩展表头的容量,利用PHP中的字符串递增来生成表头。

1
2
3
4
<?php
for($i = 'A'; $i <= 'Z'; $i++) {
echo $i . '1', PHP_EOL;
}

优点:简单快速生成表头

缺点:只能兼容到YZ1

在 Microsoft Office 365 2019 中,最大列为XFD,所以,如果要满足 Excel 的导出,我们可以这样完全兼容:

1
2
3
4
5
<?php
$alphabet = [];
for($i = 'A'; $i < 'ZZZ'; $i++) {
$alphabet[] = $i . '1';
}

好了,现在我们已经能够完全满足 Excel 的列需求了,但这里还有一点小瑕疵:我们生成的所有数据都存储在数组中,这显然是对内存的极大浪费,应该按需存取。我们再来尝试另一种新的方式:

1
2
3
4
5
6
7
8
9
10
<?php
function getAlphabet() {
for ($i = 'A'; $i < 'ZZZ'; $i++) {
yield $i;
}
}

foreach (getAlphabet() as $letter) {
echo $letter . '1';
}

现在优雅了许多。但这种方式和上一种有什么区别呢?
请注意这段代码中的yield关键字,这是迭代生成器。我们可以理解为每当 CPU 执行到yield关键字时,就会暂存当前的上下文,并将控制权交回foreach,而不像普通函数一次调用结束后销毁上下文(所以我们需要将函数所产生的变量进行返回),这让我们得以在每次需要到这段函数时,都可以从上次停下的位置继续,而无需暂存整个函数的处理结果,从而节省了内存空间。通过memory_get_usage()函数可以看到,上一种实现方式需要1.9MB,而yield只需要287.6KB,二者的空间复杂度分别为O(n)O(1)

方案三

虽然上述两种方案已经能够满足正常的需求,而且实现也足够简单。但我们还是要探索一下是否能够解决上述缺陷的方式,这样,这个问题就演变成了二十六进制的字符计数器。

思路:我们常见的有二进制、八进制、十进制、十六进制,所以我们就可以照猫画虎,写一个二十六进制。通过观察,这些进制均是末位递增,当末位为最大值时,向上一位进位,并将低位复位。那就让我们把想法转换为代码吧:

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
<?php
function getAlphabet($data)
{
$letter = [];
for ($i = 65, $count = 0; $i < 95; $i++) {
// 末位递增处理
if ($i > 64 && $i < 91) {
$lastPlace = chr($i);
}

//进位处理
if (90 === $i) {
$letter[] = substr(end($letter), 0, -1) . chr($i);

// 所有位均为Z时,位数+1
if (substr_count(end($letter), 'Z') === strlen(end($letter))) {
$letter[] = implode('', ['A', str_repeat('A', strlen(end($letter)))]);
} else {
// 同位数下递增
$letter[] = chr(ord(substr(end($letter), 0, 1)) + 1) . 'A';
/*
* 此种情况下如何进行递增检测处理?如 AXZ 递增后应为 AYA,如何保证随着位数的递增不会消耗更多的时间?
*/
}

// 进位完成后将计数器归位
$i = 65;
} else {
// 递增低位
$letter[] = substr_replace(end($letter), $lastPlace, -1, 1);
}

if (empty($letter)) {
$letter[] = 'A';
}

if ($count > count($data)) {
break;
} else {
$count++;
}
}

return $letter;
}

echo json_encode(getAlphabet(range(1,100)));

emmmm,看起来离我们的目标又近了一步:可以连续生成一段字母,并且可以根据我们的数据长度自适应,但是!多位进位如何处理,如何保障多进位处理的性能问题?关于这个问题,目前的想法是参照计算机的进位实现思想,先挖个坑,等搞清楚来填 :)

2. 导出内容过大

很多项目初期的数据量很小,Excel 导出非常快,但随着数据量的增加,导出越来越慢,直到超时、内存溢出。怎样才能一劳永逸呢?
我们知道,PHPExcel(新版为 PhpSpreadsheet)支持向 Excel 中追加内容,所以,我们依然可以使用yield来保证内存:比如内存可以承载 1 万条数据的容量,我们就可以使用 yield 先处理 1 万条暂存于内存中,然后再将这 1 万条追加入磁盘的 Excel 文件,这样既保证了内存的使用,有保证了处理效率。

3. 异步导出

众所周知,PHP 是阻塞式语言,从而导致一些交互后产生的长时间执行的任务难以处理。如果对于一些小型的异步任务通过 crontab 执行,则可能导致后期的定时任务越来越多而难以管理,而 PHP 中的fastcgi_finish_request()可以使客户端结束连接后,需要大量时间运行的任务能够继续运行,正好满足我们轻量的异步执行需求。但该方式也会有一些问题,如该方式无错误重试机制。如果有大量需要异步处理的任务需要执行,建议使用 swoole 或者支持异步执行的语言。

参考资料

  1. 注意PHP对字符串的递增运算
  2. 生成器 - PHP手册
  3. yield关键字 - PHP手册
  4. 四位计算机的原理及实现 - 阮一峰
  5. PHP如何读取大文件
因为热爱,所以执着。