実際に Codable を使う際に直面するデコード問題のまとめ(下)
合理な Response の Null フィールドの処理は、必ずしも init decoder を書き直す必要はありません

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です。例えば、曲情報を取得するときは完全な構造が返されますが、曲をお気に入りにしたりいいねした場合は、id、likeCount、likeの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)
例はまず
idとfileの2つのデータフィールドだけに簡略化します。
Song Entity は独自に Decode 方法を実装し、contains(.KEY) メソッドを使ってレスポンスに該当フィールドがあるか(値が何であっても)を判定します。存在すれば OptionalValue に Decode します。OptionalValue Enum 内では、実際に必要な値の Decode を行い、成功すれば .value(T) に、値が null(または Decode に失敗)であれば .null に格納します。
-
Response がフィールドと値を返す場合:OptionalValue.value(VALUE)
-
Response にフィールドがあり値が null の場合:OptionalValue.null
-
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??
}
-
Response にフィールドと値がある場合:Optional(VALUE)
-
Response にフィールドがあり値が null の場合:Optional(nil)
-
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 エラーが発生することが分かりました。?????
公式フォーラムでもこの問題に関するディスカッションスレッドがあります…おそらく今後修正されるでしょう。
ですので、BetterCodable や CodableWrappers といったライブラリを選ぶ際には、現在の 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
サンプル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 によって変換されました。



コメント