How can I implement polymorphic decoding of JSON data in Swift 4?

前端 未结 3 1918
被撕碎了的回忆
被撕碎了的回忆 2021-02-08 19:46

I am attempting to render a view from data returned from an API endpoint. My JSON looks (roughly) like this:

{
  \"sections\": [
    {
      \"title\": \"Feature         


        
相关标签:
3条回答
  • 2021-02-08 19:48

    Polymorphic design is a good thing: many design patterns exhibit polymorphism to make the overall system more flexible and extensible.

    Unfortunately, Codable doesn't have "built in" support for polymorphism, at least not yet.... there's also discussion about whether this is actually a feature or a bug.

    Fortunately, you can pretty easily create polymorphic objects using an enum as an intermediate "wrapper."

    First, I'd recommend declaring itemType as a static property, instead of an instance property, to make switching on it easier later. Thereby, your protocol and polymorphic types would look like this:

    import Foundation
    
    public protocol ViewLayoutSectionItemable: Decodable {
      static var itemType: String { get }
    
      var id: Int { get }
      var title: String { get set }
      var imageURL: URL { get set }
    }
    
    public struct Foo: ViewLayoutSectionItemable {
      
      // ViewLayoutSectionItemable Properties
      public static var itemType: String { return "foo" }
      
      public let id: Int
      public var title: String
      public var imageURL: URL
      
      // Foo Properties
      public var audioURL: URL
    }
    
    public struct Bar: ViewLayoutSectionItemable {
      
      // ViewLayoutSectionItemable Properties
      public static var itemType: String { return "bar" }
      
      public let id: Int
      public var title: String
      public var imageURL: URL
      
      // Bar Properties
      public var director: String
      public var videoURL: URL
    }
    

    Next, create an enum for the "wrapper":

    public enum ItemableWrapper: Decodable {
      
      // 1. Keys
      fileprivate enum Keys: String, CodingKey {
        case itemType = "item_type"
        case sections
        case sectionItems = "section_items"
      }
      
      // 2. Cases
      case foo(Foo)
      case bar(Bar)
      
      // 3. Computed Properties
      public var item: ViewLayoutSectionItemable {
        switch self {
        case .foo(let item): return item
        case .bar(let item): return item
        }
      }
      
      // 4. Static Methods
      public static func items(from decoder: Decoder) -> [ViewLayoutSectionItemable] {
        guard let container = try? decoder.container(keyedBy: Keys.self),
          var sectionItems = try? container.nestedUnkeyedContainer(forKey: .sectionItems) else {
            return []
        }
        var items: [ViewLayoutSectionItemable] = []
        while !sectionItems.isAtEnd {
          guard let wrapper = try? sectionItems.decode(ItemableWrapper.self) else { continue }
          items.append(wrapper.item)
        }
        return items
      }
      
      // 5. Decodable
      public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        let itemType = try container.decode(String.self, forKey: Keys.itemType)
        switch itemType {
        case Foo.itemType:  self = .foo(try Foo(from: decoder))
        case Bar.itemType:  self = .bar(try Bar(from: decoder))
        default:
          throw DecodingError.dataCorruptedError(forKey: .itemType,
                                                 in: container,
                                                 debugDescription: "Unhandled item type: \(itemType)")
        }
      }
    }
    

    Here's what the above does:

    1. You declare Keys that are relevant to the response's structure. In your given API, you're interested in sections and sectionItems. You also need to know which key represents the type, which you declare here as itemType.

    2. You then explicitly list every possible case: this violates the Open Closed Principle, but this is "okay" to do as it's acting as a "factory" for creating items....

      Essentially, you'll only have this ONCE throughout your entire app, just right here.

    3. You declare a computed property for item: this way, you can unwrap the underlying ViewLayoutSectionItemable without needing to care about the actual case.

    4. This is the heart of the "wrapper" factory: you declare items(from:) as a static method that's capable of returning [ViewLayoutSectionItemable], which is exactly what you want to do: pass in a Decoder and get back an array containing polymorphic types! This is the method you'll actually use instead of decoding Foo, Bar or any other polymorphic arrays of these types directly.

    5. Lastly, you must make ItemableWrapper implement the Decodable method. The trick here is that ItemWrapper always decodes an ItemWrapper: thereby, this works how Decodable is expecting.

    As it's an enum, however, it's allowed to have associated types, which is exactly what you do for each case. Hence, you can indirectly create polymorphic types!

    Since you've done all the heavy lifting within ItemWrapper, it's very easy to now go from a Decoder to an `[ViewLayoutSectionItemable], which you'd do simply like this:

    let decoder = ... // however you created it
    let items = ItemableWrapper.items(from: decoder)
    
    0 讨论(0)
  • 2021-02-08 19:52

    A simpler version of @CodeDifferent's response, which addresses @JRG-Developer's comment. There is no need to rethink your JSON API; this is a common scenario. For each new ViewLayoutSectionItem you create, you only need to add one case and one line of code to the PartiallyDecodedItem.ItemKind enum and PartiallyDecodedItem.init(from:) method respectively.

    This is not only the least amount of code compared to the accepted answer, it is more performant. In @CodeDifferent's option, you are required to initialize 2 arrays with 2 different representations of the data to get your array of ViewLayoutSectionItems. In this option, you still need to initialize 2 arrays, but get to only have one representation of the data by taking advantage of copy-on-write semantics.

    Also note that it is not necessary to include ItemType in the protocol or the adopting structs (it doesn't make sense to include a string describing what type a type is in a statically typed language).

    protocol ViewLayoutSectionItem {
        var id: Int { get }
        var title: String { get }
        var imageURL: URL { get }
    }
    
    struct Foo: ViewLayoutSectionItem {
        let id: Int
        let title: String
        let imageURL: URL
    
        let audioURL: URL
    }
    
    struct Bar: ViewLayoutSectionItem {
        let id: Int
        let title: String
        let imageURL: URL
    
        let videoURL: URL
        let director: String
    }
    
    private struct PartiallyDecodedItem: Decodable {
        enum ItemKind: String, Decodable {
            case foo, bar
        }
        let kind: Kind
        let item: ViewLayoutSectionItem
    
        private enum DecodingKeys: String, CodingKey {
            case kind = "itemType"
        }
    
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: DecodingKeys.self)
            self.kind = try container.decode(Kind.self, forKey: .kind)
            self.item = try {
                switch kind {
                case .foo: return try Foo(from: decoder)
                case .number: return try Bar(from: decoder)
            }()
        }
    }
    
    struct ViewLayoutSection: Decodable {
        let title: String
        let sectionLayoutType: String
        let sectionItems: [ViewLayoutSectionItem]
    
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            self.title = try container.decode(String.self, forKey: .title)
            self.sectionLayoutType = try container.decode(String.self, forKey: .sectionLayoutType)
            self.sectionItems = try container.decode([PartiallyDecodedItem].self, forKey: .sectionItems)
                .map { $0.item }
        }
    }
    

    To handle the snake case -> camel case conversion, rather than manually type out all of the keys, you can simply set a property on JSONDecoder

    struct Sections: Decodable {
        let sections: [ViewLayoutSection]
    }
    
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    let sections = try decode(Sections.self, from: json)
        .sections
    
    0 讨论(0)
  • 2021-02-08 19:53

    I recommend you to be judicious on the use of Codable. If you only want to decode a type from JSON and not encode it, conforming it to Decodable alone is enough. And since you have already discovered that you need to decode it manually (via a custom implementation of init(from decoder: Decoder)), the question becomes: what is the least painful way to do it?

    First, the data model. Note that ViewLayoutSectionItemable and its adopters do not conform to Decodable:

    enum ItemType: String, Decodable {
        case foo
        case bar
    }
    
    protocol ViewLayoutSectionItemable {
        var id: Int { get }
        var itemType: ItemType { get }
        var title: String { get set }
        var imageURL: URL { get set }
    }
    
    struct Foo: ViewLayoutSectionItemable {
        let id: Int
        let itemType: ItemType
        var title: String
        var imageURL: URL
        // Custom properties of Foo
        var audioURL: URL
    }
    
    struct Bar: ViewLayoutSectionItemable {
        let id: Int
        let itemType: ItemType
        var title: String
        var imageURL: URL
        // Custom properties of Bar
        var videoURL: URL
        var director: String
    }
    

    Next, here's how we will decode the JSON:

    struct Sections: Decodable {
        var sections: [ViewLayoutSection]
    }
    
    struct ViewLayoutSection: Decodable {
        var title: String = ""
        var sectionLayoutType: String
        var sectionItems: [ViewLayoutSectionItemable] = []
    
        // This struct use snake_case to match the JSON so we don't have to provide a custom
        // CodingKeys enum. And since it's private, outside code will never see it
        private struct GenericItem: Decodable {
            let id: Int
            let item_type: ItemType
            var title: String
            var feature_image_url: URL
            // Custom properties of all possible types. Note that they are all optionals
            var audio_url: URL?
            var video_url: URL?
            var director: String?
        }
    
        private enum CodingKeys: String, CodingKey {
            case title
            case sectionLayoutType = "section_layout_type"
            case sectionItems = "section_items"
        }
    
        public init(from decoder: Decoder) throws {
            let container     = try decoder.container(keyedBy: CodingKeys.self)
            title             = try container.decode(String.self, forKey: .title)
            sectionLayoutType = try container.decode(String.self, forKey: .sectionLayoutType)
            sectionItems      = try container.decode([GenericItem].self, forKey: .sectionItems).map { item in
            switch item.item_type {
            case .foo:
                // It's OK to force unwrap here because we already
                // know what type the item object is
                return Foo(id: item.id, itemType: item.item_type, title: item.title, imageURL: item.feature_image_url, audioURL: item.audio_url!)
            case .bar:
                return Bar(id: item.id, itemType: item.item_type, title: item.title, imageURL: item.feature_image_url, videoURL: item.video_url!, director: item.director!)
            }
        }
    }
    

    Usage:

    let sections = try JSONDecoder().decode(Sections.self, from: json).sections
    
    0 讨论(0)
提交回复
热议问题