LoginSignup
45
20

More than 5 years have passed since last update.

Swiftのちょっと便利なJSONのencoderを作った

Last updated at Posted at 2019-01-12

SwiftのCodableは便利ですが、FoundationJSONEncoder, JSONDecoderを使っていて微妙に不便に感じる事があります。自分がいろいろな案件をこなしたり、他人の困り事を聞いてきた上で、機能強化したencoderについてのアイデアが浮かんだので作りました。

ライブラリの名前はFineJSONです。その名の通り、JSONEncoderJSONDecoderのちょっと良い代替として設計されており、FineJSONEncoderFineJSONDecoderを提供します。

リポジトリはこちらです。https://github.com/omochi/FineJSON

機能紹介

FineJSONの機能を紹介します。

ケツカンマを許容する

Decoderはケツカンマを無視します。設定ファイルなどにJSONを使っていると、よくこのミスでハマるんですよね。

    struct A : Codable, Equatable {
        var a: Int
        var b: Int
    }

    func testAllowTrailingComma() throws {
        let json = """
[
  {
    "a": 1,
    "b": 2,
  },
]
"""
        let decoder = FineJSONDecoder()
        let x = try decoder.decode([A].self, from: json.data(using: .utf8)!)
        XCTAssertEqual(x, [A(a: 1, b: 2)])
    }

コメントを許容する

Decoderはコメントを許容します。ラインコメントとブロックコメントに対応しています。

    struct A : Codable, Equatable {
        var a: Int
        var b: Int
    }

    func testComment() throws {
        let json = """
[
  // entry 1
  {
    "a": 10,
    "b": 20
/*
    "a": 1,
    "b": 2,
*/
  }
]
"""
        let decoder = FineJSONDecoder()
        let x = try decoder.decode([A].self, from: json.data(using: .utf8)!)
        XCTAssertEqual(x, [A(a: 10, b: 20)])
    }

パースエラーが位置情報を持っている

パースエラーがJSONにおける位置情報を持っています。行番号、列番号(byte換算), バイトオフセットが得られます。JSONファイルが4000行とかあると、CodingKeysで「usersキーの要素の5番目の要素のitems要素の10番目の要素でエラーです」とか言われても、それどこなん、ってなりますよね。

    struct A : Codable, Equatable {
        var a: Int
        var b: Int
    }

    func testParseErrorLocation() throws {
        let json = """
[
  {
    "a": 1,
    "b": 2;
  }
]
"""
        let decoder = FineJSONDecoder()
        do {
            _ = try decoder.decode([A].self, from: json.data(using: .utf8)!)
            XCTFail()
        } catch {
            let message = "\(error)"
            // invalid character (";") at 4:11(28)
            XCTAssertTrue(message.contains("4:11(28)"))
        }
    }

デコーダから位置情報を取り出せる

位置情報をDecoderから取り出す事ができます。デコードはできたけど処理してみたらエラーになった、みたいなとき、何行目のエントリで問題が起こったよ、みたいな事を自分で実装できます。

    struct B : Decodable {
        var location: SourceLocation?
        var name: String
        enum CodingKeys : String, CodingKey { case name }
        init(from decoder: Decoder) throws {
            self.location = decoder.sourceLocation
            let c = try decoder.container(keyedBy: CodingKeys.self)
            self.name = try c.decode(String.self, forKey: .name)
        }
    }

    func testDecodeLocation() throws {
        let json = """
// comment
{
  "name": "b"
},
"""
        let decoder = FineJSONDecoder()
        let x = try decoder.decode(B.self, from: json.data(using: .utf8)!)
        XCTAssertEqual(x.location, SourceLocation(offset: 11, line: 2, columnInByte: 1))
        XCTAssertEqual(x.name, "b")
    }

この情報の自動デコード機能もあります。後述。

JSONのキーの順序を維持する

