雲端 Mac 上的 iOS UTF-8 串流解碼驗證

雲端 Mac 上的 iOS UTF-8 串流解碼驗證

同一份回應,在本機讀取正常,放進雲端 Mac 的流水線後卻偶爾出現亂碼,這時應先檢查解碼邊界。網路傳來的是位元組區塊,不是完整字元;「中」的三個位元組可能分三次抵達。若逐塊轉成字串,即使每次都沒有發生錯誤,也可能把合法文字變成替代字元。以下用一支獨立的 Swift 指令稿,明確驗證這項契約,不靠實際網路如何分塊的運氣。

先定義位元組串流契約

輸入限定為 UTF-8,輸出只能包含已完整解碼的 Unicode 純量值。空區塊不改變狀態,尚未完成的序列留待下一個區塊接續,結束時若仍有殘留位元組,就必須判定失敗。不要把「目前沒有文字輸出」誤認為連線已關閉。

這裡採用嚴格拒絕非法輸入的策略:孤立的接續位元組、過長編碼、代理碼區編碼,以及超過 Unicode 上限的序列,一律不能通過。以替代字元顯示損壞文字,或許適合某些呈現情境,但不能默默套用在通訊協定欄位、設定或持久化紀錄上。

網路區塊邊界、字元邊界與業務訊息邊界,是三件不同的事。UTF-8 解碼成功,不代表已經收到完整的一行或一則訊息。

若通訊協定宣告使用其他編碼,應在入口拒絕,或交給明確的轉碼層處理,不能反覆嘗試不同編碼來猜測。本文也不會自動移除位元組順序標記,是否允許應由上層決定。

實作有界的增量狀態

將以下程式碼儲存為 utf8-contract.swift。它只保留目前尚未完成的字元,跨呼叫保留的資料最多三個位元組;每次呼叫的輸出量仍取決於輸入區塊的大小。一旦發生錯誤,就應捨棄該實例,不再繼續送入資料。

import Foundation

enum DecodeFailure: Error {
    case invalidByte
    case truncated
}

struct StrictUTF8 {
    private var pending: [UInt8] = []
    private var expected = 0

    mutating func push(_ bytes: [UInt8]) throws -> String {
        var output: [UInt8] = []

        for byte in bytes {
            if pending.isEmpty {
                switch byte {
                case 0x00...0x7F:
                    output.append(byte)
                    continue
                case 0xC2...0xDF:
                    expected = 2
                case 0xE0...0xEF:
                    expected = 3
                case 0xF0...0xF4:
                    expected = 4
                default:
                    throw DecodeFailure.invalidByte
                }
                pending.append(byte)
                continue
            }

            var allowed: ClosedRange<UInt8> = 0x80...0xBF
            if pending.count == 1 {
                switch pending[0] {
                case 0xE0: allowed = 0xA0...0xBF
                case 0xED: allowed = 0x80...0x9F
                case 0xF0: allowed = 0x90...0xBF
                case 0xF4: allowed = 0x80...0x8F
                default: break
                }
            }

            guard allowed.contains(byte) else {
                throw DecodeFailure.invalidByte
            }
            pending.append(byte)

            if pending.count == expected {
                output.append(contentsOf: pending)
                pending.removeAll(keepingCapacity: true)
                expected = 0
            }
        }

        guard let text = String(bytes: output, encoding: .utf8) else {
            throw DecodeFailure.invalidByte
        }
        return text
    }

    func finish() throws {
        guard pending.isEmpty else {
            throw DecodeFailure.truncated
        }
    }
}

第一個位元組決定序列長度,第二個位元組進一步限縮允許範圍,後續位元組則檢查是否落在接續位元組的範圍內。如此一來,不必等到轉換成字串時,才發現代理碼區或超出上限的編碼。這份實作沒有自動復原邏輯,也不會把非法位元組之後的 ASCII 視為一則可信的新訊息。

窮舉分塊方式,而不是重複請求

接著附加以下測試。對這段短樣本,列舉每對相鄰位元組之間所有「切分或不切分」的組合,比只測一次切成兩段的情況更完整;另外插入空區塊,驗證它不會清除尚未完成的狀態。

let sample = "A中🙂e\u{301}"
let bytes = Array(sample.utf8)

