一、引言

在上篇文章中,我们探讨了文件导出的相关内容,而今天,我们将聚焦于文件导入功能的实现。由于上级的要求,本次导入操作依然沿用同步方式,而非异步操作。本次导入功能旨在兼容 Excel 和 CSV 两种格式的文件,以下是详细的实现思路与步骤。

二、功能实现思路

(一)整体流程

  1. 前端上传文件 :前端负责将需要导入的文件(支持 Excel 和 CSV 格式)上传至后端。
  2. 后端接收并上传到服务器 :后端接收到文件后,将其上传到服务器进行临时存储。
  3. 读取文件内容 :根据文件类型(Excel 或 CSV),调用相应的函数读取文件内容,获取数据。
  4. 存储到数据库 :将读取到的数据进行处理后存储到数据库中。
  5. 删除服务器文件 :为避免服务器空间被占用,文件读取和处理完成后,及时删除服务器上的临时文件。
  6. 给前端提示 :向前端返回导入结果,提示导入是否成功。

以下是整体流程图:

795400f8e324556b6aee4a1c8f83eb0.png

(二)技术选型

为了方便处理 Excel 文件,我们选择使用 phpoffice/phpspreadsheet 这一第三方库,可通过 composer 进行安装,在项目根目录下执行以下命令:

composer require phpoffice/phpspreadsheet

三、技术实现细节

(一)封装公共函数

app/common.php 文件内,我们封装了以下几个公共函数,用于实现文件上传、读取文件内容等功能。

1. 上传文件并读取内容的公共函数

if (!function_exists('import_file_to_process_data')) {
    /**
     * @param file $file 用户上传的文件对象
     * @param callable $rowProcessor  批数据的处理回调函数
     * @param int $batchSize 批处理的数据行数,默认为1000行
     * @return mixed
     * desc: 导入文件以处理数据(该函数负责验证、保存文件,并根据文件类型(csv、xlsx、xls)调用相应的处理函数
     *  它使用批处理方式处理文件中的数据,以提高处理效率)
     * author: lijiwei
     * datetime: 2025/3/5下午4:27
     */
    function import_file_to_process_data($file, $rowProcessor, $batchSize = 1000)
    {
        // 验证文件
        $validate = \think\facade\Validate::rule([
            'file' => 'file|fileExt:csv,xlsx,xls|fileSize:10240000'
        ]);

        if (!$validate->check(['file' => $file])) {
            throw new \think\exception\ValidateException($validate->getError());
        }

        // 保存文件
        $path = \think\facade\Filesystem::disk('public')->putFile('import', $file);
        if (!$path) {
            throw new \think\exception\ValidateException('文件上传失败');
        }

        // 获取文件的完整路径
        $filePath = \think\facade\Filesystem::disk('public')->path($path);
        if (!$filePath) {
            throw new \think\exception\ValidateException('文件上传失败【-1】');
        }

        // 获取文件扩展名字
        $extension = pathinfo($filePath, PATHINFO_EXTENSION);

        try {
            // 检查文件扩展名选择不同的读取方式
            if ($extension === 'csv') {
                return read_csv_file($filePath, $rowProcessor, $batchSize);
            } elseif (in_array($extension, ['xlsx', 'xls'])) {
                return read_excel_file($filePath, $rowProcessor, $batchSize);
            } else {
                throw new \think\exception\ValidateException('不支持的文件类型');
            }
        } catch (\Exception $e) {
            throw_exception('导入文件失败:' . $e->getMessage());
        } finally {
            \think\facade\Filesystem::disk('public')->delete($path);
        }
    }
}

2. 读取 Excel 文件的函数

if (!function_exists('read_excel_file')) {
    /**
     * @param string  $filePath Excel文件的路径
     * @param callable $rowProcessor 处理每一行数据的回调函数
     * @param int $batchSize 每批处理的行数
     * @return Generator
     * desc: 读取Excel文件并按批处理行数据(函数使用生成器模式读取Excel文件中的数据,并允许通过$rowProcessor回调函数处理每一行数据
     *  函数按批处理数据,以减少内存消耗)
     * author: author
     * datetime: 2025/3/4下午4:54
     */
    function read_excel_file(string $filePath, callable $rowProcessor, int $batchSize)
    {
        // 加载Excel文件
        $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($filePath);
        // 获取活动工作表
        $worksheet = $spreadsheet->getActiveSheet();
        // 获取最高行号
        $highestRow = $worksheet->getHighestRow();

        // 初始化当前批次和行计数器
        $dataBatchs = [];
        $currentBatch = [];
        $rowCount = 0;

        // 跳过表头,从第二行开始遍历
        for ($row = 2; $row <= $highestRow; $row++) {
            $rowData = [];
            $columnIndex = 'A';
            // 遍历当前行的每一列,直到遇到空单元格
            while ($worksheet->getCell($columnIndex . $row)->getValue() !== null) {
                $rowData[] = $worksheet->getCell($columnIndex . $row)->getValue();
                $columnIndex++;
            }

            if ($rowData) {
                // 使用$rowProcessor回调函数处理当前行的数据
                $processedRow = call_user_func($rowProcessor, $rowData);
                // 将处理后的行数据添加到当前批次
                $currentBatch[] = $processedRow;
                $rowCount++;

                // 如果达到批次大小,则生成当前批次的数据,并重置当前批次
                if ($rowCount % $batchSize === 0) {
                    $dataBatchs[] = $currentBatch;
                    $currentBatch = [];
                }
            }
        }

        // 如果还有未处理的批次,则生成剩余批次的数据
        if (!empty($currentBatch)) {
            $dataBatchs[] = $currentBatch;
        }

        return $dataBatchs;
    }
}