標準のencoderでは、オブジェクトのプロパティをエンコードした順番と、出力されるJSONにおけるキーの順番は対応付けされません。API呼び出しのリクエストなど、それが全く問題にならない場面もありますが、設定ファイルなど、人間が読み書きするJSONにおいてはそれだと不便です。FineJSONでは内部で順序付き辞書を持っていて、エンコードした順番でキーが並んだJSONを出力できます。

    struct A : Codable {
        var a: Int
        var b: String
        var c: Int?
        var d: String?
    }

    func testKeyOrder() throws {
        let a = A(a: 1, b: "b", c: 2, d: "d")
        let e = FineJSONEncoder()
        let json = String(data: try e.encode(a), encoding: .utf8)!
        let expected = """
{
  "a": 1,
  "b": "b",
  "c": 2,
  "d": "d"
}
"""
        XCTAssertEqual(json, expected)
    }

Optional.noneのエンコーディング方法を制御する

Optionalなプロパティがnoneだった時、デフォルトではJSONのキーごと削除されますが、FineJSONでは、キーとともにJSONのnullを出力する挙動を選択できます。

    func testNoneExplicitNull() throws {
        let a = A(a: 1, b: "b", c: nil, d: "d")
        let e = FineJSONEncoder()
        e.optionalEncodingStrategy = .explicitNull
        let json = String(data: try e.encode(a), encoding: .utf8)!
        let expected = """
{
  "a": 1,
  "b": "b",
  "c": null,
  "d": "d"
}
"""
        XCTAssertEqual(json, expected)
    }

インデントの深さを制御する

インデントの深さをデフォルトのスペース2つから、4つやタブ文字に変更できます。

    func testIndent4() throws {
        let a = A(a: 1, b: "b", c: 2, d: "d")
        let e = FineJSONEncoder()
        e.jsonSerializeOptions = JSON.SerializeOptions(indentString: "    ")
        let json = String(data: try e.encode(a), encoding: .utf8)!
        let expected = """
{
    "a": 1,
    "b": "b",
    "c": 2,
    "d": "d"
}
"""
        XCTAssertEqual(json, expected)
    }

1行形式ももちろん使えます。

    func testOnelineFormat() throws {
        let a = A(a: 1, b: "b", c: 2, d: "d")
        let e = FineJSONEncoder()
        e.jsonSerializeOptions = JSON.SerializeOptions(isPrettyPrint: false)
        let json = String(data: try e.encode(a), encoding: .utf8)!
        let expected = """
{"a":1,"b":"b","c":2,"d":"d"}
"""
        XCTAssertEqual(json, expected)
    }

Foundationと異なり、デフォルトを見やすい形式の方にしています。

無限精度のnumberを扱う

本来JSONはその文法仕様上、numberの桁数に制限はなく、無限精度がシリアライズできます。しかし、Foundationではデコードした時にそれを内部的に有限精度で格納してしまうため、せっかくのJSONの表現能力を失ってしまいます。また、JSONデータ上で0.1と書かれていたような場合でも、この数値は2進浮動少数では表現できないので、0.1にとても近いが0.1では無い数になってしまいます。例えば、下記のような事が起こります。

let e = JSONEncoder()
let r = String(data: try e.encode([Float(0.1)]), encoding: .utf8)!
print(r) // => [0.10000000149011612]

また、単純に桁数が大きくなるとビット幅の表現能力を超えてしまい、末尾の方の桁の値が壊れてしまう事もあります。

FineJSONでは、内部表現が文字列になっているJSONNumberという型を提供しており、これを使うことで、JSONデータ上の文字列表現のままで数値を取り扱えます。これに合わせてユーザ側で適切なライブラリを用いることで、JSON上の表現を損なう事なく操作できます。下記の例では内部的に10進表現をもつDecimal型を使って値を変更しています。

    struct B : Codable {
        var x: JSONNumber
        var y: JSONNumber
    }

    func testArbitraryNumber() throws {
        let json1 = """
{
  "x": 1234567890.1234567890,
  "y": 0.01
}
"""
        let d = FineJSONDecoder()
        var b = try d.decode(B.self, from: json1.data(using: .utf8)!)

        var y = Decimal(string: b.y.value)!
        y += Decimal(string: "0.01")!
        b.y = JSONNumber(y.description)

        let e = FineJSONEncoder()        
        let json2 = String(data: try e.encode(b), encoding: .utf8)!

        let expected = """
{
  "x": 1234567890.1234567890,
  "y": 0.02
}
"""
        XCTAssertEqual(json2, expected)
    }

