Windows 下 PHP 7 中 *getcsv 函式解析 CSV 錯誤的問題記錄

唯一丶發表於2023-04-17
封面圖片源自 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

相關文章