なぜ Swift で JSON パーサが必要なのか?

Swift で iOS アプリ開発する際、NSJSONSerialization をそのまま利用して JSON パースを実装したことはありますか? 「Swift だとなんか使いにくい!」という印象を持たれているのではないでしょうか?

do {
    guard let JSON = try NSJSONSerialization.JSONObjectWithData(JSONData, options: []) as? [String: AnyObject] else {
        ...エラー処理...
    }

    let title = JSON["title"] as? String
    let summary = JSON["summary"] as? String
    // TODO: title, summary は Optional なので、unwrap する

    if let authorJSON = JSON["author"] as? [String: AnyObject] {
        let authorName = authorJSON["name"] as? String
        // TODO: authorName は Optional なので、unwrap する
    }

    ...

} catch let error {
    ...エラー処理...
}

Swift は型に厳格な言語ですので、このように AnyObject の型を特定しながら値を取り出さなければなりません。 これでは実装も捗りませんし、ミスも起きやすいのではないかと思います。

乱立する Swift 製 JSON パーサ

おそらく世界中の開発者が同じことを思ったのでしょう、GitHub を検索すると Swift 製 JSON パーサが多数公開されています!

これだけあると、どれを使ったらいいのか困りますよね… そこでこの中から期待できそうな JSON ライブラリをピックアップし、それぞれを評価してみました。

期待の JSON パーサを比較

評価したライブラリは、以下の 4 つです。

これらについて、以下の項目の評価を行ってみました。

  • 値の取り出し方
  • 異なる型へ変換がサポートされているか?
    • String で表現された日時を NSDate に変換するケースを評価
  • 指定のキーに対する値が無い場合の振る舞いは?
  • オブジェクトと JSON の値型が一致しない場合の振る舞いは?
  • Optional なプロパティに対し、指定のキーに対する値が無い場合の振る舞いは?
  • パースできない場合にデフォルト値を与えることができるか?
  • DTO を定義しなくてもパースできるか?

その結果をざっくりまとめると、以下のようになります。

評価項目 Freddy Unbox ObjectMapper Argo
値の取り出し方 メソッドで型指定 型推論 型推論 型推論
異なる型への変換 やや難あり サポート サポート サポート
指定のキーに対する値が無い場合 throwする DTOがnil、またはthrowする スルーされる DTOがnil
型が一致しない場合 throwする DTOがnil、またはthrowする スルーされる DTOがnil
Optional なプロパティに対し、
指定のキーに対する値が無い場合
throwする 値がnilになる スルーされる 値がnilになる
DTO なしでのパース サポート サポート 不明 不明

評価した結果「Unbox が一番使いやすいな」という印象でしたので、本記事で取り上げたいと思います。

Unbox の使い方

事前準備として、Xcode プロジェクトに Unbox を組み込んでおいてください。 CocoaPods であれば、

pod 'Unbox'

で組み込みことができます。

サンプル JSON データ

以下のような JSON データのパースを例に説明します。

{
    "id": 1234,
    "title": "Swift 製 JSON パーサは Unbox がイイ!",
    "summary": "Swift で iOS アプリ開発する際、`NSJSONSerialization` をそのまま利用して JSON パースを実装したことはありますか?「Swift だと...",
    "image_url": "http://www.gaprot.jp/path/to/image.png",
    "published_at": "2016-03-01 17:00:00",
    "author": {
        "id": 847,
        "name": "ギャップラー小林"
    },
    "tags": [
        {
            "id": 123,
            "name": "iOS",
            "slug": "ios"
        },
        {
            "id": 145,
            "name": "Swift",
            "slug": "swift"
        }
    ]
}

DTO の定義

はじめに JSON の各構造に対応する DTO(Data Transfer Object)を定義します。

  • Post: 投稿記事を表す型
  • Author: 投稿者を表す型
  • Tag: タグを表す型
struct Post {
    let id: Int
    let title: String
    let summary: String
    let imageURL: NSURL?
    let publishedAt: NSDate
    let author: Author
    let tags: [Tag]
}
struct Author {
    let id: Int
    let name: String
}
struct Tag {
    let id: Int
    let name: String
    let slug: String
}

Unboxable プロトコルへの準拠

各 DTO を Unboxable プロトコルに準拠させます。 具体的には init(unboxer: Unboxer) に、JSON オブジェクトから値を取り出してプロパティを初期化するロジックを実装します。

import Unbox

extension Post: Unboxable {
    init(unboxer: Unboxer) {
        self.id = unboxer.unbox("id")
        self.title = unboxer.unbox("title")
        self.summary = unboxer.unbox("summary")
        self.imageURL = unboxer.unbox("image_url")
        ...
    }
}

Unboxer#unbox() はその実装見れば分かりますが、ジェネリクスとして定義されています。 戻り値を受け取るプロパティの型に応じた変換処理を行うため、どの型に変換したいかを暗黙的に指示できるのです。 Unbox が標準でサポートしている型は下記のとおりです。

  • Bool
  • Int, Double, Float
  • String
  • Array
  • Dictionary

ただ publishedAt, author, tags を上記と同様に実装しても、エラーが 2 つ発生するかと思います。

  • Cannot invoke 'unbox' with an argument list of type '(String)'
  • Ambiguous reference to member 'unbox'

