引言

最近在工作中需要实现一个数据导出功能。由于之前都是使用现成的工具或库,换了一家公司后,发现需要从零开始构建这个功能。最初我计划实现一个异步导出功能,但上级认为过于复杂,建议采用同步方式。于是,我开始寻找一种高效的同步导出方案。

在这个过程中,我发现了PHP中的生成器(Generator),这是一个非常强大的工具,特别适合处理大数据场景。本文将详细介绍生成器的概念、工作原理、优势以及如何利用生成器实现高效的CSV导出功能。

一、什么是生成器?

生成器(Generator)是PHP中一个非常重要的概念。它就像一个“节能引擎”,特别适合处理大数据场景。生成器允许你按需生成数据,而不是一次性将所有数据加载到内存中。

传统数组 vs 生成器对比

// 传统方式:一次性加载全部数据
function getAllUsers() {
  $users = [/* 10万条数据 */]; // 瞬间占用500MB内存
  return $users; 
}

// 生成器:逐条生成数据
function generateUsers() {
  for ($i = 0; $i < 100000; $i++) {
    yield ['id' => $i, 'name' => "User$i"]; //每次只占 1KB
  }
}

在上面的例子中,传统方式会一次性加载10万条数据,占用大量内存。而生成器则逐条生成数据,内存占用极低。

二、生成器的工作原理

yield 关键字——时空穿梭的魔法

每次执行到 yield 时,函数会暂停并保存当前状态。下次请求数据时,从暂停的位置继续执行。

function simpleGenerator() {
  echo "开始执行\n";
  yield '第一块数据';
  echo "继续执行\n";
  yield '第二块数据';
}

$gen = simpleGenerator();
echo $gen->current(); // 输出:开始执行 | 第一块数据
$gen->next();
echo $gen->current(); // 输出:继续执行 | 第二块数据

生成器执行流程图解

0ecc8cb6a8c475de2e3504ea649bb16.png

三、生成器的核心优势

1. 惰性计算(Lazy Evaluation)

生成器按需生成数据,避免提前计算。例如,读取10GB的日志文件时,传统方式可能会导致内存溢出,而生成器可以逐行获取数据。

2. 内存友好

处理10万条数据时,传统数组方式的内存峰值可能达到500MB+,而生成器仅占用2MB左右。

3. 可组合性

生成器可以链式调用,形成数据处理流水线。

function filter($generator) {
  foreach ($generator as $item) {
    if ($item['age'] > 18) {
      yield $item;
    }  
  }
}

$data = filter(getUserGenerator());

四、生成器使用注意事项

1. 不可逆性

生成器只能向前遍历,不能 rewind() 重置。

2. 资源释放

使用完成后及时关闭生成器。

$gen->close(); // 释放数据库连接等资源

3. 与数组的转换

需要时可通过 iterator_to_array() 转换,但会失去内存优势。

4. 性能陷阱

避免在生成器内部进行复杂计算,保持轻量。

五、面试常见问题

1. yieldreturn 有什么区别?

  • return 终结函数执行。
  • yield 暂停函数,保留上下文。

2. 生成器如何实现低内存占用?

通过维持执行状态(栈帧),避免一次性加载所有数据。

3. 什么时候不该用生成器?

  • 需要随机访问数据时。
  • 需要多次遍历数据集时。
  • 数据量较小时(反而增加复杂度)。

六、案例:基于生成器的CSV导出功能

由于在很多地方都需要导出数据,因此我将导出功能封装成一个公共函数,代码如下:

/**
 * @param Generator $dataGenerator 数据生成器
 * @param array $headers 表头
 * @param string $filename 文件名
 * @return void
 * desc: 批量导出CSV文件
 * author: author
 * datetime: 2025/3/3下午3:46
 */
function batch_export_csv(Generator $dataGenerator, array $headers, string $filename = '')
{
    try {
        // 设置运行环境
        set_time_limit(300); // 设置合理的超时时间

        // 清理输出缓冲区
        if (ob_get_level() > 0) {
            ob_start();
        }

        // 验证和清理文件名
        $fileName = basename($filename ?: 'export_' . date('YmdHis')) . '.csv';
        $fileName = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $fileName);

        // 设置响应头
        header('Content-Type: text/csv; charset=UTF-8');
        header('Content-Disposition: attachment; filename="' . $fileName . '"');
        header('Cache-Control: max-age=0');

        // 打开输出流
        $fp = fopen('php://output', 'w');
        fwrite($fp, chr(0xEF) . chr(0xBB) . chr(0xBF)); // 输出 UTF-8 头
        fputcsv($fp, $headers);

        // 分批处理数据
        $batchCount = 0;
        foreach ($dataGenerator as $batch) {
            foreach ($batch as $row) {
                fputcsv($fp, $row);
            }
            $batchCount++;

            // 每处理一定数量的批次刷新一次缓冲区
            if ($batchCount % 10 === 0) {
                ob_flush(); // 刷新输出缓冲
                flush();    // 刷新系统缓冲
            }
        }

        fclose($fp);
        exit();
    } catch (\Exception $e) {
        // 捕获所有类型的异常并记录日志
        error_log('Export failed: ' . $e->getMessage());
        throw new \Exception('导出失败: ' . $e->getMessage());
    }
}

