Что я узнал, создавая свою Библиотеку Синхронизации CloudKit

21 апреля 2023

Пост, который мне хотелось бы найти перед началом создания CloudSyncSession - новой библиотеки синхронизации iCloud, чей исходный код я недавно опубликовал на GitHub.

На прошлой неделе я опубликовал CloudSyncSession, библиотеку Swift, основанную на фреймворке CloudKit, которая упрощает написание приложений с возможностью синхронизации и работы в автономном режиме.

Я начал работу над CloudSyncSession более двух лет назад с целью воспроизвести поведение синхронизации NSPersistentCloudKitContainer без привязки к Core Data. Кроме того, я хотел получить решение, которое давало бы более надежные гарантии, больше контроля и более подробную диагностическую информацию. Не найдя того, что соответствовало бы всем моим требованиям, я (неохотно) приступил к созданию своего собственного.

После нескольких лет “шлифовки” данного фреймворка и исправлений различных ошибок в моем приложении, я узнал много нового о синхронизации CloudKit. Часть моих знаний вы можете почерпнуть, прочитав код, но, на мой взгляд, есть куда больше, чем я бы мог поделиться в подходящем техническом посте в блоге.

А вот и он: тот самый пост, который мне хотелось бы найти перед началом работы над CloudSyncSession.


Ключевые Понятия

Если для синхронизации данных между несколькими клиентами, работающими в автономном режиме, вы используете CloudKit, - то очень важно понимать эти основные принципы. Вы можете изучить их в официальной документации CloudKit и из видео WWDC, но я бы хотел своим словами дать им краткое объяснение.

 

Записи - Records

Записи являются атомарной единицей синхронизации CloudKit. Они представляют собой отдельный элемент, подлежащий синхронизации. Каждая запись имеет универсальный идентификатор - record ID, который состоит из имени записи и связанной зоны (*области). В одной зоне нельзя иметь две записи с одним и тем же record ID.

 

Зоны и Токены Изменений

Зоны - это коллекции записей. Вы можете запрашивать записи в зоне разными способами.

Для приложений, работающих в автономном режиме, где задачей является отображение всех данных среди всех клиентов, вы предпочтете воспользоваться ключевыми свойствами зон: ведь они отслеживают все добавления и удаления записей. Эта возможность работает с помощью токенов изменений (change tokens) - неявных уникальных идентификаторов, указывающих на конкретное изменение в зоне.

С помощью токена изменения вы можете запрашивать все изменения, произошедшие с момента изменения, которое представляет токен (см. CKFetchRecordZoneChangesOperation). Это разбитый на страницы API, поэтому для первой синхронизации токен изменения, который вы предоставляете, будет nil. Успешный ответ на запрос изменений зоны будет включать список новых записей, удаленные record ID и новый токен изменений, который вы можете использовать для следующего запроса.

 

Метки Изменений

Записи содержат различные метаданные, которые управляются непосредственно фреймворком CloudKit. Самой важной частью метаданных является метка изменения (change tag) записи.

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

 

Подписки

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

 

Как избегать ошибок и обрабатывать их

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

 

Разделение задачи

При формировании операции изменения вы должны ограничить количество новых и удаленных записей до 400 (рекомендуемое значение из документации CKError.Code.limitedExceeded). В CloudSyncSession задача автоматически разбивается на порции по 400.

Существует много типов ошибок CloudKit, которые нужно обрабатывать (см. коды CKError). Одна из них - limitExceeded. Если вы видите эту ошибку, - вам следует повторить попытку после разделения задачи на более мелкие куски(*фракции). В CloudSyncSession эти ошибки обрабатываются путем деления задания пополам.

 

Удаление Дубликатов Записей

При формировании операции изменения всегда удаляйте дубликаты записей по record ID. CloudKit выдает ошибку, если вы запрашиваете изменение записи и ее удаление в одной операции, поэтому не включайте ваши record ID для удаления в список записей для сохранения.

 

