JSON: Парсинг многоуровненного файла

February 23, 2022

Однажды для реализации одной задачи мне понадобилось декодировать сложносоставленый файл JSON, с многоуровненной вложенностью. Ниже хочу описать, как можно реализовать чтение подобных файлов.

Прежде чем взяться за самое сложное, давайте сначала глянем на базу. Например, возьмём простой файл описания фильмов:

{
	"title": "Back to the Future",
	"url": "https://www.rombik.dev/blog/back-to-the-future/",
	"category": "scifi"
}

Чтобы декодировать его нам необходимо указать, что структура использует протокол Decodable:

struct Movie: Decodable {
    // Укажем что перечисление так же соответствует протоколу Decodable
    enum Category: String, Decodable {
        case scifi, comedy
    }

    let title: String
    let description: String?
    let url: URL
    let category: Category
}

Все свойства соответствуют полям из нашего JSON файла и так как указано использование декодируемого протокола, они автоматически будут преобразованы. Свойство description указано как необязательное, это означает что в получаемом файле это поле может быть не заполнено. Тогда при декодировании у нас не возникнет ошибки исполнения.

Используя JSONDecoder, мы можем провести анализ JSON файла всего парой строк кода:

let jsonData = JSON.data(using: .utf8)!
let movie: Movie = try! JSONDecoder().decode(Movie.self, from: jsonData)

print(movie.title) // Выведет: "Back to the Future"

Порой название полей в файле JSON могут отличатся он наименования свойств в нашей структуре. Чтобы не менять наименования свойств и соответствовать соглашению CamelCase используем сопоставление ключей JSON. Допустим в нашем файле добавлено поле с именем publish-date и нам его надо сопоставить с нашим свойством. Для этого добавим в структуру перечисление CodingKeys с описанием полей:

struct Movie: Decodable {
    enum Category: String, Decodable {
        case scifi, comedy
    }

    enum CodingKeys: String, CodingKey {
        // Не забываем указывть все ключи, иначе будет ошибка декодирвания
        case title, description, url, category
        // Сопоставим ключ JSON с нашим свойством "publishDate"
        case publishDate = "publish-date"
    }

    let title: String
    let description: String?
    let url: URL
    let category: Category
    let publishDate: String?
}

А теперь самое интересное! 😀 Добавим в исходные данные массив, в котором будут хранится имена актёров, а в данных актеров ссылка на дополнительную информацию. Обозначим новые модели для актёров и дополнительной информации, а так же добавим метод init(from decoder: Decoder) где укажем для декодера описание типов для каждого из полей.

struct Actor: Decodable {
    enum CodingKeys: String, CodingKey {
        case name
        case birthday
        case additional
    }
    
    let name: String
    let birthday: String
    // Укажем тип, описывающий дополнительнуюю информацию
    let additional: AdditionalInfo?

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        
        // Попытка декодировать данные и сопоставить с ключами
        name = try values.decode(String.self, forKey: .name)
        birthday = try values.decode(String.self, forKey: .birthday)
        // Поптыка декодировать не обязательное поле, если оно заполнено
        additional = try values.decodeIfPresent(AdditionalInfo.self, forKey: .additional)
    }

    init(name: String, birthday: String, additional: AdditionalInfo?) { 
        self.name = name
        self.birthday = birthday
        self.additional = additional
    }
}

struct AdditionalInfo: Decodable {
    enum CodingKeys: String, CodingKey {
        case city
        case country
    }
    
    let city: String
    let country: String

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        
        city = try values.decode(String.self, forKey: .city)
        country = try values.decode(String.self, forKey: .country)
    }

    init(city: String, country: String) {
        self.city = city
        self.country = country
    }
}

Добавим новое свойство в нашу первоначальную модель:

struct Movie: Decodable {
    ...
    let publishDate: Date?
    // Необязательный массив с типом Actor
    let actors: [Actor]? 
}

Осталось дело за чтением файла JSON и его окончательным декодированием.

let JSON = """
{
    "title": "Back to the Future",
    "url": "https://www.rombik.dev/",
    "category": "scifi",
    "actors": [{
            "name": "Michael J. Fox",
            "birthday": "June 9, 1961",
            "additional": {
                "city": "Edmonton",
                "country": "Canada",
                }
            },
            {
            "name": "Christopher Lloyd",
            "birthday": "October 22, 1938",
            "additional": {
                "city": "Stamford",
                "country": "U.S. state",
                }
            }]
}
"""

let jsonData = JSON.data(using: .utf8)!
// создаем переменную для декодера
let decoder = JSONDecoder()
do {
    // указываем декодеру модель Movie
    let parsedData = try decoder.decode(Movie.self, from: jsonData)
    // выводим на печать наш итоговый результат
    print(parsedData)
} catch {
    // выводим ошибку, если не удалось выполнить декодирование
    print(error.localizedDescription)
}

Таким образом дополняя и усложняя структуры можно разобрать на составляющие JSON файлы любой сложности и вложенности.