33
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【Swift】型を使うという意味を考える (Codable再入門)

Posted at

Codableとは?


typealias Codable = Decodable & Encodable

Swift4で導入されたEncodableDecodableというProtocolのtype aliasで
Encodableに適合することでSwiftの型をJSONやPropertyListなどに
Decodableに適合することでJSONやPropertyListなどをSwiftの型に
変換することができます。

既存の型はCodableに適合しているものが多く
独自の型を定義する際も中のプロパティが全てCodableに適合している場合
宣言をするだけで自動で変換が可能になるため
Codableのおかげで多くのボイラープレートを削除することができ
日々の開発に使用されています。

さらに
独自の変換方法を定義して柔軟な変換を行うことも可能です。

今回は特にJSONDecodableに焦点を当て
普段何気なく使用しているCodable
改めてどのように活用できるのかを考えてみました。

基本的な使い方

まず下記のようなJSONデータがあるとします。


let usersJson = """
[
    {
        "id": 1,
        "name": "Taro",
        "email": "taro@sample.com",
    },
    {
        "id": 2,
        "name": "Jiro",
        "email": "jiro@sample.com",
    },
    {
        "id": 3,
        "name": "Saburo",
        "email": "saburo@sample.com",
    }
]
"""

これに対して
Codableに適合したUser型を定義して変換してみたいと思います。

struct User: Codable {
    let id: Int
    let name: String
    let email: String
}

let decoder = JSONDecoder()
let users = try! decoder.decode([User].self, from: Data(usersJson.utf8))

[User(id: 1, name: "Taro", email: "taro@sample.com"), 
User(id: 2, name: "Jiro", email: "jiro@sample.com"), 
User(id: 3, name: "Saburo", email: "saburo@sample.com")]

プロパティがすべてCodalbeに適合しているため
User型もCodableを宣言するだけで適合することができます。

また
JSONのキー名とプロパティの名前は一致しているため
そのまま変換をすることが可能になっています。

JSONのキーと型のプロパティを違う名前にしたい場合

名前が異なる場合はどうでしょうか?
JSONのキーとプロパティ名の関連性によって
いくつかの変換方法があります。

自動で変換してくれるkeyEncodingStrategy & keyDecodingStrategy

よくあるケースとして
APIとのやりとりの中でJSONのキー名はsnakeCaseであることがあります。

一方で
SwiftのプロパティはlowerCamelCaseにすることが
APIデザインガイドラインでも推奨されています。

Follow case conventions. 
Names of types and protocols are UpperCamelCase. 
Everything else is lowerCamelCase.

このような場合
一定の規則に則っていると
keyEncodingStrategykeyDecodingStrategyを用いることで
自動で変換してくれます。

https://developer.apple.com/documentation/foundation/jsonencoder/keyencodingstrategy
https://developer.apple.com/documentation/foundation/jsondecoder/keydecodingstrategy/convertfromsnakecase

下記に例を示します。

let usersJson = """
[
    {
        "id": 1,
        "user_name": "Taro",
        "email_address": "taro@sample.com",
    },
    {
        "id": 2,
        "user_name": "Jiro",
        "email_address": "jiro@sample.com",
    },
    {
        "id": 3,
        "user_name": "Saburo",
        "email_address": "saburo@sample.com",
    }
]
"""

struct User: Codable {
    let id: Int
    let userName: String
    let emailAddress: String
}

let decoder = JSONDecoder()

// ここでどういう風に変換するのかを決めている
decoder.keyDecodingStrategy = .convertFromSnakeCase

let users = try! decoder.decode([User].self, from: Data(usersJson.utf8))

上記のように
keyDecodingStrategyconvertFromSnakeCaseを設定するだけで
snakeCaseのキー名からlowerCamelCaseのプロパティ名の変換をしてくれます。

ただし
変換方法には下記のようなルールがあり
これに適合しない場合は手動でCodingKeysを定義する必要が出てきます。

1. アンダースコアの次に来る文字を大文字にする
2. 最初と最後のアンダースコア以外のアンダースコアを除去する
3. 全ての単語を一つの文字列にする

URIなどは合致しないため
手動での定義が必要になります。

※ 補足として
keyDecodingStrategyにはuseDefaultKeysもあり
こちらがデフォルトでdecode時に
JSONの名前をそのままプロパティの名前に使用します。

名前が異なる場合は?

