Хакер № 3/10 (134)

Я убью тебя, Google Reader! Высоконагруженный сервис своими руками
Александр Лозовюк (alex.raiden@gmail.com)
Мало кто представляет, как устроены популярные онлайн-проекты изнутри. Кажется, поставив сотню серверов, можно заставить любой сервис летать, но это не так. Важно эффективно спроектировать работу приложения и выбрать правильные технологии. Подобрав удачное сочетание, высоконагруженный сервис можно построить даже на самом скромном оборудовании!
Возьмем для примера Google Reader. Если не сильно заморачиваться, то аналог этой онлайн-читалки собрать можно очень быстро, используя голый PHP и MySQL. Но добавь туда пару тысяч RSS-фидов и несколько десяткой пользователей – твое приложение взвоет от непосильной нагрузки. Зато, если изначально подумать, в нужных местах реализовать кэширование и вместо реляционной базы данных использовать Key-Value-хранилища, а саму работу сервиса распараллелить на несколько серверов, то можно создать настоящий сервис, который выдержит серьезную нагрузку. При этом сделать это можно даже на PHP!
Что мы будем строить?
Хочу внести ясность, что именно мы будем делать. Самый простой способ уследить за жизнью сайта – это получать новости и обновления через RSS-ленты – в специальном формате, построенном на XML. Сегодня у тебя большой выбор: RSS можно читать везде — в почтовом клиенте (например, Mozilla Thunderbird), браузере, специальной программе-агрегаторе или же онлайн. Один из самых продвинутых онлайн-агрегаторов, конечно, Google Reader. Пользоваться им вроде бы легко: раз оформив подписку на нужные ленты, можно заходить на reader.google.com и в удобном виде читать новости.
Впрочем, даже у этого сервиса есть недостатки. Мне, к примеру, не сильно нравится интерфейс. А многих не устраивают серьезные задержки в получении новых сообщений (как и у любой сервис, Google Reader скачивает обновленные ленты с некоторой периодичностью). Особенно это касается Twitter-а, который выдает ленту всех твоих сообщений. Попробуем собрать свою читалку, лишенную последнего недостатка, и которой сможешь пользовать и ты, и твои друзья. Напомню, что, когда у тебя две-три ленты, с ними справится даже твой нетбук, но когда люди поймут, что и Google можно заставить курить в сторонке, и начнут добавлять свои ленты, станет плохо. А мы сделаем так, что сервис будет работать с любым количеством лент: добравшись до ограничения, нужно просто добавить еще один сервис. Задача нешуточная – предстоит высший пилотаж PHP-программирования, а именно создание распределенных систем. Помнишь, мы рассказывали про облачный компьютинг, Amazon EC2 и прочие заморские технологии? Вот с их помощью это сделать очень легко - нажал кнопочку и у тебя уже два сервера вместо одного. Используя РНР и немного магии, ты сможешь заставить работать на наше благо столько серверов, сколько достанешь.
Строить распределенную систему мы будем на базе стандартного LAMP-набора, а в качестве основной библиотеки используем Zend Framework и jQuery, а также MySQL для базы данных. Для эффективной работы на нескольких серверах понадобится механизм Gearman. Предупрежу сразу, что буду рассказывать только о самых важных моментах построения распределенной системы и не дам сразу скопипастить готовый код :). Но ты можешь скачать исходники с диска журнала и посмотреть их самостоятельно.
В разведку за лентами
Первым делом тебе надо иметь возможность добавлять фиды, которые хочешь читать. Это реально сложно, так как ты же хочешь, чтобы пользоваться читалкой мог любой, а значит, требовать прямого URL или, упаси бог, задания формата, которых у RSS несколько, никак нельзя. Да и сам URL может быть задан кучей вариантов. Так что попробуем написать универсальную «получалку» из любого адреса!
Что же можно вводить? Самый простой способ – просто указать адрес сайта, ленту которого мы будем читать. Адрес может быть как полным, с указанием http:// в начале, так и ограничиваться доменным именем. Поэтому первым делом проверь, указан ли протокол, так как наш фреймворк не умеет работать с неполными адресами. Здесь уже придется написать немного кода.
Первым из компонентов Zend Framework, который нам понадобится, будет Zend_Uri, он проверит введенный адрес. Если все нормально, мы пойдем дальше; если не получится – может, кто-то пытается надурить систему? Сам код функции, реализующий проверки, ты найдешь в исходниках (файл validURI.php), а я лишь кратко опишу, как он работает. Мы предполагаем, что введенная строка является нормальным адресом (ага, доверяем пользователю), поэтому используем метод Zend_Uri::factory для создания объекта адреса. Если что-то пойдет не так, возникнет исключение. Далее необходимо реализовать несколько проверок: например, не забыл ли пользователь указать «http://» в начале адреса – без нее URL считается некорректными. Обернув подобные проверки в исключения, мы спускаем по цепочке проверок и, в конце концов, либо получаем корректный URL, либо говорим пользователю, что введенная им строка на URL не тянет.
Хорошо, предположим у нас есть корректный адрес сайта. Это или прямая ссылка на ленту, или ссылка на веб-страницу, где эта лента может быть. В нашем случае это неважно, потому как любую из ситуаций одинаково хорошо обрабатывают заботливо созданные для нас инструменты. Будем использовать недавно появившийся в Zend'е компонент Zend_Feed_Reader, который отлично справляется с любыми типами лент (с любым форматированием и даже экзотическими лентами с встроенными XSLT-стилями — этим балуются некоторые западные сайты вроде CNBC). В нем есть встроенный метод поиска линков на страницах, и если RSS-лента на этом сайте имеется, он ее найдет! Еще одна особенность Zend_Feed_Reader – встроенное кэширование — нам очень пригодится, чтобы постоянно не перекачивать кучу данных просто так. Если лент много, хостеру это точно не понравится. Поэтому мы будем использовать файловый кэш. К тому же, если будет много пользователей, часть из них станут читать одни и те же ленты - зачем же загружать их несколько раз? Не следует пренебрегать и встроенными в HTTP возможностями кэширования. Если сервер сообщает нашему приложению, что лента не изменилась (при помощи служебных HTTP-заголовков), то можно смело доставать кэшированную версию.
Программисты Zend’а и здесь сделали за тебя всю работу! Чтобы все это включить и дать передохнуть серверу, достаточно поместить несколько строчек до первого использования компонента Zend_Feed_Reader:
$cache = Zend_Cache::factory('Core', 'File', array('lifetime' => 24 * 3600, 'automatic_serialization' => true, 'cache_id_prefix' => 'xakep_'
), array('read_control_type' => 'adler32','cache_dir' => ‘/tmp/xakep/cache’));
//Разрешаем использовать кэш
Zend_Feed_Reader::setCache($cache);
//Разрешаем учитывать HTTP-заголовки для проверки кэша
Zend_Feed_Reader::useHttpConditionalGet(true);
Так мы существенно снизим нагрузку на сервер, и даже самая простая конфигурация сможет обрабатывать десятки и сотни лент, а вот дальше уже придется использовать Memcached.
Добытые ссылки нужно куда-то сохранить. Для этого в MySql создаем две таблицы - feeds для хранения ссылок и user_subscriptions для хранения подписок. Зачем две? Очень просто - ты сможешь хранить только одну ссылку; если на ленту подписаны двое пользователей, то оба будут использовать один и тот же адрес. В таблицу feeds нелишним будет добавить поле, чтобы записывать количество ошибок доставки ленты. Код базы можно посмотреть в файле db.sql дистрибутива.
Приказано получить новости!
Теперь у нас есть некоторые вспомогательные инструменты, и можно приступать к построению самого сервиса. Рассмотрим общий принцип.
Периодически (например, по cron-у) запускается скрипт, который берет все твои ленты из базы, и последовательно перебирает их, проверяя новые сообщения. Если сообщения есть, он добавляет их. При первом запуске все сообщения в лентах будут новыми, поэтому нагрузка на сервер будет значительной (если у тебя много лент); потом добавляться будут только свежие сообщения - обычно несколько сообщений в час (хотя есть ленты, которые обновляются каждую минуту и там каждый раз несколько десятков сообщений).
Важный вопрос: как ты поймешь, что сообщение из ленты новое? Ни один из атрибутов использовать для правильной проверки не получится. Поэтому реализуем собственные уникальные идентификаторы, и будем пропускать каждую новость через сравнение со списком уже загруженных. Самый простой путь - просто использовать нашу MySQL базу данных, однако скажу, что это путь для лохов. Подумай сам, если у тебя будет хоть с десяток-другой лент, очень скоро, буквально в течение пары недель, таблица с сообщениями разрастется до огромного размера. Если мы будем постоянно проверять все новости, при каждом запуске нашего скрипта база обязательно будет ложиться.
Помнишь, мы недавно рассказывали о преемниках SQL-а в веб-приложениях - key/value хранилищах (#128 номер ][, PDF-версия статьи — на диске). Предлагаю дополнить нашу разработку кэшем, который будет использовать самую быструю NoSQL-систему - Redis. В него мы поместим все идентификаторы сообщений, которые уже загружены в базу данных – и только если встретим новость, которой там нет, запишем ее как новую.
Для работы с Redis-ом есть отличный класс – Rediska. Он же, кстати, добавляет в Zend Framework множество классов-адаптеров, например, можно переключить на него стандартный кэш. Подключить редиску просто:
$redis_conf = Array( 'namespace' => 'xakep_', 'servers' => array(array('host' => 'localhost','port' => 6379, 'weight' => 1)), 'keyDistributor' => 'crc32');));
try
{
$redis = new Rediska($redis_conf);
}
catch (Rediska_Exception $e)
{
die("[ERROR] Error creating Redis instance: " . $e->getMessage());
}
Подробнее о Redis-е мы уже писали. Расскажу только про особенности нашего проекта. Для кэша мы будем использовать структуру данных SET, так как она позволяет всего одной командой узнать, содержится в ней указанное значение или нет. Соответственно, у нас будет столько сетов, сколько уникальных лент, а в каждом сете будет содержаться набор MD5-хэшей сообщений, которые уже загружены.
Сначала напишем простую функцию, которая будет принимать один или несколько адресов лент и последовательно проверять их на наличие новых сообщений. Позже на базе этого кода мы за пять минут построим гибкую распределенную систему!
Функция processFeed (смотри файл processFeed.php) принимает массив, в котором должен быть, как минимум, один элемент - прямая ссылка на ленту. Дальше класс автоматически загрузит ленту, если это необходимо, или же вытащит ее из кэша, а тебе останется только проверить, что получилось.
$feed = Zend_Feed_Reader::import($feed_url);
if ($feed instanceOf Zend_Feed_Reader_FeedAbstract)
{ /* код обработки ленты здесь */ }
Видишь, все просто. Теперь разберемся с сообщениями. Ты же пока не знаешь, какие сообщения новые, какие старые, поэтому в любом случае надо обработать все полученные данные. Сделать это просто, ведь класс, который мы получили, не только содержит все данные из ленты, но и, что важнее, допускает работу с собой как с обычным массивом! Поэтому просто запусти цикл foreach и он переберет все новости из ленты.
Для начала вычислим уникальный ключ новости - используя md5-хэш от заголовка новости, даты публикации и линка. Далее проверим, существует ли кэш для этой ленты в Redis-е. Если его нет, значит, лента добавлена только что, и мы еще ее не обрабатывали. Получить поля сообщения также очень легко. Для простоты и удобства мы пока опустим работу с самим телом новости - это будет хорошим домашним заданием, а выведем только заголовок, ссылку и дату:
$_item['title'] = htmlspecialchars($feed->getTitle(), ENT_QUOTES);
$_item['time'] = $feed->getDateCreated()->getTimestamp();
$_item['link'] = $feed->getLink();
Теперь создадим ключ новости:
$_item['hash'] = md5($_item['title'] . '|' . $_item['link'] . '|' . $_item['time']);
Все готово, чтобы проверить новость на «старость»:
$_fhash = md5($feed_url); //хэш кэша для ленты
if ((!$redis->exists($_fhash)) || (($redis->exists($_fhash)) && (!$redis->existsInSet($_fhash, $_item['hash'])))
{
//ух ты, новость! Добавить!
$redis->addToSet(md5($feed_url), $_item['hash']);
}
else
continue;
Код будет выполняться, только если мы получили новую информацию. В базе ты хранишь ID ленты, заголовок новости, ссылку и две даты - время самой новости и время, когда она добавлена - а также посчитанный md5-хеш. Записывать сообщения, конечно, необходимо в транзакции, но, учитывая, что вставок может быть много, а ты же не хочешь завалить сервер, можно ограничиться транзакцией на всю ленту. При помощи Zend’а это делается командами: $db->beginTransaction() и $db->commit(). В крайнем случае, если что-то не так, сделай Rollback: $db->rollBack().
Вот мы и получили ссылку на ленту, получили ленту (задействовав при этом все возможности кэширования), разобрали ее на отдельные сообщения, не парясь с конкретным форматом, а потом проверили по кэшу все сообщения и записали новые в базу данных. При этом минимально затронув самое узкое место любого веб-приложения - собственно, саму СУБД!
Сервера в упряжке, или выход Gearman'а
Если запустить написанный нами скрипт, то он будет работать, как и обычная «ПеХаПешка». Сначала обработается первая лента, потом вторая и так далее. И неважно, на скольких серверах ты его запустишь.
К тому же, ошибка при обработке какой-то ленты вырубит сразу весь скрипт, а значит, остальные новости будут пропущены. Непорядок – процесс просто необходимо распараллелить.
Большинство программистов, занимающихся нагруженными сервисами, пишут масштабируемые и параллельные приложения на Java, С или хотя бы Python. И будут косо смотреть в твою сторону, если ты скажешь, что напишешь такое же на РНР. Не обращай на них внимание, давай писать на PHP! :) А поможет тебе классная технология Gearman. Это такой специальный сервер, который берет на себя всю работу по управлению заданиями, которые ты ему поручишь. Он сам выстроит их в очередь, посмотрит, сколько серверов и процессов у него есть, потом разошлет всем работу, проконтролирует ее выполнение и соберет результаты. Если ты добавишь еще один сервер, стоит только запустить на нем обработчики заданий, как Gearman сам поймет, что теперь задания можно распределить на новый сервер. И так с каждым новым сервером! Единственное, чего пока Gearman не может, так это выполнять работы по расписанию, то есть, заменить им cron нельзя. Сам сервер написан на С и очень быстрый, API есть для многих языков, также можно встроить в MySQL как UDF (пользовательскую функцию).
Для РНР есть два варианта – использовать быстрый C-модуль, который придется компилировать и устанавливать, или подключить PEAR-пакет Net_Gearman, написанный на чистом РНР, но более медленный и плохо документированный. Мы выберем модуль на С, тем более, поставить его очень легко - он есть в PECL. Установим через менеджер пакетов командой «apt-get install gearman-job-server», после чего запустим как демон «gearmand –d». По умолчанию она слушает порт 4730, на который мы и будем отправлять задания.
Затем давай подумаем, как превратить код, который мы написали ранее, в распределенный. У нас уже есть функция, которая принимает ссылку на ленту и полностью ее обрабатывает. Мы напишем обработчик для Gearman-а, который будет принимать задание - JSON-массив с линками на ленты.
В случае ошибок при обработке мы все равно будем отправлять сообщение серверу, что все ОК. Потому что, если ошибка связана с самой лентой (например, удаленный сервер лежит), то нам не надо сразу еще раз пробовать выполнить задание, лучше дождаться следующего цикла обработки. Иначе стоит появиться одной сбойной ленте, как вся система зависнет, постоянно пытаясь выполнить задание.
Далее у нас предназначен специальный скрипт: он запускается по Cron’у каждые 5 минут (если хочешь, то чаще - от этого зависит частота опроса лент) и будет выбирать все ссылки на ленты, которые надо обновить, формировать из них задания, а потом скомандует Gearman'у выполнить все задания параллельно. Если у тебя будет 10 лент, то, смотря, сколько обработчиков ты запустишь, сервер сможет проверять одновременно столько же лент. Если обработчиков меньше, то каждый из них последовательно будет обрабатывать все свои задания, пока не завершит все.
Заметь - обработчики остаются запущенными все время, они, по сути, являются демонами, поэтому если будешь небрежно писать код, будут накапливаться утечки памяти. Хорошо бы время от времени их перезапускать, например, раз в неделю. Но это никак не повлияет на работу всей системы, даже если ты просто вырубишь все скрипты - задания останутся и будут обработаны, как только ты запустишь хоть один скрипт. А если вдруг увидишь, что сервер не справляется с работой, добавь еще один, запусти там новые обработчики - и они сразу включатся в работу! Если пойти еще дальше, то можешь поставить счетчик, и на каждые, например, пять новых лент, добавленных пользователями, запускать на сервере новый обработчик. А если кто-то отпишется от лент, то можно и убрать лишний процесс.
Скрипты
Так ты получишь динамическое масштабирование в зависимости от нагрузки. Кратко распишу, как устроены все скрипты. Обработчик ленты (файл feedWorker.php) после запуска создает объект GearmanWorker, который скрывает все подробности взаимодействия с сервером. Далее регистрируемся на сервере (а можно и на нескольких) - методом addServer(). И, наконец, зарегистрируем функцию как обработчик, используя произвольное имя - методом addFunction. Теперь, если на сервере будут задания, ассоциированные с этим именем, Gearman вызовет нашу функцию и передаст ей строку с заданием. В одном скрипте можно описать несколько функций и зарегистрировать их под разными именами, но учти, что одновременно сможет работать только одна.
$worker= new GearmanWorker();
$worker->addServer();
$worker->addFunction("feedProcesor", "myFeedProcessor");
function myFeedProcessor($job)
{
$feeds = Zend_Json::decode( $job->workload() );
}
while ($worker->work());
Когда обработчик вызван, ему передается специальный объект задания, из которого в первую очередь необходимо получить нужные нам для работы данные. Так как они закодированы в строку JSON-ом, используем компонент Zend_Json, который превратит строку в обычный массив.
Всего шесть строк кода - и мы превратили нашу функцию обработки лент в распределенную! Теперь ты можешь просто заинклудить файл processFeed.php и передать функции processFeed() полученный массив ссылок $feeds.
Чтобы сообщить серверу Gearmand, что все отлично и мы выполнили задание, достаточно вызвать $job->sendComplete('OK'). Хотя вполне можно просто вернуть из функции true: система достаточно гибкая и простая, чтобы делать за тебя всю работу. Еще необходимо сформировать задачу, чтобы сервер знал, что необходимо выполнить. Этот код также очень прост:
$gmclient= new GearmanClient();
$gmclient->addServer();
$feed_links = $db->fetchAll('SELECT fid, feed_url FROM feeds WHERE errors < 3');
foreach ($feed_links as $fl)
{
echo "Add to processing queue: " . $fl['feed_url'] . "\n";
$gmclient->addTaskBackground('feedProcesor', Zend_Json::encode(array($fl)));
}
$gmclient->runTasks();
Объясню, как оно работает. Так как задания раздает клиент, мы первым делом, используя Gearman API, создаем с его помощью объект клиента и регистрируем его на сервере. А потом выбираем из базы данных ссылки на все ленты, которые имеют менее 3-х ошибок.
Если ошибок больше, вероятно, что-то с лентой не так, поэтому даже не будем пытаться проверять. В цикле перебираем все и формируем из лент задания, закодированные в JSON-формат. После того, как весь набор задач сформирован, запускаем его на выполнение командой runTasks().
Все задачи будут выполнены по возможности параллельно и в фоновом режиме. Что означает, что мы не сможем получить ответ от обработчиков.
Если же это необходимо, следует задавать задачу командой do(), тогда она выполнится в синхронном режиме, а результат будет передан тебе. Но нам значения не нужны, так же, как и вообще необходимость следить за выполнением работы - задали задачи и все, выходим из скрипта, остальную работу оставим серверу.
Остается только запустить скрипт из Cron-а, но с этой задачей, думаю, ты справишься сам. Частота обновления лент зависит от многих факторов, наверное, лучше всего начать с 10 минут, если лент не очень много. Обрати внимание: желательно, чтобы суммарное время обработки всех лент было меньше, чем интервал обновления, иначе сильно возрастет нагрузка на сервер. В таком случае просто добавь обработчиков или же, если перестает справляться внешний канал, новый сервер. Ведь тебе сначала надо запросить у сервера ленты, а это порядочный трафик.
Ну что, получилось?
Конечно, чтобы получить расширяемую систему на РНР, пришлось задействовать дополнительный сервер, компилировать модуль, но стоит сделать это один раз, как в дальнейшем у тебя будет гибкая система, чтобы выполнять любую работу. Например, если построить фотохостинг, то ресайз картинок как раз можно делегировать Gearman’у. На нем даже настоящий поисковик собираются делать, а это тебе уже не игрушки. Такие сайты, как Digg.com и Yahoo! также используют у себя Gearman. Помимо этого ты попробовал Zend Framework в работе - несмотря на возгласы скептиков, что он тяжелый и сложный, мы написали RSS-ридер всего за несколько строк! Единственное, что пока тебя, наверное, смущает - чтобы посмотреть новые новости, даже если они уже оказались на твоем сервере, все равно необходимо жать кнопку обновления. Это не беда, есть решение, которое заткнет за пояс Google Reader, но о нем я расскажу в одной из следующих статей.
Архитектура Gearman
Чтобы лучше понимать принцип работы Gearman, рассмотрим основные понятия его архитектуры. Их немного:
- Задание (работа, job) - строка, которая передается обработчику и содержит данные для работы. Здесь ты должен подумать, как передавать сложные структуры данных. Если ничего, кроме РНР, тебя не интересует, не парься и бери обычную сериализацию. Если захочешь попробовать разные языки, надо, чтобы формат понимался каждым из них. Например, хорошо подходит JSON.
- Обработчик - скрипт на РНР (любой язык, для которого есть Job API), выполняющий работу. Одновременно можно запустить несколько обработчиков, хоть на одном сервере, хоть на тысяче, каждый из них это отдельный РНР-процесс.
- Задача (Task) - несколько заданий, которые необходимо выполнить параллельно. Ты ставишь серверу задачу, чтобы он выполнил одну или несколько работ, а дальше — дело техники.
- Клиент - скрипт, который создает задания и работы и посылает их на сервер.
- Сервер - собственно, центральный элемент системы. Принимает задачи от клиентов и смотрит, каким обработчикам и в какой очередности распределить конкретные работы. Он же следит за доступностью обработчиков, собирает ответы и отсылает их назад клиенту. Если ты послал задание обработчику, но возникла ошибка, сервер это воспримет нормально и попробует переназначить задание другому доступному обработчику. На случай, если надо перегружать сервер, Gearman может задействовать MySQL или memcachedb для хранения там заданий, тогда ему нестрашны никакие падения. Конечно, самих серверов также может быть много, чтобы вырубание одного никак не влияло на выполнение работ.
Что понадобится для поднятия проекта?
- PHP 5.2.11 или еще лучше, 5.3.1.
- Веб-сервер, Apache 2.2 или Nginx.
- База данных, MySQL любой версии, но лучше всего 5.1.
- Сервер Gearmand и модуль к РНР.
- Memcachedb, чтобы Gearmand мог хранить задания при перезагрузке.
- Сервер Redis для кеширования, рекомендую Redis 1.2, который можно достать на GitHub-е.
- Zend Framework (лучше всего — trunk-версия из SVN-а). Интерфейс сделаем самый простой, на базе jQuery и jQuery UI последних версий. Для управления базой данных можно использовать phpMyAdmin.
DVD
На диске есть дистрибутив с готовым примером, а также все исходные коды и дистрибутивы используемых программ.
INFO
INFO
RSS (Really Simple Syndication) - целое семейство форматов для предоставления XML-документа с обновлениями сайта или другого источника данных. Кроме самого RSS, в который входят несколько версий спецификации, несовместимых между собой, существует продвинутый протокол Atom и устаревший сложный RDF. Atom является стандартом (http://tools.ietf.org/html/rfc4287) и активно поддерживается Google.
Содержание
ВИДЕО К ЭТОМУ НОМЕРУДебажим PeSpin В этом видеоуроке показан пример распаковки 64-битной модификации протектора PeSpin...
На коротком поводке В этом ролике мы покажем основные настройки в групповых политиках, разберем, как задавать правила в политиках ограниченного использования программ, и сравним их с настройками в AppLocker...
Делаем деньги на звездах FreePBX - это популярная веб-панель для настройки и управления Asterisk PBX, обладающая простым, интуитивно понятным интерфейсом...
Операция Аврора В этом видео демонстрируется работа сплоита Аврора, эксплуатирующего ошибку в Internet Explorer. Удается пробить браузер с помощью реализации сплоита, как на Python´е, так и с помощью модуля к Metasploit´у...
Незримое присутствие Инструментарий, позволяющий выполнить команду на удаленных системах, существенно упрощает работу админа. Не покидая рабочего места, можно запустить сервис, изменить настройки, диагностировать и исправить проблему...
Уязвимости nigma.ru Ресурс, который может похвастаться тремя миллионами уникальных пользователей ежемесячно, прокололся из-за ошибок в стороннем коде...
|
 |
|