Swift3.0でJSONを厳しく安全に扱えるライブラリを作りました

iOSエンジニアの木村です。
今回は私が開発している JAYSON という Swift で書かれた JSON ライブラリについて紹介します。
 
もともと僕はJSONの読み込みと生成のためにSwiftyJSONを使用していたのですが、大きなJSONや深い階層のJSONを読み込むときに発生するエラー(存在しないKeyなど)が追いづらいという問題を抱えていました。
 
そこで、SwiftyJSONのように使えて、エラーハンドリングを行いやすいインターフェースを持たせようと考えたのが、JAYSONを作ったきっかけです。
 
JAYSONでは、
・SwiftyJSONのような使い勝手であるEasy-Read
・try-catchを利用して厳格に値を取得するStrict-Read
の2つを用意しています。
 
以下の簡単なJSONを使って、使い方を説明していきます。
 
Sample JSON

{
    "shots" :
    [
        {
            "id": "itdkHUjTuIY",
            "created_at": "2016-09-24T04:06:19-04:00",
            "width": 4256,
            "height": 2832,
            "color": "#F7DCE2",
            "likes": 3,
            "liked_by_user": false,
            "user": {
                "id": "xzxvjf",
                "username": "muukii",
                "name": "hiroshi kimura",
                "profile_image": {
                    "small": "https://...",
                    "medium": "https://...",
                    "large": "https://..."
                },
            },
        },
        {
            "id": "irqwHfqweuIY",
            "created_at": "2016-09-24T04:06:19-04:00",
            "width": 4256,
            "height": 2832,
            "color": "#DDDCE2",
            "likes": 5,
            "liked_by_user": false,
            "user": {
                "id": "zxcvfsad3",
                "username": "john",
                "name": "john estropia",
                "profile_image": {
                    "small": "https://...",
                    "medium": "https://...",
                    "large": "https://..."
                },
            },
        }
    ]
}

 

JAYSONオブジェクトの生成

JAYSONオブジェクトを作る方法はいくつか用意しています。

JSONのDataから生成

無効なDataが入ってきた場合 throw します。

let jsonData: Data
let jayson = try JAYSON(data: jsonData)

 

事前にJSONSerializationを行ったAnyオブジェクトから生成**

AnyがJSONとして認識できない場合 throw します。
 
Any から生成

let jsonData: Data
let json: Any = try JSONSerialization.jsonObject(with: data, options: [])
let jayson = try JAYSON(any: json)

[AnyHashable: Any] から生成

let userInfo: [AnyHashable: Any]
let jayson = try JAYSON(any: json)

[Any] から生成

let objects: [Any]
let jayson = try JAYSON(any: json)

 

Easy-Read

SwiftyJSONと同様にsubscriptでアクセスすることができます。

key, indexが見つからなかった場合はNSNullが入ったJAYSONが返却されるのでクラッシュすることなく安全に動作します。(JAYSON.null もしくは JAYSON() でNSNullが入ったJAYSONが生成されます。)
 
SwiftyJSONとの違いは、型が間違っている場合にdefaultValueを返却するプロパティを用意していないため、必ずOptional値の返却になる点です。
 

Sample JSONから
・”shots” -> 0 -> “user” -> “profile_image” -> “large” を取得した例

let urlString: String? = jayson["shots"][0]["user"]["profile_image"]["large"].string

・“shots” 以下を配列として取得した例

let shots: [JAYSON]? = jayson["shots"].array

let shots = jayson["shots"].array?.map { $0["id"].string }
// => Optional([Optional("itdkHUjTuIY"), Optional("irqwHfqweuIY")])

 

Strict-Read

こちらはJAYSONの特徴です。key, indexが見つからなかったタイミング or 型が間違っていたタイミングで JAYSONError がthrowされます。
 
Easy-Readの例をStrict-Readで記述すると以下のようになります。

let urlString: String = try jayson
  .next("shots")
  .next(0)
  .next("user")
  .next("profile_image")
  .next("large")
  .getString()
let shots = try jayson.next("shots").getArray().map { try $0.next("id").getString() }
// ["itdkHUjTuIY", "irqwHfqweuIY"]

 

JAYSONError

エラーについての具体的な説明ですが、throwされるJAYSONErrorは以下のように定義しています。

