問題背景

最近在開發過程中遇到一個詭異的問題:呼叫某個 API 後,某個常數 name 的值居然是 nil,但從 raw data 看起來明明有值。

症狀檢查清單:

  • ✅ Console 印出 raw data 看起來正常
  • jsonDecode 解碼成功
  • ✅ Enum 對應的 JSON key (_Name_Ch) 完全相同
  • ✅ 瀏覽器中直接訪問 API,name 確實有值
  • ❌ Swift 中取得的 name 卻是 nil

經過反覆檢查,終於發現問題根源:不可見的 BOM (Byte-Order Mark) 字元


什麼是 BOM?

BOM (Byte-Order Mark),中文稱為位元組順序記號,是一個不可見的 Unicode 字元,用於標示文字檔的編碼位元組順序。

常見的 BOM 字元:

  • UTF-8 BOM: 0xEF 0xBB 0xBF (Unicode: U+FEFF)
  • UTF-16 BE BOM: 0xFE 0xFF
  • UTF-16 LE BOM: 0xFF 0xFE

問題診斷

根據問題分析,namenil 的原因是:

API 回應的 JSON 資料中,_Name_Ch 這個 key 的前面有一個不可見的 BOM 字元,導致 Swift 的 JSON 解碼器無法正確匹配 key 名稱。

實際的 JSON key 對比:

// 我們期望的 key
"_Name_Ch"

// 實際的 key(包含不可見的 BOM)
"\u{feff}_Name_Ch"  // \u{feff} 就是 UTF-8 BOM

由於 key 名稱不匹配,JSONDecoder 找不到對應的欄位,因此 name 變成 nil


解決方案

方法 1:清除 BOM 字元(推薦)

在解析 JSON 之前,先移除所有 BOM 字元:

// Swift 解決方案
func cleanBOM(from jsonString: String) -> String {
    return jsonString.replacingOccurrences(of: "\u{feff}", with: "")
}

// 使用方式
let rawJSON = String(data: responseData, encoding: .utf8) ?? ""
let cleanedJSON = cleanBOM(from: rawJSON)

// 然後再進行 JSON 解碼
if let data = cleanedJSON.data(using: .utf8) {
    let decoded = try JSONDecoder().decode(YourModel.self, from: data)
}

方法 2:建立 String Extension(可重用)

如果需要在多處使用,建議建立 extension:

extension String {
    /// 移除字串中的 BOM (Byte-Order Mark) 字元
    func removingBOM() -> String {
        var result = self

        // 移除 UTF-8 BOM
        result = result.replacingOccurrences(of: "\u{feff}", with: "")

        // 移除 UTF-16 BE BOM(較少見)
        result = result.replacingOccurrences(of: "\u{fffe}", with: "")

        return result
    }
}

// 使用方式
let cleanedJSON = rawJSON.removingBOM()

方法 3:後端修正(根本解決)

最佳做法是請後端團隊修正 API,確保回應的 JSON 不包含 BOM:

// Node.js Express 範例
app.get('/api/data', (req, res) => {
  const data = fetchData()

  // 確保 Content-Type 正確,明確指定編碼
  res.setHeader('Content-Type', 'application/json; charset=utf-8')

  // 使用 JSON.stringify 避免 BOM 問題
  res.send(JSON.stringify(data))
})
# Python Flask 範例
@app.route('/api/data')
def get_data():
    data = fetch_data()

    # Flask 的 jsonify 會自動設定正確的編碼
    return jsonify(data), 200, {'Content-Type': 'application/json; charset=utf-8'}

為什麼只有特定欄位受影響?

BOM 通常出現在以下情況:

  1. 檔案開頭

    • Windows 記事本等編輯器會自動在檔案開頭加入 BOM
    • 如果 JSON 資料來自檔案讀取,可能帶有 BOM
  2. 字串拼接

    • 從不同來源拼接的資料可能意外帶入 BOM
    • 例如:從資料庫讀取 + 手動拼接字串
  3. 資料庫匯出

    • 某些資料庫工具(如 Excel 匯出 CSV)會加入 BOM
    • 如果後端從這些來源讀取資料,可能保留 BOM
  4. 檔案編碼轉換

    • 編碼轉換過程中意外引入 BOM
    • 例如:UTF-16 轉 UTF-8 時沒有正確處理

