封面图片源自 Pixabay
前言
前段时间在使用 str_getcsv
和 fgetcsv
处理 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
。
注意这里,在我们 Windows 平台上 PHP 7.x 这里的 LC_CTYPE
是 Chinese (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
。