Codable

Codable 사용해보기


Codable

Concurrency Programming Guide를 공부 할 차례인데
회사 프로젝트에 Codable을 사용하고 싶어 이렇게 Codable을 먼저 하게 됐어요!
(같은 값인데 API에 따라 key 값도 value 타입도 달라서 하나씩 다 캐스팅해줬었는데 이걸 Codable로 하면 어떻게 할 수 있을까 궁금해졌어요)

공부 방법은

  1. Encoding and Decoding Custom Types 문서를 읽고
  2. 단순히 property의 key 값을 맞춰주는 정도가 아닌 제가 사용할 수 있는 예제 코드를 만들어 보려고 해요


문서 부터 시작을 하겠습니다.

Encoding and Decoding Custom Types

Codable 프로토콜을 채택하면 JSON 같은 타입을 내가 만든 model로 encoding decoding 가능하게 만들어준다.

Overview

요즘 프로그램들이 encode와 decode를 자주 하니깐 swift library에서는
Encodable, Decodable이라는 표준화된 접근 방식(프로토콜)을 정의해놨다.

이 프로토콜을 채택하면 프로토콜의 구현

1. Encodable: public func encode(to encoder: Encoder) throws
2. Decodable: public init(from decoder: Decoder) throws

을 통해서 encoding 혹은 decoding 할 수 있다.
encoding, decoding 둘 다 지원하려면 Codable 프로토콜을 준수하면 된다.

Encode and Decode Automatically

model type을 Codable하게 만드는 가장 쉬운 방법은
기본 library(String, Int, Double…), foundation(Date, Data, URL…) 같은 이미 Codable이 가능한 타입을 property로 선언하는 방법이다.
이러한 타입의 property를 가진 타입에 Codable을 채택하면 프로토콜에 정의된 메서드가 자동으로 구현된다.

아래 코드를 살펴보면
Codable을 준수하고 property들이 기본 library type이므로 자동으로 Encodable, Decodable을 준수하게 된다.

struct Landmark: Codable {
    var name: String
    var foundingYear: Int
    
    // Landmark now supports the Codable methods init(from:) and encode(to:), 
    // even though they aren't written as part of its declaration.
}

그리고 PropertyListEncoder, Decoder나 JSONEncoder, Decoder 같은 Encoder, Decoder Type들은
Codable protocol에 정의된 메서드를 사용해서 decode 혹은 encode를 한다.

아래처럼 custom type(A)이 Codable protocol을 준수한다면 custom type(A)을 proeprty로 가지는 다른 custom type(B)에서도 codable 가능 하다.

struct Coordinate: Codable {
    var latitude: Double
    var longitude: Double
}

struct Landmark: Codable {
    // Double, String, and Int all conform to Codable.
    var name: String
    var foundingYear: Int
    
    // Adding a property of a custom Codable type maintains overall Codable conformance.
    var location: Coordinate
}

Array, Dictionary 및 Optional과 같은 Built-in type은 Codable을 준수한다, 따라서 아래처럼 코드를 작성해도 codable 가능 하다.

struct Landmark: Codable {
    var name: String
    var foundingYear: Int
    var location: Coordinate
    
    // Landmark is still codable after adding these properties.
    var vantagePoints: [Coordinate]
    var metadata: [String: String]
    var website: URL?
}
Encode or Decode Exclusively

Codable은 Encodable, Decodable을 둘 다 준수하는 protocol이지만 한가지 기능만 필요하다면 아래 코드처럼 Codable이 아닌 Encodable, Decodable 둘 중 하나만 준수해도 된다.

struct Landmark: Encodable {
    var name: String
    var foundingYear: Int
}
struct Landmark: Decodable {
    var name: String
    var foundingYear: Int
}
Choose Properties to Encode and Decode Using Coding Keys
1. CodingKey protocol을 준수하고
2. rawValue가 String인 CodingKeys라는 이름을 가진 enum을 사용해 
3. 아래 코드처럼 custom type의 property와 매칭 시켜줄 수 있다.

JSON 형식을 예로 들었을 때 포함하고 싶지 않은 값은 custom type의 property를 없애고 CodingKeys도 없애면 된다.

