🕊

[Swift] JSONSerialization を使って辞書型に変換すると数値や Bool 値の型が勝手に変更されてしまう件

に公開

環境: Xcode 14.2

JSONSerialization による型の変化について

Swift における JSONSerialization の挙動は、特定のシナリオで変数の型を変更することがあります。具体的には、Double Float の小数部が .0 の場合、それは整数としてシリアライズされます。また、Boolは 1true)または 0false)としてシリアライズされることがあります。

なぜこのような挙動が発生するのか

JSON は、Swift とは異なり、静的型付けを持たないデータ形式です。このため、JSONSerialization はデータを最もシンプルな形式に変換しようとします。例えば、2.02 として、true1 として表現されます。

解決策

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 の出力

dict1
[
    "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 の出力

dict2
[
    "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

dict1
[
    "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

dict2
[
    "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 は型の変更を引き起こす可能性があるため、直接辞書変換を行うアプローチも検討したほうがいいかもしれません。

GitHubで編集を提案

Discussion