История факапа #1

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

Предыстория

2 года назад наша команда внедряла в функционал одного из модулей приложения технологию Couchbase + Sync Gateway. В двух словах: технология позволяет синхронизировать БД между приложением и сервером. Внедряли мы ее с целью избавиться от недостатков http протокола. Идея простая — http запросов для нас нет, есть постоянная синхронизация БД на сервере и на клиенте. Любой запрос на клиенте/ответ сервера клиенту реализуется при помощи записи в БД. Обе стороны слушают изменения в своей БД и реагируют на них должным образом. Такая абстракция позволяет не задумываться о том, в каком состоянии находятся http запросы. Просто настраиваем репликатор (сущность, которая синхронизирует БД) и поехали. Есть недостатки вида "как решать конфликты", "как управлять потоком синхронизации" и т.д., но сегодня не об этом.

Couchbase Lite (библиотека для работы с БД на устройстве) поддерживает работу с Data Access Object (DTO), например, как NSManagedObject в CoreData, или Object в Realm. Но сроки и нежелание чуть больше завязываться на возможности конкретной библиотеки привели нас к быстрому решению использовать Dictionary<String: Any> для мапинга моделей из БД в код и обратно. Поэтому мы написали несколько удобных расширений для этого типа и были довольны. У нас не было уверенности в технологии, мы пробовали, поэтому абстрагирование реализации моделей данных от конкретной реализации БД выглядело логично.

Работает — не трогай

От технологии мы отказались, перешли на новую, самописную (кросплатформенные с++ фреймворки). Переход был быстрым, в том числе благодаря абстрагированию от источника данных. Новая технология не реализует DTO, и отдает нам в качестве моделей голые строки. Т.е. модели в БД представлены строками, а значит, парсим каждый раз при чтении из БД (очевидно, бьет по производительности, но некритично). Это важно отметить, т.к. в дальнейшем это только усилит последствия факапа.

Парсинг не требовал изменений при переходе, работал отлично. Но было решено переехать на Codable, по ряду причин:

  • Убрать из процесса парсинга лишнее звено в виде Dictionary
  • Избавиться от кода в extension Dictionary<String: Any>
  • Воспользоваться возможностями автогенерации
  • Сделать парсинг, хорошо понятный любому разработчику

Codable

За пару дней был проведен рефакторинг всех структур данных, их в проекте около 50. Здесь поделюсь парой моментов, которые расстроили и которые редко описываются в туторах по Codable.

Структуры данных реализованы как классы, а значит, используют наследование. Есть иерархии по 4 наследника. Codable не дает использовать автогенерацию для классов-потомков, чем очень расстроил и вынудил писать (а точнее переписывать) много кода. Причина, по которой так происходит, — компилятор не в силах понять, нужно ли классу потомку наследовать родительский конструктор или нужно сгенерировать код для Codable. Об этом хорошие размышления здесь.

Второй небольшой, но неприятный момент — Codable не позволяет игнорировать тип данных. Т.е. если по ключу лежит json-объект, то нельзя просто засунуть его в Data/String в процессе парсинга. Иногда хочется забрать данные, а уже потом поглядеть, что там в них. Или, к примеру, парсить вложенный объект должны не мы, а кто-то другой. В таких ситуациях может выручить библиотека вроде этой либо можно попрактиковаться со своей реализацией. Вот также отличная статья, где автор описывает проблемы с Codable, возникшие при внедрении в проект.

Факап

Пример кода на swift, который все поломал

enum State: Int, Decodable {
    case unknown = -1
    case new = 0
    case deleted = 1
}

class Model: Decodable {
    let state: State
    
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.state = (try container.decodeIfPresent(State.self, forKey: .state)) ?? .unknown
    }
    
    enum CodingKeys: CodingKey {
        case state
    }
}

decodeIfPresent может выкинуть единственную ошибку — type mismatch. Приложение упадет, если по ключу лежит тип данных, который мы не ожидаем. В случае с enum типом, неподдерживаемое значение Int в ключе state вызовет падение.

Так и случилось. На бекенд выкатили обновление с новым кейсом за 5 дней до релиза мобилки, которая этот кейс поддерживает. Старые приложения начали падать. Нужно было срочно решать проблему со стороны бекенда.

Решение

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

В этой схеме наша моделька с неподдерживаемым enum-ом летит из прикладных сервисов (Service 1, 2, 3) в сервис событий (Event Service), сохраняется в БД этого сервиса и дальше летит в приложение и сохраняется в локальную БД.

Решения пошагово:

1) Выкатываем на прикладные сервисы хотфикс, который отдает по ключу "state" только те значения, которые поддерживаются текущим приложением. Новый расширенный enum теперь будет передаваться в новом ключе (есть достаточно времени, чтобы поддержать новый ключ в грядущих релизах мобилок).

2) Проходимся скриптом по БД в сервисе событий и делаем фикс, аналогичный пункту 1.

Предприняв эти шаги, видим, что в Firebase продолжают падать краши. Есть некоторая часть пользователей, которые успели в локальную БД затянуть невалидные значения. Проблемы можно было избежать, если бы при получении данных из сети мы парсили ее в нормальную структуру БД, а не в строку.

Теперь нужно как-то почистить локальную БД. Обновление приложения решило бы эту проблему, но до релиза еще пара дней. Переустановить приложения, обзвонив пользователей, — жестко. При логауте чистится локальная БД, значит можно попросить пользователей перелогиниться, но они не могут, т.к. приложение падает сразу же при запуске (падающий модуль запускается первым). Значит, можно разлогинить пользователей удаленно. В крашах Firebase находим id пользователей, которые упали, бекендер пишет скрипт для логаута, запускает его. Проблема решена.

Вместо вывода

Причина ошибки не в том, что я слишком строго парсил данные с сервера, а в том, что элементарно забыл про обратную совместимость. На мой взгляд, кодогенерация для Codable (например Sourcery) не позволила бы избежать проблем в данном случае, т.к. ошибка была сделана именно на уровне логики парсинга. Учитывая тот факт, что число моделей постоянно растет, есть планы прикрутить кодогенерацию, чтобы в том числе исключить человеческий фактор при рефаторинге кода.

Show Comments