struct Landmark: Codable {
    var name: String
    var foundingYear: Int
    var location: Coordinate
    var vantagePoints: [Coordinate]
    
    enum CodingKeys: String, CodingKey {
        case name = "title"
        case foundingYear = "founding_date"
        
        case location
        case vantagePoints
    }
}
Encode and Decode Manually

데이터 형식의 구조(예를 들어 JSON의 구조)가 custom type의 구조와 다를 경우
수동으로 decode와 econde를 통해 원하는 결과를 얻을 수 있다.

아래에 살펴볼 예제는 다른 레벨에 있는 데이터를 가지고 같은 레벨의 데이터를 만드는 과정을 설명한다.

{
    "A": 1,
    "B": 2,
    "C": {
        "D": 3
    }
}
1. 예를들어 이러한 JSON 데이터를 가지고
2. 1, 2, 3을 value값으로 가지는 custom type을 만든다고 가정하고
3. 아래 글을 읽으면 좋을것 같다.

아래 예제는 C(additionalInfo) 안에 있는 D(elevation)를 property로 지원하기 위해서 codingkey를 두 개 만든 코드이다.
각각 자신의 레벨에서 해당하는 key 를 가지고 있다.

struct Coordinate {
    var latitude: Double
    var longitude: Double
    var elevation: Double

    enum CodingKeys: String, CodingKey {
        case latitude
        case longitude
        case additionalInfo
    }
    
    enum AdditionalInfoKeys: String, CodingKey {
        case elevation
    }
}

아래 예제는 init(from decoder: Decoder)를 수동으로 구현한 예제이다.

extension Coordinate: Decodable {
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        latitude = try values.decode(Double.self, forKey: .latitude)
        longitude = try values.decode(Double.self, forKey: .longitude)
        
        let additionalInfo = try values.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo)
        elevation = try additionalInfo.decode(Double.self, forKey: .elevation)
    }
}

decoder 파라미터를 사용해 각 proeprty를 채워 넣는 방식으로 인스턴스 오브젝트를 초기화한다.

아래 예제는 func encode(to encoder: Encoder)를 수동으로 구현한 예제이다.

extension Coordinate: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(latitude, forKey: .latitude)
        try container.encode(longitude, forKey: .longitude)
        
        var additionalInfo = container.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo)
        try additionalInfo.encode(elevation, forKey: .elevation)
    }
}

encode(to:) 구현은 init(from decoder: Decoder)와 반대로 하면 된다.


문서는 여기까지고요. 아래에 예제 코드를 작성해봤습니다.

위에서 언급했듯이

1. 같은 값을 나타내는 필드이지만 key 값이 다르고
2. 심지어 value의 타입도 다른 경우

를 위해 작성해 봤고요. 혹시나 더 좋은 방법 있으면 알려주세요! 꼭 부탁드려요!

사용
let json = """
{
    "user": [
        {
            "name": "사용자A",
            "age": "28",
            "job": "developer",
            "force_app_version": "Y"
        },
        {
            "NAME": "사용자B",
            "age": 26,
            "job": "designer",
            "force_app_version": false
        }
    ]
}
""".data(using: .utf8)!

//user container
struct UserList: Decodable {
    let list: [User]
    
    private enum CodingKeys: String, CodingKey {
        case list = "user"
    }
}

//user
struct User {
    enum JobType: String, Decodable {
        case developer
        case designer
        case none
    }
    
    let name: String        //json의 key 값이 다르다. (uppercase, lowercase)
    let age: Int            //json의 value type 이 다르다. (String, Int -> Int)
    let job: JobType        //이건 그냥 해봤어요
    let forceUpdate: Bool   //json의 value type 이 다르다. (String, Bool -> Bool)
}

//coding key
extension User {
    struct CodingKeys: CodingKey {
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }
        
        var intValue: Int? { return nil }
        init?(intValue: Int) { return nil }
        
        private static let baseName = "name"
        static func name(letterCase: String.LetterCase) -> CodingKeys {
            return CodingKeys(stringValue: baseName.changedString(letterCase))!
        }
        static let age = CodingKeys(stringValue: "age")!
        static let job = CodingKeys(stringValue: "job")!
        static let forceUpdate = CodingKeys(stringValue: "force_app_version")!
    }
}

