やりたいこと
- Codableを使って、微妙にkey:valueの異なるjsonオブジェクトをデコードしたい
- 任意のkeyのvalueによってデコードする型を判別し、デコードしたい
具体的には?
例えば以下のようなjsonがあったとして、
-
properties
配下のkey:valueが統一されていない -
properties
配下をtype
によって型を判定してデコードしたい
[
{
"id": "xxxxx",
"name": "today's News",
"properties": {
"type": "News",
"id": "xxxxxx",
"name": "news title here",
"backgroundColor": {
"alpha": 0,
"red": 0,
"green": 0,
"blue": 0
}
}
},
{
"id": "xxxxx",
"name": "weekly Gossip",
"properties": {
"type": "Gossip",
"id": "xxxxxx",
"name": "gossip title here",
"radius": 3.6
}
},
{
"id": "xxxxx",
"name": "Technology topic",
"properties": {
"type": "Tech",
"id": "xxxxxx",
"name": "tech title here",
"refUrl": "https://xxx"
}
}
]
どうパースしたらいいだろう?
結果から書くとこんな感じにしてみた。
import Foundation
import UIKit
typealias Articles = [Article]
struct Article: Codable {
let id, name: String
let properties: Properties
}
extension Article {
private enum CodingKeys: CodingKey {
case id, name, properties
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
// decode temporarily to switch type and decode to each detailed classes
let properties = try BaseProps.init(from: container.superDecoder(forKey: .properties))
self.properties = try properties.type.metatype.init(from: container.superDecoder(forKey: .properties))
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try properties.encode(to: container.superEncoder(forKey: .properties))
}
}
protocol Properties: Codable {
var type: PropertyType { get }
var id: String { get }
var name: String { get }
}
struct Color: Codable {
var red : CGFloat = 0.0, green: CGFloat = 0.0, blue: CGFloat = 0.0, alpha: CGFloat = 0.0
// retrieve UIColor from `Color`
var uiColor : UIColor {
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
// assign red, green, blue, alpha from UIColor. name leave it nil
init(uiColor : UIColor) {
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
}
}
class BaseProps: Properties {
// this class is prepared just for dynamic switching on decode
var type: PropertyType
var id, name: String
}
class NewsProps: Properties {
// properties in common
var type: PropertyType
var id, name: String
// properties only for this class
var backgroundColor: Color
}
class GossipProps: Properties {
var type: PropertyType
var id, name: String
var radius: CGFloat
var backgroundColor: Color?
}
class TechProps: Properties {
var type: PropertyType
var id, name: String
var refUrl: String
}
enum PropertyType: String, Codable {
case News, Gossip, Tech
var metatype: Properties.Type {
switch self {
case .News:
return NewsProps.self
case .Gossip:
return GossipProps.self
case .Tech:
return TechProps.self
}
}
}
let jsonStr = """
[{"id":"xxxxx","name":"today's News","properties":{"type":"News","id":"xxxxxx","name":"news title here","backgroundColor":{"alpha":0,"red":0,"green":0,"blue":0}}},{"id":"xxxxx","name":"weekly Gossip","properties":{"type":"Gossip","id":"xxxxxx","name":"gossip title here","radius":3.6}},{"id":"xxxxx","name":"Technology topic","properties":{"type":"Tech","id":"xxxxxx","name":"tech title here","refUrl":"https://xxx"}}]
"""
let jsonData = jsonStr.data(using: .utf8)
do {
let articles = try JSONDecoder().decode([Article].self, from:jsonData!)
for article in articles {
let mirror = Mirror(reflecting: article.properties)
print(article.properties.self)
print(mirror.children.compactMap { ($0.label ?? "", $0.value) })
}
} catch let e {
print(e)
}
補足
上記をそのままplaygroundに突っ込めば動くと思うので試してみてください。
どうやってtypeでデコードを分岐させてるか
let properties = try BaseProps.init(from: container.superDecoder(forKey: .properties))
self.properties = try properties.type.metatype.init(from: container.superDecoder(forKey: .properties))
ここ↑で、一旦protocolに準拠しただけのBaseProps
にデコードしておいて、properties.type.metatype
で型を値として取得し、init(from:)
でデコードします。
このやり方については、swift公式docのMetatype Typeの項に記載されてますが、元々はこちらの記事をみつけて、踏襲してるだけです
Encoding And Decoding Polymorphic Objects In Swift
typeはenum
で定義しているので、例えばこうやってマッピングできる
case News = "news"
case Gossip = "gossip_news"
case Tech = "tech_news"
BasePropsを継承しない理由
BaseProps
は、どのクラスにも共通して存在するので、NewsProps
等の具体的なクラスはBaseProps
を継承すればいいじゃない、と思うかもしれませんが、そうすると、子クラス固有のプロパティが自動ではデコードされないので、いちいちCodingKeysとencode、initメソッドを用意しないといけないのでコードが冗長になってしまいます(多分2.5倍くらいのコード量)。なので、どのtypeもPropertiesを準拠する形で、継承関係を持たないようにしています。
今回、Mirrorで当該Codableのプロパティ一覧を取得したいという理由もあって、継承関係にすると面倒なので、Protocolに準拠する形で都合が良かったです。
いい点
- CodingKeysとかinit, encodeメソッドを用意するよりコード量は半分以下に抑えられると思う
- protocolでメソッドを宣言して、その実装を各具体クラスに任せることができる(ポリモーフィズムれる)
注意点
今回は、判定したい型の種類が多くなる予定だったし、型によって個別の処理をしたいことがあったので、デコードする際に型を分けましたが、そもそもそこまでする必要無い場合もあると思います。そういうときは、共通しないkeyをoptionalにすればいいだけなのでそのほうが楽だと思います。
例えばこういう感じ:
// To parse the JSON, add this file to your project and do:
//
// let articles = try? newJSONDecoder().decode(Articles.self, from: jsonData)
import Foundation
typealias Articles = [Article]
struct Article: Codable {
let id, name: String
let properties: Properties
}
struct Properties: Codable {
let type, name: String
let backgroundColor: BackgroundColor?
let image: String?
let radius: CGFloat?
}
struct BackgroundColor: Codable {
let alpha, red, green, blue: CGFloat
}
これは、ほぼquicktypeで作ったものそのままなので、細かい事は置いといて、見てほしいのは、struct Properties
で、backgroundColor
とimage
とradius
がoptionalになっているところ。
単純ですがこうすれば、型によってあったりなかったりするkey/valueも、optionalなので、Codableがよしなに処理してくれます。
ただ、後段で分岐が多くなったり、optionalをアンラップしないといけないのでコードが冗長になりやすかったりするので、その辺とのトレードオフかと思います。
他にも良いやり方とか、こうするといいよというのがあれば、やさしくコメントかedit request等もらえると嬉しいです。
参考
ほぼこの方のやり方を踏襲してます
https://www.digitalflapjack.com/blog/2018/5/29/encoding-and-decoding-polymorphic-objects-in-swift
wwdc2017 What's New in Foundation video