public enum JAYSONError: Error {
  case notFoundKey(key: String, jayson: JAYSON)
  case notFoundIndex(index: Int, jayson: JAYSON)
  case failedToGetString(source: Any, jayson: JAYSON)
  case failedToGetBool(source: Any, jayson: JAYSON)
  case failedToGetNumber(source: Any, jayson: JAYSON)
  case failedToGetArray(source: Any, jayson: JAYSON)
  case failedToGetDictionary(source: Any, jayson: JAYSON)
  case decodeError(source: Any, jayson: JAYSON, decodeError: Error)
  case invalidJSONObject
}

基本的にエラーが発生した場所の値とJAYSONが返却されます。

例えば以下のように存在しないkeyにアクセスしたとします。

do {
  let urlString: String = try jayson
    .next("shots")
    .next(0)
    .next("user")
    .next("profile_image")
    .next("foo")
    .getString()
} catch {
   print(error)
}

すると、throwが発生し、print(error) で以下のように出力されます。

notFoundKey("foo",
JAYSON
Path: Root->["shots"][0]["user"]["profile_image"]
SourceType: dictionary

Source:
{
    large = "https://...";
    medium = "https://...";
    small = "https://...";
})

この出力から、以下のことが分かります。
– 正常にアクセスできた階層までパス ( [“shots”][0][“user”][“profile_image”] )
– JSONの内容・型 (SourceType と Source)

これはデータ量の多いJSONや、階層の深いJSONをインポートする際のデバッグの手助けになります。

パスの取得について

JAYSONErrorからパスが取れる仕組みですが、JAYSONはSwiftyJSONと同様にJAYSONオブジェクトを返却し続けます。
 
これはEasy-Read, Strict-Read どちらでも同じ挙動です。

例:

let shots: JAYSON = jayson["shots"]
let firstShot: JAYSON = shots[0]
let id: JAYSON = firstShot["id"]

JAYSONではkey, indexによって生成されたJAYSONには親のJAYSONを保持するようにしています。この仕組を利用して、特定のJAYSONはどのようなパスを辿って生成されたのかを知ることが出来ます。

let large: JAYSON = jayson["shots"][0]["user"]["profile_image"]["large"]
print(large.currentPath())
// Root->["shots"][0]["user"]["profile_image"]["large"]

あと、おまけの機能ですが、階層を1つ上まで戻る機能もあります。

try jayson
  .next("shots")
  .next(0)
  .next("user")
  .next("profile_image")
  .back()

back()を呼び出すことで、”user”の階層のJAYSONを取得できます。もしかすると、デバッグ時に効果を発揮するかもしれません。
 

任意のオブジェクトへの変換

URLやDateはJSONの仕様には定められていないため、文字列と同じ扱いです。
 
アプリではURL, Dateが存在するため、取得時に変換したくなることはよくありますが、JAYSONの仕様はシンプルにしておきたいため、このようなproperty, getterは用意していません。プロジェクトごとにどのような変換を行うかを定義するのがベターだと考えています。
 
そこで、JAYSONでは任意のオブジェクトへの変換方法を2つ用意しています。
 
・get()に変換するclosureを渡す方法

let url: URL = try jayson
    .next("shots")
    .next(0)
    .next("user")
    .next("profile_image")
    .next("large")
    .get { (jayson) throws -> URL in
        URL(string: try jayson.getString())!
}

・Decoderを定義してget<T>(with: Decoder<T>) に渡す方法

JAYSONには単純にクロージャを保持するだけのDecoderがあります。以下のように定義しておくことで、複数の箇所で利用しやすくなります。

let urlDecoder = Decoder<URL> { (jayson) throws -> URL in
    URL(string: try jayson.getString())!
}

let url: URL = try jayson
    .next("shots")
    .next(0)
    .next("user")
    .next("profile_image")
    .next("large")
    .get(with: urlDecoder)

また、これらを内部的に実行する getURL() のようなextensionを用意しても良いかもしれません。

補足 : 検討中の機能

他のJSONライブラリでよく見られるDecodableのような変換対象の型に採用させるprotocolを用意せず、Decoderしか用意していない理由は、その型に変換する方法が一つとは限らないためです。
 
例えばDate型では、ISO8601やUnixTimeStampなど様々な型があります。APIによっては混在しているケースもあるかもしれません(あまりないかもしれませんが)。ですが、Decoderを渡すようにしておくことで、このような場合にも柔軟に対応できます。

JSONを作る

