Galapagos Tech Blog

株式会社ガラパゴスのメンバーによる技術ブログです。

JSONの読み書きでSwift 4.1のCodableを使ってみる。

ガラパゴスのコードヒーヨアン(twitter: @luinily)です。

以前Swift 4でJSONの扱いについての記事を書きましたが、今回はその続きで、Swift 4.1で追加される機能や、実際のプロジェクトをObjectMapperからCodableに変更して見た話をしようと思います。

下記の流れに沿っていこうと思います。

  • 単純な構造体でCodableとObjectMapperの紹介
  • Codableのキーとカスタムキーの紹介
  • Codableのカスタム読み込みと書き出しの紹介
  • Codableのデバグの紹介
  • プロジェクトを通したCodableとObjectMapperの比較

CodableとObjectMapperの紹介

この紹介で、下記のJSONをSwiftの構造体に読み込み、書き出しするための処理を紹介します。

JSON

{
    "food": "桜餅",
    "quantity": 1
}

構造体:

struct Food {
    let food: String
    let quantity: Int
}

Codable

CodableはSwiftが提供している構造体やクラスなどをJSONに書き出す、読み込むためのプロトコル。(厳密に言うと、JSONだけではなく、他のフォーマットにも使えまる) CodableはEncodable(書き出し用)とDecodable(読み込み用)の二つのプロトコルで構成されています。

構造体での対応:

struct Food: Codable {
    let food: String
    let quantity: Int
}

こういう単純な構造体では「Codable」というプロトコルを適用するだけです、時に実装が不要です。

JSONの読み込み:

let decoder = JSONDecoder()
let food = try? decoder.decode(data)

書き出し:

let encoder = JSONEncoder()
let data = try? encoder.encode(food)

JSONの読み込み書き出しはData型のオブジェクトから行われます。

ObjectMapper

ObjectMapperはJSONの読み込み、書き出しによく使われているライブラリー、Mappableというプロトコルを使います。

構造体での対応:

struct Food: Mappable {
    var food: String!
    var quantity: Int!

    init?(map: Map) {
        mapping(map: map)
    }

    mutating func mapping(map: Map) {
        food <- map["food"]
        quantity <- map["quantity"]
    }
}

こういう単純な構造体でも「Mappable」というプロトコルを適用した上で、各プロパティーを「let」から「var」に変えて、型に「!」を追加して、「init?(map: Map)」と「func mapping(map: Map)」という関数を実装する必要があります。

「init?(map: Map)」の実装は簡単ですが、「mapping」の実装はもうちょっと手間がかかります: 

  • 各プロパティに対して 「property <- map["キー"]」というあまり見慣れてない行を書く必要がある
  • 各プロパティのキーを入力する必要があります。
  • 書き出し、読み込みに特別な処理をする必要がある場合ちょっと複雑になります。

その処理はObjectMapperが行う処理が原因です:構造体・オブジェクトを実態化してから、JSONを読み込んでプロパティーの値を設定しますので、値が入ってない状態で実態化できる必要と、実態化後に値の変更できるようにする必要があります。

JSONの読み込み:

let food =  Mapper<Food>().map(JSONObject: jsonObject)

書き出し:

let jsonDictionarry = Mapper<Food>().toJSON(food)

比較

構造体での対応はキー、読み込み関数、書き出し関数の自動生成ができるCodableの方がはるかに軽いです。その上、Codableはinit()関数で読み込みを行なっていることで、varだけではなく、letも使えて、!を使用する必要もありません。今回のような簡単なクラスや構造体ではCodableを宣言するだけで対応できます。

ObjectMapperはキーが自動生成されませんので書く必要ありますが、キーの文字列は自由に定義できます。mapping(map)関数雨の中でJSONとの変換に必要な処理も定義できます。Codableの方は同じのようにキーをカスタマイズするのと、書き出し、読み込みの処理をカスタマイズすることができますか。

Codableのキー

キーに使われる文字列がプロパティ名そのままですので、自動生成されるCodableのキーに複数問題があります。

  • JSONでのキーの書き方はスネークケース(snake_case)、Swiftではカーメルケース(camelCase)を使います。このまま自動生成されるキーを使うと、Swiftのプロパティ名をスネークケースにするか、JSONをカーメルケースにする必要があります。できれば避けたい問題です。
  • プロパティ名と違う文字列をキーに使い時もあります。
  • クラスや構造体にJSONの書き出し、読み込みと関係ないプロパティがあった場合現状ではキーが作成されて、読み込み書き出しされようとします。

