LoginSignup
2
0

More than 3 years have passed since last update.

【Swift】Swifterに学ぶ SwiftのJSONハンドリング

Last updated at Posted at 2020-07-17

Swiftでスキーマの不明なJSONをパースする機会があったので、
SwiftからTwitterAPIを扱うFramework「Swifter」のJSONパーサはどうなっているのか調べてみました。

⚠️本題はここから始まります。前置きが長い。

また、Swiferからコードを引用した場合は「Swifter/ファイル名.swift」を明記しています。

忙しい人のための結論

SwiftyJSONに似た実装になっていましたが、だいぶコンパクトにまとめられています。
ここから拡張して、URLDateに自動変換するようなライブラリにもできるでしょう。

SwiftでJSONを扱うには

SwiftによるJSONパースの方法としては、主に以下のような方法があげられるかと思います。
(多分他にもあると思いますが、私が思いつくのはこの二つです)

  • JSONDecoderに通して構造体インスタンスを生成
  • JSONSerilalization:jsonObject でパースしたのち、as? [String: Any]でDictionaryにキャスト

それぞれのメリット、デメリット

JSONDecoderを使用する方法では、

  • 構造体インスタンスになるため、コード補完が効く
  • JSONEncoderを使用することで、同じ構造体をエンコード処理でも使いまわせる
  • スキーマが定義されていないと変換できない(つまり、外部APIの処理には向かない)

これに対し[Stirng: Any]にキャストする方法では、

  • スキーマがなくても変換可能
  • 各プロパティの値がAnyなので、どこかで再キャストしなければならない
  • 一階層下るごとにForce Unwrapする必要がある
  • 配列内のJSONオブジェクトは変換されない

といったメリット、デメリットがあります。

外部APIのレスポンス処理に適したパース方法

スキーマが不変であることを保証できる場合は、JSONEncoderも一つの実装方式としてはアリでしょう。
しかし、プロパティの多いJSONオブジェクトを扱おうとすると構造体の定義が面倒です。
また、ほとんどのAPIサービスにおいてAPIのレスポンスの形式が変わらないという保証はありません。
自分で使う範囲ならば、再びAPIリファレンスを読んで…構造体を調整して…などで済むかもしれませんが、不特定多数により使用される可能性のあるFramework、また実際にローンチすることまで考えているようなAppではそうはいきません。

このように、JSONパース処理の実装ではスキーマに対する柔軟性がとても重要になってきます。

Swifterのパース処理

では、SwifterはどのようにJSONをパースしているのでしょうか。TwitterAPIは一つのレスポンスに含まれるプロパティ数も非常に多く、構造体で全てを記述しきるのはとても現実的とはいえません。
また、新しい機能をサポートするAPIも随時追加されています。これではとても対応しきれません。

そこで、Swifterでは[String: Any]に変換し、さらにSwifter.JSONの配列に変換するという処理方法をとっています。

Swifterでは一つのAPI呼び出しにsuccessfailureというコールバック関数を設定することができます。
例えば、ホームタイムラインを取得する関数は以下のように呼び出します。(一部引数を省略しています)

Swifter/SwifterTimelines.swift
 Swifter.getHomeTimeline(count: 20, trimUser: false, includeEntities: true, success:  { (json) in
    // process
 }, failure:  { (error) in
    // error handling
 })

API呼び出しに成功すると、success(JSON)が実行されます。
前置きが長くなりましたが、 この記事ではこのJSONについて説明していきます。

Swifter.JSON

Swifter.JSON(以下、単純に JSON と記述)の実体を調べてみます。

Swifter/JSON.swift
public enum JSON : Equatable, CustomStringConvertible {
    ...
}

なんということでしょう。JSONの実体はクラスではなく単なる列挙型だったのです。クラスとはなんだったのか。もうワケがわかりません。
ですがここは一旦落ち着いて、もう少し噛み砕いてソースを読んでいきます。

