[Swift] JSONがパースできないだと?! そういうときは・・・? そうだね! LosslessStringConvertibleだね!
はじめに
CX事業本部の中安です。どーもです。
さて、以下のようなJSONをアプリで扱おうってなったとするじゃないですか。↓
{ "name": "Usami Takashi", "age": "28", "height": "178.2", "weight": "70.1", "married": "true" }
中身はご覧の通り
- 名前
- 年齢
- 身長
- 体重
- 既婚者かどうか
という構成の人物をあらわすデータであるとします。
で、それを解析(パース)して以下のようなモデル構造体としてオブジェクトに起こしたいとしますね。↓
struct Person: Decodable { let name: String let age: Int let height: Float let weight: Float let married: Bool }
そうすると、JSONDecoder
の仕組みを使って、こうやってデコードしてみようとすると思うんです。↓
do { let jsonString = (さっきのJSON文字列) let jsonData = jsonString.data(using: .utf8)! let person = try JSONDecoder().decode(Person.self, from: jsonData) } catch { print(error) print(error.localizedDescription) }
そうすると、残念ながらエラーがキャッチされて以下のように出力されてしまうんです。
typeMismatch( Swift.Int, Swift.DecodingError.Context( codingPath: [ CodingKeys( stringValue: "age", intValue: nil ) ], debugDescription: "Expected to decode Int but found a string/data instead.", underlyingError: nil ) ) The data couldn’t be read because it isn’t in the correct format.
残念。ああ残念。
なんででしょう。
エラーに書いてあるとおり、
The data couldn’t be read because it isn’t in the correct format.
正しい形式ではないので、データは読み込むことができませんでした。Expected to decode Int but found a string/data instead.
Int でデコードすることが期待されましたが、代わりに String/Data が見つかりました。
と、怒られているんですね。
出力結果を詳しく見ると「age
がおかしいよ」と言われています。
一度上の方に戻ってみてください。たしかにJSONでは"age": "28"
となっており、見かけ上は28
という数字なのですが、
実体はダブルクォーテーションがついているので"28"
という文字列です。
Int
じゃないからパースエラーが起きていたのですね。
こういうケースの場合、どうしましょうか。
「そもそものJSONを数値なら数値型にする」という解決策が考えられますが、 第三者の提供するデータでは簡単にはできないケースもあると思います。
なので、アプリ側で対応する必要が出てくると思いますが、 では「どういう対応方法があるかな?」というお話が今回の内容になります。
LosslessStringConvertible
ここで今回のタイトルにも出てくるLosslessStringConvertible
についてのお話をします。
標準で用意されているプロトコルですが、あまり聞き慣れないプロトコルだと思うのでドキュメントを確認します。
LosslessStringConvertible
A type that can be represented as a string in a lossless, unambiguous way.LosslessStringConvertibleとは、
無損失ではっきりとした手法での文字列表現になれる型ですFor example, the integer value 1050 can be represented in its entirety as the string “1050”.
例えば、整数値1050は文字列"1050"として全体を表現できます。
The description property of a conforming type must be a value-preserving representation of the original value. As such, it should be possible to re-create an instance from its string representation.
準拠する型の description プロパティは、オリジナルの値を保持する表現でなければなりません。そのため、その文字列表現からインスタンスを再生成することができるはずです。
「無損失ではっきりとした手法での文字列表現」というなんとも英語ドキュメントらしい分かりづらい言い回しですが、
要は文字列の1050
から整数値の1050
へは意味を変えずに変換できるよねってことです。
このプロトコルに準拠していると、文字列は意味を変えることなくその型へと変換することができるようになります。
例をあげてみると
let number = Int("1234")
この場合、変数number
には何が入っていてほしいですか?
Int
型の1234
ですよね!?
そうです。変数number
にはInt
型の1234
が代入されます。
すごく当たり前な挙動のように見えますが、
これはInt
型が標準でLosslessStringConvertible
に準拠しているからInt(文字列)
という書き方で容易に整数に変換できているのですね。
そして、これは
- String
- Substring
- Bool
- Double
- Float
- Float16
- Float80
- Path
- Unicode.Scalar
- Int
- Int16
- Int32
- Int64
- Int8
- UInt
- UInt16
- UInt32
- UInt64
- UInt8
このように、標準的な数値型が広くこれに準拠しています。
LosslessStringConvertibleの中身
LosslessStringConvertible
の定義を確認します。
このプロトコルはCustomStringConvertible
を継承しているので、
整理すると以下のような形式になります。
protocol LosslessStringConvertible { init?(_ description: String) var description: String { get } }
このようなイニシャライザを持っているので、Int(文字列)
という書き方ができるようになっています。
しかしながら、イニシャライザはinit?
なので変換不可能な文字列が渡されたときはnil
が返されます。
let number = Int("1234") // -> Int?
先程のこの例では、正確には変数number
はオプショナル型Int?
となるので注意です。
JSON問題の解決
話題を最初のJSONパースに戻します。
今回はこちらの資料を参考にして、
本来なら数値であるはずが、文字列が渡されてきてしまう時のために
LosslessStringConvertible
を使用する方法を取りたいと思います。
新たな型 StringTo
新しくStringTo
という構造体を定義します。
ジェネリクスを使ってLosslessStringConvertible
な型を取らせるようにしておきます。
struct StringTo<T: LosslessStringConvertible> { }
モデル側にこれを以下のように適用させます。
struct Person: Decodable { let name: String let age: StringTo<Int> let height: StringTo<Float> let weight: StringTo<Float> let married: StringTo<Bool> }
この段階だとエラーが起きると思います。StringTo
型はデコードが不可能な型だからです。
Cannot automatically synthesize 'Decodable' because 'StringTo
' does not conform to 'Decodable' 'StringTo
'が'Decodable'に準拠していないため、'Decodable'との自動合成ができません
なので、extension
してStringTo
型をDecodable
に準拠させます。
extension StringTo : Decodable { }
これでエラーは消えると思いますが、この段階だと役に立たない型のままです。
実装
ここまでは最低限の体裁ですが、実際の実装は以下のようにします。
struct StringTo<T: LosslessStringConvertible> { // (1) let value: T } extension StringTo : Decodable { init(from decoder: Decoder) throws { // (2) let container = try decoder.singleValueContainer() let string = try container.decode(String.self) // (3) guard let value = T(string) else { // (4) let debugDescription = "'\(string)' could not convert to \(T.self)." // (5) let context = DecodingError.Context( codingPath: decoder.codingPath, debugDescription: debugDescription ) throw DecodingError.dataCorrupted(context) } // (6) self.value = value } }
難しいソースコードではないですが、番号をコメントで振ってある箇所について説明を書きます。
(1) ストアドプロパティのvalue
を定義します。StringTo<Int>
と定義するとvalue
はInt
型になりますね。
(2) デコーダのコンテナから文字列を取得します。
(3) LosslessStringConvertible
は文字列を渡して初期化すると、その値が返ってきます。
先程も書いたとおりinit?
イニシャライザなのでnil
が返る可能性があります。
nil
が返るケースは文字列が変換できない場合です。
たとえば"abc"
はInt
に変換できないので例外として吐き出してあげるほうが親切な設計になります。
(4) エラー文として'abc' could not convert to Int.
と出るとデバッグで追いやすいかと。
(5) DecodingError.dataCorrupted
(データ欠損エラー)として吐き出します。
DecodingError
はコーディングパス(どのキーの値がおかしかったか)も併せて渡せられるので便利です。
(6) イニシャライザのお作法として最後にプロパティ変数に代入します。
使ってみる
ここまで実装したものを実際に使ってみます。
do { let jsonString = (さっきのJSON文字列) let jsonData = jsonString.data(using: .utf8)! let person = try JSONDecoder().decode(Person.self, from: jsonData) print(person.age.value) } catch { print(error) print(error.localizedDescription) }
"age": "28"
というJSONであっても、person.age.value
にはInt
の28
が入っていると思います。
エンコードにも対応
先程はDecodable
に準拠してデコードに対応しましたが、
逆にオブジェクトからJSONに変換するエンコード対応も書いてみます。
extension StringTo : Encodable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(value.description) } }
今度はエンコーダのコンテナにdescription
で取れた文字列を渡して、さっきの逆の処理を行わせています。
つまり最終結果としては値は文字列として吐き出されるということです。
それでは、使ってみます。
エンコードさせるためにはPerson
をEncodable
に対応させるためにDecodable
からCodable
へと変更します。
struct Person: Codable { let name: String let age: StringTo<Int> let height: StringTo<Float> let weight: StringTo<Float> let married: StringTo<Bool> }
エンコード処理はこのような感じ
do { let person = (Personオブジェクトを作る処理は割愛) let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted let data = try encoder.encode(person) let json = String(data: data, encoding: .utf8) ?? "" print(json) } catch { print(error) print(error.localizedDescription) }
実行すると
{ "age" : "28", "weight" : "70.1", "name" : "Usami Takashi", "height" : "178.2", "married" : "true" }
キーの並び順は変わりますが、元のJSON文字列と同じ値が返ってきたと思います。
CGFloat
身長と体重をあらわすheight
とweight
ですが、後々の計算などでCGFloat
のほうが都合がいいということもあるかもしれません。
そういったときはモデルの定義を下記のように変えるものとします。
struct Person: Codable { let name: String let age: StringTo<Int> let height: StringTo<CGFloat> let weight: StringTo<CGFloat> let married: StringTo<Bool> }
すると、エラーが発生してしまいます。CGFloat
がLosslessStringConvertible
に準拠していないからです。
なので、CGFloat
もLosslessStringConvertible
に準拠させてしまいます。
extension CGFloat: LosslessStringConvertible { public init?(_ description: String) { guard let double = Double(description) else { return nil } self.init(double) } }
CGFloat
はDouble
の言い換えに当たるので、一度Double
に変換して初期化します。
これによってエラーが改修できると思います。
おまけ
この記事の途中でDecodingError
にdebugDescription
を設定しましたが、
エラーキャッチするときにこの値を取得するのは割と面倒くさいです。
DecodingError
を拡張してこの値を簡単に取り出せるようにしてしまいましょう。
extension DecodingError { var debugDescription: String { return context?.debugDescription ?? "" } var key: String { return context?.codingPath.first?.stringValue ?? "" } var context: DecodingError.Context? { switch self { case .dataCorrupted(let context): return context case .keyNotFound(_, let context): return context case .typeMismatch(_, let context): return context case .valueNotFound(_, let context): return context default: // 上記の4つでcaseをカバレッジできているが、 // 将来追加の可能性により、defaultがないと警告が出る return nil } } }
こうしておくと
do { let jsonString = (さっきのJSON文字列) let jsonData = jsonString.data(using: .utf8)! let person = try JSONDecoder().decode(Person.self, from: jsonData) } catch let error as DecodingError { print(error.key) print(error.debugDescription) print(error.localizedDescription) }
デコードのエラーが「どのキー」で「どのようなエラーが起きたか」を取りやすくなると思います。
試しにJSONの文字列の"age"
を"28"
から"abcde"
に変えてみます。
{ "name": "Usami Takashi", "age": "abcde", "height": "178.2", "weight": "70.1", "married": "true" }
すると
age 'abcde' could not convert to Int. The data couldn’t be read because it isn’t in the correct format.
"age"
が"abcde"
だからエラーになったということが一目瞭然になったと思います。
参考
- Codable Tips集
https://speakerdeck.com/tattn/codable-tipsji