snake_caseとcamelCaseの自動変換(Swift 4.1から)

Swift 4.1で新しく追加された機能ですが、Encoderの「keyEncodingStrategy」やDecoderの「keyDecodingStrategy」を設定することで、camelCaseのプロパティ名からsnake_caseのキーの自動作成できるようになりました。

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase

使えるストラテージーが三つあります:

  • useDefaultKeys → プロパティ名をそのままキーに使います
  • convertFromSnakeCase / convertToSnakeCase → プロパティ名をcamelCase、snake_caseに変換します
  • custom(([CodingKey]) -> CodingKey) → 引数で渡すクロージャーを使用し、変換します。

カスタムキーの定義

Codableでも必要な時は本来自動的に生成される「CodingKeys」という列挙型を追加することでカスタムキーを使うことができます。

struct Food: Codable {
    let food: String
    let quantity: Int

    enum CodingKeys: String, CodingKey {
        case food = "Food"
        case quantity
    }
}
  • 構造体、クラスなどの中で定義する必要があります。
  • キーの列挙型の定義は「enum CodingKeys: String, CodingKey」にする必要があります。
  • ケース名はプロパティ名と同じ
  • カスタムしたいキーは「= "カスタム値"」で定義できます。
  • カスタムしないキーも定義しないといけないんですが、Stringでの値は定義しなくても自動的に作成されます。
  • CodingKeysを定義する場合、書き込み、書き出しされるプロパティ全てをcaseとして定義する必要があります。

上記の条件でわかると思いますが、書き出し、読み込みしたくないプロパティがある場合、CodingKeysを定義して、そのプロパティ以外のプロパティ全てをcaseとして定義する必要があります。

struct Food: Codable {
    let food: String 
    let quantity: Int 
    var eaten: Bool = false ← CodingKeysに定義されてないため、書出読込されない

    enum CodingKeys: String, CodingKey {
        case food = "Food"    ← カスとマイズされたキー
        case quantity         ← 自動生成されるキー
    }
}

ここでは、カスタムキーについてまだ一つの疑問が残るとお思います:キーの自動変換とカスタムキーがどういうふうに組み合わせられるのかということです。 例えば例のJSONに新しいキーを追加します。

{
    "food": "桜餅",
    "quantity": 1
    "food_weight": 100
}

「food_weight」というキーが追加されました。Swift側ではプロパティーとして追加しますが、Food型に「foodWeight」というプロパティーを入れると名前がちょっと被ってしまいます、「weight」として定義した方がいいと思います。

struct Food: Codable {
    let food: String 
    let quantity: Int 
    let weight: Int

    enum CodingKeys: String, CodingKey {
        case food = "Food"    ← カスとマイズされたキー
        case quantity         ← 自動生成されるキー
        case weight = ""
    }
}

この場合、カスタムキーの値を「food_weight」にするべきか、「foodWeight」にするべきかのどちらでしょうか。

この問題の答えはいつCodingKeysのキーがJSONのキーに変換されるかによって変わります。ヒントとしては、キーの変換の設定は、CodingKeysやFoodではなく、encoderやdecoderの方で行なっていることです。 つまりSwiftのプロパティからJSONキーの生成はこのように行われています:

f:id:glpgsinc:20180522170019p:plain

カスタムキーの定義を行う場合、CodingKeyの値を定義しますので、 JSONキーの変換前の状態にしなければなりません。

struct Food: Codable {
    let food: String 
    let quantity: Int 
    let weight: Int

    enum CodingKeys: String, CodingKey {
        case food = "Food"
        case quantity
        case weight = "foodWeight"
    }
}

先の図に入れるとこうなります: f:id:glpgsinc:20180522170413p:plain

Codableで書き出し、読み込みカスタム処理

Codableでも必要がある場合、カスタムな書き出し関数、読み込み関数の定義ができます。 例えばさっきのJSONの「food_weight」の値をIntからStringに変更します。

{
    "food": "桜餅",
    "quantity": 1
    "food_weight": "100"
}