1 つ目のエラーは、publishedAt が NSDate 型であるため、変換方法が見つからない状態になっています。 2 つ目のエラーは Author 型と Tag 型も Unboxable に準拠すれば解消されます。

extension Author: Unboxable {
    init(unboxer: Unboxer) {
        self.id = unboxer.unbox("id")
        self.name = unboxer.unbox("name")
    }
}
extension Tag: Unboxable {
    init(unboxer: Unboxer) {
        self.id = unboxer.unbox("id")
        self.name = unboxer.unbox("name")
        self.slug = unboxer.unbox("slug")
    }
}

型変換方法の指定

JSON 上は String の値を NSDate に変換して publishedAt に格納するには、フォーマッタを利用します。 カスタムのフォーマッタを実装する場合は UnboxableWithFormatter に準拠します。 なお、NSDate への変換ついては既に Unbox 側で実装されています。

extension Post: Unboxable {
    init(unboxer: Unboxer) {
        let dateFormatter = NSDateFormatter()
        dateFormatter.dateFormat = "yyyy'-'MM'-'dd' 'HH':'mm':'ss"
        ...
        self.publishedAt = unboxer.unbox("published_at", formatter: dateFormatter)
        ...
    }
}

ある型への変換ルールがアプリ内で共通であれば、UnboxableByTransform に準拠して型変換を暗黙的に行うこともできます。 実はこの例においても、image_url は JSON 上 String 型ですが、何も明示せず NSURL 型のプロパティ imageURL に格納しています。 これは Unbox で、NSURL の UnboxableByTransform への準拠を行っているためです。

JSON から DTO の生成

この段階でエラーメッセージは表示されなくなったかと思います。 それでは、JSON データから Post オブジェクトを生成してみましょう。

let JSONData = ...    // API のレスポンス等から JSON データを得る

do {
    let post: Post = try UnboxOrThrow(JSONData)
    ... パース成功...
} catch let error {
    ... パース失敗...
}

エラー時の振る舞いとデフォルト値

Unboxer#unbox() は基本的に下記の場合、エラーの振る舞いをとります。

  • 指定されたキーに対する値が存在しない
  • JSON 上の型と DTO のプロパティ型が一致しないか、変換できない場合

エラーの場合、Unboxer#unbox() の返却型が Optional かどうかによって振る舞いが異なります。

  • 非 Optional 型
    • 大元の呼び出し Unbox() は nil が返る
    • 大元の呼び出し UnboxOrThrow() は throw する
  • Optional 型
    • Unboxer#unbox() が nil を返す
    • 大元の呼び出し Unbox(), UnboxOrThrow() は成功する

今回の例ではプロパティ imageURLNSURL? 型なので、たとえ JSON データに image_url が無くてもパースは成功します。

Unbox のこの振る舞いを応用すると、指定されたキーが無い場合、プロパティにデフォルト値を設定することができます。 下記のように ?? 演算子を使うことで、Unboxer#unbox() の返却型が Optional と解釈されるので、キー未定義時のデフォルト値を与えることができます。

extension Post: Unboxable {
    init(unboxer: Unboxer) {
        ...
        self.title = unboxer.unbox("title") ?? "(タイトル未定)"
        ...
    }
}

他の JSON パーサは何がダメなの?

Unbox がすべてにおいて優れているというわけではありません。 各 JSON パーサそれぞれ特長があり、独自の機能を持っていたりします。 プロジェクトの実装ポリシや趣向に合わせて、どれを採用すべきか検討するとよいと思います。

Freddy

Unbox と大きく異なるのは、どの型として解釈するかをメソッドで明示する点です。 JSON をどのようにパースするかがコードとして明確化され、より厳格に記述することができます。

残念なのはすべて throw するメソッドであるため、すべてに try を記述しなければならないことです。 メソッドで解釈型を明示する点も相まって、ややくどい書き方に見えます。 型変換が必要な場合の拡張も、Unbox と比較すると実装しにくい場合がありました。

ObjectMapper

返却型から推定するアプローチである点は Unbox と共通です。 <- 演算子を用いた実装を行う点において Unbox よりコードがすっきりした印象ですが、機能的には劣る点が多いです。 またイニシャライザとは別に mapping() を実装しなければならない点が、やや扱いにくいと思いました。

Argo

独特の記法で実装を行います。 Haskell 風の書き方になるので、関数型プログラミングが得意な方には読みやすいのかもしれません。

実は評価した結果、けっこう大きな問題にぶつかりました。

  • curry() の制約上、1 つの DTO に定義できるプロパティ数が 16 個までとなる
  • プロパティの宣言順とパースの順序が一致していなければならない
  • 型推論とメソッドチェーンを多用するためか、Swift コンパイラが "Expression was too complex to be solved in reasonable time" エラーになることがある

制限による行き詰まりや誤りに気づきにくい問題があるなど、現時点においては正直、怖くて使えません。。。 また演算子を多用しており初見で理解し難いところも気になりました。

まとめ

アプリ開発において、モデル層の実装は裏方に当たる部分ではありますが、欠かせないものです。 ここをなるべく簡単かつ確実に実現することで、保守性の高いコードにするとともに、より良い UI/UX の開発に注力できるのではないでしょうか? 今後より良い OSS やフレームワークが登場した際にも、これらを紹介していきたいと思います。