CodableのDecode問題|ResponseのNull値を効率的に処理する方法まとめ
Codableでのデコード時に直面するNull値問題を解決。無駄なinit decoderの再実装を避け、現実的な対応策で安定したデータ処理を実現します。
本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。
記事一覧
実際の Codable 使用時に遭遇するデコード問題の事例まとめ(後編)
レスポンスの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 構造が以下のようだとします:
1
2
3
4
5
6
7
8
9
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 Decode から Entity Object に変換すると、「データフィールドを nil に設定したい」か「レスポンスで渡されていない」かを区別できません
1
2
3
4
5
A Response:
{
"id": 1,
"file": null
}
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 を使って値を格納する方法について。
1
2
3
4
5
6
7
8
9
10
11
12
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 であることを示します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 にデコードします。OptionalValue Enum 内で実際に必要な値をデコードし、値があれば .value(T) に、null(またはデコード失敗)の場合は .null に格納します。
Response にフィールドと値がある場合:OptionalValue.value(VALUE)
Response にフィールドと値が null の場合:OptionalValue.null
Response がフィールドを返さない場合:nil
これによりフィールドの存在有無を区別でき、後で Core Data に書き込む際にフィールドを null に更新するかどうか判断できます。
その他の検討 — Double Optional ❌
Optional!Optional! は Swift でこのシーンに非常に適しています。
1
2
3
4
5
6
7
8
9
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 Decode は Double Optional と Optional の両方を decodeIfPresent で処理し、どちらも Optional と見なすため、Double Optional を特別に扱いません。そのため、結果は元のままです。
その他の検討事項 — Property Wrapper ❌
もともと Property Wrapper を使って優雅にラップできると考えていました。例えば:
1
@OptionalValue var file: String?
しかし、詳細を調べる前に、Property Wrapper が付いた Codable プロパティのフィールドがある場合、API レスポンスには必ずそのフィールドが存在しなければならず、たとえそのフィールドが Optional であっても、存在しないと keyNotFound エラーが発生します。?????
公式フォーラムにもこの問題に関するディスカッションスレッドがあり…将来的に修正される見込みです。
そのため、BetterCodable や CodableWrappers のようなライブラリを選ぶ際には、現在の Property Wrapper のこの問題を考慮する必要があります。
その他の問題シナリオ
1.APIレスポンスで0/1がBoolを表す場合、どうデコードする?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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 // 1ならtrue、それ以外はfalse
} 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 を extension して 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 で解決できます:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 またはレスポンスに存在しない場合、実際に decode を実行せずに直接 nil を返すと推測しています。
なので原理はとても簡単で、Decodable 型が OptionValue
例2. 問題シーン1にもこの方法で拡張可能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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で変換。
本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。
