封面圖片源自 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 上沒有了這個問題。
- PHP: Other Changes - Manual
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。