Swift:使用Codable时忽略无效的JSON元素的方法

681 阅读6分钟

默认情况下,使用 Swift 内置的Codable API 对数组进行编码或解码是一种全有或全无的交易。要么所有的元素都被成功处理,要么就抛出一个错误,这可以说是一个很好的默认,因为它确保了数据的高度一致性。

然而,有时我们可能想调整这种行为,使无效的元素被忽略,而不是导致我们整个编码过程的失败。例如,假设我们正在使用一个基于JSON的网络API,它返回项目的集合,我们目前在Swift中是这样建模的:

struct Item: Codable {
    var name: String
    var value: Int
}

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

现在我们假设,我们正在使用的网络API偶尔会返回如下的响应,其中包括一个null ,而我们的Swift代码期望的是Int

{
    "items": [
        {
            "name": "One",
            "value": 1
        },
        {
            "name": "Two",
            "value": 2
        },
        {
            "name": "Three",
            /*HL*/"value": null/*HL*/
        }
    ]
}

如果我们试图将上述响应解码为我们的Item.Collection 模型的一个实例,那么整个解码过程就会失败,即使我们的大部分项目确实包含完全有效的数据。

上面的例子可能看起来有点矫揉造作,但在野外遇到畸形或不一致的JSON格式是非常常见的,而且我们可能并不总是能够调整这些格式来整齐地适应Swift的静态性质。

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

解决我们问题的另一种方法是为我们期望可能是null 、缺失或无效的属性定义默认值--这在我们仍然想保留任何包含无效数据的元素的情况下是一个很好的解决方案,但我们说这不是这些情况之一。

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

构建一个有损可编码的列表类型

我们基本上要做的是改变我们的解码过程,从非常严格的解码变成 "有损 "的解码。为了开始,让我们引入一个通用的 LossyCodableList 类型,它将作为一个薄的包装,围绕着一个Element 值的数组:

struct LossyCodableList<Element> {
    var elements: [Element]
}

请注意,我们没有立即让我们的新类型符合Codable ,这是因为我们希望它有条件地支持DecodableEncodable ,或者两者都支持,这取决于它所使用的Element 类型。毕竟,不是所有的类型都可以同时编码,通过分别声明我们的Codable 符合性,我们将使我们的新LossyCodableList 类型尽可能的灵活。

让我们从Decodable 开始,我们将通过使用中间的ElementWrapper 类型,以一种可选择的方式对每个元素进行解码来符合这个类型。然后我们将使用compactMap 来丢弃所有的nil 元素,这将得到我们最终的数组--像这样:

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 ,这可能不是每个项目都需要的,但在我们也想给我们的编码过程提供同样的有损行为时,它仍然可以派上用场:

extension LossyCodableList: Encodable where Element: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer()

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

有了上述内容,我们现在就可以自动丢弃所有无效的Item 值,只需让我们的嵌套Collection 类型使用我们新的LossyCodableList - 像这样:

extension Item {
    struct Collection: Codable {
        var items: LossyCodableList<Item>
    }
}

使我们的列表类型透明化

然而,上述方法的一个主要缺点是,我们现在必须使用items.elements 来访问我们的实际项目值,这并不理想。可以说,如果我们能把对LossyCodableList 的使用变成一个完全透明的实现细节,那么我们就可以把items 属性作为一个简单的值数组来访问,那会好得多。

实现这一目标的一个方法是将我们的项目集合的LossyCodableList 作为一个私有属性,然后在编码或解码时使用一个CodingKeys 类型来指向该属性。然后我们可以将items 作为一个计算的属性来实现,比如说像这样:

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 实现,这将涉及到使用LossyCodableList 对每个JSON数组进行解码,然后将结果元素分配给我们的items 属性:

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 属性(这也可以作为一个计算属性来实现)。

@propertyWrapper
struct LossyCodableList<Element> {
    var elements: [Element]

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

有了以上这些,我们现在就可以用@LossyCodableList 属性来标记任何基于Array 的属性,并且它将被有损地编码和解码--相当透明地:

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

当然,我们仍然能够继续使用LossyCodableList 作为一个独立的类型,就像我们以前做的那样。我们把它变成一个属性包装器所做的一切,就是让它能以这种方式使用,这又一次给我们增加了很多灵活性,而没有任何实际的成本。

总结

乍一看,Codable似乎是一个非常严格的、有点局限的API,要么成功,要么失败,没有任何细微差别或定制的空间。然而,一旦我们越过了表面的层次,Codable实际上是非常强大的,并且可以以很多不同的方式进行定制。

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

谢谢你的阅读!