Codable 自定义解析 JSON

2021-04-14 14:27:36 浏览数 (1)

大多数现代应用程序的共同点是,它们需要对各种形式的数据进行编码或解码。无论是通过网络下载的JSON数据,还是存储在本地的模型的某种形式的序列化表示形式,对于几乎任何 Swift 代码库而言,能够可靠地编码和解码不同的数据都是必不可少的。

这就是为什么Swift的Codable API成为Swift 4.0的新功能一部分时具有如此重要的重要原因——从那时起,它已发展成为一种标准的,健壮的机制,可以在Apple的各种平台中使用编码和解码包括服务器端Swift。

Codable 之所以如此出色,是因为它与Swift工具链紧密集成,从而使编译器可以自动合成大量编码和解码各种值所需的代码。但是,有时我们确实需要自定义序列化时值的表示方式——因此,本周,让我们看一下可以调整Codable实现来做到这一点的几种不同方式。

修改 Key

让我们从一种基本的方式开始,我们可以通过修改用作序列化表示形式一部分的键来自定义类型的编码和解码方式。假设我们正在开发一款用于阅读文章的应用,而我们的一个核心数据模型如下所示:

代码语言:javascript复制
struct Article: Codable {
    var url: URL
    var title: String
    var body: String
}

我们的模型当前使用完全自动合成的Codable实现,这意味着其所有序列化键都将匹配其属性的名称。但是,我们将从中解码Article值的数据(例如,从服务器下载的JSON)可能会使用略有不同的命名约定,从而导致默认解码失败。

幸运的是,这一问题很容易解决。要自定义Codable在解码(或编码)我们的Article类型的实例时将使用哪些键,我们要做的就是在其中定义一个CodingKeys枚举,并为与我们希望自定义的键匹配的大小写分配自定义原始值——像这样:

代码语言:javascript复制
extension Article {
    enum CodingKeys: String, CodingKey {
        case url = "source_link"
        case title = "content_name"
        case body
    }
}

通过上述操作,我们可以继续利用编译器生成的默认实现进行实际的编码工作,同时仍使我们能够更改将用于序列化的键的名称。

虽然上面的技术非常适合当我们想要使用完全自定义的键名时,但是如果我们只希望Codable使用属性名的snake_case版本(例如,将backgroundColor转换为background_color),那么我们可以简单地更改JSON解码器的keyDecodingStrategy

代码语言:javascript复制
var decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

以上两个API的优点在于,它们使我们能够解决Swift模型与用于表示它们的数据之间的不匹配问题,而无需我们修改属性名称。

忽略 Key

能够自定义编码键的名称确实很有用,但有时我们可能希望完全忽略某些键。例如,现在我们说我们正在开发一个记笔记应用程序,并且使用户能够将各种笔记分组在一起以形成一个可以包括本地草稿的NoteCollection

代码语言:javascript复制
struct NoteCollection: Codable {
    var name: String
    var notes: [Note]
    var localDrafts = [Note]()
}

但是,虽然将localDrafts纳入NoteCollection模型确实很方便,但可以说,我们不希望在序列化或反序列化此类集合时包含这些草稿。这样做的原因可能是每次启动应用程序时为用户提供整洁的状态,或者是因为我们的服务器不支持草稿。

幸运的是,这也可以轻松完成,而不必更改NoteCollection的实际Codable实现。如果像以前一样定义一个CodingKeys枚举,而只是省略localDrafts,那么在对NoteCollection值进行编码或解码时,将不会考虑该属性:

代码语言:javascript复制
extension NoteCollection {
    enum CodingKeys: CodingKey {
        case name
        case notes
    }
}

为了使以上功能正常运行,我们要省略的属性必须具有默认值——在这种情况下,localDrafts已经具有默认值。

创建匹配的结构

到目前为止,我们只是在调整类型的编码键——尽管这样做通常可以使您受益匪浅,但有时我们需要对Codable自定义进行进一步的调整。

假设我们正在构建一个包含货币换算功能的应用,并且正在将给定货币的当前汇率下载为 JSON 数据,如下所示:

代码语言:javascript复制
{
    "currency": "PLN",
    "rates": {
        "USD": 3.76,
        "EUR": 4.24,
        "SEK": 0.41
    }
}

然后,在我们的Swift代码中,我们想要将此类JSON响应转换为CurrencyConversion实例——每个实例都包含一个ExchangeRate条目数组——每个币种对应一个:

