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