Trang chủ

Những phần tuân thủ Codable vỡ trong môi trường thật

Codable là một trong những tính năng hay nhất của Swift và nó đặt ra một loạt giả định về JSON của bạn mà các API thật vi phạm thường xuyên. Mỗi thứ dưới đây đều đã khiến tôi trả giá bằng một con bug trong môi trường thật.

1. Thiếu khóa là một lỗi được ném ra, không phải nil

struct User: Codable {
    let id: Int
    let nickname: String?
}

Nếu nickname vắng mặt hoàn toàn khỏi JSON, đoạn này chạy được — một khóa thiếu sẽ giải mã một Optional thành nil. Chuyện đó thì ổn.

Thứ khiến người ta bất ngờ là chiều ngược lại: một thuộc tính không optional mà thiếu khóa sẽ ném keyNotFound, và vì việc giải mã Codable là được ăn cả ngã về không, cả đối tượng hỏng. Một trường mà backend thôi gửi làm vỡ cả màn hình.

Bản phòng thủ, cho những trường bạn không kiểm soát:

struct User: Codable {
    let id: Int
    let name: String
    let bio: String

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Int.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        bio = try container.decodeIfPresent(String.self, forKey: .bio) ?? ""
    }
}

decodeIfPresent là công cụ cần dùng. Hãy để dành decode cứng cho những trường mà sự vắng mặt của chúng thật sự nghĩa là phản hồi không hợp lệ.

2. Một phần tử hỏng giết cả mảng

let users = try JSONDecoder().decode([User].self, from: data)

Một nghìn user, một cái có null ở chỗ đáng lẽ là chuỗi, và bạn nhận về không user nào.

Cách chữa là một lớp bọc giải mã từng phần tử riêng lẻ:

struct Lossy<T: Decodable>: Decodable {
    let elements: [T]

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var result: [T] = []
        while !container.isAtEnd {
            if let element = try? container.decode(T.self) {
                result.append(element)
            } else {
                _ = try? container.decode(AnyDecodable.self)   // bỏ qua cái hỏng
            }
        }
        elements = result
    }
}

Nhánh else mới là phần quan trọng: một unkeyed container không tự tiến lên khi giải mã thất bại, nên nếu không tiêu thụ cái phần tử đó thì bạn được một vòng lặp vô hạn. Con bug ấy gỡ cũng vui đấy.

3. Ngày tháng

Mặc định của JSONDecoder là .deferredToDate, thứ mong đợi một dấu thời gian Unix dạng Double — gần như chắc chắn không phải thứ API của bạn gửi.

decoder.dateDecodingStrategy = .iso8601

Và bản thân ISO 8601 không phải một định dạng duy nhất. 2026-07-08T10:30:00Z thì phân tích được; 2026-07-08T10:30:00.123Z thì không, vì chiến lược có sẵn từ chối phần giây thập phân. Backend thêm mili giây vào mà không coi đó là thay đổi phá vỡ tương thích, còn với bạn thì đó đúng là như vậy.

decoder.dateDecodingStrategy = .custom { decoder in
    let string = try decoder.singleValueContainer().decode(String.self)
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    if let date = formatter.date(from: string) { return date }
    formatter.formatOptions = [.withInternetDateTime]
    if let date = formatter.date(from: string) { return date }
    throw DecodingError.dataCorruptedError(in: try decoder.singleValueContainer(),
                                           debugDescription: "ngày sai: \(string)")
}

Chấp nhận cả hai tốn hai dòng và dẹp đi cả một nhóm sự cố lúc ba giờ sáng.

4. Những con số thỉnh thoảng lại là chuỗi

Vài backend gửi "id": 42 và thỉnh thoảng gửi "id": "42", thường vì một dịch vụ khác đã ghi vào trường đó.

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    if let intValue = try? container.decode(Int.self, forKey: .id) {
        id = intValue
    } else {
        let stringValue = try container.decode(String.self, forKey: .id)
        guard let parsed = Int(stringValue) else {
            throw DecodingError.dataCorruptedError(forKey: .id, in: container,
                                                   debugDescription: "không phải số")
        }
        id = parsed
    }
}

Xấu, và vẫn hơn một cú sập.

5. Enum sinh ra từ chuỗi của máy chủ

enum Status: String, Codable {
    case active, archived, deleted
}

Cái ngày backend thêm suspended, mọi đối tượng chứa một Status đều giải mã hỏng. Cái enum bạn viết là một tập đóng còn của máy chủ thì không.

enum Status: String, Codable {
    case active, archived, deleted
    case unknown

    init(from decoder: Decoder) throws {
        let raw = try decoder.singleValueContainer().decode(String.self)
        self = Status(rawValue: raw) ?? .unknown
    }
}

Cảnh báo

Đây là kiểu hỏng tôi thấy làm vỡ nhiều ứng dụng nhất, vì nó vô hình cho tới khi backend phát hành. Mọi enum được giải mã từ một chuỗi do máy chủ kiểm soát đều cần một case unknown. Hãy coi đó là một quy tắc cứng.

6. keyDecodingStrategy và các từ viết tắt

.convertFromSnakeCase biến user_id thành userId, đúng thứ bạn muốn, và nó cũng biến user_url thành userUrl — không phải userURL. Nếu thuộc tính Swift của bạn theo hướng dẫn thiết kế API và viết hoa cả từ viết tắt thì nó sẽ không khớp.

Hoặc đặt tên thuộc tính là userUrl, hoặc viết CodingKeys một cách tường minh. Giờ tôi viết CodingKeys tường minh cho mọi thứ có từ viết tắt bên trong, vì kiểu hỏng là một lỗi giải mã lúc chạy chứ không phải thứ gì trình biên dịch để ý được.

Thói quen bắt được tất cả

Hãy ghi log lỗi giải mã cho tử tế. DecodingError mang theo đường dẫn khóa chính xác và lý do, và phần lớn ứng dụng vứt bỏ thứ đó:

catch let error as DecodingError {
    switch error {
    case .keyNotFound(let key, let context):
        log("thiếu \(key.stringValue) tại \(context.codingPath)")
    case .typeMismatch(let type, let context):
        log("mong đợi \(type) tại \(context.codingPath)")
    case .valueNotFound(let type, let context):
        log("null cho \(type) không optional tại \(context.codingPath)")
    case .dataCorrupted(let context):
        log("hỏng tại \(context.codingPath): \(context.debugDescription)")
    @unknown default:
        log("\(error)")
    }
}

“Màn hình trắng” trở thành “thiếu avatar_url tại [0].profile”, và đó là khác biệt giữa một buổi chiều và một phút.