[iOS] Decodable でJSONをデコードする時に要素の型を変換する その2 KeyedDecodingContainer のエクステンションで一部の要素にのみ独自のデコード処理を適用する

はじめに(承前)

以前のポスト [iOS] Decodable でJSONをデコードする時に要素の型を変換する
以下のようなデコード処理を書きました。

struct Food: Decodable {
    var 食品名: String
    var 食材: [String]
    
    enum コーディングキー: String, CodingKey {
        case 食品名
        case 食材
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: コーディングキー.self)
        
        食品名 = try container.decode(String.self, forKey: .食品名)
        let 食材String = try container.decode(String.self, forKey: .食材)
        食材 = 食材String.components(separatedBy: ",")
    }
}

この方法のデメリットとして、init(from decoder: Decoder) throws で処理しているので、
全てのデコード処理を自前で書く必要があります。

要素の数が増えれば増えるほど記述するコストが増えてしまいます。

ある程度複雑な構造の場合、自前で全てデコード処理を書くのはつらいです。

そこで本ポストでは、要素の一部のみ独自のデコード処理を適用する方法を書いていきます!

前提条件

Xcode: 12.1
Swift 5.3

やりたいこと

以前のポスト [iOS] Decodable でJSONをデコードする時に要素の型を変換する と同様、
以下のJSONデータをパースします!

{
    "食品名":"肉類",
    "食材": "鶏肉, 豚肉, 牛肉"
}

また、前提としてデコード処理は以下のように呼び出しています。

let food = try! JSONDecoder.init().decode(
    Food.self,
    from: data
)

まずは最終的なコード

struct Food: Decodable {
    var 食品名: String
    var 食材: [String]
}

extension KeyedDecodingContainer {
    func decode(_: [String].Type, forKey key: Key) throws -> [String] {
        let value = try decodeIfPresent(String.self, forKey: key)
        return value?.components(separatedBy: ",") ?? []
    }
}

説明

KeyedDecodingContainer のエクステンションを定義し、
そこに var 食材: [String] のためのデコード処理を書けばOKです。

引数の型の [String].Type ですが、[String] 型でデコードしたい要素は
全てこのエクステンションを通るという処理になります。
(微妙に臭うな…? 🤔 (このあたりは後述))

引数の key には 「食材」 という CodingKeys enum が入ってきます。

let value = try decodeIfPresent(String.self, forKey: key) の部分で、
String 型を持ち且つ「食材」の CodingKey の rawValue (= 今回の場合は「食材」となる。デフォルトのCodingKey なので、rawValue もそのようになっている)
を持つJSON要素のデコードを試みています。

デコードできない可能性を考慮して、オプショナルのStringを返却する
decodeIfPresent(_:forKey:) を利用しています。

これで "鶏肉, 豚肉, 牛肉" という String が取得できるはずなので、
あとは , で文字列を分割して配列に加工し、その配列を返却しています!


var 食品名: String のデコードではこのメソッドに入ってこず、特に記述しなくてもデコードされます。
こちらはおそらく内部的には decode(_:forKey:) のような処理が走っているのではないかと思います。

CodingKeys について

前回のポストでは以下のように CodingKey たちを独自に定義していましたが、
今回は省略して記述できています。
デコード処理で明示的には利用しない(且つ JSON と デコード後 の要素名が同じ)ためです。

enum コーディングキー: String, CodingKey {
    case 食品名
    case 食材
}

もしJSONデータと異なる要素名でデコードしたい場合、
且つ今回のように一部の要素のみ独自のデコード処理を適用したい場合は以下のように
CodingKeys というenum で定義し、rawValue でJSONでの要素名を定義します。

    enum CodingKeys: String, CodingKey {
        case 食品名
        case 食材 = "JSONでの食材要素名"
    }

iOS がデコードする際、要素名のリストとして
CodingKeys という名前の enum を参照しているようです。
参考: Apple の公式ドキュメント: Encoding and Decoding Custom Types


これで必要な箇所のみ独自のデコード処理を施すことができました!🙌

しかしこの方法にもデメリットがあります!

[String] 型にデコードしたい要素がすべてこの処理を通ってしまう

そうです、例えばJSONデータが以下のような構造だとします。

{
    "食品名":"肉類",
    "食材": "鶏肉, 豚肉, 牛肉",
    "栄養": ["タンパク質", "ビタミンA", "ビタミンB"]
}

で、以下のようなモデルデータにデコードしたいとします。

struct Food: Decodable {
    var 食品名: String
    var 食材: [String]
    var 栄養: [String]
}

食材 はJSON要素の String を [String] に変換したいが、栄養 は [String] そのままでデコードしたい。

この場合 栄養 のデコードで失敗します。

なぜなら let value = try decodeIfPresent(String.self, forKey: key) で、
栄養String 型としてデコードしようと試みますが、
栄養[String] 型だからです。

以下のようなエラーが出ると思います。

typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "栄養", intValue: nil)], debugDescription: "Expected to decode String but found an array instead.", underlyingError: nil))

ではどのようにするのがよいのか。

一つは、引数のKey によって処理を出し分ける方法が考えられます。

Key によってデコード処理を出し分ける

以下のようなコードに変えれば問題なくデコードできます。

extension KeyedDecodingContainer {
    func decode(_: [String].Type, forKey key: Key) throws -> [String] {
        if key.stringValue == "食材" {
            let value = try decodeIfPresent(String.self, forKey: key)
            return value?.components(separatedBy: ",") ?? []
        } else if key.stringValue == "栄養" {
            let value = try decodeIfPresent([String].self, forKey: key)
            return value ?? []
        } else {
            return []
        }
    }
}

これはやばい香りがしますね… 🤔
今後 [String] 型の要素が増えるたびにここを考慮する必要が出てきます。

独自の型としてデコードする

影響範囲を限定できるので、まだこちらの方がよさそうかなと思います。

struct Food: Decodable {
    let 食品名: String
    let 食材: [Foodstuff]  // 独自の型に変更
    let 栄養: [String]
}

struct Foodstuff: Decodable {
    let value: String
}

extension KeyedDecodingContainer {
    func decode(_: [Foodstuff].Type, forKey key: Key) throws -> [Foodstuff] {
        let valueString = try decodeIfPresent(String.self, forKey: key)
        let valueArray = valueString?.components(separatedBy: ",")
        return valueArray?.map { Foodstuff(value: $0) } ?? []  // 独自の型に変更
    }
}

Foodstuff 構造体を RawRepresentable に準拠させると、より Swifty になるかな? 🤔
場合によってはそうしてもいいかもしれません。

struct Foodstuff: Decodable, RawRepresentable {
    let rawValue: String
}

独自の型を定義する場合のデメリットは、そのまんまですが [String] 型として扱えなくなることですね!

let 食材: [Foodstuff] の部分を [String] で扱いつつ、
KeyedDecodingContainer のエクステンションでキーによる処理の出し分けも避けたい?

PropertyWrapper と組み合わせれば実現できそうです🤔

長くなったのでそちらのパターンについては後ほど書きます!

最後に

Codable は色々できてるので、複雑なことをやろうとすると混乱しがちです😂

以上です。
お疲れ様でした!

[iOS] Decodable でJSONをデコードする時に要素の型を変換する その2 KeyedDecodingContainer のエクステンションで一部の要素にのみ独自のデコード処理を適用する」に2件のコメントがあります

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中

%d人のブロガーが「いいね」をつけました。