Оптимизация интерактивных сайтов

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

Оглавление серии "Интерактивные сайты"

Общая архитектура

Организация клиентской части

Постоянное соединение между браузером и сервером

Повторное использование шаблонов

Серверная часть интерактивного сайта и потоки сообщений

Оптимизация

Серверная часть

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

Когда разрабатывается первая версия сайта, то многие части функционала проще всего реализовать в виде массовых рассылок, как-то так: произошло какое-то публично-доступное событие, допустим кто-то куда-то поставил "мне нравится", отправляем его в брокер сообщений с пометкой "доставить всем" (fanout). В итоге все подключенные на данный момент клиенты получают это уведомление и каждый сам решает что с ним делать - что-то менять на текущей странице или просто проигнорировать.

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

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

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

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

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

Постоянное соединение между браузером и сервером

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

Межвкладочное взаимодействие (cross-tab communication)

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

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

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

  • Flash Local Shared Cookies - даже не рассматривал как вариант, так как требуется Adobe Flash, плюс, кажется, постоянно всплывает окно вроде этого.
  • postMessage - отправка сообщения указанному окну по его идентификатору. Поддержка браузерами хорошая, но большинство примеров показывают общение с iframe, а сопутствующего API для получения списка всех открытых окон/вкладок я не нашел, может быть плохо искал.
  • Web Workers - в браузере создается не зависящий от вкладок поток, с которым можно общаться из вкладок. Поддержка браузерами хромает, а там где её нет - polyfill'ов пока не придумали.
  • Web Storage - локальное хранилище пар ключ-значение с ограничением в 5-10Мб на домен. Хорошая поддержка браузерами, а там где её нет - есть polyfill'ы. Еще бывает Web SQL, но для данной задачи это уже перебор.

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

Если есть желание и время можно работать напрямую с API хранилища, но все же самостоятельно разбираться с особенностями браузеров - занятие не благодарное, так что могу посоветовать взглянуть на имеющиеся opensource библиотеки-обертки. Из тех, что я пробовал, мне больше всего нравится jStorage из-за своей "зеленой" таблицы поддержки браузерами и готовому publish/subscribe API.

Итак, вкратце пройдемся по ориентировочному алгоритму реализации межвкладочного взаимодействия:

  • Каждая вкладка при своем открытии придумывает себе уникальный идентификатор (проще всего на основе Math.random), будем называть его tab_id.
  • В хранилище будут храниться список всех активных tab_id, допустим, tabs и tab_id главнойвкладки, допустим, master. Каждая новая вкладка смотрит есть ли другие открытые вкладки. Если есть - просто дописывает себя в tabs, если нет - то еще и объявляет себя главной и открывает соединение с браузером.
  • Далее она подписывается на сообщения отправленные лично ей (по её tab_id) и на различные типы сообщений, которые могут быть интересны всем вкладкам.
  • В обработчике события window.onbeforeunload (происходит сразу же перед закрытием вкладки) каждая вкладка убирает себя из tabs и если она была главной, то и из master тоже. Альтернативный вариант: master сразу может выбирать себе "преемника". Так как это событие срабатывает не всегда (когда компьютер жестко вырубился питанием, фатальный сбой в браузере, плюс оно не поддерживается неоправданно популярной в рунете Оперой и мобильным Safari), то придется создать альтернативный механизм проверки активности master и очистки tabs.
  • Так как какого-либо API для проверки открыта ли вкладка по её tab_id по очевидным причинам нет, нужно придумать свою схему. Самый простой рабочий вариант, пришедший мне в голову:
    • Главная вкладка пишет каждые несколько сотен миллисекунд в хранилище текущую дату/время, теоретически так как все происходит на одном компьютере, то текущее время во всех вкладках должно быть одно и то же;
    • Не-главные вкладки каждые 1-3 секунд читают значение из того же места в хранилище и если оно отстает от текущего на, допустим, больше чем секунду, то главную вкладку, вероятно, закрыли и надо её "свергнуть" - удалить из tabs и master и назначить, например, первую или последнюю запись из списка tabs новой главной вкладкой;
    • Если выбранная новая вкладка тоже оказалась уже закрыта, не беда - во всех случаях, кроме совсем неадекватных, этот не хитрый механизм переберет все tabs и найдет-таки нормальную открытую;
    • Каждая вкладка подписывается на изменения значения master, чтобы если новое значение совпадет с её tab_id открыть соединение с сервером.
  • Отправка сообщений происходит по простому publish/subscribe, где master подписывается и ретранслирует в соединение с сервером, а отправляют все остальные вкладки. Если вкладка отправляет запрос, ответ на который хочет получить только она сама (чаще всего переход на другую страницу сайта или отправка формы), то она указывает в отправляемом запросе свой "обратный адрес" в виде tab_id. Master, получив ответ на такое сообщение с указанным обратным адресом, перенаправляет его отправителю.
  • Также в хранилище полезно иметь переменную-флаг (также с подпиской на изменения), обозначающую открыто ли сейчас где-то постоянное соединение, чтобы вместо того, чтобы отправлять сообщения в никуда вкладки использовали какой-то альтернативный способ (AJAX или переход по ссылке / отправка формы средствами браузера). В качестве альтернативы можно реализовать очередь неотправленных сообщений, но по факту когда с соединением проблемы, то неизвестно когда они устранятся и устранятся ли вообще, так что смысла в ней чаще всего мало.

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