Swiftでは数多くのJSONライブラリが公開されていますが、JSONのパース専用であり、JSONを書き出す機能がないものが多い印象があります。僕が開発しているプロジェクトではAPIリクエストのBodyをJSONで送る必要があるため、JAYSONにはJSONを作る機能を付けました。
 
こちらも基本的にSwiftyJSONと同様ではありますが、すべてのオブジェクトがJAYSONに入るわけではなくJASONWritableType プロトコルを持ったオブジェクトのみになります。この仕組みによりランタイムでの失敗を少なくできます。

var jayson = JAYSON()
jayson["id"] = 18737649
jayson["active"] = true
jayson["name"] = "muukii"

jayson["images"] = JAYSON([
    "large" : "http://...foo",
    "medium" : "http://...foo",
    "small" : "http://...foo",
    ])

let data: Data = try jayson.data(options: .prettyPrinted)

出力

{
  "name" : "muukii",
  "active" : true,
  "id" : 18737649,
  "images" : {
    "large" : "http:\/\/...foo",
    "small" : "http:\/\/...foo",
    "medium" : "http:\/\/...foo"
  }
}

JSONの階層構造をつくるために以下のイニシャライザを用意しています。

[String : JAYSON] から生成

let object: [String : JAYSON]
let jayson = JAYSON(object)

[JAYSON] から生成

let object: [JAYSON]
let jayson = JAYSON(object)
let object: [JAYSONWritableType]
let jayson = JAYSON(object)
let object: [String : JAYSONWritableType]
let jayson = JAYSON(object)

JASONWritableTypeは標準で以下のオブジェクトに適用させています。

extension NSNull : JAYSONWritableType
extension String : JAYSONWritableType
extension NSString : JAYSONWritableType
extension NSNumber : JAYSONWritableType
extension Int : JAYSONWritableType
extension Float : JAYSONWritableType
extension Double : JAYSONWritableType
extension Bool : JAYSONWritableType
extension Int8 : JAYSONWritableType
extension Int16 : JAYSONWritableType
extension Int32 : JAYSONWritableType
extension Int64 : JAYSONWritableType
extension UInt : JAYSONWritableType
extension UInt8 : JAYSONWritableType
extension UInt16 : JAYSONWritableType
extension UInt32 : JAYSONWritableType
extension UInt64 : JAYSONWritableType
extension CGFloat : JAYSONWritableType

最後にちょっとリアルなサンプル

ちょっとだけリアルな使用例を用意してみました。dribbble APIの結果をパースする処理です。

let urlDecoder = Decoder<URL> { (jayson) throws -> URL in
    URL(string: try jayson.getString())! // 任意のエラーをthrowできます。
}

struct Shot {
    let id: Int
    let title: String
    let width: Int
    let height: Int
    let hidpiImageURLString: URL?
    let normalImageURLString: URL
    let teaserImageURLString: URL

    init(jayson: JAYSON) throws {
        let imagesJayson = try jayson.next("images")

        id = try jayson.next("id").getInt()
        title = try jayson.next("title").getString()
        width = try jayson.next("width").getInt()
        height = try jayson.next("height").getInt()
        hidpiImageURLString = try? imagesJayson.next("hidpi").get(with: urlDecoder)
        normalImageURLString = try imagesJayson.next("normal").get(with: urlDecoder)
        teaserImageURLString = try imagesJayson.next("teaser").get(with: urlDecoder)
    }
}
do {
    let shots: [Shot] = try jayson.getArray().map(Shot.init(jayson: ))
    print(shots)
} catch {
    print(error) // 何個目のJAYSONでどのようなpathで失敗したのかがわかります。
}

少しだけnext()などの記述が長く感じるかもしれませんが、それはプロジェクトごとにoperatorを用意しても良いかもしれません。
 
今のところ、operatorを使うとメソッドチェーンが行いづらくなるためライブラリ側では用意していません。

おわりに

SwiftyJSONの問題は、エラーが起きた際のデバッグがしづらい点で、これを解決するためにJAYSONを作りました。JAYSONを使うことでAPI連携におけるエラー検出がスムーズになるはずです。使ってもらえるとうれしいです!
 
まだまだJAYSONに改善の余地はあると思っているので、もし要望がありましたらGitHubでもTwitterでも構いませんのでご意見をお待ちしています!
 
もし気に入って頂けたら是非GitHubスターを!


????https://github.com/muukii/JAYSON????

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

Kotlinの気持ちよさ

To Make a Service, a COOL UI is Not Everything!