Decode dynamic key
Không ít lần mình gặp trường hợp cùng một API nhưng key trả về lại khác nhau. Có rất nhiều cách để giải quyết bài toán này như custom hàm init(from decoder:)
hay cầu xin đội backend sửa API. Tuy nhiên vấn đề chung của cả hai cách trên là chúng khá thủ công và tốn sức. Ở bài viết này, cùng mình sử dụng JSONDecoder
để tìm ra một giải pháp generic và reusable.
JSON với dynamic key
Hãy tưởng tượng chúng ta đang làm ứng dụng banking và đây là response từ server. Dynamic key ở trường hợp này là amount
với các biến thể như weirdAmount
, specialAmount
, hay totalAmount
let clientJSON = """
{
"clients": [
{
"name": "John",
"amount": 1000
},
{
"name": "Tony",
"weirdAmount": 2000
},
{
"name": "Alex",
"specialAmount": 3000
},
{
"name": "Paul",
"totalAmount": 4000
}
]
}
""".data(using: .utf8)!
Dựa theo response trên, ta tạo ra ClientList
chứa danh sách các Client
và conform chúng với protocol Decodable
. Client
sẽ nhóm chung tất cả các phiên bản của amount
vào biến amount
struct ClientList: Decodable {
let clients: [Client]
}
struct Client: Decodable {
let name: String
let amount: Double
enum CodingKeys: String, CodingKey {
case name
case amount
}
}
keyDecodingStrategy
Trong trường hợp lý tưởng, ta muốn JSONDecoder
tự động decode bằng cách gọi hàm
try decoder.decode(ClientList.self, from: clientJSON)
Để đạt được điều này, ta có thể cung cấp custom decoding strategy cho thuộc tính keyDecodingStrategy
của JSONDecoder
. Ngoại trừ .useDefaultKeys
, nếu bạn cung cấp một giá trị khác cho keyDecodingStrategy
, JSONDecoder
sẽ chạy strategy đó với từng key trong JSON để decode chúng
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .custom(//([CodingKey]) -> CodingKey)
JSONDecoder.KeyDecodingStrategy.custom
nhận vào một closure ở dạng ([CodingKey]) -> CodingKey
. Đây chính là closure được chạy với mọi JSON key tại thời điểm decode. Input array [CodingKey]
chứa toàn bộ các key cha dẫn đến key hiện giờ để tiện cho việc decode. Khi làm việc với array này, key mà chúng ta quan tâm luôn nằm ở vị trí cuối cùng.
DynamicKey
Key .amount
trong enum CodingKeys
của Client
là key tĩnh với rawValue
là String
"amount"
. Nếu giữ nguyên như hiện trạng, ta sẽ gặp lỗi không tìm được key khi decode các JSON object chứa weirdAmount
, specialAmount
, hay totalAmount
bởi rawValue
của chúng không khớp "amount"
.
Chính vì lý do này, ta cần tạo ra một struct
CodingKey
đặc biệt có nhiệm vụ thay thế cho key .amount
struct DynamicKey: CodingKey {
var stringValue: String
init?(stringValue: String) { self.stringValue = stringValue }
var intValue: Int?
init?(intValue: Int) {
self.stringValue = "\(intValue)"
self.intValue = intValue
}
init(string: String) { self.stringValue = string }
}
Giờ đây, mỗi khi bắt gặp JSON key có rawValue
khác với "amount"
, ta chỉ cần khởi tạo DynamicKey
với rawValue
mong muốn và swap chúng một cách dễ dàng
//inputs chứa các phiên bản động mà bạn muốn transform thành cùng 1 output
func convert(from inputs: Set<String>, to output: String) -> ([CodingKey]) -> CodingKey {
return { keys in
//trả ra rawValue rỗng nếu key array empty
guard let last = keys.last else { return DynamicKey(string: "") }
//nếu key hiện giờ có trong inputs thì swap nó với rawValue output
return inputs.contains(last.stringValue)
? DynamicKey(string: output) : last
}
}
let decoder = JSONDecoder()
let closure = convert(from: ["weirdAmount", "specialAmount", "totalAmount"],
to: Client.CodingKeys.amount.rawValue)
decoder.keyDecodingStrategy = .custom(closure)
try decoder.decode(ClientList.self)
Hoàn thiện function “convert”
DynamicKey
và function convert
là toàn bộ những gì ta cần để giải quyết bài toán. Tuy nhiên cách sử dụng hiện giờ khá thủ công và dễ xảy ra lỗi khi ta phải cung cấp input đầu vào và ouput đầu ra ở tất cả những nơi decode. Không chỉ vậy, cách làm trên còn gò bó và khó reuse bởi không gì ngăn chúng ta transform rawValue
đầu vào thành một dạng CodingKey
khác thay vì DynamicKey
.
Nếu quan sát kỹ, ta có thể khái quát việc biến đổi rawValue
thành dạng phù hợp được cấu thành từ 2 bước:
- Check điều kiện input đầu vào
- Biến đổi input thành output nếu thoả mãn điều kiện
Phiên bản generic của function convert
sẽ trông như dưới đây
func convert(transformer: @escaping (CodingKey?) -> CodingKey,
when predicate: @escaping (CodingKey) -> Bool) -> ([CodingKey]) -> CodingKey {
return { keys in
guard let last = keys.last else { return transformer(nil) }
return predicate(last) ? transformer(last) : last
}
}
Giờ đây dựa vào từng strategy cụ thể mà ta có thể tạo ra phiên bản convert
phù hợp nhất thay vì một function thiếu linh hoạt như ban đầu
func convert(from inputs: Set<String>, to output: String) -> ([CodingKey]) -> CodingKey {
return convert(transformer: { _ in DynamicKey(string: output) },
when: { inputs.contains($0.stringValue) })
}
Quay lại trường hợp dynamic key .amount
, để loại bỏ hoàn toàn việc cung cấp input - output thủ công, ta sẽ thực hiện một chỉnh sửa cuối cùng
protocol SpecialCaseConvertible: CaseIterable, RawRepresentable
where RawValue == String {
static var targetCase: String { get }
}
struct Clients: Decodable {
let clients: [Client]
}
struct Client: Decodable {
let name: String
let amount: Double
enum CodingKeys: String, CodingKey {
case name
case amount
}
enum SpecialCase: String, SpecialCaseConvertible {
case totalAmount
case weirdAmount
case specialAmount
static var targetCase: String { CodingKeys.amount.rawValue }
}
}
func convert<T: SpecialCaseConvertible>(_ type: T.Type) -> ([CodingKey]) -> CodingKey {
let inputs = Set(T.allCases.map { $0.rawValue })
return convert(transformer: { _ in DynamicKey(string: T.targetCase) },
when: { inputs.contains($0.stringValue) })
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .custom(convert(Client.SpecialCase.self))
try decoder.decode(Clients.self, from: clientJSON)
Với protocol SpecialCaseConvertible
, tất cả các type conform nó sẽ mặc định decode ra targetCase
mà không còn phải hardcode. Cách làm này giúp code clear và an toàn hơn khi tất cả những gì bạn cần biết đều tập trung ở model layer là enum SpecialCase
và thay đổi của nó không ảnh hưởng tới những nơi nó được decode.
Tổng kết
Trên đây là hướng tiếp cận của mình với bài toán decode dynamic key. Mong rằng nó sẽ có ích với bạn và hẹn gặp lại ở những bài viết tới.
Source code: Decode Dynamic Key