CodingKeyプロトコルに適合し
RawValueStringのenumを生成することで
JSONのキーとデータ構造のプロパティ名のマッピングをすることができます。


let usersJson = """
[
    {
        "id": 1,
        "user_name": "Taro",
        "email_address": "taro@sample.com",
    },
    {
        "id": 2,
        "user_name": "Jiro",
        "email_address": "jiro@sample.com",
    },
    {
        "id": 3,
        "user_name": "Saburo",
        "email_address": "saburo@sample.com",
    }
]
"""

struct User: Codable {
    let id: Int
    let name: String
    let email: String

    // ここでJSONのキー名とUserのプロパティ名をマッピング
    // 必要のないものはそのまま
    enum CodingKeys: String, CodingKey {
        case id
        case name = "user_name"
        case email = "email_address"
    }
}

DateEncodingStrategy & DateDecodingStrategy

JSONの型とプロパティの型が異なる場合があります。

その中でも日付の変換に関しては
DateEncodingStrategyDateDecodingStrategyというenumを使うことで
自動で変換をしてくれるようになります。

https://developer.apple.com/documentation/foundation/jsonencoder/2895363-dateencodingstrategy
https://developer.apple.com/documentation/foundation/jsondecoder/datedecodingstrategy


let usersJson = """
[
    {
        "id": 1,
        "name": "Taro",
        "email": "taro@sample.com",
        "entry_date": 1558483628,
    },
    {
        "id": 2,
        "name": "Jiro",
        "email": "jiro@sample.com",
        "entry_date": 1559483628,
    },
    {
        "id": 3,
        "name": "Saburo",
        "email": "saburo@sample.com",
        "entry_date": 1560483628,
    }
]
"""

struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let entryDate: Date
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

// dateDecodingStrategyを設定することでDate型へ自動で設定してくれる
decoder.dateDecodingStrategy = .secondsSince1970

let users = try! decoder.decode([User].self, from: Data(usersJson.utf8))

print(users)

User(id: 1, name: "Taro", email: "taro@sample.com", entryDate: 2019-05-22 00:07:08 +0000), 
User(id: 2, name: "Jiro", email: "jiro@sample.com", entryDate: 2019-06-02 13:53:48 +0000),
User(id: 3, name: "Saburo", email: "saburo@sample.com", entryDate: 2019-06-14 03:40:28 +0000)

※ もし型がうまく合わない場合は独自でフォーマットを定義することもできます。

例えば下記のようISO8601形式のフォーマットは自動変換してくれません。
https://ja.wikipedia.org/wiki/ISO_8601


// この形式はDate型への変換に失敗する🙀

let usersJson = """
[
    {
        "id": 1,
        "name": "Taro",
        "email": "taro@sample.com",
        "entry_date": "2019-05-22T05:50:00.000Z",
    },
    {
        "id": 2,
        "name": "Jiro",
        "email": "jiro@sample.com",
        "entry_date": "2019-05-23T05:50:00.000Z",
    },
    {
        "id": 3,
        "name": "Saburo",
        "email": "saburo@sample.com",
        "entry_date": "2019-05-24T05:50:00.000Z",
    }
]
"""

struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let entryDate: Date
}

そこで独自フォーマットを指定することで
この問題を解決します。


extension DateFormatter {
    static let fullISO8601: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        return formatter
    }()
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

// ここで独自フォーマットを指定する
decoder.dateDecodingStrategy = .formatted(DateFormatter.fullISO8601)

let users = try! decoder.decode([User].self, from: Data(usersJson.utf8))

User(id: 1, name: "Taro", email: "taro@sample.com", entryDate: 2019-05-22 05:50:00 +0000), 
User(id: 2, name: "Jiro", email: "jiro@sample.com", entryDate: 2019-05-23 05:50:00 +0000), 
User(id: 3, name: "Saburo", email: "saburo@sample.com", entryDate: 2019-05-24 05:50:00 +0000)

SingleValueEncodingContainer & SingleValueDecodingContainer

例えばUserを下記のように変更してみます。


struct User: Codable {
    let id: Int
    let name: String
    let email: Email
}

struct Email: Codable {
    let value: String
}

Userのemailを
一つのプロパティを持ったEmail型にします。

このような場合
今までのようなJSON形式では変換できません。

自動変換するにはJSONが下記のような形である必要があります。


