Swift:如何对笨拙但常规的 JSON 结构进行 DRY 可解码解析

Swift: How to DRY Decodable-parsing of an awkward but regular JSON structure

我们的 json-over-rest(ish) API 遵循编码模式 URLs 可从列表格式的特定对象访问,在 @links 钥匙。虚构的例子:

{ "id": "whatever", 
  "height_at_birth": 38,
  "@links": [
    { "name": "shield-activation-level",
      "url": "https://example.com/some/other/path" },
    { "name": "register-genre-preference",
      "url": "https://example.com/some/path" }
  ]
}

在 Swift 方面,我们使用虚拟类型和可选性来实现类型安全。例如上面的 json 可能对应于这样的结构:

struct Baby {
    let id: String
    let heightAtBirth: Int
    let registerGenrePreference: Link<POST<GenrePreference>>
    let shieldActivationLevel: Link<GET<PowerLevel>>?
    let magicPowers: Link<GET<[MagicPower]>>?
}

幽灵类型确保我们不能 post 意外地 registerGenrePreference URL 的喂养计划,并且可选性表明格式良好的 Baby-json 将始终包含 registerGenrePreference@links 条目,但其他两个 link 可能存在也可能不存在。到目前为止一切顺利。

我想使用 Decodable 来使用这种 json 格式,最好使用最少的 init(decoder:Decoder) 自定义实现。但我被 @links 个条目难倒了。

我想我知道如果我手动完成整个解码会是什么样子:

  1. 拿到宝宝的容器,
  2. 从中获取 @links 键的嵌套无键容器
  3. 遍历其值(应该是 [String:String] 字典)并构建一个字典匹配名称 URLs
  4. 对于每个 link Baby 期望,在字典中查找它(如果 属性 是非可选的并且 link 丢失则抛出)

但是每个 class 遵循此模式(不理想)的步骤 2 和 3 都是相同的,更糟糕​​的是,必须这样做还会阻止我使用编译器提供的 Decodable 实现所以我还必须手动解码 Baby 的所有其他 属性。

如果有帮助,我非常乐意重组 Baby;一个可能有帮助的明显步骤是:

struct Baby {
    let id: String
    let heightAtBirth: Int
    let links: Links

    struct Links {
        let registerGenrePreference: Link<POST<GenrePreference>>
        let shieldActivationLevel: Link<GET<PowerLevel>>?
        let magicPowers: Link<GET<[MagicPower]>>?
    }
}

当然我希望我们必须添加编码键,即使只是为了 snake/camel-case 转换和 @:

enum CodingKeys: String, CodingKey {
    case id
    case heightAtBirth = "height_at_birth"
    case links = "@links"
}

我可能会按照上面的模式为 Baby.Links 手动 Decodable 一致性,但这仍然意味着对每个 class 重复一次 "get the unkeyed collection, transform it to a dict, look up coding-keys in the dict" 步骤遵循这种模式。

有没有办法集中这个逻辑?

您的链接实际上有一个定义明确的结构。它们是字典 [String : String],因此您可以利用它来使用 Decodable。

您可能需要考虑像下面这样设置您的结构。这些链接是从 JSON 解码而来的,并且扩展提供了您正在寻找的确切链接的可选性。

可链接协议可用于将一致性添加到任何需要它的class/struct。

import Foundation

struct Link: Decodable {
    let name: String
    let url: String
}

protocol Linkable {
    var links: [Link] { get }
}

extension Linkable {
    func url(forName name: String) -> URL? {
        guard let path = links.first(where: { [=10=].name == name })?.url else {
            return nil
        }

        return URL(string: path)
    }
}

struct Baby: Decodable, Linkable {
    let id: String
    let heightAtBirth: Int
    let links: [Link]

    enum CodingKeys: String, CodingKey {
        case id = "id"
        case heightAtBirth = "height_at_birth"
        case links = "@links"
    }

    static func makeBaby(json: String) throws -> Baby {
        guard let data = json.data(using: .utf8) else {
            throw CocoaError.error(.fileReadUnknown)
        }

        return try JSONDecoder().decode(Baby.self, from: data)
    }
}

extension Baby {
    var registerGenrePreference: URL? {
        return url(forName: "register-genre-preference")
    }

    var shieldActivationLevel: URL? {
        return url(forName: "shield-activation-level")
    }
}

let baby = try Baby.makeBaby(json: json)
baby.registerGenrePreference
baby.shieldActivationLevel

以下模式为我提供了完整的类型安全,并且大部分实现都在一次性实用程序中 class UntypedLinks。每个模型 class 都需要定义一个嵌套的 class Links 和一个自定义的 decode(from: Decoder),但是这些模型的实现完全是样板文件(可能可以使用简单的代码生成工具实现自动化) 并且具有合理的可读性。

public struct Baby: Decodable {
    public let id: String
    public let heightAtBirth: Int
    public let links: Links

    enum CodingKeys: String, CodingKey {
        case id
        case heightAtBirth = "height_at_birth"
        case links = "@links"
    }

    public struct Links: Decodable {
        let registerGenrePreference: Link<POST<GenrePreference>>
        let shieldActivationLevel: Link<GET<PowerLevel>>?
        let magicPowers: Link<GET<[MagicPower]>>?

        enum CodingKeys: String, CodingKey {
            case registerGenrePreference = "register-genre-preference"
            case shieldActivationLevel = "shield-activation-level"
            case magicPowers = "magic-powers"
        }

        public init(from decoder: Decoder) throws {
            let links = try UntypedLinks<CodingKeys>(from: decoder)
            registerGenrePreference = try links.required(.registerGenrePreference)
            shieldActivationLevel = links.optional(.shieldActivationLevel)
            magicPowers = links.optional(.magicPowers)
        }
    }
}

public class UntypedLinks<CodingKeys> where CodingKeys: CodingKey {
    let links: [String: String]
    let codingPath: [CodingKey]

    class UntypedLink: Codable {
        let name: String
        let url: String
    }

    public init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var links: [String: String] = [:]
        while !container.isAtEnd {
            let link = try container.decode(UntypedLink.self)
            links[link.name] = link.url
        }
        self.links = links
        self.codingPath = container.codingPath
    }

    func optional<Phantom>(_ name: CodingKeys) -> Link<Phantom>? {
        return links[name.stringValue].map(Link.init)
    }

    func required<Phantom>(_ name: CodingKeys) throws -> Link<Phantom> {
        guard let link: Link<Phantom> = optional(name) else {
            throw DecodingError.keyNotFound(
                name,
                DecodingError.Context(
                    codingPath: codingPath,
                    debugDescription: "Link not found")
            )
        }
        return link
    }
}