Codable 解析 JSON 忽略无效的元素

2021-04-07 10:24:39 浏览数 (1)

默认情况下,使用 Swift 内置的 Codable API 编码或解码数组只有全部成功或者全部失败两种情况。可以成功处理所有元素,或者引发错误,这可以说是一个很好的默认设置,因为它可以确保高水平的数据一致性。

但是,有时我们可能希望调整该行为,以便忽略无效元素,而不是导致整个编解码过程失败。例如,假设我们正在使用基于JSON 的 Web API,该API返回当前正在 Swift 中建模的item集合,如下所示:

代码语言:javascript复制
struct Item: Codable {
    var name: String
    var value: Int
}

extension Item {
    struct Collection: Codable {
        var items: [Item]
    }
}

现在,假设我们正在使用的网络 API 偶尔会返回如下数据,其中包含null 值,而我们的 Swift 代码期望该响应为 Int

代码语言:javascript复制
{
    "items": [
        {
            "name": "One",
            "value": 1
        },
        {
            "name": "Two",
            "value": 2
        },
        {
            "name": "Three",
            "value": null
        }
    ]
}

如果我们尝试将以上数据解码为Item.Collection模型的实例,那么即使我们的大多数商品确实包含完全有效的数据,整个解码过程也会失败。

上面的示例似乎有些人为设计,但意外遇到格式错误或不一致的JSON 数据其实非常常见,我们可能无法始终调整这些格式以使其完全适应Swift 天然的静态性。

当然,一种潜在的解决方案是简单地将 value 属性设置为可选(Int?),但是这样做可能会在我们的代码库中引入各种复杂性,因为我们现在必须每次都希望拆开这些值。将它们用作具体的,非可选的 Int值。

解决问题的另一种方法是为我们认为可能缺失或无效的属性定义默认值——在我们仍想保留任何包含无效数据的元素的情况下,这是一个很好的解决方案,但是这不是我们今天要讨论的情况。

因此,让我们来看一下如何在解码任何 Decodable 数组时忽略所有无效元素,而不必对 Swift 中数据的结构进行任何的重大修改。

建立有损的可编码列表类型

我们本质上希望做的是将我们的解码过程从非常严格的更改为“有损的”。首先,让我们介绍一个通用的 LossyCodableList 类型,该类型将充当 Element 数组的精简包装:

代码语言:javascript复制
struct LossyCodableList<Element> {
    var elements: [Element]
}

请注意,我们没有立即使新类型符合 Codable协议,这是因为我们希望它根据要使用的 Element 类型有条件地支持DecodableEncodable 或同时支持这两种类型的协议。毕竟,并非所有类型都可以同时编解码,并且通过分别声明我们对 Codable 协议的支持与否,我们将使新的 LossyCodableList 类型尽可能地灵活。

让我们从 Decodable 开始,我们将遵循中间的 ElementWrapper 类型以可选的方式对每个元素进行解码。然后,我们将使用 compactMap 丢弃所有nil元素,这将为我们提供最终的数组——如下所示:

代码语言:javascript复制
extension LossyCodableList: Decodable where Element: Decodable {
    private struct ElementWrapper: Decodable {
        var element: Element?

        init(from decoder: Decoder) throws {
            let container = try decoder.singleValueContainer()
            element = try? container.decode(Element.self)
        }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let wrappers = try container.decode([ElementWrapper].self)
        elements = wrappers.compactMap(.element)
    }
}

接下来,Encodable,它可能不是每个项目都需要的东西,但是在我们还希望为编码过程提供相同的有损行为的情况下,它仍然可以派上用场:

代码语言:javascript复制
extension LossyCodableList: Encodable where Element: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer()

        for element in elements {
            try? container.encode(element)
        }
    }
}

完成上述操作后,我们现在只需将嵌套的Collection类型使用新的LossyCodableList即可自动丢弃所有无效的Item值,如下所示:

代码语言:javascript复制
extension Item {
    struct Collection: Codable {
        var items: LossyCodableList<Item>
    }
}

使我们的列表类型透明

但是,上述方法的一个主要缺点是,我们现在总是必须使用items.elements 来访问我们的实际项目值,这并不理想。如果可以将LossyCodableList的用法转换为完全透明的实现细节,以使我们可以继续将我们的items属性作为一个简单的值数组进行访问,那将是更好的选择。

一种实现方法是将项目集合的LossyCodableList存储为私有属性,然后在编码或解码时使用CodingKeys类型指向该属性。然后,我们可以将项目实现为计算属性,例如:

代码语言:javascript复制
extension Item {
    struct Collection: Codable {
        enum CodingKeys: String, CodingKey {
            case _items = "items"
        }

        var items: [Item] {
            get { _items.elements }
            set { _items.elements = newValue } 
        }
        
        private var _items: LossyCodableList<Item>
    }
}

另一个选择是给我们的Collection类型一个完全自定义的Decodable实现,这将涉及在将结果元素分配给我们的items属性之前,使用LossyCodableList解码每个JSON数组:

代码语言:javascript复制
extension Item {
    struct Collection: Codable {
        enum CodingKeys: String, CodingKey {
            case items
        }

        var items: [Item]

        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            let collection = try container.decode(
                LossyCodableList<Item>.self,
                forKey: .items
            )
            
            items = collection.elements
        }
    }
}

以上两种方法都是完美的解决方案,但让我们看看是否可以通过使用Swift的属性包装器功能使事情变得更好。

类型和属性包装器

关于在Swift中实现属性包装器的方式的一件真正整洁的事情是,它们都是标准的Swift类型,这意味着我们可以对LossyCodableList进行改造,使其还可以充当属性包装器。

我们要做的就是用 @propertyWrapper 属性标记它,并实现所需的 wrappedValue 属性(可以再次将其作为计算属性来完成):

代码语言:javascript复制
@propertyWrapper
struct LossyCodableList<Element> {
    var elements: [Element]

    var wrappedValue: [Element] {
        get { elements }
        set { elements = newValue }
    }
}

完成上述操作后,我们现在可以使用@LossyCodableList属性标记任何基于数组的属性,并且可以对其进行有损编码和解码——相对透明:

代码语言:javascript复制
extension Item {
    struct Collection: Codable {
        @LossyCodableList var items: [Item]
    }
}

总结

乍一看,Codable 看起来像是一个极其严格且受某种程度限制的API,无论成功还是失败,都没有任何细微差别或自定义的余地。但是,一旦我们超越了表面层次,Codable实际上具有不可思议的强大功能,并且可以通过许多不同的方式进行自定义。

静默地忽略无效元素不是永远正确的做法——很多时候,我们确实希望我们的编码过程在遇到任何无效数据时都会失败——但是,如果不是这种情况,那么本文中使用的任何一种技术都可以提供一种很好的方法使我们的编码代码更加灵活和有损,而又不会带来大量额外的复杂性。

译自 John Sundell 的 Ignoring invalid JSON elements when using Codable

0 人点赞