June 17, 2017

JSONDecoderのちょっぴり痒い所

Categories: 技術 | Tags: #Swift4 #JSONDecoder


先日のWWDCのWhat”s New in FoundationでCodableについて触れられていたので早速使ってみて、これは良いと思ったので今までお世話になっていたHimotokiから引っ越しを決意したものの、、ちょっと壁にぶつかりました..
※普通に使う分には全く困らないと思います

ネストしているケースで壁にぶつかった

例えば、以下のようなJSON(Data),JSONDecoderがあったとします

var json: String = """
{
    "result": {
        "persons": [
            {
                "name": "taro",
                "age": 25
            },
            {
                "name": "hanako",
                "age": 23
            }
        ],
        "code": 0,
        "flag": true
    }
}
"""

let data = json.data(using: .utf8)!
let decoder = JSONDecoder()

これをSwift4のDecodableを使って素直にパースしようと思うと、以下のような感じになります。

struct PersonsResponse: Decodable {
    struct Result: Decodable {
        struct Person: Decodable {
            let name: String
            let age: Int
        }

        let persons: [Person]
        let code: Int
        let flag: Bool
    }

    let result: Result
}

do {
    let response = try decoder.decode(PersonsResponse.self, from: data)
    // ...
} catch let error {
    print(error)
}

いたって普通ですね。普通なのですが…
result.persons 内だけパースして使いたいなって場合に困ってきます。
つまり、

struct Person: Decodable {
    let name: String
    let age: Int
}

// ※正しくないコードです
do {
    let persons = try decoder.decode([Person].self, from: data)
    // ...
} catch let error {
    print(error)
}

としたいのですが、このままだとうまくいきません。

keyPathを渡してdecodeできるようにする

JSONDecoderにextensionを追加してみます。

extension JSONDecoder {
    func decode<T: Decodable>(_ type: T.Type, from data: Data, keyPath: String) throws -> T {
        let topLevel = try JSONSerialization.jsonObject(with: data)
        if let nestedJson = (topLevel as AnyObject).value(forKeyPath: keyPath) {
            let nestedJsonData = try JSONSerialization.data(withJSONObject: nestedJson)
            return try decode(type, from: nestedJsonData)
        } else {
            throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Nested json not found for key path \"\(keyPath)\""))
        }
    }
}

一度JSONSerializationでdataからjsonオブジェクトを作り、keyPathを使ってネストされたjsonを取得し、再度dataを作ってから本来のdecode(_:from:)関数を呼び出してあげます。
エラーは一旦DecodingErrorに統一しようとdataCorruptedを使っていますが、別のエラーとして返してあげてもよいのかもしれません🤔
これで準備が整ったので、keyPathとしてresult.personsを渡してdecodeします。

struct Person: Decodable {
    let name: String
    let age: Int
}

// just works!
do {
    let persons = try decoder.decode([Person].self, from: data, keyPath: "result.persons")
    // ...
} catch let error {
    print(error)
}

パフォーマンスは?

一度デシリアライズして、ネストしたjsonをkeyPathで取得した後に再度シリアライズしているため、素直にパースした場合と、今回のケースでは差がでてきます。

かなり雑多ですが、単純にそれぞれのケースを1000回ループさせて実行させて計測しました。Date使っているのはご了承を。

let date = Date()
for _ in 0..<1000 {
    do {
        let _ = try decoder.decode(PersonsResponse.self, from: data)
    } catch let error {
        print(error)
    }
}
print("1:", Date().timeIntervalSince(date))
// ===============================================
let date = Date()
for _ in 0..<1000 {
    do {
        let _ = try decoder.decode([Person].self, from: data, keyPath: "result.persons")
    } catch let error {
        print(error)
    }
}
print("2:", Date().timeIntervalSince(date))
ケース 1000回実行した場合の時間(秒)
素直にパースした場合 0.0513710379600525
keyPathで必要な部分だけパースした場合 1.99045598506927

お、おそい、、
結構な差が出てしまいますね.

decodeって何してるの?

ここを見る限り、最初にJSONSerializationでデシリアライズしているようです。

// https://github.com/apple/swift/blob/master/stdlib/public/SDK/Foundation/JSONEncoder.swift#L884L888 から引用
open func decode<T : Decodable>(_ type: T.Type, from data: Data) throws -> T {
    let topLevel = try JSONSerialization.jsonObject(with: data)
    let decoder = _JSONDecoder(referencing: topLevel, options: self.options)
    return try T(from: decoder)
}

なので、今回keyPath渡してパースするケースの場合、デシリアライズして、シリアライズして渡して、それがデシリアライズされて…とかなり非効率になっていますね。
_JSONDecoderを直接触れたら…

まとめ

もちろん、result.persons ってならないようにJSONを構築するのも大事ですが、例えば
https://developer.github.com/v3/search/#search-repositories を叩いた結果のうち、items 以下だけパースして使いたい、なんて場合だとやっぱり困ってしまうんですよね。
JSONの構造通り構造体用意しろ! っていうのが正しい気はするものの、、悩ましい。

ちなみにHimotokiだと

let persons: [Person] = try decodeArray(json, rootKeyPath: ["result", "persons"])

とできます。

Decodable使うか、Himotoki使うか悩みますね。

そしてより良い方法を知ってる人がいたら是非教えてください。

Gistはこちらです。


written by sgr-ksmt