Автор: Хендерсон, Кэл
Кажется, всего несколько дней назад мы рассказывали вам об истории Flickr, и вот — статья, написанная главным разработчиком этой компании Кэлом Хендерсоном. Статья довольно специфичная — это вполне конкретные советы по решению вполне конкретных проблем, с которыми может столкнуться практически любой веб-разработчик. Как правило, мы стараемся не публиковать материалы такого плана, однако и Веб 2.0 — штука довольно новая, и проблемы, которые поднимает Хендерсон, далеко не для всех очевидны (что уж говорить о способах их преодоления), и литературы, освещающей эти вопросы, тоже не очень много, — к сожалению, большинство авторов компьютерных книжек делает упор на подробное описание синтаксиса, избегая рассуждать о тонкостях применения тех или иных техник. Хендерсон на синтаксис не отвлекается. Не рассказывает он и о том, как делать приложения, активно использующие JavaScript и CSS. Его интересует другое — «как сделать эти приложения по-настоящему интерактивными и быстрыми». — В.Г.
Так называемые приложения Веб 2.0 предполагают более интенсивное использование CSS и JavaScript, чем раньше. Но для быстрой и качественной работы приложения мы должны оптимизировать размер и выдачу контента, который требуется для рендеринга страницы. На практике это означает, что мы должны сделать статический контент настолько маленьким и быстрым для загрузки, насколько это возможно, избегая немотивированной раздачи файлов, которые не претерпели изменений.
Эта задача несколько усложняется самой природой файлов CSS и JavaScript. В отличие от изображений, исходный код CSS и JavaScript скорее всего с течением времени будет меняться неоднократно. И нам нужно, чтобы после каждого изменения клиенты могли загрузить обновленные версии файлов, не пытаясь использовать старые версии, сохраненные в их локальном кэше (а также во всех кэшах, которые встретятся по пути). В этой статье мы обсудим, как облегчить жизнь пользователю — при первой и последующих загрузках страницы, а также по мере того, как приложение развивается и обновляется.
Кроме того, мы должны максимально упростить жизнь разработчиков, так что обсудим заодно и способы автоматизации процессов оптимизации. Немного предварительной возни позволит нам совместить легкость разработки с удобством использования — и все без изменения привычных практик.
Раньше считалось, что максимальной производительности можно добиться, объединив многочисленные CSS— и JavaScript-файлы в более крупные блоки. Вместо десятка JavaScript-файлов по 5 Кбайт каждый мы делали один файл размером 50 Кбайт. Хотя общий размер кода при этом не менялся, мы сокращали накладные расходы на обработку HTTP-запросов.
Также важен аспект распараллеливания. По умолчанию и IE и Mozilla/Firefox при использовании стабильного соединения загружают только два файла с одного домена (см. спецификацию HTTP 1.1, секция 8.1.4). Это означает, что пока не загрузятся все скрипты, мы не загружаем картинки. Все это время пользователи видят страницу без изображений.
Однако у этого подхода есть и недостатки. Совмещая все наши ресурсы, мы заставляем пользователя загружать всё и сразу. Разделив содержимое между разными файлами, мы могли бы распределить тяжесть загрузки поверх нескольких страниц (или вообще избежать загрузки отдельных блоков — зависит от поведения пользователя). Если же мы замедлим загрузку первой страницы, чтобы ускорить загрузку всех остальных, то можем столкнуться с тем, что второй страницы многие пользователи просто не дождутся.
Исторически этот крупный недостаток такого подхода нечасто брался в расчет. Но для системы, в которой постоянные изменения содержимого статических файлов — обычное дело, это важно, поскольку любое изменение в большом едином блоке требует, чтобы клиент перегрузил себе полный рабочий набор CSS или JavaScript. Если приложение представляет собой монолитный исходник на JavaScript размером 100 Кбайт, значит, каждая мелочь приводит к принудительной загрузке лишних 100 Кбайт.
В качестве альтернативного подхода постараемся придерживаться золотой середины. Разобъем наши CSS— и JavaScript-ресурсы на множество подфайлов, сохраняя в то же время количество этих файлов функционально невысоким. С одной стороны, нам удобно разрабатывать приложения, разбивая код на логические блоки. С другой стороны, для работы приложения важно, чтобы этих блоков было не слишком много (так что нам приходится объединять эти файлы). Компромисса можно добиться, сделав определенные добавления к системе сборки билдов (набор инструментов, превращающий «грязный» код разработки в рабочий, готовый для развертывания код).
Для прикладного окружения, в котором среда разработки и среда исполнения четко разделены, подойдут простые техники управления кодом. Пусть в среде разработки код для пущей ясности разбит на множество логических блоков. Создадим в Smarty (язык шаблонов для PHP) простую функцию загрузки JavaScript:
{insert_js files="foo.js,bar.js,baz.js"}
function smarty_insert_js($args){
foreach (explode(‘,’, $args[‘files’]) as $file){
echo «n";
Пока все просто. Но затем мы во время сборки билда объединяем нужные файлы. Представьте, что в нашем примере мы должны объединить foo.js и bar.js в foobar.js, раз уж они почти всегда подгружаются вместе. Учтем этот факт при настройке нашего приложения и модифицируем код с учетом этой информации:
{insert_js files="foo.js,bar.js,baz.js"}
# map of where we can find .js source files after the build process
# has merged as necessary
$GLOBALS[‘config’][‘js_source_map’] = array(
‘foo.js’ => ‘foobar.js’,
‘bar.js’ => ‘foobar.js’,
‘baz.js’ => ‘baz.js’,
function smarty_insert_js($args){
if ($GLOBALS[‘config’][‘is_dev_site’]){
$files = explode(‘,’, $args[‘files’]);
$files = array();
foreach (explode(‘,’, $args[‘files’]) as $file){
$files[$GLOBALS[‘config’][‘js_source_map’][$file]]++;
$files = array_keys($files);
foreach ($files as $file){
echo «n";
Исходный код шаблонов не меняется, что позволяет нам сохранять эти файлы разделенными во время разработки. Кроме того, мы можем написать собственный процесс объединения на PHP и использовать тот же самый конфигурационный блок при самом объединении (а использование одного и того же конфигурационного файла избавляет нас от необходимости синхронизации). А если брать по максимуму, то можно проанализировать использование скриптов и стилей на страницах сайта, чтобы определить, какие именно файлы лучше объединять (хорошие кандидаты для такого объединения — это файлы, которые почти всегда подгружаются вместе).
В случае с CSS можно начать с полезной модели, состоящей из двух стилей: основного стиля и стиля подраздела. Единый основной стиль используется во всем приложении, а разные стили подразделов контролируют разные функциональные области. В этом случае подавляющее большинство страниц подгружают только два стиля (один из которых кэшируется при первой загрузке — это, конечно, основной).
Для небольших наборов CSS и JavaScript такой подход может замедлить обработку первого запроса (в сравнении с «монолитным» подходом), но если сохранять количество компонентов на относительно низком уровне, то возможно и ускорение работы, поскольку в расчете на одну страницу приходится загружать меньше данных.
Когда речь заходит о сжатии, многие немедленно вспоминают mod_gzip. Однако с ним нужно соблюдать осторожность — mod_gzip, в общем-то, является злом или, по меньшей мере, причиной кошмарного расходования ресурсов. Основная идея сжатия проста. Браузеры, запрашивая ресурсы, указывают в заголовке, в каком виде они могут принять содержимое страницы. Это может выглядеть, например, так:
Accept-Encoding: gzip,deflate
Видя такой заголовок, сервер сжимает отсылаемое содержимое методами gzip или deflate с тем, чтобы клиент распаковал его на месте. Это загружает процессор как на клиенте, так и на сервере, однако уменьшает объем передаваемых данных. Это нормально. Однако вот как работает mod_gzip: он сжимает данные во временный файл на диске, а после отсылки данных удаляет этот файл. На больших системах вы очень быстро столкнетесь с ограничениями скорости ввода/вывода. Этого можно избежать, используя вместо mod_gzip mod_deflate (только в Apache 2), поскольку последний сжимает данные в оперативной памяти. Пользователи первой версии Apache могут создать RAM-диск и хранить временные файлы mod_gzip там — это не так быстро, как работа напрямую с оперативкой, но намного шустрее, чем писать все на жесткий диск.
Но даже без этого мы можем полностью избежать вышеописанных проблем, используя предварительное сжатие нужных статических ресурсов и последующую отправку их клиенту с помощью mod_gzip. Если добавить предварительное сжатие в сборку билда, то сам процесс пройдет для нас совершенно незаметно. Файлов, требующих упаковки, обычно не так уж много — мы не сжимаем изображения (поскольку это вряд ли приведет хоть к какой-то экономии — изображения и так уже достаточно сжаты), так что нам остается упаковать JavaScript, CSS и прочий статический контент. Но мы должны указать mod_gzip, где искать файлы для предварительной компрессии:
mod_gzip_can_negotiate Yes
mod_gzip_static_suffix .gz
AddEncoding gzip .gz
В последних версиях mod_gzip (от 1.3.26.1a и выше) для автоматической предварительной упаковки файлов в конфигурационных опциях достаточно добавить одну строчку. Нужно лишь удостовериться, что в Apache установлены корректные разрешения на создание и перезапись упакованных файлов.
mod_gzip_update_static Yes
Но не все так просто. Текущие версии Netscape 4 (точнее, 4.06—4.08) в заголовке утверждают, что понимают содержимое, сжатое с помощью gzip, однако, на самом деле, не умеют распаковывать эти архивы. Прочие версии Netscape 4 тоже испытывают разнообразные трудности с загрузкой сжатых скриптов и стилей. А значит, мы должны отсекать этих агентов на стороне сервера и подставлять им неупакованный контент. Это довольно просто. Куда интереснее проблемы, возникающие у Internet Explorer (версии с 4 по 6).
Загружая сжатый с помощью gzip JavaScript, Internet Explorer порой некорректно распаковывает его или прерывает распаковку в процессе, отдавая клиенту половину файла. Если для вас критична работоспособность JavaScript, вы должны избегать отсылки сжатых скриптов по запросу IE. Даже в тех случаях, когда IE способен загрузить сжатый JavaScript, он зачастую не кэширует его, независимо от указаний, записанных в тегах (актуально для некоторых версий IE 5.x).
Поскольку сжатие с помощью gzip иногда выходит себе дороже, мы можем обратиться к другим способам упаковки контента, не предполагающих смену формата. Сейчас доступно множество скриптов, сжимающих JavaScript, большая часть которых использует для уменьшения исходного кода наборы правил на основе регулярных выражений. С их помощью мы действительно можем сделать код меньше, удалив комментарии, лишние пробелы, сократив имена переменных и убрав необязательные синтаксические конструкции.
К сожалению, подавляющее большинство этих скриптов либо не слишком эффективны, либо в определенных случаях разрушают код (а иногда — и то и другое вместе). Без подробного грамматического разбора упаковщику трудно отличить комментарий от похожей конструкции, размещенной в закавыченной строке. Кроме того, с помощью регулярных выражений не так-то просто оценить, какая из переменных имеет ограниченный контекст, так что некоторые техники сокращения имен переменных могут разрушить сам код.
Этих проблем можно избежать, сжимая код с помощью Dojo Compressor (alex.dojotoolkit.org/shrinksafe), использующий Rhino (мозилловский JavaScript-движок, написанный на Java) для построения дерева, которое оптимизируется перед работой с файлами. С работой Dojo Compressor справляется неплохо, ресурсов отнимает немного. Расширив наш процесс сборки билда с помощью этого инструмента, мы можем забыть об экономии, писать пространные комментарии, вставлять сколько угодно пробелов и т. д. На рабочем коде это нисколько не отразится.
По сравнению с JavaScript CSS упаковывать легко. Поскольку в стилях практически не используются закавыченные строки (как правило, это пути или названия шрифтов), мы можем справиться с пробелами с помощью регулярных выражений. Если же у нас закавыченные строчки все же есть, мы почти всегда можем свести последовательности пробелов к одному пробелу (маловероятно, что последовательности, состоящие из нескольких пробелов, встретятся нам в указаниях путей или названиях шрифтов). Для этого нам вполне хватит простенького скрипта на Perl:
#!/usr/bin/perl
my $data = ‘’;
open F, $ARGV[0] or die «Can’t open source file: $!»;
$data .= $_ while
$data =~ s!/*(.*?)*/!!g; # remove comments
$data =~ s!s+! !g; # collapse space
$data =~ s!} !}n!g; # add line breaks
$data =~ s!n$!!; # remove last break
$data =~ s! { ! {!g; # trim inside brackets
$data =~ s!; }!}!g; # trim inside brackets
print $data;
«Скормим» этому скрипту все имеющиеся у нас CSS по очереди:
perl compress.pl site.source.css > site.compress.css
С помощью такой несложной оптимизации мы можем уменьшить объем передаваемых данных на 50 процентов (во многом это зависит от вашего стиля кодирования — выигрыш может быть и гораздо меньше), а значит, увеличить скорость работы конечного пользователя. Но в идеале нам хотелось бы, чтобы пользователи вообще не запрашивали файлы до тех пор, пока это не станет совершенно необходимо. И для этого нам придется заняться HTTP-кэшированием.
Когда пользовательский агент запрашивает данные с сервера первый раз, он кэширует ответ, чтобы избегать повторных запросов в будущем. Как долго будет храниться этот кэш, зависит от двух факторов — настроек агента и соответствующих заголовков с сервера. Опции настройки агентов имеют незначительные различия, однако большинство из них сохраняет кэш по меньшей мере до окончания сессии, если им прямо не указано обратное.
Вы посылаете заголовки, запрещающие кэширование динамических страниц, чтобы не позволить браузеру кэшировать страницы, которые постоянно изменяются. В PHP это делается с помощью одной строчки:
header(«Cache-Control: private»);
Слишком просто, чтобы быть правдой? Ну, в общем-то, да — некоторые агенты порой игнорируют этот заголовок. Чтобы по-настоящему запретить браузеру кэшировать документ, следует быть немного более убедительным:
# ‘Expires’ in the past
header(«Expires: Mon, 26 Jul 1997 05:00:00 GMT»);
# Always modified
header(«Last-Modified: „.gmdate(„D, d M Y H:i:s“).“ GMT»);
# HTTP/1.1
header(«Cache-Control: no-store, no-cache, must-revalidate»);
header(«Cache-Control: post-check=0, pre-check=0», false);
# HTTP/1.0
header(«Pragma: no-cache»);
Это годится для контента, который мы не хотим кэшировать, но если контент не меняется при каждом запросе, нам нужно добиться от браузера обратного поведения. Для этого в заголовке запроса используется конструкция If-Modified-Since. Получив такой запрос, Apache (или любой другой веб-сервер) может выдать код 304 (Not Modified), тем самым сообщая браузеру, что у того в кэше уже находится актуальная версия документа. Благодаря этому механизму, нам не приходится пересылать файл заново, однако лишний запрос обрабатывать все же пришлось. Гм.
Использование entity tags похоже на работу с конструкцией if-modified-since. Apache на запрос к статическому ресурсу может отдавать заголовок Etag, содержащий контрольную сумму, сгенерированную из размера файла, времени последнего изменения и номера индексного дескриптора. Браузер может запросить заголовок файла, чтобы проверить e-tag документа перед загрузкой. Очевидно, что использование e-tag сопряжено с теми же накладными расходами, что и механизм if-modified-since, — клиент все еще вынужден делать лишний HTTP-запрос, чтобы определить валидность локальной копии.
Кроме того, нужно соблюдать осторожность с if-modified-since и e-tags, если выдача контента идет с нескольких серверов. В системе из двух хорошо сбалансированных серверов любой документ может быть запрошен одним и тем же агентом с любого из двух серверов — или с каждого (не одновременно). Это нормально. Для этого мы и выравнивали нагрузку. Однако если серверы генерируют разные e-tags или разные даты изменения документов, браузер не сможет нормально поддерживать актуальный кэш. По умолчанию e-tag генерируются с использованием индексных дескрипторов, которые на разных серверах разные. Это можно запретить с помощью следующей опции в настройках Apache:
FileETag MTime Size
Теперь Apache для генерации e-tag будет использовать только время последнего изменения и размер файла. Это, к сожалению, приводит нас к другой проблеме использования e-tag, которая тоже актуальна для if-modified-since (хоть и в меньшей степени). Поскольку e-tag зависит от времени последнего изменения, нам необходимо следить за синхронизацией. Если мы распределяем файлы по разным веб-серверам, всегда остается шанс, что на один из серверов файл попадет на секунду или две позже, чем на другой. В этом случае e-tag, сгенерированные серверами, будут отличаться. Мы можем изменить настройки так, чтобы генерировать e-tag только на основании размера файла, но это означает, что файл не обновится в браузере, если мы изменим его содержимое, а размер останется неизменным. Тоже не идеально.
Дело в том, что мы подходим к проблеме не с той стороны. Все возможные стратегии кэширования отталкиваются от того, что клиент спрашивает сервер, насколько актуальна копия, хранимая в кэше. Если бы сервер сам, без запроса, сообщал клиенту об изменениях файлов, то клиент в любой момент времени знал бы, что кэшированная копия валидна. Но веб устроен иначе — клиент запрашивает сервер, и никак иначе.
Или все же слегка иначе? Ведь перед отправкой любых JavaScript— или CSS-файлов клиент запрашивает страницу, которая на них ссылается с помощью тегов