Яндекс.Почта — большое и сложное веб-приложение. Для первоначальной загрузки ей необходимо более 1 МБ статических ресурсов (JS/CSS/Шаблонов). При этом Яндекс.Почта обновляется два раза в неделю, а иногда и чаще.
Но при обновлениях от версии к версии меняется не так много кода — особенно в случае хотфиксов. Это показывают и фризы. Чтобы снизить время загрузки почты при выходе новых версий, мы уже делаем следующее:
Но этого нам недостаточно. Даже при фризе, если в релизе меняется всего один файл, в котором несколько строк, хэш от контента этого файла меняется и кэш инвалидируется, следовательно файл перезакачивается целиком. Чтобы избежать этой проблемы и еще более эффективно грузить новые ресурсы, мы придумали механизм инкрементальных обновлений.
Мы подумали: «А что если хранить где-то старую версию файлов (например, в localStorage), а при выходе новой передавать только diff между ней и той, которая сохранена у пользователя?» В браузере же останется просто наложить патч на клиенте. О том, что из этого получилось и каким выводам мы с Panya пришли, читайте под катом.
На самое деле эта идея не нова. Уже существуют стандарты для HTTP — например, RFC 3229 “Delta encoding in HTTP” и Google SDHC, — но по разным причинам они не получили должного распространения в браузерах и на серверах.
Мы же решили сделать свой аналог на JS. Чтобы реализовать этот метод обновления, начали искать реализации diff на JS. На популярных хостингах кода нашли библиотеки: VCDiff, google-diff-patch-match, jsdiff, Pretty Diff и jsdifflib.
Последние две библиотеки (jsdifflib и Pretty Diff) нам сразу не подошли, потому что не умеют накладывать патч, а показывают только изменения между строками. А jsdiff генерирует патч в формате, похожем на google diff patch match, но накладывает его в пять раз медленнее. В итоге у нас осталось два кандидата.
Для окончательного выбора библиотеки нам нужно сравнить их по двум ключевым для нас метрикам. Первая — размер генерируемого патча. Мы нагенерировали патчей для разных ресурсов разных версий и сравнили google diff patch match и vcdiff с разным размером блока.
Как видно по таблице результатов, vcdiff с размером блока 20 байт имеет наименьший размер патча. Вторая ключевая метрика для нас — время наложения патча на клиенте.
Тут тоже vcdiff выигрывает c большим отрывом. В IE 9 и ниже google-diff-patch-match накладывает патч больше, чем за секунду.
У нас определился победитель — vcdiff. Этот алгоритм был предложен в 2002 году (RFC3284). Он достаточно популярен и имеет множество реализаций на разных языках, в том числе на C++, Java и JS.
После того как мы определились с библиотекой для диффа, нужно определиться с тем, где и как хранить статику на клиенте. Яндекс.Почта — современное веб-приложение, у нас нет старых браузеров, поэтому почти все поддерживают localStorage. Он является удобным местом для хранения статики. Никаких личных данных пользователя там нет, поэтому нет проблем с безопасностью. Каждый файл хранится в отдельном ключе. В этот ключ вшито не только название ресурса, но и его версия. Это позволяет избежать проблем, когда версия смогла записаться в localStorage, а сам файл — нет (или наоборот), в варианте, когда файл и его метаданные хранятся в разных ключах.
При сборке нового релиза мы генерируем патчи для нескольких предыдущих версий каждого ресурса — для начала выбрали три. Информация о наличии патча для предыдущих версий отдается в загрузчик.
Формат файла с патчами для проекта выглядит так:
То есть это обычный массив из объектов. Каждый объект — отдельный ресурс. У каждого объекта есть три свойства. «k» — названия ключа в localStorage для этого ресурса. «p» — патч для ресурса, который сгенерировал vcdiff. «s» — чексумма для ресурса актуальной версии, чтобы потом можно было проверить правильность наложения патча на клиенте. Чексумма вычисляется по алгоритму Флетчера.
Почему именно алгоритм Флетчера, а не другие популярные алгоритмы вроде CRC16/32 или md5? Потому что он быстрый, компактный и легок в реализации.
В самом начале загрузчик смотрит в localStorage. Если у пользователя в нем ничего нет или версии настолько старые, что у нас для них нет патчей, то загружаются все файлы целиком.
Если же есть сохраненная старая версия в localStorage, посылаем запрос за файлом с патчем. Далее обновляем все ресурсы и проверяем чексумму. Если все чексуммы совпадают, обновляем содержимое ключей в localStorage.
На этом процесс обновления закончен. Дальше все необходимые ресурсы мы берем из localStorage. Код JS-модулей и скомпилированных шаблонов мы выполняем через new Function(), а CSS подключаем через динамическую вставку тега
Но при обновлениях от версии к версии меняется не так много кода — особенно в случае хотфиксов. Это показывают и фризы. Чтобы снизить время загрузки почты при выходе новых версий, мы уже делаем следующее:
- включаем gzip;
- выставляем заголовки кэширования;
- фризим CSS, JS, шаблоны и картинки;
- используем CDN;
- загружаем статику параллельно через кроссдоменные AJAX-запросы.
Но этого нам недостаточно. Даже при фризе, если в релизе меняется всего один файл, в котором несколько строк, хэш от контента этого файла меняется и кэш инвалидируется, следовательно файл перезакачивается целиком. Чтобы избежать этой проблемы и еще более эффективно грузить новые ресурсы, мы придумали механизм инкрементальных обновлений.
Мы подумали: «А что если хранить где-то старую версию файлов (например, в localStorage), а при выходе новой передавать только diff между ней и той, которая сохранена у пользователя?» В браузере же останется просто наложить патч на клиенте. О том, что из этого получилось и каким выводам мы с Panya пришли, читайте под катом.
На самое деле эта идея не нова. Уже существуют стандарты для HTTP — например, RFC 3229 “Delta encoding in HTTP” и Google SDHC, — но по разным причинам они не получили должного распространения в браузерах и на серверах.
Мы же решили сделать свой аналог на JS. Чтобы реализовать этот метод обновления, начали искать реализации diff на JS. На популярных хостингах кода нашли библиотеки: VCDiff, google-diff-patch-match, jsdiff, Pretty Diff и jsdifflib.
Последние две библиотеки (jsdifflib и Pretty Diff) нам сразу не подошли, потому что не умеют накладывать патч, а показывают только изменения между строками. А jsdiff генерирует патч в формате, похожем на google diff patch match, но накладывает его в пять раз медленнее. В итоге у нас осталось два кандидата.
Для окончательного выбора библиотеки нам нужно сравнить их по двум ключевым для нас метрикам. Первая — размер генерируемого патча. Мы нагенерировали патчей для разных ресурсов разных версий и сравнили google diff patch match и vcdiff с разным размером блока.
Vcdiff (размер блока 3) | Vcdiff (размер блока 10) | Vcdiff (размер блока 20) | google diff patch match |
---|---|---|---|
13957 | 3586 | 3431 | 9297 |
865 | 367 | 309 | 910 |
4615 | 1854 | 1736 | 6740 |
Как видно по таблице результатов, vcdiff с размером блока 20 байт имеет наименьший размер патча. Вторая ключевая метрика для нас — время наложения патча на клиенте.
Библиотека | IE 9 | Opera 12 | Firefox 19 | Chrome |
---|---|---|---|---|
vcdiff (размер блока 10) | 8 | 5 | 5 | 3 |
google diff patch match | 1363 | 76 | 43 | 35 |
Тут тоже vcdiff выигрывает c большим отрывом. В IE 9 и ниже google-diff-patch-match накладывает патч больше, чем за секунду.
У нас определился победитель — vcdiff. Этот алгоритм был предложен в 2002 году (RFC3284). Он достаточно популярен и имеет множество реализаций на разных языках, в том числе на C++, Java и JS.
После того как мы определились с библиотекой для диффа, нужно определиться с тем, где и как хранить статику на клиенте. Яндекс.Почта — современное веб-приложение, у нас нет старых браузеров, поэтому почти все поддерживают localStorage. Он является удобным местом для хранения статики. Никаких личных данных пользователя там нет, поэтому нет проблем с безопасностью. Каждый файл хранится в отдельном ключе. В этот ключ вшито не только название ресурса, но и его версия. Это позволяет избежать проблем, когда версия смогла записаться в localStorage, а сам файл — нет (или наоборот), в варианте, когда файл и его метаданные хранятся в разных ключах.
При сборке нового релиза мы генерируем патчи для нескольких предыдущих версий каждого ресурса — для начала выбрали три. Информация о наличии патча для предыдущих версий отдается в загрузчик.
Формат файла с патчами для проекта выглядит так:
[ { "k": "jane.css", "p": [patch], "s": 4554 }, { “k”: “jane.js”, “p”: [patch], “s”: 4423 }, ...]
То есть это обычный массив из объектов. Каждый объект — отдельный ресурс. У каждого объекта есть три свойства. «k» — названия ключа в localStorage для этого ресурса. «p» — патч для ресурса, который сгенерировал vcdiff. «s» — чексумма для ресурса актуальной версии, чтобы потом можно было проверить правильность наложения патча на клиенте. Чексумма вычисляется по алгоритму Флетчера.
Почему именно алгоритм Флетчера, а не другие популярные алгоритмы вроде CRC16/32 или md5? Потому что он быстрый, компактный и легок в реализации.
Процесс обновления
В самом начале загрузчик смотрит в localStorage. Если у пользователя в нем ничего нет или версии настолько старые, что у нас для них нет патчей, то загружаются все файлы целиком.
Если же есть сохраненная старая версия в localStorage, посылаем запрос за файлом с патчем. Далее обновляем все ресурсы и проверяем чексумму. Если все чексуммы совпадают, обновляем содержимое ключей в localStorage.
На этом процесс обновления закончен. Дальше все необходимые ресурсы мы берем из localStorage. Код JS-модулей и скомпилированных шаблонов мы выполняем через new Function(), а CSS подключаем через динамическую вставку тега
.
Что получили
Фактически мы экономим 80-90% трафика. Размер загружаемой статитки в байтах:
Релиз
С патчем
Без патча
7.7.20 — 7.7.21
397
174 549
7.7.21 — 7.7.22
383
53 995
7.7.22 — 7.8
18 077
611 378
7.8 — 7.8.50
2 817
137 820
7.8.50 — 7.8.8000
14 868
443 159
Что же мы получили в реальности?
В реальности скорость загрузки выросла совсем немного. Почему так?
У нас сразу появилась проблема с проверкой чексуммы. Считать его для всего файла оказалось очень дорого. Даже в современных браузерах на мощных компьютерах на один файл уходит примерно 20мс, а в Opera 12 и IE9 — более 100мс. Для загрузки Почты надо минимум шесть файлов. С учетом того, что JavaScript в браузере — однопоточный, то получаем последовательный расчет хеша для каждого файла, то есть в лучшем случае это будет минимум 120мс, а в реальности — еще больше.
Чтобы избежать таких больших потерь в скорости загрузки, мы решили для эксперимента считать сумму только для части файла — начала, середины, конца. Это, естественно, решило проблему задержки, но принесло другую — любая ошибка в процессе генерирования или наложения патча могла привести к непредсказуемым последствиям, которые будет сложно найти в процессе тестирования.
Проблема усугубляется еще и тем, что, скажем, валидность JS мы можем проверить просто исполнив его. Если полученный JS не запускается, то патч не прошел — надо все стирать и перезагружать. А вот с CSS проблема остается открытой. Невалидный CSS просто исполнится, не вызовет никаких ошибок, но пользователь увидит не то (у нас даже были такие случаи). Да, можно дописать валидатор CSS после его исполнения, но это опять же повлияет на скорость загрузки — по аналогии с хешсуммой.
Еще есть проблема со слишком большими патчами. Мы ограничили размер патча 30% от исходного файла. Если патч получается слишком большим, то браузер загружает весь файл целиком. И в итоге с этой схемой мы получили блокирующий HTTP-запрос. Рассказать заранее, с каких версий можно обновляться, а с каких — нет, мы не можем, потому что информация о кеше в браузере. Соответственно, наш загрузчик понимает, что есть кеш и идет за патчами. В этот момент ничего больше не грузится. Если в патчах сказано «грузи все полностью», то загрузчик начинает обычный процесс загрузки, как будто нет кешей, но время уже потеряно.
По сути патч VS. скачивание файла — это соревнование между скоростью браузера и скоростью подключения к интернету. Если смотреть на мировые тенденции в этом размере, то компьютеры не становятся быстрее. Наоборот, мощные стационарные компьютеры заменяются планшетами и ноутбуками, которые не обладают той же производительностью. В то же время интернет становится быстрее, причем обычно бесплатно. Обновляются тарифы провайдеров, которые строят сети нового поколения.
В итоге получается, что быстрее полностью параллельно скачать шесть файлов, чем последовательно для каждого взять версию из localStorage, скачать для каждого патч, наложить, проверить чексумму и запустить.
Итог
Мы поняли, что ни на какие 146% загрузку почты таким способом не ускорить. А лишнюю головную боль как разработчикам, так и тестировщикам получить легко — появляется еще один способ загрузки страницы, который надо проверять для каждого релиза. В то же время фризы и грамотное деление кода на модули дает понятный и абсолютно безопасный, с точки зрения тестирования и поддержки, способ ускорения.
Кстати, в HTTP/2 хотели включить механизм, похожий на RFC3229, но в последних версиях спецификации его убрали. В итоге эксперимент с инкрементальным обновлением мы признали неудачным и решили от него отказаться. Теперь ждем встроенной поддержки такого механизма в браузеры.