Food構造体の方ではIntのままにします、StringとIntの変換が必要ようになります。 ついでに、Foodにもう一つのオプショナルプロパティを追加します。 オプショナルプロパティはCodableでもObjectMapperでもJSONにキーがない可能性があるときに使います。

struct Food: Codable {
    let food: String 
    let quantity: Int 
    let weight: Int
    let origin: String?

    enum CodingKeys: String, CodingKey {
        case food = "Food"
        case quantity
        case weight = "foodWeight"
    }
}

カスタム読み込み

読み込み関数は「init(from decoder: Decoder) throws」で定義します。

init(from decoder: Decoder) throws {
    let values = try decoder.container(keyedBy: CodingKeys.self)

    food = try values.decode(String.self, forKey: . food)
    quantity = try values.decode(Int.self, forKey: . quantity)
    weight = try Int(values.decode(String.self, forKey: . weigh))
    origin = try values.decodeIfPresent(String.self, forKey: . quantity)
}

まずは「decoder」からキーを使って、「values」を取得します。

そのあとは各キーに対する値を取得して、必要な追加処理をします。

  • 必須キーの場合、「try values.decode(String.self, forKey: . food)」を使います。一つ目の引数はそのキーの値の型、ここではString、二つ目の引数はキー
  • キーが必須でない場合、「try values.decodeIfPresent(String.self, forKey: . quantity)」を使います。「decode」と同じ使い方ですが、キーがなかった場合にエラーを起こしません。
  • 今回はweightをStringとして取得したあと、Intに変換するカスタム処理を追加しています。

注意点としては、キーがない可能性がある場合はちゃんと「decodeIfPresent」を使うことと、カスタム処理行わないキーに対しても処理を書かなければなりません。

カスタム書き出し

書き出しは読み込みに似ていて、定義する関数は「func encode(to encoder: Encoder) throws」です。

func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(food, forKey: . food)
    try container.encode(quantity, forKey: . quantity)
    try container.encode(String(weight), forKey: . weight)
    try container.encodeIfPresent(origin, forKey: . origin)
}    

読み込みの場合はDecoderからvaluesを取得していたが、今回はEncoderからcontainerを取得します。

そのあと各キーに対する書き出し処理を書きます。

  • 必須キーの場合は、「try container.encode(food, forKey: . food)」を使います。引数はプロパティ値とキーです。
  • プロパティがnilになっている可能性がある場合、「container.encodeIfPresent(origin, forKey: . origin)」を使います。encodeと同じ使い方が、値がnilであった場合エラー起こしません。
  • 今回はweightをStringに変換してから書き出します。

Codableで楽に読み込みと書き出しのカスタム関数がかけることがわかりました。現状ではカスタム処理されるキーだけではなく、全てのキーに対しての処理を書く必要があります。いずれは特別処理をしたいキーの処理を書いた後に、「残りのキーは自動処理して良いよ」のようなやり方があればさらに使いやすくなるかと思います。

列強型

Codableのデバグ

今まで書いたコードを見た気づいたと思いますが、Codableの処理はエラーを返すことができます。「init(from decoder: Decoder)」も、「func encode(to encoder: Encoder)」も「throws」が付いていますし、「encode」、「decode」関数を使うところはtryをつけています。 このおかげで、読み込みや書き出しの際に何らかの問題があった場合エラー処理とバグ対応がしやすくなっています。

lldbにdecodeの際にエラーが出た場合どういう情報得られるか複数の例を見て見ましょう。

JSONに必須キーが含まれていない。

構造体:

struct Food {
    let food: String
    let quantity: Int
}

JSON

{
    "food": "桜餅",
}

lldbで読み込み処理して見るとこうなります。

(lldb) po decoder.decode(Food.self, from: jsonData)
▿ DecodingError
  ▿ keyNotFound : 2 elements
    - .0 : CodingKeys(stringValue: "quantity", intValue: nil)
    ▿ .1 : Context
      - codingPath : 0 elements
      - debugDescription : "No value associated with key CodingKeys(stringValue: \"quantity\", intValue: nil) (\"quantity\")."
      - underlyingError : nil

