ZhgChg.Li

CodableのDecode問題|ResponseのNull値を効率的に処理する方法まとめ

Codableでのデコード時に直面するNull値問題を解決。無駄なinit decoderの再実装を避け、現実的な対応策で安定したデータ処理を実現します。

CodableのDecode問題|ResponseのNull値を効率的に処理する方法まとめ
本記事は AI による翻訳です。お気づきの点があればお知らせください。

実際に Codable を使う際に直面するデコード問題のまとめ(下)

合理な Response の Null フィールドの処理は、必ずしも init decoder を書き直す必要はありません

Photo by Zan

Photo by Zan

前書き

前回の「現実の Codable 利用で遭遇した Decode 問題のまとめ」に続き、開発が進む中で新たな場面や問題に直面しました。そこで今回の後編では、遭遇した状況や検証の過程を引き続き記録し、後で振り返りやすくしています。

前回は主に JSON String → Entity Object の Decodable マッピングを解決しました。Entity Object があれば、それを Model Object に変換してプログラム内で受け渡し、View Model Object でデータ表示のロジックを処理することができます。一方で、Entity を NSManagedObject に変換してローカルの Core Data に保存する必要があります

主な問題

例えば、私たちの曲の Entity 構造が以下のようになっているとします:

struct Song: Decodable {
    var id: Int
    var name: String?
    var file: String?
    var converImage: String?
    var likeCount: Int?
    var like: Bool?
    var length: Int?
}

APIのエンドポイントは必ずしも完全なデータフィールドを返すわけではありません(idだけは必ず返されます)。そのため、id以外のフィールドはすべてOptionalです。例えば、曲情報を取得するときは完全な構造が返されますが、曲をお気に入りにしたりいいねした場合は、idlikeCountlikeの3つの関連する変更フィールドだけが返されます。

私たちは、APIレスポンスのすべてのフィールドデータをCore Dataに保存し、データが既に存在する場合は変更されたフィールドのみを更新(インクリメンタルアップデート)したいと考えています。

しかしここで問題が発生します:CodableでデコードしてEntityオブジェクトに変換した後、「データフィールドがnilに設定されている」か「レスポンスでフィールドが送られていない」かを区別できません

Aのレスポンス:
{
  "id": 1,
  "file": null
}
Bのレスポンス:
{
  "id": 1,
  "like": true,
  "likeCount": 1
}

A Response、B Response の file はどちらも null ですが、意味は異なります。A は file フィールドを null に設定(元のデータをクリア)したい場合で、B は他のデータを更新したいだけで、単に file フィールドを渡していないだけです。

Swiftコミュニティの開発者から、date Strategyのようなnull StrategyをJSONDecoderに追加する提案があり、これにより上記の状況を区別できるようになりますが、現時点では導入予定はありません。

解決策

前述の通り、私たちの構成は JSON String -> Entity Object -> NSManagedObject なので、Entity Object を受け取った時点ではすでにデコード済みであり、生のデータを操作することはできません。ここで元の JSON String を使って比較操作も可能ですが、それならむしろ Codable を使わない方が良いでしょう。

まずは 前回の記事 を参照し、Associated Value Enum を使って値を格納する方法を確認します。

enum OptionalValue<T: Decodable>: Decodable {
    case null
    case value(T)
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let value = try? container.decode(T.self) {
            self = .value(value)
        } else {
            self = .null
        }
    }
}

ジェネリックを使用し、T は実際のデータフィールドの型です;.value(T) はデコードされた値を保持し、.null は値が null であることを示します。

struct Song: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case file
    }
    
    var id: Int
    var file: OptionalValue<String>?
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        self.id = try container.decode(Int.self, forKey: .id)
        
        if container.contains(.file) {
            self.file = try container.decode(OptionalValue<String>.self, forKey: .file)
        } else {
            self.file = nil
        }
    }
}

var jsonData = """
{
    "id":1
}
""".data(using: .utf8)!
var result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

jsonData = """
{
    "id":1,
    "file":null
}
""".data(using: .utf8)!
result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

jsonData = """
{
    "id":1,
    "file":\"https://test.com/m.mp3\"
}
""".data(using: .utf8)!
result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

例はまず idfile の2つのデータフィールドだけに簡略化します。

Song Entity は独自に Decode 方法を実装し、contains(.KEY) メソッドを使ってレスポンスに該当フィールドがあるか(値が何であっても)を判定します。存在すれば OptionalValue に Decode します。OptionalValue Enum 内では、実際に必要な値の Decode を行い、成功すれば .value(T) に、値が null(または Decode に失敗)であれば .null に格納します。

  1. Response がフィールドと値を返す場合:OptionalValue.value(VALUE)

  2. Response にフィールドがあり値が null の場合:OptionalValue.null

  3. Response がフィールドを返さない場合:nil

これにより、フィールドが存在するかどうかを区別できるため、後で Core Data に書き込む際に、そのフィールドを null に更新するか、更新しないかを判断できます。

その他の検討 — Double Optional ❌

Optional!Optional! は Swift でこのシーンを扱うのに非常に適しています。