接口调用

/**
 * 获取SKU列表或导出SKU数据
 *
 * 该方法根据请求参数获取SKU列表,并支持分页和导出功能。
 * - 如果 `export` 参数为0,则返回分页的SKU列表。
 * - 如果 `export` 参数为1,则导出SKU数据为CSV文件。
 *
 * @return \think\Response JSON响应或导出CSV文件
 */
public function skuList()
{
    // 输入验证与初始化参数
    $limit = intval($this->request->post('limit', 20)); // 每页显示条数,默认值为20
    $page = intval($this->request->post('page', 1)); // 页数,默认值为1
    $export = intval($this->request->post('export', 0)); // 是否导出,默认值为0(不导出)

    // 验证分页参数是否合法
    if ($limit <= 0 || $page <= 0) {
        return json(['status' => 400, 'message' => 'Invalid limit or page']); // 返回错误信息,提示无效的分页参数
    }

    // 验证导出参数是否合法
    if (!in_array($export, [0, 1])) {
        return json(['status' => 400, 'message' => 'Invalid export parameter']); // 返回错误信息,提示无效的导出参数
    }

    // 构建查询条件
    $where = [];
    $memberId = $this->request->post('membe_id', $this->info['membe']['membe_id'], 'intval'); // 获取成员ID,并进行整数验证
    if (empty($memberId)) {
        return json(['status' => 400, 'message' => 'Invalid member ID']); // 返回错误信息,提示无效的成员ID
    }
    $where['membe_id'] = $memberId;

    // 定义关联查询字段
    $withJoin = [
        'inventory' => ['product_sku_id', 'amount', 'shelves_amount'], // 关联库存表,选择特定字段
    ];

    // 显示字段列表
    $field = 'product_sku_id,company_id,membe_id,sku_No,product_title,product_describe,product_category,hs_code,univalent,weight,specifications,volume,create_time';
    
    // 构建查询对象
    $query = ProductSku::where(formatWhere($where))
        ->field($field)
        ->withJoin($withJoin, 'left') // 左连接库存表
        ->order('product_sku_id desc'); // 按产品SKU ID降序排列

    // 判断是否需要导出数据
    if (!$export) {
       /*列表逻辑*/
    } else {
        ob_clean(); // 清除输出缓冲区

        // 定义导出数据生成器函数
        $dataGenerator = function ($query, $chunkSize = 2000) {
            $page = 1;
            while (true) {
                try {
                    // 分页查询数据
                    $data = $query->page($page, $chunkSize)->select();
                    if ($data->isEmpty()) break; // 如果没有数据则退出循环

                    // 构建导出数据行
                    $sku_data = [];
                    foreach ($data as $item) {
                        $row = $item->toArray();
                        $sku_data[] = [
                            $row['sku_No'] ?? '', // SKU编号
                            $row['sku_No'] ?? '', // 卖家SKU(重复列)
                            $row['product_title']['zh'] ?? '', // 产品名称(中文)
                            $row['product_title']['en'] ?? '', // 产品名称(英文)
                            $row['weight'] ?? '', // 重量
                            $row['volume']['L'] ?? 0, // 长度
                            $row['volume']['W'] ?? 0, // 宽度
                            $row['volume']['H'] ?? 0, // 高度
                            $row['univalent'] ?? 0, // 单价
                            '启用', // 状态
                            $row['create_time'] // 添加时间
                        ];
                    }
                    yield $sku_data;

                    // 判断是否最后一页
                    if (count($data) < $chunkSize) break;
                    $page++;
                } catch (\Exception $e) {
                    // 记录日志并继续
                    error_log($e->getMessage());
                    continue;
                }
            }
        };

        // 定义CSV头部
        $headers = ['Fnsku', 'seller_sku', '产品名称', '产品英文名称', '重量', '长', '宽', '高', '货值', '状态', '添加时间'];

        // 调用批量导出CSV函数
        batch_export_csv(
            $dataGenerator($query),
            $headers,
            'sku_list'
        );
    }
}

执行效果

image.png

点击发送,在Postman上效果如上图所示,或者点击【发送并保存响应】,就能直接看到CSV文件了。

结语

通过本文的介绍,我们了解了生成器的基本概念、工作原理及其在大数据处理中的优势。利用生成器,我们可以实现高效、低内存占用的CSV导出功能,特别适合处理大数据场景。希望本文对你理解和应用生成器有所帮助。


白穹雨
31 声望1 粉丝

热爱技术,热爱生活。学过Java,现在从事PHP。