「Decoding」エラーがプリントアウトされて、エラーの詳細が見れます。

  • エラーの種類が書いてあります:「keyNotFound」(キーがみつからなかったと)
  • どのキーがエラーの原因になったか:「CodingKeys(stringValue: "quantity", intValue: nil)」

結論は「quantity」というキーがJSONで見つからなかったため読み込みができなかったことがわかります。

カスタム読み込み関数を定義した場合、メンバーのタイプがオプショナルになっても、読み込み関数で「decodeIfPresent」ではなく、「decode」を使うと同じエラーが出ます。

② データのタイプが違う

構造体:

struct Food {
    let food: String
    let quantity: Int
}

JSON

{
    "food": "桜餅",
        "quantity": "1"
}

lldbで読み込み処理して見るとこうなります。

(lldb) po decoder.decode(Food.self, from: jsonData)
▿ DecodingError
  ▿ typeMismatch : 2 elements
    - .0 : Swift.Int
    ▿ .1 : Context
      ▿ codingPath : 1 element
        - 0 : CodingKeys(stringValue: "quantity", intValue: nil)
      - debugDescription : "Expected to decode Int but found a string/data instead."
      - underlyingError : nil

同じくエラーが表示されます。

  • エラーの種類は「typeMismatch」(型が一致しません)
  • どのキーが原因となったか:「 CodingKeys(stringValue: "quantity", intValue: nil)」
  • エラーの詳細:「Expected to decode Int but found a string/data instead.」(Intを読み込む期待でしたが、代わりにstring/dataがありました。)

「quantity」というキーに対して、Intを期待していたのに、Stringがあったことがわかります。

③ 無効なJSON

そもそもJSONが無効な場合(「,」や「}」がたりないなどの場合)

(lldb) po decoder.decode(Food.self, from: jsonData)
▿ DecodingError
  ▿ dataCorrupted : Context
    - codingPath : 0 elements
    - debugDescription : "The given data was not valid JSON."
    ▿ underlyingError : Optional<Error>
  • エラーの種類は「dataCorrupted」(損傷したデータ)
  • エラーの詳細:「The given data was not valid JSON.」(与えられたデータが有効なJSONではありません)

一つのアプリでObjectMapperからCodableへの移行試した感想

Swiftのバージョンと共に更新される

Codableの強みの一つはSwiftの1部になっていることです。Swiftのバージョンが上がっても、同時にリリースされますし、ライブラリーの追加が不要です。ObjectMapperの方はSwiftのバージョンが上がっても、対応に時間かかる可能性があります。

ObjectMapperより対応が軽いが、場合によって総合行数があまり変わらない場合も

Codableの実装とObjectMapperの実装に使うコード量を比較してみました。 もともとJSONに関係するクラスと構造体が25個あります。ObjectMapperでは全てのクラス、構造体に実装が必要でしたが、 Codableの方では、14クラスに実装が不要でした(Codableをクラス宣言に追加するだけ)、6クラスにキーだけの実装が必要、残りの5クラスに読み込みや書き出しの関数の実装が必要でした。

ただ、ObjectMapperと違って、書き出し読み込み関数を別々で書く必要があって、今回のプロジェクトで書き出し読み読み込みがカスタムされたクラスが大き目だったため、Codableでの総合対応行数がObjectMapperより1割ぐらいしか変わりませんでした。

ObjectMapperよりデバグしやすい

エラーの説明があるため、どこが悪いかわかりやすいです。ObjectMapperは逆にブレークポイントなど使っても悪いところが探しにくいです。

列挙型のための処理が不要!

CodableはStringやIntというタイプになっている列挙型にも適用できますので、JSON用のIntやStringへの変換の処理を書き出し読み込み関数に追加しなくていいです。

(個人的な)結論

ObjectMapperとCodableを両方使った経験からしては、新規アプリであればぜひCodableの方をオススメしたいと思います。Codableの方が対応が楽のと、問題があった場合デバグしやすいところが大きいです。Swiftのバージョンが変わった時対応を待つ必要もないところが便利だと思います。

ただし、既存アプリの場合は、Codableに変更するための対応に加えて、[String: Any]のJSONディクショナリーではなく、Dataからの読み込み書き出しになりますので、場合によってネットワーク系のライブラリーにも作業が必要可能性がありますので、問題ないかのを確認してから検討した方がいいかと思います。