Codable 自定义解析 JSON

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

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

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

修改 Key

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

struct Article: Codable {
    var url: URL
    var title: String
    var body: String
}

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

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

extension Article {
    enum CodingKeys: String, CodingKey {
        case url = "source_link"
        case title = "content_name"
        case body
    }
}

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

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

var decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

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

忽略 Key

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

struct NoteCollection: Codable {
    var name: String
    var notes: [Note]
    var localDrafts = [Note]()
}

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

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

extension NoteCollection {
    enum CodingKeys: CodingKey {
        case name
        case notes
    }
}

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

创建匹配的结构

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

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

{
    "currency": "PLN",
    "rates": {
        "USD": 3.76,
        "EUR": 4.24,
        "SEK": 0.41
    }
}

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

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模型所需的所有逻辑,如下所示:

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属性仅充当该私有属性的面向公众的代理:

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,这意味着可以将其转换为字符串表示形式,也可以将其从字符串表示形式转换为我们要的类型:

protocol StringRepresentable: CustomStringConvertible {
    init?(_ string: String)
}

extension Int: StringRepresentable {}

我们将上述协议基于标准库中的 CustomStringConvertible,因为它已经包含将值描述为字符串的属性要求。有关将协议定义为其他协议的特化的更多信息,请查看“Specializing protocols in Swift”

接下来,让我们创建另一个专用类型——这次是为任何可以由字符串支持的值——并让它包含解码和编码一个值到字符串和从字符串编码所需的所有代码:

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属性执行此操作的示例:

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容