WEb практикум. Web'cepbep
Скачать 4.76 Mb.
|
Страница загружена из кеша"); return 1; } else return 0; } // Функция записи кеша function WriteCache($CacheName, $CacheData) { $cf = @fopen("cache/$CacheName.htm", "w") or die ("Can't write cache"); fputs ($cf, $CacheData); fclose ($cf); @chmod ("cache/$CacheName.htm", 0777); } // Основной код web-страницы if (ReadCache("MainPage")==1) exit; ob_start(); print("Главная web-страница"); print(" Это тестовая web-страница "); WriteCache("MainPage", ob_get_contents()); ob_end_flush(); ?> Это максимально простое решение, которое не отличается чистотой кода, но я хотел просто показать идею. Основной код сценария начинается с запроса загрузки web-страницы из кеша. Для этого у нас в листинге создана функция ReadCache. Функции передается имя файла, который нужно загрузить, ведь в кеше могут быть сохранены и другие web- страницы. В данном случае подразумеваем, что сценарий формирует главную web- страницу с именем MainPage, и именно это значение мы передаем в качестве параметра. Давайте теперь посмотрим, что происходит в функции ReadCache. Здесь у нас происходит проверка на существование файла с указанным именем. Если такой файл существует, то он подключается с помощью require, и функция возвращает значение 1. Для примера я еще вывожу на экран сообщение о том, что эта web-страница была взята из кеша, чтобы вы видели результат работы. Вернемся к основному коду. Если функция ReadCache вернула 1, то выполнение сценария прерывается. Нет смысла тратить время на формирование web-страницы, если она была взята из кеша. Далее выполняем функцию ob_start, чтобы начать кеширование вывода, и начинаем формировать web-страницу. Перед тем как вызвать ob_end_flush, необходимо сохранить содержимое сгенерированной web-страницы, которую можно получить с помощью ob_get_contents. Для сохранения кеша в нашем сценарии создана функция writeCache. Ей нужно передать имя, по которому определяется имя файла кеша. Второй параметр — это данные, которые будут записаны в файл. В нашем случае это результат функции ob_get_contents. После создания файла изменяются его права доступа на 0777, что соответствует правам, при которых доступ к файлу разрешен всем: @chmod ("cache/$CacheName.htm", 0777); Загрузите с помощью браузера этот сценарий и попробуйте обновить. После обновления внизу вы увидите сообщение о том, что web-страница взята из кеша. Программные решения Кеширование можно реализовать и с помощью сторонних программных решений, например, с помощью proxy-сервера, который можно настроить между пользователем и web-сервером. В качестве такого кеша чаще используют nginx или squid. В этом случае на стороне кода нужно, главное, указать, что можно кешировать, а что нельзя, с помощью специальных параметров заголовка. Когда вы разрабатываете стратегию кеширования, то нужно быть очень осторожным, потому что в такой кеш может попасть какая-то персональная информация. Например, если есть URL: www.bankname/account/balance и на ней отображается баланс аккаунта текущего пользователя, то попадание этой информации в кеш приведет к тому, что в результате последующих запросов к этой же странице все будут видеть баланс одного и того же пользователя. Кеширование информации с помощью сторонних приложений — эффективное и очень хорошее решение, но может быть опасным с точки зрения безопасности. Рассмотрим некоторые заголовки, которые позволят указать, что можно или нельзя кешировать: cache-control - самый популярный и основной параметр. Пример использования: cache-control: private, max-age=0, no-cache После двоеточия можно указать private или public, и этот параметр как раз говорит есть ли на странице приватная информация. Если указать public, то такую информацию может кешировать даже прокси-сервер. Если указать private, то кешировать имеет право только браузер для текущего пользователя. Серверы тоже могут закешировать эту информацию, но это уже можно будет отнести к уязвимости кеш сервера. В данном случае указано private — значит страница содержит частные данные. Дополнительно еще указано no-cache, что запрещает кеширование даже в браузере. С помощью max-age можно указать, сколько времени можно хранить в кеше информацию. Очень часто ресурсы могут оставаться без изменения достаточно долго, и можно указать заранее, что браузер может сохранить файл и хранить его определенное время — для этого используется заголовок Expires. Пример использования: Expires: Sat, 1 Jan 2022 01:01:01 GMT Не самый универсальный параметр, но все же можно использовать для определенных случаев, указав определенную дату. Более эффективный способ использование Etag: ETag: 1a926-130-9a7d79 Это какой-то уникальный код, в качестве которого может выступать контрольная сумма файла или дата его последнего изменения. Браузер запрашивает файл, и если он не изменился (код остался тем же), то браузер использует версию, которая уже у него есть. В заголовке можно использовать и дату последнего изменения в виде параметра Last-Modified: Last-Modified: Sat, 1 Jan 2020 01:01:01 GMT С помощью кеширования web-страниц вы можете реально повысить скорость работы сценария, но теряете возможность создать web-сайт, ориентированный на пользователя. В этом случае трудно реализовать выбор расцветки, настройки отображения определенных частей web-сайта индивидуально каждым пользователем (например, в зависимости от предпочтений, дать возможность выбирать нужные категории новостей для отображения). Реализовать подобное с помощью кеширования достаточно сложно, а если и возможно, то эффект от его использования уменьшается настолько, что овчинка выделки не стоит. Медленный код Достаточно популярная проблема, с которой мне приходилось сталкиваться, — это объединение двух таблиц в одну. Допустим, что у нас есть два массива Person и Address, которые мы получили из двух разных источников или от двух разных SQL-запросов. Я на работе сталкиваюсь с таким регулярно. Классическая задача, обычно выполняемая с помощью JOIN в SQL, должна быть сделана с помощью кода, потому что данные просто прибыли из разных источников и мы не смогли объединить их заранее. Рассмотрим код на C#, который я видел уже много раз: for (int i =0; i < persons.length; i++) { persons[i].addresses = addresses.where(m => m.PersonId = persons[i].PersonId ) ; } Почему-то программисты думают, что если они используют LINQ, то он магическим образом выполняется быстрее, чем следующий код: for (int i =0; i < persons.length; i++) { for (int j =0; о < addresses.length; j++) { if (addresses[j].PersonId = persons[i].PersonId) persons[i].addresses.Add(addresses[j]); ) ; } Большинство понимает, что второй вариант очень медленный, потому что он будет выполняться persons.length * address.length раз. Если в каждом цикле по 1000 элементов, то всего будет миллион итераций. Но первый вариант не сильно отличается по скорости. .NET умеет производить определенную оптимизацию, но он не может магически находить элементы в произвольном списке. Так что оба варианта ужасны с точки зрения скорости. Самый простой вариант решения проблемы — запустить два цикла: Hashtable addresses_hash = new Hashtable(); for (int j =0; о < addresses.length; j++) { if (!addresses_hash.containsKey(addresses[j].PersonId)) { addresses_hash[addresses[j].PersonId] = new List(); } addresses_hash[addresses[j].PersonId] .Add(addresses[j] ) ; ); for (int i =0; i < persons.length; i++) { if (!addresses_hash.containsKey(persons[i].PersonId)) { persons[i].addresses = addresses_hash[persons[i].PersonId]; } } В первом цикле создается Hash-таблица, доступ к элементам которой достаточно быстрый, зависит от реализации, но тут уже есть магия быстрого доступа. Второй цикл использует эту таблицу, и такой вариант будет выполняться persons.length + address.length раз. Если в каждом из массивов по 1000 элементов, то теперь все выполнится за 2000 раз. Если вы работаете с C# и предпочитаете LINQ, то первый цикл можно реализовать в одну строку: addresses.toLookup(m => m.PersonId); Казалось бы, достаточно простой код и очень простая проблема, но я продолжаю регулярно оптимизировать подобные ошибки. Именно поэтому я решил добавить этот пример в книгу, хотя по хорошей оптимизации кода можно писать отдельную книгу, и такие есть — на обложке обычно написано слово "алгоритмы". Конкретную рекомендовать не буду, потому что те, что я читал, скорее всего, уже не выпускаются. Асинхронный код В современных языках все больше появляется поддержки асинхронности, потому что это действительно позволяет сэкономить ресурсы сервера, хотя правильнее сказать - эффективнее использовать ресурсы сервера. Когда запрос поступает на сервер, то код начинает обрабатывать данные и формировать ответ. В языках с поддержкой многопоточности для каждого запроса пользователя может выделяться отдельный поток, и таким образом сервер будет обрабатывать множество запросов одновременно. Но количество потоков не безгранично, нельзя просто выделить миллион потоков для всех пользователей, которые могут пытаться загрузить сайт. Допустим, что на сервер поступил запрос от пользователя загрузить страницу index. Web-сервер начинает выполнять код и находит команды, которым нужно получить какие-то данные от сервера баз данных. В этот момент на сервер базы данных направляется SQL-запрос, который обрабатывается в базе, а поток на web- сервере останавливается и ожидает ответа от SQL-сервера. Зачем сидеть и ждать ответа, на обработку которого может уйти даже секунда. Вместо этого можно обрабатывать какие-то другие пользовательские данные. Если в вашем приложении есть код, который ожидает обработки со стороны других серверов или сервисов, то вместо ожидания можно выполнять другие действия, и тут очень хорошо помогает асинхронное выполнение кода. Асинхронный код не может сделать так, чтобы код выполнялся быстрее, он будет работать с той же скоростью, как и при синхронном выполнении. Асинхронность не про скорость, а про более эффективное использование ресурсов. Блокировки Как еще хакер может произвести DoS-атаку? Если на web-сайте найдена ошибка, позволяющая реализовать SQL-инъекцию, то хакер может сформировать собственный SQL-запрос, который будет нагружать систему. У каждой СУБД есть запросы, выполнение которых требует значительного процессорного времени. Например, для Oracle это просмотр текущих блокировок: SELECT * FROM v$lock, v$session WHERE v$lock.sid = v$session.sid and v$session. username = USER Если у web-сервера есть заблокированные ресурсы, то выполнение такого SQL- запроса может отнять очень много времени. А можно самому заблокировать все записи, и тогда web-сервер не сможет производить обновление данных. Например, все в той же СУБД Oracle для этого достаточно выполнить SQL-запрос: SELECT * FROM имя_таблицы FOR UPDATE Таким образом, установим блокировку на все записи из таблицы, и если у других пользователей запросы выполняются без опции no wait, то они будут зависать в ожидании снятия блокировки. Блокировки есть и в СУБД MS SQL Server. Тут необходимо учитывать, что каждая СУБД по-разному работает с блокировками. Некоторые записи блокируют данные целыми блоками или даже таблицами. Например, если изменяется только одна строка в определенной странице данных, то блокируются все записи этой страницы. Oracle, если не указано другого, блокирует каждую запись в отдельности. Именно поэтому определение блокировок требует значительных ресурсов. База данных может иметь слабое место в виде задачи, выполнение которой требует значительных ресурсов. Если web-сервер и СУБД находятся на одном физическом сервере, то это еще больше усложнит задачу по защите от данной атаки. Другие ресурсы Процессорное время — не единственное, что может повлиять на работу web- сервера. Он может перестать работать и при отсутствии достаточного дискового пространства. Вот тут снова возникает проблема возможности загрузки файлов на web-сервер. Хакер может загрузить столько файлов, что все пространство будет заполнено, и прием новых данных станет невозможным, ведь их негде будет сохранить. Это одна из причин, по которой многие программисты стремятся ограничить размер загружаемых файлов. Чем меньше возможный размер загружаемого файла, тем больше нужно приложить усилий, чтобы заполнить все дисковое пространство web-сервера. А если еще сделать ограничение на количество загрузок от определенного пользователя или IP-адреса, то возможность успеха такой атаки значительно уменьшается. Файлы — не единственное, что можно загружать. Если есть форум, то можно написать программу, которая в цикле будет отправлять сообщения, содержащие Большую энциклопедию. Таким образом, можно заполнить все свободное пространство СУБД, и пользователи не смогут оставлять новые сообщения, а некоторые СУБД при нехватке дискового пространстве вообще перестанут работать. Свободное дисковое пространство необходимо не только данным, но и журналам транзакций и журналам web-сервера. Если этого пространства не будет, то работа может быть нарушена. Желательно сделать ограничения на размер сообщений и на форумах, и в гостевых книгах, и в любых других сценариях, которые получают данные от пользователя. Например, даже через простую подписку на новости можно переполнить базу данных бессмысленными адресами электронной почты. Да, это уже сложнее, но при должном старании возможно все. Итак, при получении любых данных от пользователя и перед сохранением их в базе данных необходимо реализовать следующие две простые проверки: нельзя принимать от одного пользователя более одного сообщения за определенный промежуток времени. Например, на форуме такой промежуток времени должен быть не менее 2 минут; необходимо проверять размер получаемых данных перед их сохранением. Например, поле для хранения адреса электронной почты или имени пользователя может быть ограничено 50 символами, а вот поле для хранения сообщения на форуме может иметь тип text (или другой тип, в зависимости от базы данных). В него пользователь может загрузить огромное количество данных, и ограничение на эти данные может быть очень большим, которое опять же зависит от базы данных. Определите более разумный предел. Например, для сообщения на форуме вполне может быть достаточно 5000 символов. Для особо важного функционала сайта можно реализовать каптчу. Я на своих сайтах обязательно ставлю каптчу на добавление комментариев, потому что без нее спамеры могут замусорить базу, а любители DoS — заполнить огромным количеством данных, а пространство для базы на сервере у меня не резиновое. Да, мой тариф позволяет хранить в базе данных достаточно много данных, но без каптчи хакер может написать скрипт, который будет бесконечно добавлять данные в базу и через какое-то время переполнит даже доступное мне пространство. При ограничении в 5000 символов и разрешении оставлять всего одно сообщение в две минуты, один хакер будет очень долго отправлять сообщения в надежде переполнить дисковое пространство сервера. Для удачной атаки тут уже понадобится сотня, а то и тысяча хакеров или один хакер с большой сетью из пользователей "зомби". (Это пользователи интернета, компьютеры которых подвластны хакеру, и он может использовать их для реализации своих злых планов и DoS/DDoS-атак.) Оптимизация в C# Отдельный раздел я решил посвятить моему любимому языку C#, который обладает отличными возможностями, но при этом его нередко используют неверно. Очень частая ошибка при работе с C# — программисты не освобождают ресурсы. Платформа .NET достаточно интеллектуальная и умеет подчищать ресурсы за пользователем, но ей нужно на это время. После выполнения кода ресурсы сначала помечаются как неиспользуемые, а потом сборщик мусора реально освободит память. Посмотрим на следующий пример: public ActionResult Index() { SqlConnection connection = new SqlConnection(); connection.ConnectionString = "строка соединения"; connection.Open(); return View () ; } Внимание, вопрос — когда будет закрыто соединение с базой данных? Да, соединение будет закрыто за нас, и нам не нужно, по идее, заботиться о ресурсах, но вот когда эти ресурсы будут освобождены? А фиг его знает. После выполнения этого кода соединение будет все еще открытым и занятым. Реальное освобождение ресурсов может произойти через минуту, а может через пять. Это значит, что на сервере будет большое количество открытых ресурсов, запрещенных к использованию. Сервер не безграничен в количестве одновременно доступных соединений, тут все зависит от настроек, и когда вы дойдете до максимума, новое соединение открыть будет невозможно и сайт упадет до тех пор, пока сборщик мусора не освободит неиспользуемые соединения. Внимание, тут я упомянул два очень важных термина — открытые и занятые соединения. Надо понимать, что это разные вещи. Давайте посмотрим на цикл жизни соединения. Когда вы впервые открываете (вызываете метод Open) объект SqlConnection, то .NET делает следующее: выделяет необходимые объекту ресурсы; устанавливает соединение с базой данных. После этого соединение открыто и занято вашим объектом. Когда вы вызываете метод Close или Dispose, то .NET-объект SqlConnection уничтожается, а открытое соединение остается в живых на некоторое время и находится в специальном пуле. Соединение все еще открыто, но оно свободно для использования и может быть привязано к любому другому объекту SqlConnection. Теперь, если вы попытаетесь открыть новый объект sqlconnection, то .NET проверит пул на наличие уже открытых соединений с такой же строкой подключения. Если они есть, то будет создан новый объект, но новое физическое соединение с сервером устанавливаться не будет, будет использовано существующее из пула. Таким образом экономится время на обмен приветственными сообщениями с базой данных, а объект Sqlconnection практически мгновенно становится доступным к использованию. Я сопровождал сайт с миллионами пользователей, и у нас даже в час пик количество одновременно открытых соединений к базе данных не превышает 600. А вне часы пик, держится на отметке в 200 соединений. Недавно мы запускали небольшое обновление и вне часа пик количество соединений взлетело до 1500, а в час пик сайт упал из-за того, что не хватило свободного места в пуле. Мы увидели проблему как раз тогда, когда сайт упал из-за недостаточного количества соединений. Сразу после обновления не проверили, сколько открытых соединений. Проблема была как раз в одном таком коде, когда открывалось соединение, но явно не закрывалось. Из-за того, что вовремя ресурсы не освобождались, мы теряли их без особого смысла. Когда я нашел проблему и исправил, количество соединений упало опять с более чем 1000 до 200. При разработке web-приложений, если где-то нужно открывать ресурсы, всегда используйте конструкцию using: public ActionResult Index() { using (Sqlconnection connection = new SqlConnection()) { connection.ConnectionString = "строка соединения"; connection.Open(); } return View(); } В этом случае .NET знает, что соединение нам нужно только на период, пока мы находимся внутри using-блока. Как только мы выходим за его пределы, платформа сразу видит, что этот ресурс нам не нужен и его можно освобождать. И тут мы точно можем знать (если программисты Microsoft не совершат ошибку в своем коде, что маловероятно), что соединение с базой данных будет закрыто сразу после выхода из блока using. Вам необязательно вызывать явно метод Close, достаточно просто использовать using. Сборка мусора в .NET работает отлично, главное, правильно ею пользоваться и помогать платформе, указывая на то, когда ресурсы уже больше не нужны. Дальше она все возьмет на себя. Я рекомендую использовать именно using. Посмотрим на следующий пример: public ActionResult Index() { connection.ConnectionString = "строка соединения"; connection.Open(); connection.Close() ; return View () ; } Казалось бы, здесь все тоже отлично, мы открываем и явно закрываем соединение, поэтому утечки не должно быть. И ее не будет, если перед открытием не произойдет ошибки. Любая исключительная ситуация между Open и Close приведет к тому, что код прервет выполнение и соединение останется открытым. Если хакер сможет найти ситуацию, при которой генерируется исключение (например, неверный параметр), то он может реализовать отказ в обслуживании многократным вызовом страницы с такими неверными параметрами, чтобы соединения не освобождались, и в конце концов израсходовать все возможные соединения. А как насчет такого случая, когда нужно использовать соединение в разных местах программы для выполнения двух разных запросов в двух разных местах? Если using не может объять оба кода (они находятся в разных методах), то не стоит даже пытаться объять необъятное. Создайте два объекта SqlConnection и откройте соединение дважды. Это не проблема для сервера, потому что он может использовать пул соединений Connection Pool. Сразу же после закрытия первого соединения, оно реально не будет закрыто. Будет уничтожен объект, и соединение будет помечено как свободное. В течение некоторого времени (легко конфигурируется) соединение будет оставаться открытым, и при создании следующего объекта SqlConnection, не нужно тратить время на установку соединения с сервером, можно использовать уже открытое из пула. В этом примере я использовал в качестве иллюстрации соединение с базой данных как наиболее популярный тип ресурса для подобных приложений. То же самое может касаться и таких ресурсов, как файлы, и, наверное, чего угодно, что требует закрытия. В таких случаях классы реализуют интерфейс Idisposable, и если вы работаете с одним из таких классов, то старайтесь использовать его вместе с using. Вы не обязаны закрывать соединение самостоятельно, хотя все же явное закрытие является хорошим тоном, и помните, что это позволит вам защититься от DoS- атаки. |