Xpoint
   [напомнить пароль]

Слежение за контентом на динамических сайтах

Оглавление

Предыстория

Эта статья была задумана и написана благодаря форуму XPoint.

Методы, описанные ниже, не являются личным изобретением автора. Просто он, будучи совсем еще чайником, лично сталкивался с описываемой проблемой и не смог найти толкового материала на предмет ее решения. Решение было найдено путем прочтения десятка статей сколь-нибудь близкой тематики, мученья людей с форума XPoint и недельной ночной баталии с PHP.

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

Внимание: не факт, что Вам все удастся сделать с первого раза за полчаса. Факт, что прежде чем что-то делать, никогда не лишне почитать о том, как это делали другие.

О чем эта статья

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

  1. «правильное» кэширование;
  2. «правильные» HTTP заголовки.

Поясняю первый пункт. Кэширование — механизм, позволяющий клиенту (то есть пользователю на том конце соединения, точнее — его браузеру, для пользователя этот процесс незаметен) при просмотре одних и тех же файлов (например, картинок, составляющих элементы дизайна сайта, или файлов стилей CSS) не скачивать их каждый раз заново, а скачивать только однажды, а затем, по мере необходимости, использовать сохраненную на компьютере клиента копию. Принцип его работы примерно таков: при возникновении очередной необходимости скачать файл клиент обращается к серверу с запросом, не был ли файл изменен (не устарела ли копия на машине клиента), и если нет, не скачивает его заново, а использует сохраненный вариант. В браузерах, точно придерживающихся спецификации протокола HTTP, можно также добиться того, что запросы вообще не будут посылаться каждый раз, если есть сохраненная копия файла и точно известно, что она не успела устареть. Прелесть в том, что трафик (объем скачанных из Интернет данных) клиента уменьшается, и пользователь видит, что «сайт работает быстро». Естественно, уменьшается и трафик сервера, что позволяет снизить нагрузку на сервер и даже сэкономить, если Вы пользуетесь платным хостингом.

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

Поясню теперь пункт второй. Когда клиент запрашивает у сервера файл по протоколу HTTP, он, кроме содержимого самого файла (код в случае HTML файла, текст в случае текстового файла и т.п.), получает также HTTP headers — заголовки HTTP. Это служебные текстовые поля с информацией, которая не отображается в браузере, но интерпретируется им и, в основном, служит для сообщения клиенту данных о запрашиваемой странице.

Заголовок ETag («объектная метка»), например, служит для присвоения каждой странице уникального идентификатора, который остается неизменным, пока страница не модифицирована, и изменяется, если изменились данные на странице. Этот заголовок сохраняется на клиенте, и в случае необходимости повторного скачивания «меченой» страницы позволяет браузеру обращаться к серверу с запросом 'If-None-Match' — в таком случае сервер должен по значению ETag-метки сохраненной на клиенте копии определить, не устарела ли она, и если нет, ответить кодом '304 Not Modified' («не модифицировано»), и страница не будет скачена еще раз.

Заголовок Last-Modified («последнее изменение») предназначен для того, чтобы сообщить клиенту дату и время, когда последний раз изменилась запрашиваемая страница. Используя его, клиент, подобно случаю с ETag, может обращаться к серверу с запросом 'If-Modified-Since' — в этом случае сервер должен сравнить дату последней модификации копии, сохраненной на клиенте, с актуальной датой последней модификации. Если они совпадут, это значит, что копия в кэше клиента не устарела, и повторное скачивание не нужно (код ответа '304 Not Modified'). Last-Modified также необходим для корректной обработки Вашего сайта роботами - спайдерами (спайдер, англ. «паук» — это робот, который ходит по паутине Интернета и индексирует сайты, чтобы их можно было найти через поисковые системы, например, Google), которые используют информацию о дате модификации страниц в целях сортировки результатов поиска по дате, а также для определения частоты обновляемости Вашего сайта (см. например, что об этом пишет Яndex).

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

Есть еще заголовок Expires («истечение») — он сообщает браузеру, какой временной промежуток можно считать, что копия страницы в кэше свежа, и вообще не обращаться к серверу с запросами. Это удобно для таких файлов, о которых вы точно знаете, что они не изменятся ближайший час/день/месяц: фоновая картинка страницы, например. К сожалению, поддерживается не всеми браузерами. В рассматриваемом примере Expires будет равен десяти минутам, что подходит для большинства сайтов, на которых информация обновяется не слишком часто (~ раз в час).

Чтобы реализовать отправку «правильных» HTTP заголовков страницами Вашего сайта, надо как-то определять, когда модифицируются эти страницы. Проблема состоит в том, что в отличие от ситуации со статичными HTML файлами, когда дата модификации файла и его содержимого это одно и тоже, динамические страницы могут менять свое содержимое в зависимости от предусмотренных разработчиком внешних факторов (время суток, запрос пользователя, импорт данных из БД) без изменения файла скрипта. То есть дата модификации файла и информации, которую он отсылает клиенту, запросто могут не совпадать. Другая проблема, вытекающая из предыдущей, заключается в том, что часто на динамических сайтах на каждую страницу ставят голосование, "шутку дня" или баннерокрутилку, код которых меняется при каждой следующей загрузке. То есть надо смотреть не на всю сгенерированную страницу, а только на ту ее часть, которая несет основную информацию — текст статьи, прайс-лист и т.п.

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

Теория

Для решения вышеописанных проблем необходимо:

  1. "Отловить" со страницы ту часть контента, за которой мы "следим";
  2. Сравнить то, что получилось, с тем, что получилось в прошлый раз — для этого использовать БД, где и хранить информацию о страницах;
  3. В случае модификации данных — обновить информацию о странице в БД;
  4. Отослать клиенту HTTP заголовки в зависимости от его запроса.

Сделать это можно разными способами. Мы будем рассматривать систему, написанную на языке PHP, так как этот язык сейчас популярен, довольно прост для понимания и поддерживается большинством хостингов, в том числе и бесплатными. Данные мы будем хранить в БД MySQL по тем же причинам.

Итак, что там у нас там, на сайте? Динамические страницы, то есть PHP скрипты. Как функционирует эта «динамика»? Если сайт у Вас небольшой, то, скорее всего, у Вас под каждую страницу существует свой скрипт: index.php – для главной страницы, news.php для страницы новостей и т.п. Если же сайт у Вас выходит за рамки «домашней странички» и имеет сложную структуру, свой форум или пользовательскую зону, построен с использованием баз данных, то, вероятно, одним скриптом типа showpage.php генерируются сотни концептуально различных страниц (например, страницы форума генерирует один скрипт, но страницы-то разные, и надо следить за каждой отдельно). Первый случай проще для рассмотрения, хотя, если Вы поймете суть предлагаемой системы, Вы сможете без особых проблем интегрировать ее и на сайте, описанном во втором случае. А мы рассмотрим случай первый.

Чего надо «избежать» при «отлове» контента? Баннеров, счетчиков и всего, что написано в меню. Это не принципиально — Вы сами решаете, какой объем информации, отдаваемой Вашими скриптами, будет использоваться для определения их модификации.

Если «нужная» информация и «ненужные» примочки выводятся разными функциями, то все «нужное» можно просто забить в одну переменную. Что-нибудь вроде этого:

<?php

// Это – часть гипотетического PHP файла.
// Он не имеет никакого смысла и используется только как пример.

include 'settings.inc.php'; // Подключаем какие-нибудь настройки, модули, классы и т.п.
print page_header(); // Выводим шапку – нам она не нужна;
print page_ads(); // Выводим рекламу – аналогично;

print $contentmonitoring_var = main_info(); // Показываем "основную" информацию;
// это как раз то, что нам нужно - кладем данные в переменную $contentmonitoring_var

print $temp = info2(); // Еще какая-нибудь нужная информация;
$contentmonitoring_var .= $temp; // добавляем данные в ту же переменную

print something_other(); // Опять что-то не нужное;
print page_footer(); // Выводим футер - он нам тоже не понадобится.

?>

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

<!--content-->

а после — строчку

<!--/content-->

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

Теперь мы отделили «мух от котлет» и информация, которую будем «контент-мониторить», заключена у нас между HTML-комментариями. Остается включить буферизацию, в конце скрипта взять данные из буфера, выловить оттуда часть кода между комментариями, и обработать его на предмет модификации. По результатам этой обработки, а также в зависимости от запроса клиента, отослать HTTP заголовки. Все это будет делать скрипт, который надо подключить ко всем Вашим скриптам. Делается это так: в начале каждого файла, сразу после строчки <?php вставляем код

ob_start(); // Слежение за контентом – запускаем буферизацию.

а в конце, перед строчкой ?> код

include_once('content_monitoring.inc.php'); // Слежение за контентом – подключаем исполняемый скрипт.

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

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

Практика

Код исполняемого скрипта:

<?php

// Файл content_monitoring.inc.php, система контент-мониторинга.
// Должен лежать в той же папке, что и Ваши скрипты.

if (!isset($page_id)) $page_id = $_SERVER['PHP_SELF'];
// $page_id это идентификатор страницы в базе данных.
// Если Вы используете схему "1 скрипт = 1 страница", оставьте эту строчку как есть.
// Если у Вас один скрипт генерирует много концептуально разных страниц,
// придется генерировать $page_id этим скриптом динамически.
// Переменная не должна содержать ничего кроме латиницы, цифр и подчеркивания.

ConnectToDatabase(); // Соединяемся с базой данных.
// Функция ConnectToDatabase() не определена в этом примере, так как
// подключение к БД MySQL и обработка возникших ошибок выходят
// за рамки данной статьи. Вам придется самому написать эту функцию.

$page_all_contents = ob_get_contents(); // Достаем вывод скрипта из буфера
$page_main_content = preg_replace("#.*?(<!--content-->(.*?)<!--/content-->|$)#is", "$2", $page_all_contents);
$page_hash = md5($page_main_content); // Вычисляем хэш "нужной" части страницы

// Проверим, создана ли таблица статистики...
if (!mysql_num_rows(mysql_query("SHOW TABLES LIKE 'content_monitoring'"))) {
    // Таблица статистики не найдена.
    mysql_query("CREATE TABLE content_monitoring (cm_id VARCHAR(255) NOT NULL, cm_md5 CHAR(32), cm_modified TIMESTAMP(14), PRIMARY KEY(cm_id(255)))"); // Создаем таблицу.
    mysql_query("INSERT INTO content_monitoring VALUES ('$page_id', '$page_hash', NULL)"); // Пишем в таблицу первую запись.
}

// Достаем из базы данных информацию о текущей странице.
$FILE_INFO = mysql_fetch_row(mysql_query("SELECT cm_id, cm_md5, UNIX_TIMESTAMP(cm_modified) as modified FROM content_monitoring WHERE cm_id = '$page_id'"));

