Декодировать массив с поврежденным элементом

Допустим, вы хотите загрузить и отобразить список пользователей (User) в своем приложении. Каждому User требуется имя и фамилия; отчество не является обязательным.

struct User: Codable {
    let firstName: String
    let middleName: String?
    let lastName: String
}

В идеальном мире вы получите JSON, соответствующий модели.

[
    {
        "firstName": "John",
        "lastName": "Doe"
    },
    {
        "firstName": "Luffy",
        "middleName": "D.",
        "lastName": "Monkey"
    }
]

Мы можем без проблем расшифровать этот JSON. Мы получим массив из двух пользователей.

let json = """...the JSON string above..."""

do {
    let users = try JSONDecoder().decode([User].self, from: json.data(using: .utf8)!)
    XCTAssertEqual(users.count, 2)
} catch {
    print(error)
}

Поведение по умолчанию

Мир не совершенен. Иногда вы можете получить неожиданный формат JSON.

[
    {
        "firstName": "John",
        "lastName": "Doe"
    },
    {
        "firstName": "Luffy",
        "middleName": "D.",
        "lastName": "Monkey"
    },
    {
        "firstName": "Alice" // <1>
    }
]

<1> Отсутствует lastName, которая является обязательным полем.

Когда вы попытаетесь декодировать приведенный выше JSON, вы получите DecodingError.keyNotFound, потому что у последнего объекта нет lastName.

DecodingError
  ▿ keyNotFound : 2 elements
    - .0 : CodingKeys(stringValue: "lastName", intValue: nil)
    ▿ .1 : Context
      ▿ codingPath : 1 element
        ▿ 0 : _JSONKey(stringValue: "Index 2", intValue: 2)
          - stringValue : "Index 2"
          ▿ intValue : Optional<Int>
            - some : 2
      - debugDescription : "No value associated with key CodingKeys(stringValue: \"lastName\", intValue: nil) (\"lastName\")."
      - underlyingError : nil

Поведение декодера по умолчанию рассматривает декодирование массива как атомарную операцию. Массив JSON будет успешно декодирован после декодирования каждого элемента в массиве. Это означает, что один искаженный или поврежденный объект в массиве приведет к сбою декодирования всего массива. Как и в нашем предыдущем примере, отсутствие фамилии в последнем объекте приводит к сбою всего массива.

Я думаю, что это правильное поведение; Спецификация API должна работать как источник правды. Codable уже поддерживает необязательное (?) значение, если какие-либо поля могут быть нулевыми, это должно быть задокументировано в спецификации, чтобы мы могли создать правильную структуру для объекта.

Мы бы объявили наш объект User таким образом, если бы lastName допускало значение NULL.

struct User: Codable {
    let firstName: String
    let middleName: String?
    let lastName: String?
}

Если JSON не соответствует спецификации, операция должна завершиться неудачно как можно быстрее, поэтому мы можем поднять вопрос для бэкэнда, чтобы решить проблему.

Проблема

Такое поведение по умолчанию имеет смысл, если у вас есть полный контроль над API. Если вы этого не сделаете, такое поведение может быть немного резким. Может быть время, когда вы ничего не сможете сделать с API, например, сторонним API, устаревшим API, который отказываются менять разработчики бэкэнда, API с устаревшими поврежденными данными. Когда придет время, вы, возможно, не захотите, чтобы один поврежденный элемент мешал вам отображать остальные действительные.

Решение

Чтобы смягчить проблему, мы создадим объект-оболочку OptionalObject<T>, который работает точно так же, как SwiftOptional.

public struct OptionalObject<Base: Decodable>: Decodable {
    public let value: Base?

    public init(from decoder: Decoder) throws {
        do {
            let container = try decoder.singleValueContainer()
            self.value = try container.decode(Base.self)
        } catch {
            self.value = nil
        }
    }
}

Попробуйте еще раз декодировать искаженный JSON, но на этот раз используйте OptionalObject<User>.

do {
    let optionalUsers = try JSONDecoder().decode([OptionalObject<User>].self, from: json.data(using: .utf8)!)
} catch {
    print(error)
}

Операция завершится успешно, но мы получим массив OptionalObject<User>, а не User. Мы можем исправить это с помощью compactMap(_:).

compactMap(_ 🙂

Возвращает массив, содержащий ненулевые результаты вызова данного преобразования с каждым элементом этой последовательности.

do {
    let optionalUsers = try JSONDecoder().decode([OptionalObject<User>].self, from: json.data(using: .utf8)!)
    let users = optionalUsers.compactMap { $0.value }
} catch {
    print(error)
}

Теперь пользователи будут массивом из двух допустимых пользовательских объектов.

Массив в другом объекте

Если ваш проблемный массив является свойством другого объекта, скажем, у вас есть компания (Company), в которой есть сотрудники ([User]).

struct Company: Codable {
    let employees: [User]
}

Декодирование искаженного JSON приведет к той же ошибке.

let json =
"""
{
"employees":
    [
        {
            "firstName": "John",
            "lastName": "Doe"
        },
        {
            "firstName": "Luffy",
            "middleName": "D.",
            "lastName": "Monkey"
        },
        {
            "firstName": "Alice"
        }
    ]
}
"""

do {
    let company = try JSONDecoder().decode(Company.self, from: json.data(using: .utf8)!)
} catch {
    print(error)
}
DecodingError
  ▿ keyNotFound : 2 elements
    - .0 : CodingKeys(stringValue: "lastName", intValue: nil)
    ▿ .1 : Context
      ▿ codingPath : 2 elements
        - 0 : CodingKeys(stringValue: "employees", intValue: nil)
        ▿ 1 : _JSONKey(stringValue: "Index 2", intValue: 2)
          - stringValue : "Index 2"
          ▿ intValue : Optional<Int>
            - some : 2
      - debugDescription : "No value associated with key CodingKeys(stringValue: \"lastName\", intValue: nil) (\"lastName\")."
      - underlyingError : nil

Решение такое же, как мы обсуждали ранее, но на этот раз мы должны поместить логику декодирования в инициализатор.

struct Company: Codable {
    let employees: [User]

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)

        let nullableEmployees = try values.decode([OptionalObject<User>].self, forKey: .employees) // <1>
        self.employees = nullableEmployees.compactMap { $0.value } // <2>
    }
}

<1> Используйте OptionalObject<User> вместо User.

<2> Отфильтруйте nil значение и снова назначьте пользователей employees.

Просто так и теперь у вас есть работающий объект компании.