Ставим Задачу в Очередь

CloudKit не любит, когда его одновременно просят сделать слишком много. Лучшие результаты мною были получены, когда единовременно была запущена лишь одна операция. Это не только приводит к уменьшению количества ошибок потери скорости работы CloudKit, но также делает обработку более предсказуемой и устраняет множество классов ошибок.

CloudSyncSession сделает это за вас, но он не будет удалять дубликаты записей при нескольких операциях изменения. По этой причине я рекомендую управлять отдельной очередью, независящей от CloudSyncSession. Откладывайте запросы на изменение до тех пор, пока сеанс не освободится от текущей операции. Чтобы избежать конфликтов, попробуйте использовать последнюю копию записи с наиболее последними метками изменений записи.

Также нет смысла отправлять параллельные запросы на получение данных. Как только завершится запрос на получение данных, - после некоторого фиксированного интервала, например, через 30 секунд, или когда приложение получит уведомление о подписке, -  вы можете отправить другой запрос.

 

Интерпретируйте Фатальные и Переходные Ошибки По-разному.

Катастрофическая ошибка говорит нам: что-то определенно пошло не так, и лучше просто остановить все, чем продолжать. Например, ошибки badDatabase или notAuthenticated не могут быть легко устранимыми и требуют особого взаимодействия с пользователем.

CloudSyncSession проверяет наличие этих ошибок и останавливается. Обязанность приложения - отслеживать остановку сеанса, и каким-то образом обрабатывать ошибку. Например, можно показать состояние ошибки и предупредить пользователя.

Для катастрофических ошибок лучше приостановить работу и избегать отправки запросов, которые могут привести к тому, что CloudKit начнет ограничивать клиента.

 

Повтор Переходной Ошибки с Использованием Стратегии Отката

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

При повторной попытке вы должны использовать поле userInfo[CKErrorRetryAfterKey] в CKError, чтобы определить, как долго ждать перед повторной попыткой операции. Если данного поля нет, я рекомендую использовать экспоненциальный откат, основанный на количестве попыток. Также я бы рекомендовал ограничить количество возможных повторов, чтобы избежать постоянных неудачных операций в CloudKit.

 

Срабатывание при Ограничении Скорости

CloudKit реализует ограничение скорости. Для этого есть специальный код ошибки CKError - requestRateLimited, но я никогда не видел ошибку с данным кодом. На практике ограничение скорости указывается ошибкой serviceUnavailable (CKError 6, HTTP-код 503). Какое-то время я был сбит этим с толку, т.к. не ожидал увидеть код 503 для обозначения ограничения скорости.

Я отправил фидбэк об этом неожиданном и курьезном поведении в Apple. Их ответ был обоснованным:

Исходя из описания, похоже, что вы сталкиваетесь с ограничением скорости, т.к. посылаете слишком много запросов слишком быстро.

Вы правы, что для ограничения скорости более подходящим был бы код 4xx, но по историческим причинам мы используем 503 и не можем легко переключиться.

В CloudSyncSession я не выдаю параллельные операции и жду минимальное время в 2 секунды между завершением одной операции и началом другой. Если происходит сбой любого типа, я экспоненциально увеличиваю время ожидания. Затем, после успешного запроса, я уменьшаю время ожидания на 33%. Не следует уменьшать время ожидания слишком быстро, т.к. это приводит к частым ограничениям скорости.

 

Избегайте Спама Плохих Запросов

Также CloudKit не любит, когда вы делаете много плохих запросов, которые приводят к ошибкам за короткий период времени.

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

 

Иногда Вам Просто Нужно Попробовать Позже

И даже после реализации всех этих мер по смягчению и логики обработки ошибок, вы все равно столкнетесь со случайными, непонятными ошибками. Даже демон фреймворка CloudKit может просто выйти из строя или перестать отвечать. В моем приложении, FoodNoms, я проверяю состояние CloudSyncSession каждый раз, когда приложение выходит на передний план. Если оно остановлено из-за катастрофической ошибки или после неудачной попытки повтора операции, я перезапускаю сеанс.

 