プリミティブな値のデコードにおける弱い型付け

JSONにおいて数値と文字列は区別されていますが、現実世界では残念ながら、時折この違いを考慮していない困ったJSONデータに出会う事があります。FineJSONではこれらを相互に暗黙変換してデコードするので、そういったJSONデータでもそのまま読み込めます。

    struct C : Codable {
        var id: Int
        var name: String
    }

    func testWeakTypingDecoding() throws {
        let json = """
{
  "id": "123",
  "name": 4869.57
}
"""
        let d = FineJSONDecoder()
        let c = try d.decode(C.self, from: json.data(using: .utf8)!)

        XCTAssertEqual(c.id, 123)
        XCTAssertEqual(c.name, "4869.57")
    }

通信の世界は「厳密に書いて柔軟に受け取る」のが良いという考え方があり、JSONエンコーディングについてもそう言える場合があると思います。ただ、気に食わない場合はCodablePrimitiveJSONDecoderプロトコルのオブジェクトを注入することで挙動を変更できるようにしてあります。

複雑な構造のJSONをそのまま扱う

FineJSONが提供しているJSON型を使えばCodableで扱えないような複雑な構造も扱えます。Any型でも似たようなことはできますが、Any型は本当になんでも格納できるため緩すぎて不便です。JSON型は、JSONに定義された6つの型のenumなので適度な硬さで扱えます。

    struct F : Codable {
        var name: String
        var data: JSON
    }

    func testJSONTypeProperty() throws {
        let json = """
{
  "name": "john",
  "data": [
    "aaa",
    { "bbb": "ccc" }
  ]
}
"""
        let d = FineJSONDecoder()
        let f = try d.decode(F.self, from: json.data(using: .utf8)!)

        XCTAssertEqual(f.name, "john")
        XCTAssertEqual(f.data, JSON.array(JSONArray([
            .string("aaa"),
            .object(JSONObject([
                "bbb": .string("ccc")
                ]))
            ])))
    }

特定のプロパティだけJSONでのキーを変更する

通常のCodableにおいてJSONのキーをプロパティ名から変更しようとした場合、CodingKeysを自分で書かねばなりません。

struct G : Codable {
    enum CodingKeys : String, CodingKey {
        case id = "no"
        case point
        case userName = "user_name"
    }

    var id: Int
    var point: Int
    var userName: String
}

本来的に意図しているのは、「idプロパティはnoキー, userNameプロパティはuser_nameキーを使う」という事であるのに対して、case pointも書かねばならないのがいただけません。関心のオーダーと作業量のオーダーが一致していないからです。この例ではpointの1つだけですが、これがもし20個のプロパティがあったら、caseを書くのが面倒です。

Foundation.JSONEncoderには、keyEncodingStrategyというものがありますが、.convertToSnakeCaseでは対応できない場合もありますし、.customの仕様は設定が型の定義箇所と分断するので、関心とコードの局所性の不一致をもたらすため使いにくいと思います。

FineJSONでは、下記のように対象の型に対してJSONAnnotatableプロトコルを適用して、keyAnnotationsプロパティを定義することで、Codableの自動生成機能が有効であるままで、JSONのキーを変更することができます。

    struct G : Codable, JSONAnnotatable {
        static let keyAnnotations: JSONKeyAnnotations = [
            "id": JSONKeyAnnotation(jsonKey: "no"),
            "userName": JSONKeyAnnotation(jsonKey: "user_name")
        ]

        var id: Int
        var point: Int
        var userName: String
    }

    func testAnnotateJSONKey() throws {
        let json1 = """
{
  "no": 1,
  "point": 100,
  "user_name": "john"
}
"""
        let d = FineJSONDecoder()
        var g = try d.decode(G.self, from: json1.data(using: .utf8)!)

        XCTAssertEqual(g.id, 1)
        XCTAssertEqual(g.point, 100)
        XCTAssertEqual(g.userName, "john")

        g.point += 3

        let e = FineJSONEncoder()
        let json2 = String(data: try e.encode(g), encoding: .utf8)!

        let expect = """
{
  "no": 1,
  "point": 103,
  "user_name": "john"
}
"""
        XCTAssertEqual(json2, expect)
    }