代码语言:javascript复制
struct CurrencyConversion {
    var currency: Currency
    var exchangeRates: [ExchangeRate]
}

struct ExchangeRate {
    let currency: Currency
    let rate: Double
}

但是,如果我们仅仅只是使以上两个模型都符合Codable,我们将再次导致Swift代码与我们要解码的JSON数据不匹配。但是这次,不只是关键字名称的问题——结构上有根本的不同。

当然,我们可以修改Swift模型的结构,使其与JSON数据的结构完全匹配,但这并不总是可行的。尽管拥有正确的序列化代码很重要,但是拥有适合我们实际代码库的模型结构也同样重要。

相反,让我们创建一个新的专用类型——它将在JSON数据中使用的格式与Swift代码的结构体之间架起一座桥梁。在这种类型中,我们将能够封装将JSON汇率字典转换为一系列ExchangeRate模型所需的所有逻辑,如下所示:

代码语言:javascript复制
private extension ExchangeRate {
    struct List: Decodable {
        let values: [ExchangeRate]

        init(from decoder: Decoder) throws {
            let container = try decoder.singleValueContainer()
            let dictionary = try container.decode([String : Double].self)

            values = dictionary.map { key, value in
                ExchangeRate(currency: Currency(key), rate: value)
            }
        }
    }
}

使用上述类型,我们现在可以定义一个私有属性,该名称与用于其数据的JSON密钥相匹配——并使我们的exchangeRates属性仅充当该私有属性的面向公众的代理:

代码语言:javascript复制
struct CurrencyConversion: Decodable {
    var currency: Currency
    var exchangeRates: [ExchangeRate] {
        return rates.values
    }
    
    private var rates: ExchangeRate.List
}

上面的方法起作用的原因是,在对值进行编码或解码时,永远不会考虑计算属性。

当我们想使我们的Swift代码与使用非常不同的结构的JSON API兼容时,上述技术可能是一个很好的工具——且无需完全从头实现Codable

转换值

在解码时,尤其是在使用我们无法控制的外部JSON API进行解码时,一个非常常见的问题是,以与Swift的严格类型系统不兼容的方式对类型进行编码。例如,我们要解码的JSON数据可能使用字符串来表示整数或其他类型的数字。

让我们来看看一种可以让我们处理这些值的方法,再次以一种自包含的方式,它不需要我们编写完全自定义的Codable实现。

我们本质上想要做的是将字符串值转换为另一种类型,以Int为例。我们将从定义一个协议开始,该协议使我们可以将任何类型都标记为StringRepresentable,这意味着可以将其转换为字符串表示形式,也可以将其从字符串表示形式转换为我们要的类型:

代码语言:javascript复制
struct StringBacked<Value: StringRepresentable>: Codable {
    var value: Value
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let string = try container.decode(String.self)
        
        guard let value = Value(string) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Failed to convert an instance of (Value.self) from '(string)'"
            )
        }
        
        self.value = value
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(value.description)
    }
}

就像我们以前为兼容JSON的基础存储创建私有属性的方式一样,现在我们可以对编码后由字符串后端的任何属性执行相同的操作,同时仍将数据适当地公开给其他Swift代码类型。这是一个针对视频类型的numberOfLikes属性执行此操作的示例:

代码语言:javascript复制
struct Video: Codable {
    var title: String
    var description: String
    var url: URL
    var thumbnailImageURL: URL
    
    var numberOfLikes: Int {
        get { return likes.value }
        set { likes.value = newValue }
    }
    
    private var likes: StringBacked<Int>
}

在必须手动为属性定义settergetter的复杂性与必须回退到完全自定义的Codable实现的复杂性之间,这里肯定有一个折中——但是对于上述Video 结构体这样的类型,它在其中仅具有一个属性需要自定义,使用私有支持属性可能是一个不错的选择。

结语

尽管编译器能够自动合成不需要任何形式的自定义的所有类型的Codable支持,这真是太棒了,但是我们能够在需要时进行自定义,这一事实同样是太棒了。

更好的是,这样做实际上并不需要我们完全放弃自动生成的代码,而是采用手动实现——很多时候,可以稍微调整类型的编码或解码方式,同时仍然让编译器做大部分繁重的工作。

谢谢阅读!

译自 John Sundell 的 Customizing Codable types in Swift

0 人点赞