封面圖片源自 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
。