//decode
extension User: Decodable {
    typealias LetterCase = String.LetterCase
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try [LetterCase.lower, LetterCase.upper].extractOneElement(type: String.self) { letterCase -> String? in
            return try? container.decode(String.self, forKey: .name(letterCase: letterCase))
        }
        age = try container.decode(type: Int.self, forKey: .age)
        job = try container.decode(type: JobType.self, forKey: .job)
        forceUpdate = try container.decode(type: Bool.self, forKey: .forceUpdate)
    }
}

do {
    let decoder = JSONDecoder()
    let products = try decoder.decode(UserList.self, from: json)
    dump(products.list)
    /*
     ▿ 2 elements
     
     ▿ Codable.User
     - name: "사용자A"
     - age: 28
     - job: Codable.User.JobType.developer
     - forceUpdate: true
     
     ▿ Codable.User
     - name: "사용자B"
     - age: 26
     - job: Codable.User.JobType.designer
     - forceUpdate: false
     */
} catch {
    print("Decode Error: \(error.localizedDescription)")
}
Extension
//error
public struct DecodeError: Error, LocalizedError {
    let description: String
    
    public var errorDescription: String? {
        return description
    }
}

//decoder
extension KeyedDecodingContainer {
    public func decode<T>(type: T.Type, forKey key: KeyedDecodingContainer.Key) throws -> T where T : Decodable {
        switch type {
        case is Int.Type:
            do {
                let value =  try intDecode(forKey: key)
                return value as! T
            } catch {
                throw error
            }
        case is Bool.Type:
            do {
                let value =  try boolDecode(forKey: key)
                return value as! T
            } catch {
                throw error
            }
        default:
            do {
                return try decode(type, forKey: key)
            } catch {
                throw DecodeError(description: "Decode Error")
            }
        }
    }
    //int
    public func intDecode(forKey key: KeyedDecodingContainer.Key) throws-> Int? {
        do {
            return try [Int.self, String.self].extractOneElement(type: Int.self) { convertibleType -> Int? in
                switch convertibleType {
                case is Int.Type:
                    return try? decode(Int.self, forKey: key)
                case is String.Type:
                    guard let value = try? decode(String.self, forKey: key) else { return nil }
                    return Int(value)
                default:
                    return nil
                }
            }
        } catch {
            throw error
        }
    }
    //bool
    public func boolDecode(forKey key: KeyedDecodingContainer.Key) throws-> Bool? {
        do {
            return try [Bool.self, String.self].extractOneElement(type: Bool.self) { convertibleType -> Bool? in
                switch convertibleType {
                case is Bool.Type:
                    return try? decode(Bool.self, forKey: key)
                case is String.Type:
                    guard let value = try? decode(String.self, forKey: key) else { return nil }
                    return value.asBool
                default:
                    return nil
                }
            }
        } catch {
            throw error
        }
    }
}

extension Array {
    public func extractOneElement<T>(type: T.Type, _ body: (Element) -> T?) throws -> T {
        for element in self {
            let decodedElement = body(element)
            guard decodedElement == nil else { return decodedElement! }
        }
        throw DecodeError(description: "Extract One Element Empty Error")
    }
}

extension String {
    enum LetterCase {
        case upper
        case lower
    }
    
    func changedString(_ letterCase: LetterCase) -> String {
        switch letterCase {
        case .lower:
            return lowercased()
        case .upper:
            return uppercased()
        }
    }
}

extension String {
    var asBool: Bool {
        return self == "1" || self == "OK" || self == "true" || self == "Y"
    }
}
느낀점
1. 데이터 형식의 key와 value가 일정할 때 사용하면 정말 좋을 것 같다.(대부분은 일정하니 정말 좋은 기능이다.)
2. 그렇지 않다면 기존에 수동(JSON -> Dictionary -> Casting)으로 해주는 것과 비슷하게 처리해 줘야 한다.

잘못된 부분이 있다면 알려주시면 바로 수정하겠습니다.