動態

詳情 返回 返回

Windows 下 PHP 7 中 *getcsv 函數解析 CSV 錯誤的問題記錄 - 動態 詳情

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

  • 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

user avatar wujingquan 頭像 kinra 頭像 xingzoudedahuoji 頭像 leeqvip 頭像 rui_sen 頭像 buildyuan 頭像 laoduan 頭像 biliangxianting 頭像 49u7s8yz 頭像 junxiudetuoba 頭像 manongsir 頭像 xiaotuyu 頭像
點贊 16 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.