Swiftでスキーマの不明なJSONをパースする機会があったので、
SwiftからTwitterAPIを扱うFramework「Swifter」のJSONパーサはどうなっているのか調べてみました。
⚠️本題はここから始まります。前置きが長い。
また、Swiferからコードを引用した場合は「Swifter/ファイル名.swift」を明記しています。
忙しい人のための結論
SwiftyJSONに似た実装になっていましたが、だいぶコンパクトにまとめられています。
ここから拡張して、URL
やDate
に自動変換するようなライブラリにもできるでしょう。
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呼び出しにsuccess
、failure
というコールバック関数を設定することができます。
例えば、ホームタイムラインを取得する関数は以下のように呼び出します。(一部引数を省略しています)
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
と記述)の実体を調べてみます。
public enum JSON : Equatable, CustomStringConvertible {
...
}
なんということでしょう。JSON
の実体はクラスではなく単なる列挙型だったのです。クラスとはなんだったのか。もうワケがわかりません。
ですがここは一旦落ち着いて、もう少し噛み砕いてソースを読んでいきます。
まず、JSON
のソースを以下の3つに分割して勝手に名前をつけます。(実際にはこれ以外にもextensionなどが存在し、より汎用的に使えるようになっています。ここではJSONパースに使われている部分のみをピックアップしています。)
- 付属型enum部
- キャスト部
- プロパティ部
付属型enum部
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」と呼称しています)
キャスト部(イニシャライザ部)
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以下を実行する」という動作になります。これについてはこの記事が参考になるかと思います。
イニシャライザにData
を渡して初期化しようとすると、JSON
は内部でJSONSerialization.jsonObject
を呼び出し、さらにその値をJSON
のイニシャライザに渡します。
先述の通り、JSONSerialization.jsonObject
は[String: Any]
にキャストできます。
そのため、イニシャライザは次の処理を実行します。
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
に変換してしまうのです。
この際再びvalue
がJSON
のイニシャライザに渡され、Double
、String
、[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]
のままでは使いにくいが、適切に変換すれば柔軟に対応可能