LoginSignup
4
4

[Swift] JSONで値の型が色々ある場合でもCodableでパースする

Last updated at Posted at 2023-06-04

JSONSerializationでシコシコJSONをパースしていたのも今は昔。

Swift4にCodableが現れて以降、JSONのパースは非常に手軽なものになっています。

また強力な型システムの恩恵を受けながらデータを扱えることは、潜在的なエラーの発生も抑えることができます。

ただ型が強力であるが故、逆に扱いづらくなるケースも存在します。

たとえばこんな感じです。

{
    "data": [
        {
            "id": 1,
            "embed": {
                "type": "video",
                "url": "./xxx.mp4",
                "duration": 120
            }
        },
        {
            "id": 2,
            "embed": {
                "type": "book",
                "url": "./xxx.pdf",
                "page": 300
            }
        }
    ]
}

embedの下にぶら下がるデータがvideoの場合とbookの場合があり、それぞれ付随するプロパティーが違っています。

この例で言えばdurationpageをオプショナルにするなどでも良いかもしれないですが、booksummarycoverImageを追加するなど、タイプやプロパティーが増えていくと1つの型で扱うのは厳しくなります。

JSONSerializationでセコセコ頑張るのも1つの手ですが、こういったフォーマットのJSONももちろんCodableで扱うことができます。


上記JSONをパースするコードはこのようになります。

import Foundation

let json = """
{
    "data": [
        {
            "id": 1,
            "embed": {
                "type": "video",
                "url": "./xxx.mp4",
                "duration": 120
            }
        },
        {
            "id": 2,
            "embed": {
                "type": "book",
                "url": "./xxx.pdf",
                "page": 300
            }
        }
    ]
}
"""

/// ルート
struct Root: Codable {
    let data: [Element]

    /// 要素
    struct Element: Codable {
        let id: Int
        let embed: Embed
    }
}

/// 添付データ
enum Embed: Codable {
    case video(Video)
    case book(Book)

    /// ビデオ
    struct Video: Codable {
        let type: `Type`
        let url: URL
        let duration: Int
    }

    /// 本
    struct Book: Codable {
        let type: `Type`
        let url: URL
        let page: Int
    }

    /// タイプ
    enum `Type`: String, Codable {
        case video = "video"
        case book = "book"
    }

    /// コーディングキー
    private enum CodingKeys: CodingKey {
        case type
    }

    /// デコード
    /// - Parameter decoder: デコーダー
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let type = try values.decodeIfPresent(`Type`.self, forKey: .type)
        switch type {
        case .video:
            self = .video(try Video(from: decoder))
        case .book:
            self = .book(try Book(from: decoder))
        case .none:
            let error = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown type")
            throw DecodingError.typeMismatch(Embed.self, error)
        }
    }

    /// エンコード
    /// - Parameter encoder: エンコーダー
    public func encode(to encoder: Encoder) throws {
        switch self {
        case .video(let video):
            try video.encode(to: encoder)
        case .book(let book):
            try book.encode(to: encoder)
        }
    }
}

// デコード
let decoded = try JSONDecoder().decode(Root.self, from: json.data(using: .utf8)!)
decoded.data.forEach {
    switch $0.embed {
    case .video(let video):
        print(video.duration) // 120
    case .book(let book):
        print(book.page) // 300
    }
}

enumで型の違いを吸収し、連想値で実際のデータを渡す、という方法です。

肝はデコーダーを実装している部分です。

    /// デコード
    /// - Parameter decoder: デコーダー
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let type = try values.decodeIfPresent(`Type`.self, forKey: .type)
        switch type {
        case .video:
            self = .video(try Video(from: decoder))
        case .book:
            self = .book(try Book(from: decoder))
        case .none:
            let error = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown type")
            throw DecodingError.typeMismatch(Embed.self, error)
        }
    }

init(from decoder: Decoder)Decodableで指定されているメソッドです。

StringIntなどはあらかじめDecodableに適合しており、それらの型を使うだけの場合はデコーダーを実装する必要はありません。

ただし今回は、値の内容によって型を振り分ける必要があるため、デコーダを実装しています。

        let values = try decoder.container(keyedBy: CodingKeys.self)
        let type = try values.decodeIfPresent(`Type`.self, forKey: .type)

こちらの部分でとりあえず、typeだけパースします。

decodeIfPresentはデコードが成功したのみだけ値を返すメソッドで、将来的にmusicimageなどのタイプが増えた場合に備えています。

その後タイプによってEmbedの値を決定します。

        case .video:
            self = .video(try Video(from: decoder))

VideoDecodableにすでに適合しているため、デコーダーを渡すことで初期化できます。


場合によってはタイプがない、なんてこともあるかもしれません。

{
    "data": [
        {
            "id": 1,
            "embed": {
                "url": "./xxx.mp4",
                "duration": 120
            }
        },
        {
            "id": 2,
            "embed": {
                "url": "./xxx.pdf",
                "page": 300
            }
        }
    ]
}

こういった場合も対応できます。

    /// デコード
    /// - Parameter decoder: デコーダー
    init(from decoder: Decoder) throws {
        if let video = try? Video(from: decoder) {
            self = .video(video)
        } else if let book = try? Book(from: decoder) {
            self = .book(book)
        } else {
            let error = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown type")
            throw DecodingError.typeMismatch(Embed.self, error)
        }
    }

型に対して随時初期化を試みることでパースするという方法です。

ただ、これに関してはJSONのフォーマットを変えた方が良いかもしれません・・・。


型の違いをenumで吸収するというテクニックは、Appleが提供しているMusicKitから知りました。

具体的にはTrackという部分です。

こちらは音楽ファイルとミュージックビデオを収める型になります。

ミュージックアプリのプレイリスト内はどちらも混在できるようになっており、配列としてトラック部分が渡されるためにこういった型になっています。

実際のAPIは以下のようになっており、そこをパースするためにこのテクニックが使われています。

data (Required) The ordered songs and music videos in the tracklist of the playlist.
[*] Possible types: MusicVideos, Songs

Playlists.Relationships.PlaylistsTracksRelationship | Apple Developer Documentation


Codableはかなり柔軟にできており、どんなJSONであれ、パースできなかった経験は今のところありません。

Swiftの強みを活かす意味でも、Codableの理解は深めていきたいと思います。

4
4
0

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
4
4