Минимизация размера сообщений

JSON хоть и сильно выигрывает у XML по объему сериализованных сообщений, но все же является текстовым форматом с указанием схемы (название для каждого значения) внутри самого сообщения. Почему минимизация объема передаваемых по постоянному соединению данных - дело полезное, объяснять, думаю, не стоит.

Первое, что приходит в голову, чтобы уменьшить объем сообщений - избавиться от включенной в них схемы, оставив только чистые данные. Изобретать свой формат ни к чему, есть неплохой широко распространенный кандидат в виде Google Protocol Buffers. Кстати, недавно нашел библиотеку с JavaScript реализацией Protocol Buffers с хорошими отзывами, в ближайшее время думаю сам опробую: protojs. Если кто уже работал с ней - буду рад, если поделитесь впечатлениями.

Но на практике оказалось, что эта самая схема обычно занимает максимум 10-20% от сообщения, так как большинство данных все же текстовые. Использование Protocol Buffers было бы намного более выгодным, если бы было необходимо "упаковать" много чисел или флагов, для текстовых данных выигрыш намного меньше.

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

  • LZW - есть реализации на большинстве языков программирования, но компрессия не очень сильная (раза в полтора-два в лучшем случае), плюс реализация под интересующий меня Erlang оказалась дико неэффективна по памяти, а на бинарных строках сходу не нашлась.
  • zlib.js - умеет zlib (deflate) и gzip, но, к сожалению, в моем браузере не могла разжать обратно то, что сжала, плюс объем кода библиотеки очень большой.
  • js-deflate - не обновлялась уже 4 года, отсутствует документация, но зато в целом работает. Подбирать метод компрессии для серверной стороны пришлось почти экспериментально, оказался zlib (deflate) без заголовков и контрольной суммы (в Erlang встроенная функция zlib:zip). Компрессия примерно в 3-4 раза.

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

По поводу дополнительных вычислительных ресурсов, которые будут потребляться на компрессию/декомпрессию, вопрос, конечно, спорный, в целом надо все мерять и делать выводы. Но если учесть, что почти во всех современных устройствах, даже телефонах, как минимум 1Ггц процессор, а на сервере можно кэшировать уже сжатые данные, то это не особо большая проблема. К слову объем сообщений уменьшается тоже не гарантированно, бывает что "сжатая" версия оказывается такой же или даже чуть больше, чем оригинал. В общем, использовать компрессию нужно осторожно :)

Повторное использование шаблонов

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

Кэширование шаблонов - идеальный пример. Получив от сервера шаблоны он кладет их не только в объект-обертку, но и в локальное хранилище. На сервере помимо самого JSON'а с шаблонами генерируем хэш (md5, sha или crc - не важно) текущей версии. Клиент, когда открывает соединение, сообщает серверу есть ли у него какая-то версия и если есть, то какая, сервер отправляет новую версию в ответ только если хэши не совпали.

Аналогичным образом можно кэшировать и другую редко меняющуюся объемную информацию, например данные для автодополнения в текстовых полях (autocomplete).

Заключение

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

Эта статья - шестая и заключительная в серии про Интерактивные сайты, автор - Иван Блинков, основано на личном опыте. До встречи на страницах Insight IT!