头图
封面图片源自 Pixabay

前言

前段时间在使用 str_getcsvfgetcsv 处理 CSV 文件的时候遇到的一个问题:

测试中,文,foo,bar,123

预期情况下,应该返回一个数组。["测试中", "文", "foo", "bar", "123"],而实际却得到了 ["测试中,文,foo", "bar", "123"],是的,测试中,文 居然没有被分开,经过一番测试和查证,最后发现,这个问题默认情况下只会在 Windows 上的 PHP 7 版本(5 测试的时候没有问题,但是会乱码)中出现(还跟字符长度有关),Linux 下默认没问题。

问题来源

因为是直接从文件进行获取处理,同事一开始直接使用的 explode(',', $row) 进行处理,一开始是好的,然而当 CSV 列中出现了 , 号的时候,就会被意外分开了,至于源数据,不便做修改。为了解决这个问题,我将其改为 str_getcsv 进行处理,却引发了这个问题。

简单说一下 CSV 格式,一般情况下,使用逗号(,)分割列,用换行来表示新行,而同事一开始就是以 explode 的方式来解析单行的数据,而这种情况下,如果有一列的数据中出现了 逗号(,) 就会导致被意外分割,多处一列数据来,显然这是不合理的,为此就需要引入转义处理。

为了在单列数据中使用逗号(,),那就需要使用英文的双引号(")把这一列数据包起来(对于需要换行的数据也需要这样处理),而当我们需要表示一个双引号时,就需要双写这一个双引号,就像这样子。

"php,composer",foo,bar"","
say"

上面的例子应当被解析为:

array(4) {
  [0]=>
  string(12) "php,composer"
  [1]=>
  string(3) "foo"
  [2]=>
  string(5) "bar"""
  [3]=>
  string(4) "
say"
}

处理问题

经过多个环境验证,发现在 Linux 下没有问题,在 PHP 8 也没问题,就只有 PHP 7 上有这个问题。

当搜索过一番时,发现遇到过最多的问题,都是乱码,偶有人提到过这个问题。

因为这里编码解析正常,自然不认为是编码的问题,所以继续找资料,顺带还问了问 ChatGPT,一开始他也文不对题的说,是分隔符的问题,最后再引导下,他提到,可以添加 UTF-8 BOM(字节顺序标记(英语:byte-order mark,BOM))来解决。

于是便调整代码,大致如下:

$str = '';
$str .= "\xEF\xBB\xBF";
$str .= '测试中,文,foo,bar,123';
var_dump(str_getcsv($str));

当尝试添加 BOM 之后,结果从原先的 ["测试中,文,foo", "bar", "123"] 变成了 ["测试中", "文,foo", "bar", "123"] 🤔。

但是有些情况下就会正确了,假设去掉第二列的 字,就可以符合预期,但是这显然不行,因为这样(添加 BOM)不能处理所有情况,所以还是不合时宜的。

经过在 PHP 的 Change Log 里面一番搜索 csv ,找到了一条。

  • Fixed bug #72330 (CSV fields incorrectly split if escape char followed by UTF chars).

在这个 bug 中,有人遇到了同样的问题,并且提供了完整的复现步骤给出了。

其中有人给出了一个解决方案,就是通过设置 setlocale(LC_ALL, 'C') 方法设置本机运行的 locale 信息,从而解决。

既然要设置,不妨先看看,当前的 locale 是什么,在我的 Windows 平台上,执行 setlocale(LC_ALL, 0),其返回为:

LC_COLLATE=C;LC_CTYPE=Chinese (Simplified)_China.936;LC_MONETARY=C;LC_NUMERIC=C;LC_TIME=C

而当在 Linux 上执行时,这里返回 C

image.png

注意这里,在我们 Windows 平台上 PHP 7.x 这里的 LC_CTYPEChinese (Simplified)_China.936,而自 PHP 8 开始,在 Windows 平台上 LC_CTYPE,将默认为 C,所以在 PHP 8 上没有了这个问题。

setlocale(LC_ALL, 'C');
$str = '测试中,foo,bar,123';
var_dump(str_getcsv($str));

现在这个结果将符合预期,输出:["测试中", "文", "foo", "bar", "123"]

看起来一切都很好,问题被实打实的解决,但是,在后续的讨论中,PHP 官方回复指出,因为 str_getcsv 考虑了 locale ,所以是可以通过设置 locale 来解决这个问题。

但是这并不是一个好的解决方案,正如 setlocale 在文档中所写的。

区域信息是按进程维护的,而不是线程。如果在多线程服务器 API 上运行 PHP,区域设置可能在脚本运行时突然变化,尽管脚本本身并没有调用 setlocale()。这是因为其它脚本在同一时刻的同一进程的不同线程中运行,使用 setlocale() 改变了进程级别的区域。在 Windows 上,自 PHP 7.0.5 起,每个线程都维护自己的区域信息。

而给出的另一个方案是,将源字符串转为 CSV 可以识别并处理的编码,处理以后,再转回去。🤔

在 中文环境下的 Windows 平台上,将会是这样,结果符合预期。

$str = '测试中,文,foo,bar,123';
$str = mb_convert_encoding($str, 'gb2312', 'UTF-8');
$arr = str_getcsv($str);
$arr = array_map(function ($v) {
    return mb_convert_encoding($v, 'UTF-8', 'gb2312');
}, $arr);

var_dump($arr);
总之,就是最好的实现方式就是提供一个不依赖用户 locale 设置的方法来处理。

问了问 ChatGPT ,TA 给出了一份答案:

function user_str_getcsv($input, $delimiter = ',', $enclosure = '"', $escape = '\\')
{
    $output = array();
    $string = '';
    $quote = false;

    $strlen = mb_strlen($input);
    for ($i = 0; $i < $strlen; $i++) {
        $char = mb_substr($input, $i, 1);

        if ($char === $enclosure) {
            $quote = !$quote;
        } elseif (!$quote && (($char === $delimiter) || ($char === "\n"))) {
            $output[] = $string;
            $string = '';
            if ($char === "\n") {
                break;
            }
        } elseif ($char === $escape) {
            $i++;
            $string .= ($i < $strlen) ? mb_substr($input, $i, 1) : '';
        } else {
            $string .= $char;
        }
    }

    $output[] = $string;
    return $output;
}

但是这样的性能或许不一定高。


这份回复后,PHP 文档中,将原本在下面的 “此函数考虑区域设置。如果 LC_CTYPE 是类似 en_US.UTF-8 的值,此函数将错误的读取单字节编码的字符串。”

总结

解决这个问题的方案有几个:

  • 1、使用 setlocale 方法设置 locale 为 C。可以仅设置 LC_CTYPE。
  • 2、手动对传入的数据进行编码转换处理
  • 3、实现自行实现一个 CSV 方法[1]
  • 4、使用 PHP8

locale 的设置影响内置函数的行为比较多的,所以请谨慎处置 LC_ALL


唯一丶
23.1k 声望8.7k 粉丝

友情链接