如果只有 name 欄位受影響,可能是因為:

  • 該欄位的值來自不同的資料來源
  • 該欄位經過特殊的處理或轉換
  • 資料輸入時使用了包含 BOM 的編輯器

預防措施

開發階段

1. 檢查 API 回應是否有 BOM

使用 hexdump 或類似工具檢查原始位元組:

# 使用 hexdump 檢查檔案開頭
hexdump -C response.json | head

# 如果看到 "ef bb bf" 開頭,就是 UTF-8 BOM
00000000  ef bb bf 7b 22 6e 61 6d  65 22 3a 22 76 61 6c 75  |...{"name":"valu|

2. 配置編輯器避免 BOM

// VS Code 設定 (.vscode/settings.json)
{
  "files.encoding": "utf8",  // 使用 UTF-8 without BOM
  "files.autoGuessEncoding": false
}
// Sublime Text 設定
Preferences → Settings
{
  "default_encoding": "UTF-8",
  "fallback_encoding": "UTF-8"
}

測試階段

建立自動化測試檢查 BOM:

import XCTest

class APITests: XCTestCase {
    func testNoBOMInAPIResponse() async throws {
        // 呼叫 API
        let response = try await APIClient.shared.fetchData()

        // 轉換為字串檢查
        let jsonString = String(data: response, encoding: .utf8)

        // 斷言不包含 BOM
        XCTAssertFalse(
            jsonString?.hasPrefix("\u{feff}") ?? false,
            "API response should not contain BOM character"
        )
    }

    func testJSONDecodingWithBOM() throws {
        // 測試解碼器能否處理帶 BOM 的 JSON
        let bomJSON = "\u{feff}{\"name\":\"Test\"}"
        let data = bomJSON.data(using: .utf8)!

        // 這個測試應該會失敗(除非有處理 BOM)
        XCTAssertThrowsError(
            try JSONDecoder().decode(TestModel.self, from: data)
        )

        // 清除 BOM 後應該成功
        let cleanedData = bomJSON.removingBOM().data(using: .utf8)!
        XCTAssertNoThrow(
            try JSONDecoder().decode(TestModel.self, from: cleanedData)
        )
    }
}

生產環境監控

在 API Client 中加入 BOM 偵測和警告:

class APIClient {
    func fetchData() async throws -> Data {
        let (data, response) = try await URLSession.shared.data(from: url)

        // 檢查是否有 BOM
        if let jsonString = String(data: data, encoding: .utf8),
           jsonString.hasPrefix("\u{feff}") {
            // 記錄警告到分析工具
            Analytics.logWarning("API response contains BOM", metadata: [
                "endpoint": url.absoluteString
            ])

            // 自動清理 BOM
            let cleaned = jsonString.removingBOM()
            return cleaned.data(using: .utf8) ?? data
        }

        return data
    }
}

結論

不可見的 BOM 字元是 JSON 解析中一個隱藏但常見的陷阱。當遇到看似正常的 JSON 卻無法正確解析時,記得檢查是否有 BOM 字元的存在。

關鍵要點:

  • ✅ BOM 是不可見的 Unicode 字元,用於標示編碼順序
  • ✅ 會導致 JSON key 名稱匹配失敗
  • ✅ 可以用字串替換方法移除
  • ✅ 最好從 API 後端根除問題
  • ✅ 建立自動化測試和監控機制

最佳實踐:

  1. 開發環境:確保編輯器不會自動加入 BOM
  2. API 開發:後端確保輸出不包含 BOM
  3. 客戶端:建立防禦性程式碼,自動清理 BOM
  4. 測試:加入 BOM 偵測的自動化測試

參考資源