{
    "id": 1,
    "name": "Taro",
    "email": { "value": "taro@sample.com" },
}

解決するために
SingleValueEncodingContainerSingleValueDecodingContainerが活用できます。

https://developer.apple.com/documentation/swift/singlevaluedecodingcontainer
https://developer.apple.com/documentation/swift/singlevalueencodingcontainer

これを使うことで型を単純な値として扱えるようになります。


struct Email: Codable {
    let value: String

    // SingleValueDecodingContainerを用いることでJSONの値から直接型に変換できる
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        self.value = try container.decode(String.self)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(value)
    }
}

RawRepresentableでもう少しスッキリさせることも

上記の例はRawRepresentableを使うと
もう少しコードを短くすることができます。


struct Email: Codable, RawRepresentable {
    let rawValue: String
}

これだけでOKです。
これはRawRepresentableがextensionで
Encodable, Decodableに適合しているからです。


extension RawRepresentable where Self : Encodable, Self.RawValue == String {

    /// Encodes this value into the given encoder, when the type's `RawValue`
    /// is `String`.
    ///
    /// This function throws an error if any values are invalid for the given
    /// encoder's format.
    ///
    /// - Parameter encoder: The encoder to write data to.
    public func encode(to encoder: Encoder) throws
}

extension RawRepresentable where Self : Decodable, Self.RawValue == String {

    /// Creates a new instance by decoding from the given decoder, when the
    /// type's `RawValue` is `String`.
    ///
    /// This initializer throws an error if reading from the decoder fails, or
    /// if the data read is corrupted or otherwise invalid.
    ///
    /// - Parameter decoder: The decoder to read data from.
    public init(from decoder: Decoder) throws
}

さらにプロパティの名前をrawValueにすることで
イニシャライザも自動で定義されるようになりました。

ネストされた型を変換する

APIなどから返ってくるJSONは
JSONの中にさらにJSONが含まれているような形式のものが多く見られ
作りたいデータ構造とは異なる形式である場合があります。

そのような際に独自の変換が活用できます。
これもいくつかの方法があります。

データ構造を合わせる

上記の例と同様にデータ構造を合わせることで変換が可能になります。

例えば下記のようなJSONに対して


let usersJson = """
[
    {
        "id": 1,
        "name": "Taro",
        "email": "taro@sample.com",
        "profile": {
            "nick_name": "t",
            "profile_image_url": "profile/taro.jpg",
            "follow_number": 10,
        }
    },
    {
        "id": 2,
        "name": "Jiro",
        "email": "jiro@sample.com",
        "profile": {
            "nick_name": "j",
            "profile_image_url": "profile/jiro.jpg",
            "follow_number": 20,
        }
    },
    {
        "id": 3,
        "name": "Saburo",
        "email": "saburo@sample.com",
        "profile": {
            "nick_name": "s",
            "profile_image_url": "profile/saburo.jpg",
            "follow_number": 30,
        }
    },
]
"""

下記のようなデータ構造を作成することで
自動変換が可能になります。


struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let profile: Profile

    struct Profile: Codable {
        let nickName: String
        let profileImageUrl: URL
        let followNumber: Int
    }
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let users = try! decoder.decode([User].self, from: Data(usersJson.utf8))

[User(id: 1, name: "Taro", email: "taro@sample.com", profile: User.Profile(nickName: "t", profileImageUrl: profile/taro.jpg, followNumber: 10)), 
User(id: 2, name: "Jiro", email: "jiro@sample.com", profile: User.Profile(nickName: "j", profileImageUrl: profile/jiro.jpg, followNumber: 20)), 
User(id: 3, name: "Saburo", email: "saburo@sample.com", profile: User.Profile(nickName: "s", profileImageUrl: profile/saburo.jpg, followNumber: 30))]

KeyedEncodingContainer & KeyedDecodingContainer

例えば上記のJSONの形式と
欲しいデータ構造の形が異なる場合を考えてみたいと思います。

先ほどと似たような例ですが
一部変更を加えています。


let usersJson = """
[
    {
        "id": 1,
        "name": "Taro",
        "email": "taro@sample.com",
        "profile": {
            "nick_name": "t",
            "profile_image_url": "profile/taro.jpg",
            "follow_number": 10,
        }
    },
    {
        "id": 2,
        "name": "Jiro",
        "email": "jiro@sample.com",
        "profile": {
            "nick_name": "j",
            "profile_image_url": "profile/jiro.jpg",
            "follow_number": 20,
        }
    },
    {
        "id": 3,
        "name": "Saburo",
        "email": "saburo@sample.com",
        "profile": {
            "nick_name": "s",
       // profile_imageがない
            "follow_number": 30,
        }
    },
]
"""