3. 读取 CSV 文件的函数

if (!function_exists('read_csv_file')) {
    /**
     * @param string  $filePath CSV文件的路径
     * @param callable $rowProcessor 处理每一行的回调函数
     * @param int $batchSize 每个批次包含的行数
     * @return array|Generator 生成器,每次生成一个批次的处理结果
     * desc: 读取CSV文件并按批次返回处理后的行(本函数打开指定的CSV文件,并使用提供的行处理器函数处理每一行当处理的行数达到指定的批次大小时,
     *  会生成当前批次的处理结果并清空当前批次的缓存,以支持大文件的分批处理)
     * author: author
     * datetime: 2025/3/5下午4:41
     */
    function read_csv_file($filePath, $rowProcessor, $batchSize)
    {
        // 检查文件是否存在且可读
        if (!is_readable($filePath)) {
            return [];
        }

        $handle = fopen($filePath, 'r');
        if (!$handle) {
            return [];
        }

        // 处理 BOM 头(仅当文件大小 >=3 字节时)
        if (filesize($filePath) >= 3) {
            $bom = fread($handle, 3);
            if ($bom !== "\xEF\xBB\xBF") {
                rewind($handle);
            }
        } else {
            rewind($handle);
        }

        // 读取表头
        $header = fgetcsv($handle, 0, ',');
        if ($header === false) {
            fclose($handle);
            return [];
        }
        // 循环读取数据行
        $dataBatchs = [];
        $currentBatch = [];
        $rowCount = 0;
        while (($row = fgetcsv($handle, 0, ',')) !== false) {
            $processedRow = call_user_func($rowProcessor, $row);
            if ($processedRow !== null) {
                $currentBatch[] = $processedRow;
                $rowCount++;

                if ($rowCount % $batchSize === 0) {
                    $dataBatchs[] = $currentBatch;
                    $currentBatch = [];
                }
            }
        }

        // 生成剩余数据
        if (!empty($currentBatch)) {
            $dataBatchs[] = $currentBatch;
        }

        fclose($handle);

        return $dataBatchs;
    }
}

(二)接口实现

1. 控制器层

    /**
     * @return \think\response\Json
     * desc: 导入SKU
     * author: author
     * datetime: 2025/3/5上午10:19
     */
    public function importSKU()
    {
        $file = $this->request->file('file');
        if (empty($file)) throw_exception('请选择文件');

        SkuService::getInstance()->import_sku($file);
        return success([], '导入成功');
    }

2. Service 层

    /**
     * @param $file
     * @return void
     * desc: 导入SKU信息(本函数负责处理SKU数据的导入,包括读取文件、验证数据、数据库事务处理以及向第三方系统发送数据)
     * author: author
     * datetime: 2025/3/5上午11:44
     */
    public function import_sku($file)
    {
        // 定义处理每一行数据的回调函数
        $rowProcessor = function ($row) {
           // 这里可以根据实际需求处理每一行数据
            return [
                'column1' => $row[0],
                'column2' => $row[1],
                // 根据实际情况添加更多列
            ];
        };

        // 读取数据(调用封装的公共方法)
        $data = import_file_to_process_data($file, $rowProcessor);

        // 校验数据

        // 实例化 ProductSku 和 ProductSkuItem 模型
        $productSkuOrderModel = new ProductSku();
        $productSkuItemModel = new ProductSkuItem();

        // 开启事务
        $productSkuOrderModel->startTrans();
        try {
            // 添加数据
            foreach ($data as $batch) {
                foreach ($batch as $item) {
                    $product_sku_id = $productSkuOrderModel->insertGetId($item);
                    $productSkuItemModel->create([
                        'column1'    => $item['column1'],
                        'column2'    => $item['column2'],
                        'column3'    => $item['column3'],
                       //...............................//
                    ]);
                }
            }
            // 提交事务
            $productSkuOrderModel->commit();
        } catch (\Exception $e) {
            // 回滚事务
            $productSkuOrderModel->rollback();
            throw_exception($e->getMessage());
        }
    }

四、常见问题与解决方法

(一)Generator 生成器数据使用问题

Generator 生成器的数据只能使用一次,如果需要多次使用,得重新调用相应的方法。在本次导入功能中,如果不小心重复使用生成器数据,可能会导致获取不到数据的情况,需要特别注意。

(二)CSV 文件生成方式问题

不能直接将 Excel 文件复制一份并将后缀名改为 CSV,而是应该打开 Excel 文件,点击 “文件”,然后点击 “另存为”,选择 CSV 类型进行保存,否则可能会出现获取不到数据的情况。

五、总结

以上就是文件导入功能的详细介绍,通过合理的函数封装和逻辑处理,我们实现了一个兼容 Excel 和 CSV 文件的导入功能,同时保证了数据处理的效率和服务器空间的合理利用。在代码实现过程中,我们充分考虑了错误处理、事务管理以及大文件处理等场景,确保了功能的健壮性和可靠性。

六、未来演进方向

异步处理架构
fda8d23e23c226abc18476b08bf982d.png


白穹雨
31 声望1 粉丝

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