Несмотря на то, что с каждым релизом iPhone становятся все быстрее и быстрее, сетевые задержки остаются постоянным препятствием на пути пользователя. Скорость доставки информации к месту назначения ограничена скоростью света, и во многих случаях на этом пути возникают дополнительные замедления (3G-соединения, туннели метро, спутниковый интернет и т.д.). Сокращение размера передаваемых данных по-прежнему приносит пользу пользователям, и поэтому мы рассмотрим относительно новую технику — «сжатие с общим словарем» (shared dictionary compression). Хотя эта техника уже давно используется в таких компаниях, как Google и Amazon, в последнее время она получила широкое распространение в сообществе разработчиков. В основном это касается браузеров, но в этой статье я покажу, как можно легко использовать сжатие с общим словарем в приложениях для iOS.
Текущее состояние сжатия (без словарей)
Чтобы понять суть сжатия с общим словарем, давайте сначала сделаем краткий обзор его альтернативы. Приложение для iOS («клиент») и сервер договариваются о некоторой библиотеке сжатия, которую они оба поддерживают, и сервер отправляет ответ, сжатый с помощью этой библиотеки. Две наиболее популярные библиотеки сжатия для этого — gzip, старейшая и наиболее широко поддерживаемая библиотека, и Brotli, относительный новичок, который в целом быстрее gzip. Для приложений на iOS обе библиотеки поддерживаются URLSession
прозрачно с iOS 11 (а в случае с gzip даже раньше).
Важно, чтобы разработчики получили как можно больше пользы от этого вида сжатия, прежде чем рассматривать сжатие с общим словарем, поскольку его проще поддерживать и оно работает для более широкого круга ответов. Также важно понимать практические последствия сжатия передающихся данных. Например, использование коротких ключей в словаре JSON, таких как «lc
» для «like_count
», не обязательно сильно уменьшит размер сжатого ответа. Библиотека сжатия может (в идеале) увидеть, что «like_count
» повторяется во всем JSON, сохранит фактическую строку только один раз и просто будет ссылаться на нее во всех ее повторениях. Другое дело, что альтернативные JSON форматы данных, такие как Protobuf, не обязательно сильно уменьшают размер ответа после сжатия (и даже могут увеличить его!). Это связано с тем, что многие ненужные вещи в JSON, такие как необязательные пробельные символы и кавычки вокруг строк, повторяются много раз, а повторяющиеся данные можно очень хорошо сжимать. Короче говоря, сжатие позволяет разработчикам быть более «ленивыми» и не так сильно беспокоиться о повторяющемся мусоре. Более подробную информацию и пример того, как Brotli может обеспечить преимущества перед gzip, можно найти в этой статье.
А пока мы здесь, подумайте о переходе на HTTP/3, если вы этого еще не сделали, и пройдитесь по контрольному списку, подобному этому, чтобы убедиться, что вы уже используете простое ускорение работы сети, прежде чем использовать сжатие с общим словарем.
Что такое сжатие с общим словарем?
Сжатие с общим словарем — это когда приложение для iOS и сервер, с которым оно взаимодействует, имеют по копии некоторой части данных («словаря»), которые можно использовать для уменьшения размера запроса. Например, если клиент и сервер хранят ответ предыдущего дня для некоторой публичной конечной точки, а сегодняшний ответ для этой конечной точки изменился незначительно, то сервер может просто вернуть разницу между двумя ответами и сказать клиенту использовать эту разницу в сочетании с ответом предыдущего дня (словарем). Предположим, что конечная точка называется /latest_news, и вчера она вернула следующий ответ:
{ "articles": [ ... { "title": "Something new has happened", "description": "..." }, ] }
Теперь клиент запрашивает новую копию /latest_news за сегодня, и ответ практически идентичен. Со вчерашнего дня была написана только одна новая статья:
{ "articles": [ ... { "title": "Something new has happened", "description": "..." }, { "title": "Something even newer has happened", "description": "..." } ] }
При сжатии общего словаря клиент и сервер могут сообщить, что у каждого из них есть копия вчерашнего ответа, и сервер может использовать ее в качестве словаря, чтобы просто отправить разницу:
@@ -200,1 +200,8 @@ - } + }, + { + "title": "Something even newer has happened", + "description": "..." + }
Эта стратегия приводит к все большему и большему уменьшению размера передаваемых данных при все большем и большем количестве других статей в ответе. Более того, на практике для работы двух ответов не требуется простое различие. Современные библиотеки сжатия, такие как Brotli и Zstandard, могут ловко вычленять фрагменты словаря и, по сути, говорить что-то вроде «в данный момент в новом ответе используйте символы с 435 по 526 из предыдущего ответа. Затем добавьте новую строку ‘foo’. Затем используйте символы с 826 по 879 в предыдущем ответе». Единственное, что имеет значение, — это наличие некоторой общности между двумя ответами. На самом деле словарь вовсе не обязательно должен быть ответом. Это может быть просто набор фрагментов, которые обычно появляются в конечной точке /latest_news. Например, он может выглядеть как [" has happened", "\"\n {\n \"title\": "Something ", ...]
(см. флаг --train
для инструмента zstd
для примера того, как это может быть создано).
Какое влияние это может оказать?
Подсказка: больше, чем вы думаете.
Сжатие с общим словарем, будучи более сложной альтернативой сжатия, требует хороших результатов, чтобы оправдать себя. Измерить это с помощью математических вычислений на скорую руку очень сложно. Можно легко посмотреть на полезную нагрузку в ответе, которая уже сжата с помощью Brotli до 20 килобайт, и сказать: «Большинство устройств WiFi и 4G имеют скорость загрузки не менее 625 килобайт/с. 20/625 = 0.032, или 32 мс, что означает, что сжатие с общим словарем имеет потолок в 32 мс, что совсем не много!».
Однако при этом игнорируется несколько важных факторов. Во-первых, небольшие задержки могут оказывать удивительно большое влияние на поведение пользователей (исследование Amazon показало, что каждые дополнительные 100 мс задержки стоили им 1% продаж). Другое дело, что оценки пропускной способности в основном касаются идеального случая: длительной загрузки, когда клиент и сервер выяснили скорость соединения (и, соответственно, скорость отправки данных клиенту) и закончили с начальными формальностями. С другой стороны, в небольшом запросе размером 20 Кб могут преобладать такие затраты, как предварительное выяснение сервером скорости передачи байтов клиенту, так что в действительности он не достигает теоретического максимума. В данном случае более важной проблемой является время обхода (round-trip time, RTT) — время, необходимое для прохождения данных от клиента к серверу и обратно. Время в пути ограничено скоростью света, и, к сожалению, оно может быть только таким быстрым, в отличие от скорости передачи данных, которая со временем значительно увеличилась. В общем, детали этого связаны с такими вещами, как медленный старт TCP и изменения в HTTP2, и могут быть настолько сложными, что даже веб-эксперты не всегда согласны со всеми положениями. Наконец, легко переоценить скорость пользователей и забыть о том, сколько существует исключительно медленных случаев. Например, пользователи в странах с более низкой скоростью интернета, пользователи в поезде метро, где связь то появляется, то исчезает, и пользователи в изолированных районах, где интернет работает неравномерно.
В завершение приведу цитату неназванного разработчика Chromium (обратите внимание, что под «SDCH» здесь подразумевается конкретный алгоритм сжатия с общим словарем):
Еще одно изменение в мире — это то, что пропускная способность продолжает расти, хотя (как я уже подчеркивал в случае с [одним интернет-протоколом]) скорость света остается постоянной, и, следовательно, RTT не падает. Если мы посмотрим на такие географические регионы, как Индия или Россия, то пропускная способность может расти не так быстро, время RTT обычно приближается к 400 мс, и, что еще хуже, потери пакетов могут регулярно приближаться к 15-20 % <gulp!!!>. При таких длительных RTT и значительных потерях скорость сжатия оказывает даже большее влияние на задержку, чем просто затраты на «сериализацию». Чем больше пакетов вы отправляете, тем больше вероятность того, что один из них будет потерян, и тем больше вероятность того, что для доставки полезной нагрузки потребуется дополнительный RTT (или 2+!!!). В результате неудивительно, что Amazon видит большую экономию в хвосте распределения при использовании SDCH в таких областях. Я полагаю, что большинство сайтов, которые готовы приложить усилия для поддержки SDCH (и имеют постоянных посетителей!!!), имеют все шансы увидеть аналогичное значительное уменьшение задержки (и сайты могут оценить степень сжатия для данного словаря, прежде чем тратить время на его доставку!).
Более точное измерение воздействия
Поскольку мы увидели, как сложно измерить эффект с помощью теоретического анализа, более перспективным вариантом является измерение в продакшене. Можно провести A/B-тест, в котором в экспериментальной группе будет включено сжатие с общим словарем. Разработчик может измерить как реальное время отклика, так и любые изменения в вовлеченности пользователей, как результат влияния.
Результаты реальных компаний
Было показано, что различные сетевые запросы к популярным веб-сайтам значительно уменьшаются в размерах при использовании сжатия с общим словарем. И такие победы в размерах приводят к реальным результатам: Amazon сообщила о сокращении времени загрузки страниц в браузере на 10% в США. Что касается мобильных устройств, то в десятке лучших приложений в App Store есть приложение, использующее такое сжатие. Эффект зависит от приложения, но потенциал для многих приложений очень велик.
Конкретная схема
Сжатие с общим словарем может быть реализовано с помощью любого количества схем. Разработчик может выбрать, как клиент сообщает серверу, что у него есть общий словарь, как клиент сообщает серверу, какие алгоритмы декомпрессии он поддерживает, и т.д. Однако существует проект спецификации для сквозного решения, который набирает обороты. Хотя эта спецификация в значительной степени ориентирована на браузеры и содержит больше «свистелок и пердело», чем может понадобиться, она является хорошей отправной точкой для мобильных разработчиков. Вот краткое изложение спецификации в том виде, в котором она может быть применена к приложениям для iOS (обратите внимание, что «шаблон соответствия URL» (URL match pattern) в этом разделе относится именно к этому):
- Как сервер доставляет общие словари клиенту
- Сервер может указать клиенту общий словарь одним из двух способов:
- Добавив поле
Use-As-Dictionary: <options>
в заголовок ответа (см. главу 2 проекта спецификации). Этот заголовок указывает, что данный ответ должен использоваться в качестве словаря для будущих запросов. В нем указывается шаблон соответствия URL, для каких запросов он подходит (например,match="/product/*"
), а также опционально может быть указан идентификатор (например,match="/product/*"
,id=foo1234
). Обратите внимание, что эти относительные URL ссылаются на тот же базовый URL, что и в запросе. - Указав заголовок
Link: <url>
, который предоставляет URL для словаря, который может быть загружен по желанию клиента (например, в фоновом задании сессии URL). Ответ с этого URL должен также содержать заголовокUse-As-Dictionary
в том же формате, что и выше.
- Добавив поле
- Если словарь поставляется с элементами управления кэшем, такими как
Cache-Control: max-age=3600
, то клиент должен пытаться использовать этот общий словарь только в то время, когда он еще может быть использован для кэширования (например, пока он еще «свежий»).
- Сервер может указать клиенту общий словарь одним из двух способов:
- Как клиент отправляет новый запрос, работающий с общим словарем
- Для всех схем сжатия, которые поддерживает клиент, он добавляет эти схемы сжатия в заголовок
Accept-Encoding
. Например, если клиент поддерживает Zstandard (обычный режим без общих словарей), Zstandard для общих словарей и Brotli для общих словарей, то он будет использоватьAccept-Encoding: zstd, dcz, dcb
. Обратите внимание, чтоdcz
означает сжатие с общим словарем Zstandard, аdcb
— сжатие с общим словарем Brotli. Технически, это «сырые» варианты Zstandard и Brotli, но более подробная информация об этом выходит за рамки данной статьи и содержится в 2.1.4 спецификации. Если у клиента есть один или несколько подходящих словарей для запроса, он может выбрать один из них и уведомить сервер о том, что он у него есть. Он должен выбрать словарь с самым длинным совпадением или, в случае равенства, самый последний. Для этого используется следующий заголовок:Available-Dictionary: <SHA-256 hash of dictionary>
. - Если в параметрах
Use-As-Dictionary
для этого словаря указан идентификатор, то он также должен быть указан в заголовкеDictionary-ID: <id>
.
- Для всех схем сжатия, которые поддерживает клиент, он добавляет эти схемы сжатия в заголовок
- Как сервер возвращает ответ
- Если у сервера есть словарь с этим SHA и (если применимо) с этим ID, и он поддерживает одну из кодировок общих словарей, которые использует клиент, то он может вернуть ответ, сжатый с этим словарем и этой кодировкой. Он должен установить заголовок
Content-Encoding: ...
соответствующим образом.
- Если у сервера есть словарь с этим SHA и (если применимо) с этим ID, и он поддерживает одну из кодировок общих словарей, которые использует клиент, то он может вернуть ответ, сжатый с этим словарем и этой кодировкой. Он должен установить заголовок
- Соображения безопасности
- Словарь должен быть столь же чувствителен, как и содержимое, поскольку изменение словаря может привести к изменению размера и содержимого распакованного ответа.
- Чтобы предотвратить внедрение словарей сжатия третьими лицами для чувствительного содержимого, словари должны использоваться только для запросов к тому же домену, из которого был загружен словарь.
- Более подробную информацию см. в главе проекта, посвященной безопасности.
- Прочее
- Любые кэши между клиентом и сервером должны поддерживать
Vary
для заголовковAccept-Encoding
иAvailable-Dictionary
. В противном случае сервер может доставить поврежденные данные, если он вернет кэшированный ответ, использующий словарь, клиенту, у которого нет такого словаря.
- Любые кэши между клиентом и сервером должны поддерживать
Мобильные разработчики не ограничены тем, что поддерживают браузеры, и могут использовать собственные сетевые соглашения в подобной ситуации. Тем не менее, здорово, что у вас есть такая возможность. Если будет интерес, я могу выпустить собственную эталонную Swift-реализацию и 70-килобайтную версию библиотеки Zstandard только для распаковки.
Конкретные случаи использования
Хотя мы уже показали пример для конечной точки со статьями, которая медленно изменяется с течением времени, вот еще несколько примеров:
- Приложение-календарь синхронизирует свое состояние с сервером, загружая весь календарь пользователя при каждом открытии приложения, включая все запланированные им события, прошлые, настоящие или будущие. Это не позволяет масштабировать приложение с ростом числа событий, и поэтому у давних опытных пользователей все работает медленно. Можно использовать сложную логику диффиринга, чтобы отправлять только те события, которые уже есть у пользователя, при этом сервер отслеживает, какие именно события пользователь загрузил/не загрузил на каждое устройство. Но как насчет использования «тупого» сжатия с общим словарем, не зависящего от содержания, чтобы получить аналогичные результаты с меньшими усилиями и риском возникновения ошибок?
- Приложение для чата хочет сохранить небольшой объем сообщений в чатах пользователя. Время от времени оно предоставляет внеполосный (т. е. заголовок
Link:
) словарь сжатия для каждого активного чата 1 на 1 с небольшой историей этого чата.
Заключение
Подводя итог, можно сказать, что сжатие с общим словарем — это мощный, но недостаточно используемый метод уменьшения размера трафика и повышения производительности сети в приложениях для iOS. Используя сходство между различными ответами, эта техника может привести к значительному улучшению времени загрузки, особенно для пользователей в медленных или ненадежных сетях. Несмотря на то что на начальном этапе может потребоваться определенная работа, включая измерения и реализацию на стороне сервера, преимущества могут быть значительными. Для получения дополнительной информации прочтите запись в блоге Chrome на эту тему.