まず、JSONのソースを以下の3つに分割して勝手に名前をつけます。(実際にはこれ以外にもextensionなどが存在し、より汎用的に使えるようになっています。ここではJSONパースに使われている部分のみをピックアップしています。)

  • 付属型enum部
  • キャスト部
  • プロパティ部

付属型enum部

JSON.swiftの一番上に記述されているこれです。

Swifter/JSON.swift
 case string(String)
 case number(Double)
 case object(Dictionary<String, JSON>)
 case array(Array<JSON>)
 case bool(Bool)
 case null
 case invalid

JSONは様々な値を持ちます。文字列に整数、浮動小数点数、真偽値、さらにそれらに階層構造をもたらすオブジェクト、配列…さらに細かく区分するとすれば、URLや特有の属性値などが記述されることもあるでしょう。
ここでは、そのような多様な値をJSONで一元的に管理するために「付属型enum」を使用しています。これにより各要素に値を持たせることができ、またパターンマッチによって値を取り出すことができます。

(こちらの記事を参考に「付属型enum」と呼称しています)

Swiftの列挙型(enum)おさらい

キャスト部(イニシャライザ部)

JSONのイニシャライザは Any型を受け入れるようになっており、内部で自動変換されたのち自身を初期化します。この際の処理は(単純に原理のみ記述すると)以下のようになっています。

init(_ value: Any){
    switch value {

    case let string as String:
        self = .string(string)

    case let number as Double:
        self = .number(number)

    case let bool as Bool:
        self = .bool(bool)

    default:
        self = .null

    }
}

switch文中に何度も出てくるcase let U as Tは、「switchに入力された変数がT型にキャストできれば、変数Uにキャスト済の値を代入しcase以下を実行する」という動作になります。これについてはこの記事が参考になるかと思います。

Swiftの「case is」とか「case as」って何なの?

イニシャライザにDataを渡して初期化しようとすると、JSONは内部でJSONSerialization.jsonObjectを呼び出し、さらにその値をJSONのイニシャライザに渡します。
先述の通り、JSONSerialization.jsonObject[String: Any]にキャストできます。
そのため、イニシャライザは次の処理を実行します。

Swifter/JSON.swift
case let dict as [String: Any]:
    var newDict = [String: JSON]()
    for (key, value) in dict {
        newDict[key] = JSON(value)
    }
    self = .object(newDict)

[String: Any][String: JSON]、つまりDictionaryのvalueをJSONに変換してしまうのです。
この際再びvalueJSONのイニシャライザに渡され、DoubleString[JSON]Array<JSON>等々さまざまな型にキャストされます。これが再帰的に繰り返されることで、やがて [String: Any]は完全に[String: JSON]にキャストされます。
結果的にJSON内の全てのデータを列挙型の値に確定できるため、取り出す際にforce unwrapする必要がなくなります

プロパティ部

さて、イニシャライザによってキャストは終了しましたが、このままではJSON中の各プロパティの値がenumのままになってしまいます。「各要素の値を取り出す処理」が必要です。
先ほど、「付属型enumの要素から値を取り出すには、パターンマッチが必要」と書きました。
では、このパターンマッチ処理を実際に記述するとどのぐらいの分量になるでしょうか。

guard case .number(let value) = self else {
    return nil
}

この量です。一度取り出すのに3行必要になります。これではSwiftyのスの字もありません
これを避けるため、JSONでは取り出す値の型ごとにパターンマッチ処理が記述されています。また各マッチ処理は算出型プロパティのgetterとして記述されているため、JSONから値を取り出す場合は

json[0]["id"].string // -> String型の値を取り出す

json[0]["id"]["likes_count"].integer // -> Int型の値を取り出す

このように書くことができます。非常にシンプルです。

Summary

  • 外部API等を使用する場合はJSONEncoderでは処理しきれない部分もある
  • Swiftの列挙型は色々でき(すぎ)る
  • [String: Any]のままでは使いにくいが、適切に変換すれば柔軟に対応可能
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0