[Swift] JSONがパースできないだと?! そういうときは・・・? そうだね! LosslessStringConvertibleだね!

宇佐美 貴史(うさみ たかし、1992年5月6日 - )は、京都府長岡京市出身のプロサッカー選手。J1リーグ・ガンバ大阪所属。ポジションはミッドフィールダー、フォワード。日本代表。妻はタレントの宇佐美蘭。
2020.09.07

はじめに

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>と定義するとvalueInt型になりますね。

(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にはInt28が入っていると思います。

エンコードにも対応

先程はDecodableに準拠してデコードに対応しましたが、 逆にオブジェクトからJSONに変換するエンコード対応も書いてみます。

extension StringTo : Encodable {
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(value.description)
    }
}

今度はエンコーダのコンテナにdescriptionで取れた文字列を渡して、さっきの逆の処理を行わせています。 つまり最終結果としては値は文字列として吐き出されるということです。

それでは、使ってみます。 エンコードさせるためにはPersonEncodableに対応させるために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

身長と体重をあらわすheightweightですが、後々の計算などでCGFloatのほうが都合がいいということもあるかもしれません。 そういったときはモデルの定義を下記のように変えるものとします。

struct Person: Codable {
    let name: String
    let age: StringTo<Int>
    let height: StringTo<CGFloat>
    let weight: StringTo<CGFloat>
    let married: StringTo<Bool>
}

すると、エラーが発生してしまいます。CGFloatLosslessStringConvertibleに準拠していないからです。

なので、CGFloatLosslessStringConvertibleに準拠させてしまいます。

extension CGFloat: LosslessStringConvertible {
    
    public init?(_ description: String) {
        guard let double = Double(description) else {
            return nil
        }
        self.init(double)
    }
}

CGFloatDoubleの言い換えに当たるので、一度Doubleに変換して初期化します。

これによってエラーが改修できると思います。

おまけ

この記事の途中でDecodingErrordebugDescriptionを設定しましたが、 エラーキャッチするときにこの値を取得するのは割と面倒くさいです。

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"だからエラーになったということが一目瞭然になったと思います。

参考