Избегайте Расхождения Данных, Тщательно Отслеживая Задачу

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

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

Вам понадобится механизм для отслеживания записей, которые нужно сохранить в CloudKit через операции изменения. Как бы вы это ни делали, вам нужно, чтобы он был устойчивым. Считайте, что задача выполнена только после успешного завершения операции. Если приложение внезапно завершилось, - при следующем запуске следует повторить точно такой же набор задач. В моем приложении для этого используется постоянная очередь с указателем контрольной точки, указывающим на последнюю запись, успешно сохраненную в CloudKit.

 

Разрешение Конфликтов

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

Определение наилучшего алгоритма разрешения конфликта для вашего приложения может быть затруднительным. Вам нужно рассмотреть, на каком уровне нужно гарантировать согласованность: записи или поля? Также учитывайте отношения между записями. Есть ли причинно-следственные/временные связи между различными записями? Какие реалистичные сценарии использования вы ожидаете увидеть? Сколько одновременно работающих устройств? Насколько вероятно, что одно устройство будет отключено или не будет синхронизировано в течение продолжительного времени? Насколько вероятно, что записи будут изменены вообще? Нужно ли изменять записи или можно считать все записи неизменяемыми?

Есть много стратегий, которые стоит рассмотреть, но позвольте мне рассказать о компромиссах среди нескольких распространенных и простых.

Популярная и простая стратегия - «последний автор прав». Вы можете реализовать это, сравнивая значения записей modificationDate на сервере, которые представляют дату, когда сервер последний раз сохранил эту копию записи (соответствует метке изменения). Однако это может привести к проблемам при очереди локальных изменений. Вот примерный сценарий: пользователь вносит изменения в запись в быстрой последовательности - не настолько быстро, чтобы их можно было объединить, но достаточно быстро, чтобы обе копии встали в очередь. Желаемое поведение заключается в том, что остаться должна вторая правка. Вместо этого происходит следующее:

  1. Оба изменения добавлены в очередь с использованием одинаковой метки изменения записи и датой изменения.
  2. Первое изменение записи успешно сохранено в облаке.
  3. Второе изменение вызывает конфликт, поскольку CloudKit обнаруживает устаревшую метку изменения.
  4. Далее обработчик конфликтов видит, что серверная копия имеет более позднюю дату изменения. Поскольку это "последний автор", он “прав” в этом конфликте.

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

Третья простая стратегия, которую я использую сегодня в FoodNoms, заключается в отслеживании количества изменений для каждой записи. Каждый раз, когда запись редактируется, - счет растет. Затем в обработчике конфликтов приоритет отдается записи с более высоким счетчиком изменений. Это очень примитивная стратегия, но она работает достаточно хорошо для приложения, используемого одним пользователем, как FoodNoms.

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

 

Проектирование схемы

 

Records ID

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

 

Ссылки

Я обнаружил, что ссылки на записи не так уж полезны для использования, когда конечная цель - отразить все данные среди всех клиентах. Вместо этого можно сохранять внешние ключи как обычные поля записи, которые клиент может использовать для выполнения операций объединения.

 

Контроль Версий

Рассмотрите вашу стратегию управления версиями. Схемы CloudKit могут эволюционировать с учетом совместимости в будущем, другими словами - вы можете добавлять новые поля, но не удалять или изменять существующие. Исходя из этого, в вашем клиентском коде старайтесь, если это возможно, относиться к полям как к необязательным. В будущем эти поля вам могут быть не нужны.

