環境: Xcode 14.2
JSONSerialization による型の変化について
Swift における JSONSerialization
の挙動は、特定のシナリオで変数の型を変更することがあります。具体的には、Double
や Float
の小数部が .0
の場合、それは整数としてシリアライズされます。また、Boolは 1
(true
)または 0
(false
)としてシリアライズされることがあります。
なぜこのような挙動が発生するのか
JSON は、Swift とは異なり、静的型付けを持たないデータ形式です。このため、JSONSerialization
はデータを最もシンプルな形式に変換しようとします。例えば、2.0
は 2
として、true
は 1
として表現されます。
解決策
JSONSerialization
の代わりに、直接辞書への変換を行う方法を採用することで、型の変更問題を回避できます。この記事では、Mirror
を使った方法を紹介しています。
Struct → Dictionary の変換の記述
extension Encodable {
func asDictionary(keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy) throws -> [String: Any] {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = keyEncodingStrategy
do {
let data = try encoder.encode(self)
guard let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "Couldn't convert to [String: Any] dictionary"))
}
return jsonObject
} catch {
throw error
}
}
}
Dictionary -> Struct の変換の記述
extension Decodable {
init(from dictionary: [String: Any], keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy) throws {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = keyDecodingStrategy
let data = try JSONSerialization.data(withJSONObject: dictionary, options: [])
let decodedObject = try decoder.decode(Self.self, from: data)
self = decodedObject
}
}
サンプルの定義
struct Sample: Codable {
let sampleInt: Int
let sampleString: String
let sampleDouble: Double
let sampleFloat: Float
let sampleBool: Bool
let sampleDate: Date
let sampleStruct: SampleStruct
let sampleEnum: SampleEnum
}
struct SampleStruct: Codable {
let hogeProperty: Int
}
enum SampleEnum: Codable {
case hogeCase
}
Struct -> Dictionary の変換
let sample = Sample(sampleInt: 1, sampleString: "a", sampleDouble: 2.0, sampleFloat: 3.0, sampleBool: true, sampleDate: Date(), sampleStruct: SampleStruct(hogeProperty: 4), sampleEnum: .hogeCase)
do {
let dict1 = try sample.asDictionary(keyEncodingStrategy: .useDefaultKeys)
let dict2 = try sample.asDictionary(keyEncodingStrategy: .convertToSnakeCase)
print(dict1)
print(dict2)
} catch {
print("\(error)")
}
.useDefaultKeys で変換した Dictionary の出力
[
"sampleInt": 1,
"sampleString": "a",
"sampleDouble": 2, // Double(2.0) が勝手に Int(2) に丸め込まれる
"sampleFloat": 3, // Double(3.0) が勝手に Int(3) に丸め込まれる
"sampleBool": 1, // Bool(true) が勝手に Int(1) に丸め込まれる
"sampleDate": 713534174.328627,
"sampleStruct": [
"hogeProperty": 4
],
"sampleEnum": [
"hogeCase": {}
]
]
.convertToSnakeCase で変換した Dictionary の出力
[
"sample_int": 1,
"sample_string": "a",
"sample_double": 2, // Double(2.0) が勝手に Int(2) に丸め込まれる
"sample_float": 3, // Double(3.0) が勝手に Int(3) に丸め込まれる
"sample_bool": 1, // Bool(true) が勝手に Int(1) に丸め込まれる
"sample_date": 713534174.328627,
"sample_struct": [
"hoge_property": 4
],
"sample_enum": [
"hoge_case": {}
]
]
Dictionary -> Struct の変換
let sample = Sample(sampleInt: 1, sampleString: "a", sampleDouble: 2.0, sampleFloat: 3.0, sampleBool: true, sampleDate: Date(), sampleStruct: SampleStruct(hogeProperty: 4), sampleEnum: .hogeCase)
do {
let dict1 = try sample.asDictionary(keyEncodingStrategy: .useDefaultKeys)
let dict2 = try sample.asDictionary(keyEncodingStrategy: .convertToSnakeCase)
// 以下はどちらも同じ出力になる( Encodable & Decodable の Struct -> Dictionary -> Struct の変換がうまくいっている)
print(try Sample(from: dict1, keyDecodingStrategy: .useDefaultKeys))
print(try Sample(from: dict2, keyDecodingStrategy: .convertFromSnakeCase))
} catch {
print("\(error)")
}
Dictionary -> Struct の変換(エラーの検証)
let sample = Sample(sampleInt: 1, sampleString: "a", sampleDouble: 2.0, sampleFloat: 3.0, sampleBool: true, sampleDate: Date(), sampleStruct: SampleStruct(hogeProperty: 4), sampleEnum: .hogeCase)
do {
let dict1 = try sample.asDictionary(keyEncodingStrategy: .useDefaultKeys)
let dict2 = try sample.asDictionary(keyEncodingStrategy: .convertToSnakeCase)
// わざと、asDictionary で指定した KeyDecodingStrategy と異なる値を指定してみる
print(try Sample(from: dict1, keyDecodingStrategy: .convertFromSnakeCase)) // ← なぜかエラーにならない
print(try Sample(from: dict2, keyDecodingStrategy: .useDefaultKeys)) // ← エラーになる
} catch {
print("\(error)")
}
Codable や JSONSerialization を使わないで Dictionary にする方法
Mirror
を使うことで型をなるべく残した状態で辞書型に変換できます。
public enum CaseFormat {
case original
case snakeCase
}
public protocol ConvertibleToDictionary {
func asDictionary(caseFormat: CaseFormat) -> [String: Any]
}
public extension ConvertibleToDictionary {
func asDictionary(caseFormat: CaseFormat = .original) -> [String: Any] {
let mirror = Mirror(reflecting: self)
var dictionary: [String: Any] = [:]
mirror.children.forEach { child in
guard let keyName = child.label else { return }
switch caseFormat {
case .original:
dictionary[keyName] = child.value
case .snakeCase:
dictionary[keyName.toSnakeCase] = child.value
}
}
return dictionary
}
}
extension String {
var toSnakeCase: String {
let snakeCased = unicodeScalars.reduce("") { result, scalar in
if CharacterSet.uppercaseLetters.contains(scalar) {
return "\(result)_\(Character(scalar))"
} else {
return result + String(scalar)
}
}.lowercased()
if snakeCased.hasPrefix("_") {
return String(snakeCased.dropFirst())
} else {
return snakeCased
}
}
}
let sample = Sample(sampleInt: 1, sampleString: "a", sampleDouble: 2.0, sampleFloat: 3.0, sampleBool: true, sampleDate: Date(), sampleStruct: SampleStruct(hogeProperty: 4), sampleEnum: .hogeCase)
let dict1 = sample.asDictionary(caseFormat: .original)
let dict2 = sample.asDictionary(caseFormat: .snakeCase)
print(dict1)
print(dict2)
.original で変換した Dictionary
[
"sampleInt": 1,
"sampleString": "a",
"sampleDouble": 2.0,
"sampleFloat": 3.0,
"sampleBool": true,
"sampleDate": "2023-08-12 12:48:45 +0000",
"sampleStruct": "SampleStruct(hogeProperty: 4)",
"sampleEnum": "SampleEnum.hogeCase"
]
.snakeCase で変換した Dictionary
[
"sample_int": 1,
"sample_string": "a",
"sample_double": 2.0,
"sample_float": 3.0,
"sample_bool": true,
"sample_date": "2023-08-12 12:48:45 +0000",
"sample_struct": "SampleStruct(hogeProperty: 4)",
"sample_enum": "SampleEnum.hogeCase"
]
そもそもなぜこのようなことをやりたいのか?
Protocol Buffers の google.protobuf.Value
で扱える型 は NullValue
, double
, string
, bool
, Struct
, ListValue
のみで、map<string, google.protobuf.Value>
に Swift の Struct を辞書型に変換するときに使うことが目的でした。
そして、以下のような処理を書く際に JSONSerialization
を使うと型情報が失われてしまい、思ったような変換をしてくれなかったので、Mirror
で書くことになりました。
import Foundation
import SwiftProtobuf
var toProtoBufValueDictionary: [String: Google_Protobuf_Value] {
asDictionary(caseFormat: .snakeCase).mapValues { value in
var protoValue = Google_Protobuf_Value()
switch value {
case let intValue as Int:
protoValue.numberValue = Double(intValue)
case let doubleValue as Double:
protoValue.numberValue = doubleValue
case let floatValue as Float:
protoValue.numberValue = Double(floatValue)
case let stringValue as String:
protoValue.stringValue = stringValue
case let dateValue as Date:
protoValue.stringValue = ISO8601DateFormatter.sharedWithFractionalSeconds.string(from: dateValue)
case let boolValue as Bool:
protoValue.boolValue = boolValue
default:
assertionFailure("Unexpected type encountered while converting to Google_Protobuf_Value")
}
return protoValue
}
}
extension ISO8601DateFormatter {
static let sharedWithFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone.current
return formatter
}()
}
まとめ
JSONSerialization
は型の変更を引き起こす可能性があるため、直接辞書変換を行うアプローチも検討したほうがいいかもしれません。