for mask in 0..<(1 << (bytes.count - 1)) {
    var decoder = StrictUTF8()
    var chunk: [UInt8] = []
    var result = ""

    for index in bytes.indices {
        chunk.append(bytes[index])
        let cut = index == bytes.count - 1
            || (mask & (1 << index)) != 0
        if cut {
            result += try decoder.push(chunk)
            result += try decoder.push([])
            chunk.removeAll(keepingCapacity: true)
        }
    }

    try decoder.finish()
    precondition(Array(result.utf8) == bytes)
}

這裡直接比較 UTF-8 位元組,避免字串比較時的正規等價性掩蓋內容變化。組合重音符號也提醒我們:完整的純量值,不一定就是使用者眼中的完整字元。解碼器可以先輸出字母,再輸出組合標記;若介面需要以字素為單位操作,應交由更上層處理。

窮舉組合的數量會隨樣本長度呈指數成長,因此只適用於短小、固定的測試向量。長文字則另以固定的隨機種子產生切分方式,並在失敗時儲存各區塊長度的序列。

也為拒絕路徑加上斷言

合法文字能通過,只證明了一半。附加以下測試向量,分別涵蓋孤立的接續位元組、過長編碼、代理碼區、超出上限、錯誤的接續位元組,以及結尾截斷。每筆輸入都逐一送入單個位元組,確保非法輸入無法藉由分塊方式繞過檢查。

let invalid: [[UInt8]] = [
    [0x80],
    [0xC0, 0xAF],
    [0xED, 0xA0, 0x80],
    [0xF4, 0x90, 0x80, 0x80],
    [0xE4, 0x41],
    [0xF0, 0x9F, 0x99]
]

for input in invalid {
    var decoder = StrictUTF8()
    var rejected = false

    do {
        for byte in input {
            _ = try decoder.push([byte])
        }
        try decoder.finish()
    } catch {
        rejected = true
    }

    precondition(rejected)
}

還應補上合法範圍的邊界案例,例如最大合法純量值所對應的四位元組序列。不要只以字串中是否出現替代字元作為判斷依據,因為輸入原本就可能合法地包含該字元。嚴格解碼必須根據原始位元組判斷。

接入流水線並保留錯誤邊界

在選定的 Xcode 命令列工具環境中執行:

xcrun swift utf8-contract.swift

正常結束即代表通過;若拋出錯誤或斷言失敗,就必須阻止後續工作繼續執行。正式整合時,應將解碼器放入共用原始碼,並將測試向量移至測試 target;獨立指令稿適合先驗證演算法,但不能取代實際 iOS target 的編譯與執行。

每條連線都要有自己的實例,並依接收順序序列化呼叫。重新連線時應建立新實例,不能把舊連線留下的半個字元接到新回應前面。取消作業時也應捨棄狀態,不走「成功結束」的處理路徑。

最後還要檢查資料消費端:逐塊產生的輸出只能先放入暫存緩衝區;等一則業務訊息完整接收且驗證通過後,才能提交持久化儲存或觸發動作。若前半段已寫入資料庫,後半段才發現非法編碼,即使採用嚴格解碼,也無法自動撤銷已產生的副作用。錯誤紀錄只需保留位元組偏移量、區塊長度與錯誤類別,避免預設上傳完整回應內容。如此驗收的就不只是「沒有亂碼」,而是分塊方式改變也不影響結果,錯誤也不會被當成成功交付。

常見問題

每次收到 Data 後直接轉成字串是否可靠?

不可靠。網路區塊不保證落在字元邊界,嚴格轉換可能失敗,容錯轉換則可能插入替代字元。應保留尚未完整的位元組序列,等後續資料抵達再解碼。

串流結束時剩下的位元組可以忽略嗎?

不可以。若協定要求完整 UTF-8 文字,這代表字元遭到截斷,應回報失敗。若允許復原,必須另外明訂策略,不能把截斷當成成功。

讓整台 Mac 專心執行團隊的建置工作

選擇 Apple Silicon M4 獨享實體 Mac mini,保留團隊原有的工具鏈與 runner 設定。提供日租、週租、月租與季租,實際可用狀態請以控制台即時資訊為準。

租用建置節點

依建置需求選擇規格

先確認記憶體與儲存空間,再選擇計費週期與節點。