キーがない場合のデフォルト値の指定

設定ファイルのJSONをCodableで扱っている場合などに、設定項目が追加された時、単純に型にプロパティを追加してしまうと、その新しいプロパティが含まれていない古い設定ファイルが読み込めなくなってしまうため、プロパティの型をOptionalにしなければならない事があります。しかし、意味的にはその項目は必須であり、JSONに書いてないならばデフォルト値を与えて、プロパティの型としてはOptionalでなくしたい事があります。FineJSONではそのようなデフォルト値を簡単に設定する事ができます。

    struct H : Codable, JSONAnnotatable {
        static let keyAnnotations: JSONKeyAnnotations = [
            "language": JSONKeyAnnotation(defaultValue: JSON.string("Swift"))
        ]

        var name: String
        var language: String
    }

    func testDefaultValue() throws {
        let json = """
{
  "name": "john"
}
"""
        let d = FineJSONDecoder()
        let h = try d.decode(H.self, from: json.data(using: .utf8)!)

        XCTAssertEqual(h.name, "john")
        XCTAssertEqual(h.language, "Swift")
    }

位置情報の自動デコード

位置情報を自動でCodable対応できます。エンコードした時は消えます。

    func testAutoLocationDecoding() throws {
        let json = """
// comment
{
  "name": "b"
},
"""
        let decoder = FineJSONDecoder()
        let x = try decoder.decode(C.self, from: json.data(using: .utf8)!)
        XCTAssertEqual(x.location, SourceLocation(offset: 11, line: 2, columnInByte: 1))
        XCTAssertEqual(x.name, "b")

        let encoder = FineJSONEncoder()
        let json2 = String(data: try encoder.encode(x), encoding: .utf8)!
        XCTAssertEqual(json2, """
{
  "name": "b"
}
""")

    }

サポートしているビルド環境

SwiftPMによるビルドをmac, iOSでサポートしています。

Carthageによるビルドをmac, iOSでサポートしています。

手動でのxcworkspaceによるビルドをmac, iOSでサポートしています。個人的にはCarthageによるバージョン解決と組み合わせて手動ビルドするのがおすすめです。詳しくはこちらの記事で解説しています。

実際には、ビルドはtvOS, watchOSも通していて、テストの実行はtvOSでもできていますが、これらについてはよく知らないのでわかりません。

SwiftPMで通っていて、macなものへの依存は無いはずなのでちょっとやればLinuxでも動く気がしますがまだ試していません。

実装

FineJSONを実装する上で、RichJSONParserという自作のJSONシリアライザと、OrderedDictionaryという自作の順序付き辞書ライブラリに依存しています。

RichJSONParserは、ケツカンマ、コメント、位置情報などを取り扱うために使っています。

OrderedDictionaryはキーの順序を保持するために使っています。

FoundationNSJSONSerializationでは、JSONのobjectの内部表現としてNSDictionaryを使います。NSDictionaryはキーの順序を保持しないため、これはだめです。また、JSONのnumberの内部表現としてNSNumberを使うため、有限精度しか扱えません。そこで、情報の喪失の無い外部のJSONシリアライザが必要でした。

また、SwiftにもFoundationにも順序付き辞書がありません。DictionaryArrayのペアをラップして簡易に構築する事ができますが、こうすると要素の挿入操作のオーダーがO(n)になってしまい、Dictionaryよりも悪くなってしまいます。これを通常のDictionaryと同様にO(1)で挿入するためには、辞書と一緒に連結リストを使う必要があります。残念ながら連結リストも提供されていないので、これらをまとめて自作しました。OrderedDictionaryライブラリで連結リストのLinkedListを実装するにあたって、SwiftらしくCopy on Writeな値型にするために、涙ぐましい努力をしているので興味があったらついでに見てください。

45
20
2

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
45
20