ネストした JSON をフラットな構造体にマッピングする

はじめに

  • Swift では Encodable, Decodableプロトコルと JSONEncoder, JSONDecoder を利用すれば、 HTTP 通信で取得した JSON と Swift オブジェクトを一発変換できます🙂
  • が、ネストした JSON を扱う場合には Swift 側の対応する型(構造体を使うことが多い)も同じ構造にネストする必要があります😔
  • 公開されている Web API では、何階層にもネストしてる JSON も多いので、ネストした階層分だけ構造体を定義するのは面倒ですし、扱いづらくなります
  • そのような場合には、以下の 2 つを実装すると解決できます
    • Encodable のメソッド encode(to:)
    • Decodable のイニシャライザ init(from:)
  • 定義は少し面倒ですが、一度作成してしまえばとても使いやすくなります

検証環境

  • macOS Big Sur 11.2.1
  • Xcode 12.4

サンプル

{
    "user_name": "山田二郎",
    "scores": [
        { "score": 65 },
        { "score": 24 }
    ]
}

上記の JSON は、構造として全体を表す {} の中に、 "scores" 部分が配列となっており、その要素が {} となっています。 つまり、「オブジェクト」->「配列」->「オブジェクト」の 3 階層です。 配列は Swift で Array 型が定義されているので、自分で用意する必要があるのは 2 つの構造体であることがわかります。

JSON に対応させた構造体(基本)

この JSON の階層に単純に対応させるなら、以下のような 2 つの構造体が必要となります。

// JSONと対応させるPerson型(Codableに準拠)
struct Person: Codable {
    let name: String
    let scores: [Score]

    /// SwiftのプロパティとJSONのキーをマッピング
    enum CodingKeys: String, CodingKey {
        // case Swift側の名前 = "JSON側のキー"
        case name = "user_name"
        case scores
    }
}

// Personのプロパティとして利用する型(Codableに準拠)
struct Score: Codable {
    let score: Int
}

しかし、 "scores" 部分は属性が "score" しかないため、整数の配列にしておいた方が扱いやすそうです。

JSONに対応させた構造体(階層構造を変更)

クラス定義

こんな感じで、 1 階層浅くしたら使いやすそうですね。 "scores"Int の配列であるため、定義する型は Person のみです。 上のサンプルにある、 Score 型は定義する必要はありません。

階層ごとの CodingKeys だけは用意しておきましょう。名前は任意です。

// JSONに対応する構造体
struct Person: Codable {
    let name: String
    let scores: [Int]

    // トップレベルの属性に対応するCodingKeys
    enum CodingKeys: String, CodingKey {
        case name = "user_name"
        case scores
    }
    
    // ネストしたJSONの属性に対応するCodingKeys
    enum ScoresCodingKeys: String, CodingKey {
        case score
    }
}

このままでは、 JSON と形が異なるため相互変換ができません。 エクステンションで、カスタムデコード用のイニシャライザとカスタムエンコード用のメソッドを追加してみましょう。

カスタムデコード用の追加実装

extension Person {
    init(from decoder: Decoder) throws {
        // CodingKeysを指定し、JSON直下の属性("user_name"と"scores"にあたる部分)に対するコンテナを取得
        let rootContainer = try decoder.container(keyedBy: CodingKeys.self)
        
        // JSONのキー"user_name"にあたる部分の値を取得
        let name = try rootContainer.decode(String.self, forKey: .name)
        
        // ネストしたオブジェクト(キー"scores")の配列部分(配列なので中身の各要素にはキーがない)のコンテナを取得
        var arrayContainer = try rootContainer.nestedUnkeyedContainer(forKey: .scores)

        var scores: [Int] = []

        // 配列の要素の最後になるまで繰り返し
        while !arrayContainer.isAtEnd {
            // ネストした部分のCodingKeys(ここではScoresCodingKeys)を指定し配列内のオブジェクト部分のコンテナを取得
            let scoreContainer = try arrayContainer.nestedContainer(keyedBy: ScoresCodingKeys.self)
            
            // JSONのキー"score"にあたる部分の値を取得
            let score = try scoreContainer.decode(Int.self, forKey: .score)

            // 取得した値を配列に追加
            scores.append(score)
        }

        // 取得した値をメンバワイズイニシャライザに渡して初期化
        self.init(name: name, scores: scores)
    }
}

実装のポイント(JSON -> 構造体)

  • イニシャライザの引数である Decoder を利用する
    • 解析は、この Decoder を通して行います
  • Decodercontainer(keyedBy:)CodingKeys を渡して、該当部分のコンテナを取得
    • CodingKeys が JSON のキー(と、それに対応する構造体のプロパティ名)を保持しているため、そのコンテナを通して値を取得できるようになります
  • コンテナからは decode(_:forKey:)CodingKeys に定義したキーを渡して、該当部分の値を取得
    • コンテナには container(keyedBy:)CodingKeys が渡っているため、そこに定義したキーで値を取得できます
  • ネストした部分のコンテナは、上位階層のコンテナから nestedContainer(keyedBy:)nestedUnkeyedContainer(forKey:) で取得
    • 通常の属性の場合は nestedContainer(keyedBy:) を利用しますが、値が配列の場合には中の各要素にキーがないため、 nestedUnkeyedContainer(forKey:) を利用します
  • nestedContainer(keyedBy:) を呼び出すごとに、内部的なカーソルが次へ移動する
    • コンテナの isAtEnd を条件としてループを回せば、要素の回数だけループを回せます
    • そのためループ内で、nestedContainer(keyedBy:) を呼び忘れると無限ループに陥るので注意が必要です

カスタムエンコード用の実装

extension Person {
    // カスタムでエンコードするためのメソッド
    func encode(to encoder: Encoder) throws {
        // CodingKeysを指定し、JSON直下の属性("user_name"と"scores"にあたる部分)に対するコンテナを取得
        var container = encoder.container(keyedBy: CodingKeys.self)
        
        // JSONのキー"name"にあたる部分をエンコード
        try container.encode(self.name, forKey: .name)

        // ネストしたオブジェクト(キー"scores")の配列部分(配列なので中身の各要素にはキーがない)のコンテナを取得
        var scoresContainer = container.nestedUnkeyedContainer(forKey: .scores)

        // scores配列をループし、各要素をエンコード
        for score in scores {
            // ネストした部分のCodingKeys(ここではScoresCodingKeys)を指定し配列内のオブジェクト部分のコンテナを取得
            var arrayContainer = scoresContainer.nestedContainer(keyedBy: ScoresCodingKeys.self)

            // JSONのキー"score"にあたる部分をエンコード
            try arrayContainer.encode(score, forKey: .score)
        }
    }
}

実装のポイント(構造体 -> JSON)

  • 基本的な考え方は、デコードの際と同じです
  • JSON と構造体の構造を注意深く比較し、上の階層から順に処理していけばできると思います

まとめ

  • 階層に合わせて構造体を複数定義しても問題ないですが、フラットな構造の方が扱いやすいですよね
  • 記述量は増えますが、利用する場面のことを考えると、最初に手間をかけておくメリットは十分にあると思います
  • サンプルは GitHub に置きました

参考サイト