Рассмотрим поле version, которое указывает семантическую версию конкретного экземпляра записи. С помощью этого поля вы можете проверить, является ли версия записи будущей версией приложения, в таком случае вы, вероятно, захотите остановить всю синхронизацию CloudKit и подтолкнуть пользователя обновить приложение до последней версии. Вы также можете проверить, содержит ли запись неизвестные ключи, в таком случае вы, вероятно, снова сделаете то же самое. Вы также можете использовать это поле для выполнения миграций на стороне клиента или адресных привязок из более старых версий.

 

Зашифрованные Значения

Зашифрованные поля записи очень просты в использовании. Вместо record["foo"] используйте record.encryptedValues["foo"]. Недостатком зашифрованных полей является то, что они препятствуют созданию индексов на стороне сервера для этого поля. Однако, если вы создаете приложение, которое будет работать оффлайн и отражать все данные из облака, - это не так важно. Компромиссным решением будет зашифровать не все поля, а лишь некоторые. Например, вы можете выбрать только те поля, которые являются конфиденциальными и, вероятно, никогда не будут использоваться в операциях поиска записей.

 

Другие советы


Как Удалить Все Данные из Зоны

Удаление всех пользовательских данных из iCloud в приложении легко и просто. Для этого запросите удаление зоны с помощью CKModifyRecordZonesOperation. После этого убедитесь, что синхронизация отключена и все теги изменений записей сброшены, иначе приложение будет донимать CloudKit записями, которые будут рассматриваться как конфликтные, что наверняка приведет к серьезному ограничению скорости.

 

Как Разрабатывать в Production Среде

Установите com.apple.developer.icloud-container-environment в Production в файле предоставления прав вашего приложения.

 

Следите за Удаленными Записями

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

Может возникнуть ситуация, когда ваше приложение отправляет запрос на изменение или создание записи, а затем запрос на удаление этой же записи. В зависимости от задержек в сети и того, как эти операции ставятся в очередь, ваше приложение может начать обработку успешного ответа первого запроса, до того, как закончится запрос на удаление. Вам следует учитывать это и пропускать повторное сохранение любых записей, о которых клиент знает, как об удаленных. В FoodNoms для этого используется локальная таблица базы данных, содержащая все ранее удаленные record ID.

 

Диагностическая Информация Необходима для Хорошего Взаимодействия с Пользователем и Службой Поддержки

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

  • Сколько записей ожидают синхронизации?
  • Сколько локально сохраненных записей имеют метки изменений?
  • Когда была последняя успешная операция получения данных?
  • Когда была последняя успешная операция изменения данных?
  • Какой статус аккаунта CloudKit?
  • Есть ли выполняемая операция?
  • Какая была последняя завершенная операция?
  • Какая текущая продолжительность задержки?
  • Остановлена ли синхронизация из-за ошибки? Если да, то какая ошибка?
  • Если сейчас мы повторяем операцию, сколько раз мы ее уже повторили? Какая ошибка привела к повтору?

Вы можете использовать комбинацию из этих вопросов, чтобы вывести более качественную сводку для пользователя, например, "Загрузка ..." или "Готово" или "Ошибка". Далее вы можете отобразить информацию пользователю в более подробном виде и/или в диагностических логах.

Экраны диагностики в Food Noms, ориентированные на пользователя

Я надеюсь, что это будет полезно для тех, кто пытается изучить CloudKit, создать приложение на основе CloudKit или, возможно, столкнулся с непонятным поведением CloudKit. Я постарался включить все то, что я хотел бы знать с самого начала работы с CloudSyncSession.

Я все еще считаю, что лучший способ изучить некоторые из этих вещей - это чтение фактического кода. Вы можете ознакомиться с исходным кодом на CloudSyncSession и Cirrus на GitHub. Обе библиотеки имеют много общего и в целом стремятся к достижению одной и той же конечной цели, но принимают разные подходы и предлагают разные компромиссы.

Если у вас есть отзывы, которыми вы хотели бы поделиться со мной, пожалуйста, напишите мне в Mastodon.

Оригинал статьи

Содержание