struct Song: Decodable {
    var id: Int
    var name: String??
    var file: String??
    var converImage: String??
    var likeCount: Int??
    var like: Bool??
    var length: Int??
}
  1. Response にフィールドと値がある場合:Optional(VALUE)

  2. Response にフィールドがあり値が null の場合:Optional(nil)

  3. Response がフィールドを返さない場合:nil

しかし…. Codable の JSONDecoder は Double Optional と Optional の両方を decodeIfPresent で処理し、どちらも Optional として扱うため、特に Double Optional を区別しません。そのため、結果は元のままです。

その他の検討 — Property Wrapper ❌

もともと Property Wrapper を使ってエレガントにラップできると考えていました。例えば:

@OptionalValue var file: String?

しかし、まだ詳細を調べ始める前に、Property Wrapper が付いた Codable プロパティのフィールドは、APIレスポンスに必ずそのフィールドが存在しなければならず、たとえそのフィールドが Optional であっても、存在しないと keyNotFound エラーが発生することが分かりました。?????

公式フォーラムでもこの問題に関するディスカッションスレッドがあります…おそらく今後修正されるでしょう。

ですので、BetterCodableCodableWrappers といったライブラリを選ぶ際には、現在の Property Wrapper の問題点を考慮する必要があります。

その他の問題シナリオ

1.APIレスポンスでBoolを0/1で表現している場合、どうデコードするか?

import Foundation

struct Song: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case name
        case like
    }
    
    var id: Int
    var name: String?
    var like: Bool?
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.name = try container.decodeIfPresent(String.self, forKey: .name)
        
        if let intValue = try container.decodeIfPresent(Int.self, forKey: .like) {
            self.like = (intValue == 1) ? true : false  // 0または1をBoolに変換
        } else if let boolValue = try container.decodeIfPresent(Bool.self, forKey: .like) {
            self.like = boolValue
        }
    }
}

var jsonData = """
{
    "id": 1,
    "name": "告五人",
    "like": 0
}
""".data(using: .utf8)!
var result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

前回に続き、init Decode 内で int や Bool にデコードしてから自分で値を代入することで、元のフィールドが 0/1/true/false を受け入れられるように拡張できます。

2. 毎回 init decoder を書き直したくない場合

自分で Decoder を作成したくない場合、既存の JSON Decoder を拡張して機能を追加する。

私たちは自分で KeyedDecodingContainer の public メソッドを拡張して定義できます。Swift はモジュール内で再定義したメソッドを優先的に実行し、元の Foundation の実装を上書きします。

影響するのはモジュール全体です。

そして実際には override ではなく、super.decode を呼び出せないため、自分自身を呼び出さないよう注意が必要です(例:decode(Bool.Type, for: key) の中で decode(Bool.Type, for: key) を呼ぶなど)。

decode には2つの方法があります:

  • decode(Type, forKey:) は Optional でないデータフィールドを処理する方法

  • decodeIfPresent(Type, forKey:) は Optional なデータフィールドの処理

例1. 前述の主要な問題は、extensionで直接対応できます:

extension KeyedDecodingContainer {
    public func decodeIfPresent<T>(_ type: T.Type, forKey key: Self.Key) throws -> T? where T : Decodable {
        //より良い方法:
        switch type {
        case is OptionalValue<String>.Type,
             is OptionalValue<Int>.Type:
            return try? decode(type, forKey: key)
        default:
            return nil
        }
        // または単に return try? decode(type, forKey: key) とする
    }
}

struct Song: Decodable {
    var id: Int
    var file: OptionalValue<String>?
}

主な問題は Optional なデータフィールドと Decodable タイプなので、私たちは decodeIfPresent<T: Decodable> メソッドをオーバーライドしています。

ここでは、元の decodeIfPresent の実装は、データが null またはレスポンスに存在しない場合、直接 nil を返し、実際には decode を実行しないと推測しています。

原理も非常に簡単で、Decodable 型が OptionValue であれば、どんな場合でもデコードを試みることで、異なる状態の結果を取得できます。ただし、Decodable 型を判別しなくても問題なく、すべての Optional フィールドでデコードを試みることも可能です。

サンプル2. 問題シーン1にもこの方法で拡張可能:

extension KeyedDecodingContainer {
    public func decodeIfPresent(_ type: Bool.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> Bool? {
        if let intValue = try? decodeIfPresent(Int.self, forKey: key) {
            return (intValue == 1) ? (true) : (false) // 1ならtrue、それ以外はfalseを返す
        } else if let boolValue = try? decodeIfPresent(Bool.self, forKey: key) {
            return boolValue // Bool型の値を返す
        }
        return nil // 値が存在しない場合はnilを返す
    }
}

struct Song: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case name
        case like
    }
    
    var id: Int
    var name: String?
    var like: Bool?
}

var jsonData = """
{
    "id": 1,
    "name": "告五人",
    "like": 1
}
""".data(using: .utf8)!
var result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

結語

Codable の使用における様々なテクニックはほぼ使い切りましたが、中にはかなり複雑なものもあります。Codable の制約が非常に強いため、実際の開発で必要な柔軟性が犠牲になっているからです。最終的には、なぜ最初に Codable を選んだのか、その利点がどんどん減っていくことを考え始めるほどです…。

参考資料

振り返り

Post は Medium から ZMediumToMarkdown によって変換されました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント