Статьи

Перевод: Николай Мациевский aka sunnybear
Опубликована: 23 июня 2008

Быстрый-быстрый JavaScript

Примечание: ниже расположен перевод статьи "Serving JavaScript Fast", написанной года два назад, но нисколько не потерявшей своей актуальности. Автор предлагает достаточно большой комплекс мер для ускорения загрузки и работы CSS/JS-файлов. Ссылки и частичные переводы данной статьи достаточно широко цитируются в Рунете, однако, полностью она еще нигде не появлялась, а полезных советов в ней довольно много. Мои комментарии далее курсивом.

Следующее поколение веб-приложений будет использовать весьма «тяжелые» JavaScript- и CSS-framework'и. Мы собираемся продемонстрировать, как увеличить скорость взаимодействия таких приложений и ускорить их работу.

Все эти так называемые «Веб 2.0» приложения, их глубокое взаимодействие с содержанием страницы и самим пользователем сильно увеличили сложность использования CSS и JavaScript. Для того чтобы быть уверенными в небольшом размере приложений, нам нужно оптимизировать как размер, так и саму природу всех файлов, которые нужны для нормальной работы нашей страницы. Мы должны быть уверены, что добились оптимума удобства использования сайта для пользователей. На практике это означает, что нам нужно добиться максимального уменьшения размера страницы и ускорения ее работы, при этом предотвращая загрузку ненужных ресурсов, которые не изменились с момента последнего обращения.

Некоторая сложность в выполнении поставленной задачи состоит в самой природе CSS и JavaScript файлов. Она сильно отличается от привычной нам природы картинок. CSS- и JavaScript-файлы могут быть изменены много раз во время жизни сайта. При каждом таком изменении мы вынуждены заставлять наших пользователей загружать файлы снова и снова, делая ненужными текущие их версии в локальном кеше (и все предыдущие, сохраненные там же). В данной статье мы рассмотрим, как можно максимально ускорить работу сайта для конечных пользователей — начальную загрузку страницы, последующие загрузки страницы и дальнейшие загрузки ресурсных файлов, которые требуются по мере работы приложения или изменения содержания страницы.

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

Монолитный подход

В прошлом бытовало поверье, что мы можем добиться оптимальной производительности, если объединим все наши CSS- и JavaScript-файлы в несколько достаточно крупных кусков. Например, вместо того чтобы иметь 10 JavaScript-файлов по 5Кб, мы можем объединить их все вместе в один файл в 50Кб. При этом общий размер кода останется прежним, но мы избежим большого количества дополнительных издержек, связанных с множеством HTTP-запросов. Каждый запрос включает в себя фазу установления соединения и его разрыв, также дополнительной объем данных в серверных и клиентских заголовках. дополнительно большое число запросов к серверу «отъедает» ресурсы последнего за счет дополнительных обрабатывающих процессов (и, возможно, больше расходует процессорное время при gzip-сжатии «на лету»).

Однако, не стоит забывать и про параллельные запросы. По умолчанию и Internet Explorer, и Mozilla/Firefox могут загружать только 2 файла с одного хоста (для последних версий Firefox этот параметр настраиваемый) при использовании постоянных (keep-alive) соединений (как и было предложено в спецификации HTTP 1.1, раздел 8.1.4). Это означает, что пока будет идти загрузка JavaScript-файлов, не более двух одновременно, мы не сможем загружать изображения — следовательно, пользователи увидят страницу безо всяких картинок.

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

У подхода, который предполагает загрузку одного файла, есть еще один крупный недостаток, о котором, так исторически сложилось, часто забывают. Если мы часто меняем какие-либо функции в нашей библиотеке, то любые изменения повлекут обновления нашего файла, который включает все. Это означает, что пользователям придется загрузить заново весь CSS- или JavaScript-файл. Если в нашем веб-приложении используется монолитный 100Кб JavaScript-файл, тогда любые незначительные изменения будут означать, что всем пользователям придется загрузить эти 100Кб заново.

Разделенный подход

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

Если у вашего приложения имеются четко разнесенные среды разработки и ежедневного использования (тестовое и «боевое» окружения), вы можете использовать пару простым методов для сохранения контроля над разработкой кода. В тестовом окружении можно разбить весь используемый код на множество небольших частей для лучшего понимания процесса. В Smarty (PHP-шаблонизаторе) можно использовать следующую простую функцию для управления подключением множества JavaScript-файлов:

SMARTY:
{insert_js files="foo.js,bar.js,baz.js"}

PHP:
function smarty_insert_js($args){
  foreach (explode(',', $args['files']) as $file){

   echo "<script type=\"text/javascript\" src=\"/javascript/$file\"></script>\n";
  }
}

HTML:

<script type="text/javascript" src="/javascript/foo.js"></script>
<script type="text/javascript" src="/javascript/bar.js"></script>
<script type="text/javascript" src="/javascript/baz.js"></script>

До сих пор все было просто. Однако, нам нужно создать процесс публикации наших изменений, чтобы мелкие файлы объединялись в один большой. В нашем примере мы будем объединять foo.js и bar.js в foobar.js, потому что они, практически, всегда загружаются вместе. Можно отметить этот факт в конфигурации приложения и изменить шаблонную функцию, чтобы она могла использовать эту информацию.

SMARTY:
{insert_js files="foo.js,bar.js,baz.js"}

PHP:
# создадим хеш, указывающий на то, где будет находится каждый исходный .js файл
# после своего объединения с чем-либо еще по необходимости

$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']);
  }else{

    $files = array();

    foreach (explode(',', $args['files']) as $file){

      $files[$GLOBALS['config']['js_source_map'][$file]]++;
    }

    $files = array_keys($files);
  }

  foreach ($files as $file){

   echo "<script type=\"text/javascript\" src=\"/javascript/$file\"></script>\n";
  }
}

HTML:
<script type="text/javascript" src="/javascript/foobar.js"></script>
<script type="text/javascript" src="/javascript/baz.js"></script>

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

Для CSS наиболее практичной моделью поведения будет, пожалуй, использование основной и ряда дополнительных таблиц стилей. Основная таблица стилей будет содержать стили, общие для всего приложения (может быть, еще ряд дополнительных, общий объем которых увеличивает файл не более, чем на 20%), тогда как дополнительные CSS-файлы будут описывать различные разделы. При этом подходе большинство страниц будут загружать только 2 таблицы стилей, одна из которых (основная) закешируется при первом посещении любой страницы.

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

Сразу небольшое замечание: характерными размерами отдельных файлов будут 5–10Кб: время их загрузки примерно равно времени запроса файла с сервера. Делать отдельные файлы меньше крайне нежелательно, ибо на сетевые издержки будет тратиться намного больше, чем на фактическую загрузку данных. Поэтому если у вас есть несколько скриптов, общий размер которых не превосходит 10КБ — объедините их в один большой файл, пусть он загружается на всех страницах, вы потеряете при таком подходе гораздо меньше, чем выиграете.

Предположим, что на запрос к серверу уходит 70мс, на загрузку файла на канала 100Кбит/с — 100мс (файл в 10Кб). Таким образом для двух страниц, использующих 2 независимые части данного файла по 5Кб, при последовательном их посещении вы сэкономите (с учетом кеширования) 100 + 70 = 170мс, при этом потеряете (в загрузке каждой страницы) по 50мс. Общий выигрыш составит (в расчете на обе страницы) 170 - 2*50 = 70мс. Цифры эти весьма ориентировочные и сильно зависят от скорости канала и времени установления соединения, для более точного расчета нужно будет учитывать распределение скоростей подключения и времени установления соединения ваших пользователей.

Сжатие

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

Accept-Encoding: gzip,deflate

Когда сервер получает такой заголовок, он может применить либо gzip-, либо deflate-сжатие к содержанию файлов, которые он отправляет пользователю, после чего пользовательских браузер сможет их распаковать. Это расходует процессорное время как на стороне сервера, так и не стороне клиента (как показали тесты, на данный момент это уже совершенно не существенно), но объем передаваемых данных уменьшается. Все это хорошо, конечно, но mod_gzip обычно работает следующим образом: он создает временный файл на диске, в котором содержатся сжатые данные, затем отправляет уже его и удаляет сжатый файл. Для высоконагруженных систем вы быстро упретесь в операции чтения-записи на диск. Одним из выходов является использование mod_deflate (только для Apache 2), который производит все операции в оперативной памяти. Для пользователей Apache 1 вы можете создать виртуальный диск в операционной памяти и указать для mod_gzip писать результаты сжатия именно туда — это будет не настолько же быстро, как прямые операции в памяти, но и не так медленно, как запись на жесткий диск.

Однако, мы можем избежать ряда издержек, если предварительно сожмем все необходимые статические файлы сами и положимся на mod_gzip, чтобы отдать сжатую версию тем пользователям, которые смогут ее распаковать. Если мы добавим такое сжатие в процесс сборки обновлений для нашего сайта, все будет происходить совершенно прозрачно. Число файлов, которые могут быть сжаты, обычно весьма мало: мы не сжимаем картинки, ибо выигрыш будет устрашающе мал, если вообще будет (ибо графические форматы обычно и так уже сжаты). Поэтому нам лишь нужно сжать наши CSS- и JavaScript-файлы (и любые другие статические ресурсы). Можно сконфигурировать 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, люди еще пользуются таким старьем?) сообщают о том, что понимают сжатые файлы (отправляют соответствующие заголовки), однако, распаковать корректно их это браузеры не могут. Большое количество другие версий Netscape 4 имеют проблемы с загрузкой сжатых JavaScript- и CSS-файлов, проблемы эти разнообразны и весьма экзотичны. Нам нужно определить эти браузеры и убедиться, что для них отдается несжатая версия файлов. С этим довольно легко справиться, однако, Internet Explorer (от 4 до 6 версий) обладает еще более интересными проблемами. При загрузке JavaScript в виде архива Internet Explorer иногда неверно некорректно распаковывает его или обрывает его распаковку на полпути, показывая только половину файла пользователю. Если вы полностью полагаетесь на работоспособность JavaScript, вам придется избегать использования сжатия для Internet Explorer (в свете того, что доля IE 6- составляет сейчас не более 35%, это уже может быть адекватным решением). Даже в том случае, если Internet Explorer правильно получает сжатый JavaScript, некоторые старые версии 5.x не будут его кешировать, хотя для файла и выставлены e-tag заголовки.

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

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

Единственный инструмент, который лишен описанных недостатков, — это Dojo Compressor (здесь выложена готовая к использованию версию), который работает на Rhino (Mozilla JavaScript-движок, реализованный на Java). Dojo Compressor создает дерево JavaScript-файла, которое затем максимально уменьшает в размере перед записью его в файл. Этот инструмент позволяет получить достаточно хорошие результаты при невысоких затратах: время единичного сжатия достаточно невелико. Если встроить данное сжатие в процесс публикации изменений на «боевом» сервере, то весь начальная процедура никак не изменится. Вы можете добавить сколько угодно пробелов и комментариев в JavaScript в рабочем окружении, при этом не придется беспокоиться о том, что получившийся код будет слишком велик.

За 2 года ситуация изменилась достаточно сильно. Сейчас на арену вышли два новых игрока: Packer и YUI Comressor, которые показывают более хорошие результаты. Сравнение инструментов для сжатия JavaScript.

По сравнению с JavaScript, CSS относительно просто сжимать. В силу, практически, полного отсутствия строк, заключенных в кавычки (в основном, пути и названия шрифтов) мы можем изничтожить проблемы обычными регулярными выражениями. Когда же мы действительно встречаемся со строкой в кавычках, то мы можем объединить множественные пробелы в один (так как мы не рассчитываем обнаружить их в количестве больше чем 1 в URL или названиях шрифтов). Простейший скрипт на Perl может обеспечить нам все необходимые преобразования:

#!/usr/bin/perl

my $data = '';
open F, $ARGV[0] or die "Can't open source file: $!";
$data .= $_ while <F>;
close F;

$data =~ s!\/\*(.*?)\*\/!!g;  # удаляем комментарии
$data =~ s!\s+! !g;           # сжимаем пробелы
$data =~ s!\} !}\n!g;         # добавляем переводы строки
$data =~ s!\n$!!;             # удаляем последний перевод строки
$data =~ s! \{ ! {!g;         # удаляем лишние пробелы внутри скобок
$data =~ s!; \}!}!g;          # удаляем лишние пробелы и синтаксис внутри скобок

print $data;

И мы можем прогнать все наши CSS-файлы через этот скрипт, чтобы сжать их, например:

perl compress.pl site.source.css > site.compress.css

Путем простых текстовых преобразований мы можем уменьшить общий объем передаваемых данных почти на 50% (очень сильно зависит от вашего стиля кодирования, обычно будет иметь место менее впечатляющий результат), что обеспечит более быструю работу сайта для конечных пользователей. Но что нам действительно нужно, так это не дать пользователям запросить файлы, пока в них никакой необходимости — и в этом нам на помощь придет кеширование.

Кеширование — ваш друг

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

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

<?php
  header("Cache-Control: private");
  header("Cache-Control: no-cache", false);
?>

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

<?php
# Срок действия истекает в прошлом
header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");

# Всегда новый
header("Last-Modified: ".gmdate("D, d M Y H:i:s")." GMT");

# HTTP/1.1
header("Cache-Control: no-store, no-cache, must-revalidate");
# для Internet Explorer
header("Cache-Control: post-check=0, pre-check=0", false);

# HTTP/1.0
header("Pragma: no-cache");
?>

Это замечательно сработает для файлов, которым мы не хотим кешировать. Однако, нам хотелось бы, чтобы те файлы, которые не меняются между запросами браузера, кешировались весьма агрессивно. Заголовок If-Modified-Since в запросе браузера как раз предназначен для этой цели. Если браузер посылает заголовок If-Modified-Since, Apache (или любой другой ваш веб-сервер) может ответить с кодом 304 (Not Modified), указывая браузеру, что тот может использовать свою кешированную копию ресурса. Используя описанный механизм, мы можем избежать повторную отправку файлов браузеру, но мы по-прежнему страдаем от избыточных HTTP-запросов (на самом деле, проблема тут, скорее, отсутствует: браузеры сначала показывают кешированную версию файла, а затем обновляют ее, если получают не 304 ответ, поэтому фактическая скорость загрузки страницы из-за этого не страдает. Хммм.

Теги объетов (entity tags, ETag) работают аналогичным образом. Под Apache как ответ со статическим файлом снабжается заголовком ETag, который содержит проверочную сумму, полученную из времени изменения файла, его размера и номера inode. Браузер затем может выполнить HEAD-запрос, чтобы проверить ETag для ресурса без его загрузки. Однако, проблема у e-tags такая же, как и для механизма if-modified-since: браузеру все равно нужно сделать запрос, чтобы убедиться, что закешированная версия файла актуальна.

Также вы должны быть аккуратны с использованием if-modified-since и ETag, если статические файлы у вас отдаются с различных серверов. Для системы из двух балансирующих серверов отдельный файл может быть запрошен браузером с любого из них. Он может быть запрошен либо с первого, либо со второго, либо с обоих в различное время. Это замечательно: мы можем разделить нагрузку таким образом. Однако, если для одних и тех же файлов оба сервера создают разные ETag или у них разное время изменения, тогда браузеры не смогут их нормально кешировать. По умолчанию ETag создаются при помощи номера inode для файла, который меняется от сервера к серверу. Вы можете отменить эту зависимость при помощи следующей настройки в конфигурации Apache:

FileETag MTime Size

При наличии этой настройки Apache будет использовать только время изменения файла и его размер для создания ETag. Однако, это ведет к другой проблеме, связанной как с ETag, так и с if-modified-since (хотя то уже лучше, чем было). Так как ETag зависит от времени изменения файла, нам нужно, чтобы это время было одинаковым для всех синхронизированных версий файла. Если вы располагаете файлы на нескольких веб-серверах, всегда существует вероятность, что времена загрузки файлов будут немного различаться на одну-две секунды. В этом случае ETag, созданные на обоих серверах, будут по-прежнему разными. Мы можем изменить настройки конфигурации, чтобы создавать ETag только на основе размера файла, однако, это будет означать, что сам ETag может не измениться, если мы поменяем содержание файла (а размер останется прежним). Не айс.

Естественным выходом из ситуации, как мне кажется, является «ручная» настройка ETag (задания специфических значений, которые будут одинаковы по всем серверам и будут изменяться при изменении файла). Также можно рассмотреть вариант с пост-синхронизацией: когда после загрузки всех файлов на зеркала производится автоматическая смена времени изменения на заданное (чтобы оно совпадало по всем зеркалам).

Кеширование — ваш лучший друг

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

Однако, тут есть небольшая лазейка. Перед тем, как запросить любой JavaScript- или CSS-файл, браузер делает запрос на сервер за исходной страницей, на которой вызываются эти файлы через теги <script> или <link>. Мы можем использовать первоначальный ответ сервера для извещения пользователя, что произошли какие-либо изменения в ресурсных файлах. Сейчас немного непонятно, но я поясню: если мы изменим имена JavaScript- и CSS-файлов при изменении их содержания, мы сможем позволить браузеру кешировать их содержание навечно, потому что содержание каждого конкретного URL никогда не изменится.

Если мы уверены, что данный файл никогда не изменится, мы можем ответить с весьма агрессивными кеширующими заголовками. На PHP это будет выглядеть примерно так (всего пара строк):

<?php
header("Expires: ".gmdate("D, d M Y H:i:s", time()+315360000)." GMT");
header("Cache-Control: max-age=315360000");
?>

В них мы сообщаем браузеру, что срок давности содержания истечет через 10 лет (в 10 годах примерно 315360000 секунд, больше или меньше) и что он может хранить данный файл все эти 10 лет. Естественно, мы, скорее всего, не отдаем наш JavaScript и CSS через PHP, я вернусь к более корректной записи этого момента чуть позже.

Ошибки, ошибки и еще раз ошибки

Вручную изменять вызовы наших ресурсных файлов при каждом их изменении — весьма трудная и опасная задача. Что произойдет в том случае, если вы переименуете сам файл, но забудете это сделать во всех шаблонах, которые его используют? Что произойдет, если вы измените вызовы в одних шаблонах, а в других забудете это сделать? И что случится, если вы измените вызовы в шаблонах, но забудете переименовать сам файл? Во всех случаях это будет плохой идеей: вы просто измените ссылку на ресурс, а ресурса по ней не будет. Лучшее, что произойдет, — это пользователь увидит старое содержание по новой ссылке. В худшем случае он увидит 404-ошибку, и сайт развалится. Все это звучит крайне неприятно.

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

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

RewriteEngine on
RewriteRule ^/(.*\.)v[0-9.]+\.(css|js|gif|png|jpg)$	/$1$2	[L]

Это правила соответствует любому URL, которое заканчивается одним из обозначенных расширений, при этом в нем содержится и версионный «кусочек». Такое правило перенаправляет запросы к URL на физические адреса без упоминания версии. Несколько примеров:

URL			   Path
/images/foo.v2.gif	-> /images/foo.gif
/css/main.v1.27.css	-> /css/main.css
/javascript/md5.v6.js	-> /javascript/md5.js

С этим правилом мы можем изменять URL (меняя номер версии), но не переименовывать сам физический файл на диске. Так как URL изменится, браузер подумает, что перед ним новая ссылка на ресурс. Для достижения наилучшего результаты вы можете совместить этот подход с предыдущим, который объединял несколько JavaScript-файлов в один, и выводить необходимый список тегов <script> с версиями.

Сейчас вы вправе спросить, почему бы нам не добавить дополнительную строку запроса после URL нашего ресурса: /css/main.css В соответствии с HTTP-спецификацией в области кеширования (автор немного перевирает, если верить данной спецификации, то кеширование таких URL запрещено только для HTTP/1.0), пользовательские агенты никогда не должны кешировать URL, содержащие строки запроса. Internet Explorer и Firefox это игнорируют, Opera и Safari так не делают. Чтобы быть уверенными в том, что произвольный браузер будет кешировать наши ресурсы, мы должны избегать в них строки запроса (всего, что идет после ? в URL; насчет того, кто как что кеширует, информацию не проверял).

Теперь мы можем изменить наши URL без перемещения самих физических файлов, мы можем также замечательно автоматизировать этот процесс. Для небольшого рабочего окружения (или для тестового окружения в случаях больших систем разработок) мы можем сделать это весьма просто при помощи небольшой шаблонной функции. Нижеприведенный пример действителен для Smarty, но его можно легко распространить и на другие движки.

SMARTY:
<link href="{version src='/css/group.css'}" rel="stylesheet" type="text/css" />

PHP:
function smarty_version($args){

  $stat = stat($GLOBALS['config']['site_root'].$args['src']);
  $version = $stat['mtime'];

  echo preg_replace('!\.([a-z]+?)$!', ".v$version.\$1", $args['src']);
}

OUTPUT:
<link href="/css/group.v1234567890.css" rel="stylesheet" type="text/css" />

Для каждого вызываемого ресурса мы можем определить физическое расположение файла на диске, проверить его mtime (дата и время последнего изменения файла) и вставить это в URL как номер версии. Это будет замечательно работать для сайтов с небольшими объемами трафика (когда операция stat относительно быстро выполняется) и для тестового окружения, но очень плохо масштабируется на системы под реальной нагрузкой: каждый вызов stat требует обращения к жесткому диску.

Наше решение будет предельно простым. Для большой системы у нас уже есть номер версии для каждого используемого ресурса, он заложен в системе контроля версий (вы уже используете какую-нибудь SVN, не так ли?). В тот момент, когда мы собираем наш сайт для публикации в «боевом» окружении, мы можем проверить все номера ревизий для наших ресурсов и записать их в статический файл конфигурации.

<?php
$GLOBALS['config']['resource_versions'] = array(

  '/images/foo.gif'    => '2.1',
  '/css/main.css'      => '1.27',
  '/javascript/md5.js' => '6.1.4',
);
?>

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

<?php
function smarty_version($args){

  if ($GLOBALS['config']['is_dev_site']){

    $stat = stat($GLOBALS['config']['site_root'].$args['src']);
    $version = $stat['mtime'];
  }else{
    $version = $GLOBALS['config']['resource_versions'][$args['src']];
  }

  echo preg_replace('!\.([a-z]+?)$!', ".v$version.\$1", $args['src']);
}
?>

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

Собираем все вместе

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

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

Apache:
RewriteRule ^/(.*\.)v[0-9.]+\.(css|js|gif|png|jpg)$  /redir.php?path=$1$2  [L]

PHP:
header("Expires: ".gmdate("D, d M Y H:i:s", time()+315360000)." GMT");
header("Cache-Control: max-age=315360000");

# пропускаем пути с '..'
if (preg_match('!\.\.!', $_GET[path])){ go_404(); }

# убеждаемся, что путь начинается с известной папки
if (!preg_match('!^(javascript|css|images)!', $_GET[path])){ go_404(); }

# файл сущестует?
if (!file_exists($_GET[path])){ go_404(); }

# выводим MIME-заголовок
$ext = array_pop(explode('.', $_GET[path]));
switch ($ext){
  case 'css':
    header("Content-type: text/css");
    break;
  case 'js' :
    header("Content-type: text/javascript");
    break;
  case 'gif':
    header("Content-type: image/gif");
    break;
  case 'jpg':
    header("Content-type: image/jpeg");
    break;
  case 'png':
    header("Content-type: image/png");
    break;
  default:
    header("Content-type: text/plain");
}

# выводим содержимое файла
echo implode('', file($_GET[path]));

function go_404(){
  header("HTTP/1.0 404 File not found");
  exit;
}

Хотя все это работает, но не является идеальным решением (проблеск разумной мысли в рассуждениях автора). PHP потребляет много памяти, и его время выполнения выше, чем обычный запрос через Apache (не говоря уже об использовании «легких» серверов типа nginx). Дополнительно ко всему нам придется дополнительно заботиться, чтобы через path или параметры строки запроса не прошло какое-либо вредоносное значение и не воспользовалось серверной уязвимостью. Для избавления от этой головной боли мы попросим Apache добавить все эти заголовки напрямую. Директива RewriteRuleпозволит выставить нам все необходимые переменные окружения при срабатывании правила, при этом директива Header добавит все необходимые заголовки, если выставлена соответствующая переменная окружения. Совместив эти две директивы, мы можем записать наши изыскания в области версионности и кеширования в следующем виде.

RewriteEngine on
RewriteRule ^/(.*\.)v[0-9.]+\.(css|js|gif|png|jpg)$ /$1$2 [L,E=VERSIONED_FILE:1]

Header add "Expires" "Mon, 28 Jul 2014 23:30:00 GMT" env=VERSIONED_FILE
Header add "Cache-Control" "max-age=315360000" env=VERSIONED_FILE

В связи с порядком выполнения директив в Apache нам нужно добавить строку с правилом RewriteRule в основной конфигурационный файл (httpd.conf), а не в файл для соответствующей директории (.htaccess). Иначе наши строки с Header сработают самыми первыми еще до того, как выставится переменная окружения. При этом строки с Header могут быть как в основном конфигурационном файле, так и в дополнительных .htaccess — это ничего не меняет.

Разделываем кроликов — в качестве заключения

Если вы будете использовать вышеописанные решения, то сможете создать достаточно гибкое окружения для разработки и высокопроизводительное и эффективное для конечных пользователей. Естественно, для ускорения работы вашего сайта еще можно сделать довольно много. Мы также можем рассмотреть использование многих других методов (раздельную выдачу статических файлов, несколько доменных имен для увеличения числа параллельных запросов) и способов применения описанных выше теоретических основ (создание фильтра на уровне Apache для изменения URL в исходном HTML и добавления версионной информации «на лету»). Все это может стать темой для будущих разговоров.

Внимательные читатели и опытные оптимизаторы, скорее всего, для себя почерпнут мало нового из данной статьи, однако, в ней собрано большое количество техник, относящихся именно к CSS/JS-файлам, и приведено много примеров PHP-кода и конфигурации Apache. Да и повторение — мать учения.

Читать дальше

Все комментарии (habrahabr.ru)