これを下記のデータ構造に変換したいと思います。


struct User: Codable {
    let id: Int
    let name: String
    let email: String

    // この2つはProfileから取ってきたい
    let nickName: String
    let profileImageUrl: String
}

このような場合は
CodingKeyに適合したenumと
KeyedEncodingContainerKeyedDecodingContainer
を用いて変換方法を定義することができます。

https://developer.apple.com/documentation/swift/keyedencodingcontainer
https://developer.apple.com/documentation/swift/keyeddecodingcontainer


struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let nickName: String
    let profileImageUrl: String?

    // JSONのキーを指定
    enum CodingKeys: String, CodingKey {
        case id
        case name
        case email
        case profile

        enum ProfileKeys: String, CodingKey {
            case nickName
            case profileImageUrl
            case followNumber
        }
    }

    init(from decoder: Decoder) throws {

        // decoderからcontainerを取得する
        let container = try decoder.container(keyedBy: CodingKeys.self)

        // containerを使って各値をdecodeする
        id = try container.decode(Int.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        email = try container.decode(String.self, forKey: .email)

        // containerからさらにnestedContainerを取得する
        let profileContainer = try container.nestedContainer(keyedBy: CodingKeys.ProfileKeys.self, forKey: .profile)

        // nestedContainerを使って各値をdecodeする
        nickName = try profileContainer.decode(String.self, forKey: .nickName)
        profileImageUrl = try profileContainer.decodeIfPresent(String.self, forKey: .profileImageUrl)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(name, forKey: .name)
        try container.encode(email, forKey: .email)        
        var profileContainer = container.nestedContainer(keyedBy: CodingKeys.ProfileKeys.self, forKey: .profile)
        try profileContainer.encode(nickName, forKey: .nickName)
        try profileContainer.encodeIfPresent(profileImageUrl, forKey: .profileImageUrl)
    }
}

こうすることでネストされた型から値を取得したり
JSONから取得した値をさらに加工して設定したりなど可能になります。

また場合によっては値がないものに関してはdecodeIfPresentのように
IfPresentをつけることでnilが入るようになります。

※ ちなみにIfPresent系のメソッドにthrowsが付いていますが
これは値があった際に変換する型が合わなかった場合にtypeMismatchが起きるからです。
https://github.com/apple/swift/blob/master/stdlib/public/core/Codable.swift.gyb#L474

UnkeyedEncodingContainer & UnkeyedDecodingContainer

もっと複雑な例を考えています。
ネストされたJSONの中に配列があるとします。


let usersJson = """
[
    {
        "id": 1,
        "name": "Taro",
        "email": "taro@sample.com",
        "profile": {
            "nick_name": "t",
            "profile_image_url": "profile/taro.jpg",
            "follow_number": 10,
            "follows": [
                { "id": 1 },
            ],
        }
    },
    {
        "id": 2,
        "name": "Jiro",
        "email": "jiro@sample.com",
        "profile": {
            "nick_name": "j",
            "profile_image_url": "profile/jiro.jpg",
            "follow_number": 20,
            "follows": [
                { "id": 1 },
                { "id": 2 },
            ],
        }
    },
    {
        "id": 3,
        "name": "Saburo",
        "email": "saburo@sample.com",
        "profile": {
            "nick_name": "s",
            "profile_image_url": "profile/saburo.jpg",
            "follow_number": 30,
            "follows": [
                { "id": 1 },
                { "id": 2 },
                { "id": 3 },
            ],
        }
    },
]
"""

これにUnkeyedEncodingContainerUnkeyedDecodingContainer
を活用してみます。

これはCodingKeyを使用せずに
Continerの中身に順番にアクセスすることができます。

https://developer.apple.com/documentation/swift/unkeyedencodingcontainer
https://developer.apple.com/documentation/swift/unkeyeddecodingcontainer


struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let nickName: String
    let profileImageUrl: String
    let followIds: [Int]

    enum CodingKeys: String, CodingKey {
        case id
        case name
        case email
        case profile

        enum ProfileKeys: String, CodingKey {
            case nickName
            case profileImageUrl
            case followNumber
            case follows

            enum FollowsKeys: String, CodingKey {
                case id
            }
        }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Int.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        email = try container.decode(String.self, forKey: .email)

        let profileContainer = try container.nestedContainer(keyedBy: CodingKeys.ProfileKeys.self, forKey: .profile)
        nickName = try profileContainer.decode(String.self, forKey: .nickName)
        profileImageUrl = try profileContainer.decode(String.self, forKey: .profileImageUrl)

        // ここでUnkeyedContainerを取得する(ここではfollowersの配列)
        // varであることに注意
        var followsContainer = try profileContainer.nestedUnkeyedContainer(forKey: .follows)
        var ids: [Int] = []

        // UnkeyedContainerの中の配列を順番に辿って中の値を取得する
        // followsContainerの値を抜き出しているためfollowsContainerはvarである必要がある
        while !followsContainer.isAtEnd {
            let idContaier = try followsContainer.nestedContainer(keyedBy: CodingKeys.ProfileKeys.FollowsKeys.self)
            let id = try idContaier.decode(Int.self, forKey: .id)
            ids.append(id)
        }
        followIds = ids
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(name, forKey: .name)
        try container.encode(email, forKey: .email)
        
        var profileContainer = container.nestedContainer(keyedBy: CodingKeys.ProfileKeys.self, forKey: .profile)
        try profileContainer.encode(nickName, forKey: .nickName)
        try profileContainer.encode(profileImageUrl, forKey: .profileImageUrl)
        
        var followsContainer = profileContainer.nestedUnkeyedContainer(forKey: .follows)
        var idContaier = followsContainer.nestedContainer(keyedBy: CodingKeys.ProfileKeys.FollowsKeys.self)
        for id in followIds {
            try idContaier.encode(id, forKey: .id)
        }
    }
}


let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let users = try! decoder.decode([User].self, from: Data(usersJson.utf8))

[User(id: 1, name: "Taro", email: "taro@sample.com", nickName: "t", profileImageUrl: "profile/taro.jpg", followIds: [1]), 
User(id: 2, name: "Jiro", email: "jiro@sample.com", nickName: "j", profileImageUrl: "profile/jiro.jpg", followIds: [1, 2]), 
User(id: 3, name: "Saburo", email: "saburo@sample.com", nickName: "s", profileImageUrl: "profile/saburo.jpg", followIds: [1, 2, 3])]

ちょっと複雑なので

上記では
UnkeyedEncodingContainerUnkeyedDecodingContainerを使いましたが
ちょっとわかりづらいかなと個人的に感じました。

このような複雑な場合は
下記のように変換用のデータ構造を挟むことで
よりわかりやすく表現できるのではないかと私は思います。


struct CodableUser: Codable {
    let id: Int
    let name: String
    let email: String
    let profile: Profile

    struct Profile: Codable {
        let nickName: String
        let profileImageUrl: URL
        let followNumber: Int
        let follows: [Follow]

        struct Follow: Codable {
            let id: Int
        }
    }
}

struct User {
    let id: Int
    let name: String
    let email: String
    let nickName: String
    let profileImageUrl: String
    let followIds: [Int]

    init(codable: CodableUser) {
        self.id = codable.id
        self.name = codable.name
        self.email = codable.email
        self.nickName = codable.profile.nickName
        self.profileImageUrl = codable.profile.profileImageUrl.absoluteString
        self.followIds = codable.profile.follows.map { $0.id }
    }
}


let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let codableUsers = try! decoder.decode([CodableUser].self, from: Data(usersJson.utf8))
let user = codableUsers.map(User.init)

まとめ

Codableは自動で変換をしてくれることで
余分なコードを削除できることに加え
独自の変換方法でSwiftの型とJSONを変換できる
柔軟性も兼ね備えていることがわかりました。

ただし
あまりにも複雑になっていると感じた場合は
データ構造や設計を見直すタイミングであるのかもしれません。

今回考えてきたケース以外にも
まだまだ使い方はたくさんあります。

Appleのドキュメントにもサンプルコードがありますので
こちらもぜひご参照ください。

また
もしこういう使い方があるといったご意見や
ここはもっとこうした方が良いなどのご指摘がございましたら
教えていただけましたら幸いです🙇🏻‍♂️

33
34
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?