if (empty($FILE_INFO)) {
    // Запись для файла не найдена.
    mysql_query("INSERT INTO content_monitoring VALUES ('$page_id', '$page_hash', NULL);"); // Создаем запись о текущем файле.
    $FILE_INFO[2] = time(); // Модификация - сейчас.
    $last_modified = gmdate("D, d M Y H:i:s", $FILE_INFO[2]);
}
else {
    // Запись для файла найдена.
    if ($page_hash != $FILE_INFO[1]) {
    mysql_query("UPDATE content_monitoring SET cm_md5='$page_hash' WHERE cm_id='$page_id';"); // Обновляем запись о текущем файле, если он изменился.
    $FILE_INFO[2] = time(); // Модификация - сейчас.
    }

    $last_modified = gmdate("D, d M Y H:i:s", $FILE_INFO[2]);

    // Дальше делаем обработку Conditional GET'а:
    if (!isset($_SERVER['HTTP_IF_NONE_MATCH']) && !isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
        // Conditional Get не задан - просто отдаем файл.
        header('ETag: "'.$page_hash.'" '); // присваеваем метку
        header("Last-Modified: $last_modified GMT"); // последнее изменение - сейчас
        header('Expires: '.gmdate("D, d M Y H:i:s", time()+60*10).' GMT'); // страница остается неизменной 10 минут
    }

    elseif (!isset($_SERVER['HTTP_IF_NONE_MATCH']) && isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
    // Случай первый - Conditional GET задан, проверка только по If-Modified-Since:
    $unix_ims = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); // значение If-Modified-Since в UNIX формате
    if ($unix_ims > time() || !is_int($unix_ims)) {
        // Ошибка Conditional GET - просто отдаем файл.
        header('ETag: "'.$page_hash.'" '); // присваеваем метку
        header("Last-Modified: $last_modified GMT"); // последнее изменение - сейчас
        header('Expires: '.gmdate("D, d M Y H:i:s", time()+60*10).' GMT'); // страница остается неизменной 10 минут
    }
    else {
        // Conditional GET корректен.
        if ($unix_ims >= $FILE_INFO[2]) {
            // Копия файла в кеше клиента не устарела - сообщаем ему об этом...
            header("HTTP/1.1 304 Not Modified"); // не модифицировано
            header('ETag: "'.$page_hash.'" '); // присваеваем метку
            // ...и заканчиваем выполнение скрипта, не отсылая сам файл.
            while(ob_get_level()) ob_end_clean();
            exit;
        }
        else {
            // Похоже, что копия клиента устарела.
            header('ETag: "'.$page_hash.'" '); // присваеваем метку
            header("Last-Modified: $last_modified GMT"); // последнее изменение - сейчас
            header('Expires: '.gmdate("D, d M Y H:i:s", time()+60*10).' GMT'); // страница остается неизменной 10 минут
            }
        }
    }

    elseif (isset($_SERVER['HTTP_IF_NONE_MATCH']) && !isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
    // Случай второй - Conditional GET задан, проверка только по If-None-Match:
    $INM = split('[,][ ]?', $_SERVER['HTTP_IF_NONE_MATCH']); // массив значений If-None-Match
    foreach($INM as $enity) {
        if ($enity == "\"$page_hash\"") {
            // Копия файла в кеше клиента не устарела - сообщаем ему об этом...
            header("HTTP/1.1 304 Not Modified"); // не модифицировано
            header('ETag: "'.$page_hash.'" '); // присваеваем метку
            // ...и заканчиваем выполнение скрипта, не отсылая сам файл.
            while(ob_get_level()) ob_end_clean();
            exit;
            }
        // Если дошло до этой линии, копия клиента устарела. Отдаем файл.
        header('ETag: "'.$page_hash.'" '); // присваеваем метку
        header("Last-Modified: $last_modified GMT"); // последнее изменение - сейчас
        header('Expires: '.gmdate("D, d M Y H:i:s", time()+60*10).' GMT'); // страница остается неизменной 10 минут
        }
    }

    else {
    // Случай третий - проверка и по If-Modified-Since, и по If-None-Match:
    $unix_ims = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); // значение If-Modified-Since в UNIX формате
    $INM = split('[,][ ]?', $_SERVER['HTTP_IF_NONE_MATCH']); // массив значений If-None-Match
    if ($unix_ims > time() || !is_int($unix_ims)) {
        // Ошибка Conditional Get - просто отдаем файл.
        header('ETag: "'.$page_hash.'" '); // присваеваем метку
        header("Last-Modified: $last_modified GMT"); // последнее изменение - сейчас
        header('Expires: '.gmdate("D, d M Y H:i:s", time()+60*10).' GMT'); // страница остается неизменной 10 минут
        }
    else {
        // Conditional GET корректен.
        foreach($INM as $enity) {
            if ($enity == "\"$page_hash\"" && $unix_ims >= $FILE_INFO[2]) {
            // Копия файла в кеше клиента не устарела - сообщаем ему об этом...
            header("HTTP/1.1 304 Not Modified"); // не модифицировано
            header('ETag: "'.$page_hash.'" '); // присваеваем метку
            // ...и заканчиваем выполнение скрипта, не отсылая сам файл.
            while(ob_get_level()) ob_end_clean();
            exit;
            }
        // Если дошло до этой линии, копия клиента устарела. Отдаем файл.
        header('ETag: "'.$page_hash.'" '); // присваеваем метку
        header("Last-Modified: $last_modified GMT"); // последнее изменение - сейчас
        header('Expires: '.gmdate("D, d M Y H:i:s", time()+60*10).' GMT'); // страница остается неизменной 10 минут
        }
    }
}
}

?>

Несколько слов напоследок

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

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

В любом случае буду рад, если кому-нибудь помог.

Антон Клесс[досье]

Ссылки

  1. GET-запрос с условием (Conditional GET)
  2. Hypertext Transfer Protocol - HTTP/1.1
  3. Header Field Definitions - ETag
  4. Header Field Definitions - Expires
  5. Header Field Definitions - Last-Modified
Powered by POEM™ Engine Copyright © 2002-2005