타입을 모를때 디코딩 처리하기
사내 프로젝트에서 사용자 이벤트 추적을 위해 Amplitude를 사용하는데, Amplitude에서는 이벤트 키와 같이 properties를 같이 로깅할 수 있다.
이때 properties의 타입이 [String: Any]인데, 서버사이드에서 이미 렌더링하는 값들이 있다보니 properties에서 요구하는 원본값들을 가져오기 어려웠고, 결국 서버에서 로깅을 위한 객체를 내려주기로 했다.
따라서 오브젝트를 [String: Any]로 바꿔주어야 한다. 그런데 오브젝트 안이 어떤 키로 내려올지 정해지지 않고, 타입 또한 달라진다.
디코딩
JSON으로 서버와 통신하고 있기 때문에 Decodable한 객체의 값을 [String: Any]를 목표로 하여 바꿔주어야 한다.
그런데 [String: Any]가 Decodable하지 않기 때문에 커스텀 디코딩을 해주어야 한다.
import Foundation
struct AnyCodable: Decodable {
private let value: Any?
init(from decoder: Decoder) throws {
let singleValueContainer = try decoder.singleValueContainer()
if let value = try? singleValueContainer.decode(Int.self) {
self.value = value
} else if let value = try? singleValueContainer.decode(Double.self) {
self.value = value
} else if let value = try? singleValueContainer.decode(String.self) {
self.value = value
} else if let value = try? singleValueContainer.decode(Bool.self) {
self.value = value
} else if let value = try? singleValueContainer.decode([AnyCodable].self) {
self.value = value
} else if let value = try? singleValueContainer.decode([String: AnyCodable].self) {
self.value = value
} else {
self.value = nil
}
}
}
값의 타입도 모르기 때문에 Any로 선언해 value값을 채워주었다.
이렇게 하면 일단 타입을 모르는 채로 디코딩 처리를 할 수 있게 된다.
struct Response: Decodable {
let log: [String: AnyCodable]
}
Decodable -> [String: Any]
그런데 이걸 그대로 사용하면 로깅되는 프로퍼티에서 AnyCodable 자체가 사용된다. 여기서 value를 꺼내 써야 하는데, 보통 이렇게 타입에 wrapping될때는 프로퍼티래퍼를 쓰겠지만 잘 되지 않았다.
AnyCodable의 value를 보고 한 번 값을 꺼내서 사용하면 될까 했는데, 오브젝트 타입에서 다시 문제가 된다. 따라서 다시 Encodable의 도움을 받아야 한다.
사실 그래서 처음엔 이름이 AnyDecodable이었는데 결국 이름이 AnyCodable이 되었다.
extension AnyCodable: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if let value = self.value as? Encodable {
try container.encode(value)
}
}
}
보통 iOS 프레임워크에서 userInfo란 이름으로 익명클래스처럼 사용되는 [String: Any]타입을 얻기 위해 JSONSerializer를 사용했다. 잘 되는듯 했는데 테스트해 보니 Bool값들이 전부 0과 1로 표현되고 있었다.
알아보니 JSONSerialization이 Bool타입을 NSNumber로 바꿔서 저장한다고 한다. 다행히도 CFGetTypeID(:_)에서 타입 비교로 NSNumber가 Bool타입인지 여부를 알 수 있었고, 명시적으로 Bool값으로 변환해 주었다.
object나 array같은 중첩이 있기 때문에 재귀로 처리했다.
extension Encodable {
var dictionary: [String: Any] {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
guard let data = try? encoder.encode(self) else {
return [:]
}
guard let serialized = try? JSONSerialization.jsonObject(
with: data,
options: .allowFragments
) else {
return [:]
}
let boolInspected = self.boolInspected(serialized)
guard var dictionay = boolInspected as? [String: Any] else {
return [:]
}
return dictionay
}
/// Bool 타입이 JSONSerialization에서 Any가 될 때 NSNumber로 처리됨, 타입으로 검증해 명시적 Bool타입으로 변경
private func boolInspected(_ value: Any) -> Any {
if let dictValue = value as? [String: Any] {
return dictValue.mapValues { value in
self.boolInspected(value)
}
} else if let arrayValue = value as? [Any] {
return arrayValue.map { value in
self.boolInspected(value)
}
} else if let boolValue = value as? NSNumber, CFGetTypeID(boolValue) == CFBooleanGetTypeID() {
return boolValue.boolValue
} else {
return value
}
}
}
마무리
마지막으로 모든 Response 내에서 [String: AnyCodable] 타입을 계속 선언해야 한다는 점이 조금 거슬려서 다시한번 SingleValueContainer를 사용해 Codable 적용시 타입을 숨겨주었고, 새로운 타입을 로깅함수의 인자로 받았다.
이러면 조금 더 명확한 의도를 나타낼 수 있게 된다.
public struct ServerEventLog: Codable {
private let log: [String: AnyCodable]
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.log = try container.decode([String: AnyCodable].self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.loggilogng)
}
}
struct Response: Decodable {
let log: ServerEventLog
}
/// Amplitude Logger
public func log(event: AmplitudeEvent, log: ServerEventLog? = nil) {
let eventKey = event.eventKey
let eventProperties = log.dictionary
Amplitude.instance().logEvent(
eventKey,
withEventProperties: eventProperties
)
}
확인해보면 Object, Array 모두 잘 나오고 있다.
let json = """
{
"logging": {
"array": [
123,
"foo",
false,
{
"arrayObjectBool": false
}
],
"bool": true,
"double": 3.141592,
"int": 0,
"object": {
"objectArray": [
1,
"2",
true,
],
"objectBool": false,
"objectDouble": 0.0001,
"objectInt": 1
},
"string": "Hello, World!",
}
}
"""
let response = try! JSONDecoder().decode(
Response.self,
from: json.data(using: .utf8)!
)
response.log.forEach { (k, v) in
print("🔑: \(k): 🎁 \(v)")
}
/*
🔑: array: 🎁 [123, foo, false, ["arrayObjectBool": false]]
🔑: bool: 🎁 true
🔑: double: 🎁 3.141592
🔑: int: 🎁 0
🔑: object: 🎁 ["objectArray": [1, 2, true], "objectBool": false, "objectDouble": 0.0001, "objectInt": 1]
🔑: string: 🎁 Hello, World!
*/