SwiftでJSONのマッピングにはUnboxが便利らしい - Re.Ra.Ku アドベントカレンダー day 7

Re.Ra.Ku アドベントカレンダー 7日目です。

こんにちは、Re.Ra.Kuさんのパートナーとして開発に携わっています、神場です。 アドベントカレンダーをやるということで、せっかくならと思い手を挙げたところ参加出来ることになりましたので、書かせていただきます!

概要

今回はSwift製のJSON decoderであるUnboxをプロダクトで採用してみて、かなり使いやすいということが分かりましたので、いくつか使い方のサンプルを紹介するという内容の記事になっています。

Unboxを選択した理由

Swift3でJSONを扱うライブラリにはSwiftyJSONArgoがあると思いますが、今取り掛かっているプロダクトでは

  • マッピング時に型推論が出来る
  • 異なる型への変換が楽に出来る
  • インターフェースが直感的である
  • 実装が軽い
  • Githubのスター数がある程度(300以上ぐらい?)付いている

等の理由からUnboxを採用しました。

この記事で紹介する内容

この記事では

  • 基本的な使い方
  • unboxのネストの仕方
  • 異なる型への変換(format, transform)
  • DTO(Data Transfer Object)を使わないマッピング(performCustomUnboxing)

の流れで使い方を紹介したいと思います。

なお、日本語を読むのが億劫な人向けに、先にコードの全文へのURLを貼っておきます。

UnboxSample

基本的な使い方

Unboxの最も基本的な使い方としては、マッピングしたいclassもしくはstructにUnboxableプロトコルを実装するだけです。例えば

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "ユーザー1"
}

というJSONを

struct User {
    let id: String
    let name: String
}

にマッピングしたい場合は

extension User: Unboxable {
    init(unboxer: Unboxer) throws {
        self.id = try unboxer.unbox(key: "id")
        self.name = try unboxer.unbox(key: "name")
    }
}

のように書いておきます。実際に使うときは

let user: User = try unbox(dictionary: dictionary)

もしくは

let user: User = try unbox(data: data)

のようになります。この記事やサンプルコードではdictionary([String : Any]のエイリアス)を使う方で統一してあります。

コードの全文

ネストの仕方

マッピングしたいオブジェクトがネストする場合は、ネストしている子のほうにもマッピングを定義するだけで大丈夫です。

{
  "group": {
    "name": "グループ1",
    "users": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "ユーザー1"
      },
      {
        "id": "b078bb12-7616-4207-9f16-6a991c7ae16a",
        "name": "ユーザー1"
      }
    ]
  }  
}

のようなJSONに対して、さきほどのUserの配列を持つ

struct Group {
    let name: String
    let users: [User]
}

というstructへのマッピングを考えると、Groupに対して

extension Group: Unboxable {
    init(unboxer: Unboxer) throws {
        self.name = try unboxer.unbox(keyPath: "group.name")
        self.users = try unboxer.unbox(keyPath: "group.users")
    }
}

のようにしてやれば、

let group: Group = try unbox(dictionary: dictionary)

を実行する際に、usersのマッピングでUserのinit(unboxer: Unboxer)が呼び出されるようになります。 (JSONのほうがネストしている場合は上記のようにkeyPathで直接取得することが出来ます。)

コードの全文

異なる型への変換

マッピング時に、String -> UserIdのように特定の型へ変換したい時の処理です。これには二通りの方法があります。 ここでは、先ほどのUserのIDを独自の型であるUserIdへマッピングする例を考えましょう。コードにすると

struct UserId {
    let value: UUID
}

struct User {
    let id: UserId
    let name: String
}

のような感じです。(サンプルなので実態は単なるUUIDですが。。)

unbox時にformatterを渡す

こちらはunbox時に明示的に変換するformatterを渡してやる方法です。UnboxFormatterを実装したクラス

class UserIdFormatter: UnboxFormatter {
    typealias UnboxRawValue = String
    typealias UnboxFormattedType = UserId
    func format(unboxedValue: UnboxRawValue) -> UnboxFormattedType? {
        guard let uuid = UUID(uuidString: unboxedValue) else { return nil }
        return UserId(value: uuid)
    }
}

を用意して

 self.id = try unboxer.unbox(key: "id", formatter: UserIdFormatter())

のように渡します。

DTO(Data Transfer Object)自体にtransform(UnboxableByTransformプロトコル)を実装する

さきほどはformatterunbox時に明示的に渡していましたが、こちらはunbox時にマッピングしたい先のDTOに定義しておいた変換が暗黙的に呼ばれる方法です。DTO自体にUnboxableByTransformを実装します。

extension UserId: UnboxableByTransform {
    typealias UnboxRawValue = String
    static func transform(unboxedValue: UnboxRawValue) -> UserId? {
        guard let uuid = UUID(uuidString: unboxedValue) else { return nil }
        return UserId(value: uuid)
    }
}
 self.id = try unboxer.unbox(key: "id")

コードの全文

DTOを使わないマッピング

上のサンプルはDTOにextensionでマッピングを定義していきましたが、performCustomUnboxingを使うことで直接クロージャーとしてマッピングを渡すことも出来ます。単純な例として

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "ユーザー1"
}

というJSONであれば

    func printIdAndName(dictionary: UnboxableDictionary) throws {
        let pair: (UserId, String) = try Unboxer.performCustomUnboxing(dictionary: dictionary, closure: { unboxer in
            let id: UserId = try unboxer.unbox(key: "id", formatter: UserIdFormatter())
            let name: String = try unboxer.unbox(key: "name")
            return (id, name)
        })
        print(pair)
    }

のようにしてidnameを取り出すことが出来ます。 ネストする場合はunboxしたものをさらにperformCustomUnboxingのクロージャーでunboxしていく形になります。(長いのでコードの全文を参照)

コードの全文

まとめ

どうでしょうか。Unboxの機能自体はそこまで多くありませんが、以上の程度のことが出来ればプロダクションでも十分使えるかと思います。JSONのマッピングで消耗している方はUnboxの導入を検討していてはいかがでしょうか。ここで紹介した以外にもEnumへのマッピング等他の機能もありますので、気になる方は公式ドキュメントのほうをご覧ください。

余談

tryを多用するのはあまりスマートでない気もしますが、かといってイニシャライザをオプショナルにして全てにif letguard letなどを付けるのも冗長なので、この辺りを割り切ったのがUnboxなのかなという印象です。冒頭でちょっと触れたArgoでは(Curryを使って)マッピング用のイニシャライザをカリー化して巧妙に回避しているので、もし機会があれば検証したいです。