SwiftのCodable
は便利ですが、Foundation
のJSONEncoder
, JSONDecoder
を使っていて微妙に不便に感じる事があります。自分がいろいろな案件をこなしたり、他人の困り事を聞いてきた上で、機能強化したencoderについてのアイデアが浮かんだので作りました。
ライブラリの名前はFineJSON
です。その名の通り、JSONEncoder
とJSONDecoder
のちょっと良い代替として設計されており、FineJSONEncoder
とFineJSONDecoder
を提供します。
リポジトリはこちらです。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はキーの順序を保持するために使っています。
Foundation
のNSJSONSerialization
では、JSONのobjectの内部表現としてNSDictionary
を使います。NSDictionary
はキーの順序を保持しないため、これはだめです。また、JSONのnumberの内部表現としてNSNumber
を使うため、有限精度しか扱えません。そこで、情報の喪失の無い外部のJSONシリアライザが必要でした。
また、SwiftにもFoundation
にも順序付き辞書がありません。Dictionary
とArray
のペアをラップして簡易に構築する事ができますが、こうすると要素の挿入操作のオーダーがO(n)
になってしまい、Dictionary
よりも悪くなってしまいます。これを通常のDictionary
と同様にO(1)
で挿入するためには、辞書と一緒に連結リストを使う必要があります。残念ながら連結リストも提供されていないので、これらをまとめて自作しました。OrderedDictionary
ライブラリで連結リストのLinkedList
を実装するにあたって、SwiftらしくCopy on Writeな値型にするために、涙ぐましい努力をしているので興味があったらついでに見てください。