同一份回應,在本機讀取正常,放進雲端 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 設定。提供日租、週租、月租與季租,實際可用狀態請以控制台即時資訊為準。
租用建置節點