Опубликован: 28.04.2010 | Уровень: специалист | Доступ: платный | ВУЗ: Новосибирский Государственный Университет
Лекция 9:

Событийно-ориентированные архитектуры

< Лекция 8 || Лекция 9: 12 || Лекция 10 >

Реализация кэширующего прокси.

Рассмотрим приведенные выше общие рассуждения применительно к конкретной задаче разработки кэширующего прокси для протокола HTTP 1.0. В рамках данной задачи мы будем делать упрощенный вариант прокси, хранящий кэш в оперативной памяти. (дальнейшее обсуждение предполагает, что слушатель знает, как устроен протокол HTTP, что такое прокси, а также знаком с основами программирования сетевых приложений с использованием сокетов TCP/IP. Если слушатель с этим не знаком, на этом занятие можно закончить). Разумеется, при перезапуске прокси такой кэш будет теряться, что сильно ограничивает практическое применение нашей программы. но это избавит нас от многих проблем, например связанных с обеспечением согласования содержимого кэша при аварийных остановках.

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

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

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

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

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

Работа обработчика событий клиентского соединения начинается с прихода запроса от клиента. Скорее всего, мы обнаруживаем это по тому факту, что слушающий сокет нашего прокси "готов к чтению" с точки зрения select/poll.

Мы принимаем запрос на соединение при помощи accept и переходим в состояние ожидания запроса. Обычные браузеры, такие, как Internet Explorer или Mozilla Firefox, присылают запрос сразу после установления соединения, но при тестировании мы можем захотеть присоединиться к нашему прокси при помощи команды telnet(1) и набрать команду вручную. При этом, разумеется, первая строка запроса придет через весьма значительное(с точки зрения компьютера) время после установления соединения. Тот же эффект может возникать, если клиент присоединяется к нашему прокси по медленному каналу, или клиенту плохо по каким-то внутренним причинам (например он работает на Windows системе с малым объемом памяти).

Так или иначе, рано или поздно мы должны получить заголовок HTTP-запроса. Первая строка запроса обязана иметь формат [команда] [url] [версия HTTP] и заканчиваться символами " \r\n ". Для упрощения отладки нам, наверное, не следует слишком удивляться, если вместо " \r\n " клиент пришлет нам только " \n ". При тестировании прокси нам вряд ли удастся добиться, чтобы первая строка запроса пришла к нам по частям - типичная первая строка запроса меньше MTU распространенных протоколов канального уровня и меньше минимально допустимого MTU IPv4. К тому же, если набирать строку в telnet, надо иметь в виду, что telnet по умолчанию буферизует ввод по строкам. Тем не менее, мы обязаны быть готовы к тому, что протокол транспортного уровня по неведомым нам причинам разрежет первую строку запроса на части и мы вынуждены будем делать read(2) несколько раз, чтобы собрать ее.

По первой строке запроса мы уже можем решить, что нам делать с этим запросом. Во первых, мы можем увидеть версию HTTP/1.1, которую по техзаданию мы поддерживать не обязаны. На это мы обязаны ответить кодом ошибки 505 (HTTP Version not supported). Умный браузер после этого обязан повторить запрос с версией 1.0. Некоторые браузеры, например lynx, могут прислать запрос с версией 0.9 - на самом деле, функций, поддерживаемых lynx, вполне достаточно для общения с ним как с браузером 1.0, но на такой запрос мы тоже имеем право ответить ошибкой 505. После этого мы с чистой совестью можем закрыть соединение и завершиться. Может быть нам даже не следует считывать остаток запроса.

Во вторых, по первой строке мы определяем метод запроса. Для упрощения прокси нам следует реализовать только методы GET и, наверное, HEAD (многие браузеры шлют запрос HEAD чтобы определить, изменилась ли страница и следует ли ее скачивать целиком, или можно взять копию из локального кэша браузера). В действительности, как мы увидим далее, ничто не мешает нам реализовать также методы POST и PUT. Метод CONNECT, напротив, нам, скорее всего реализовать вообще не следует - без управления доступом этот метод может использоваться для несанкционированного подключения к вашей сети в обход файрволла, а расширять наш прокси средствами фильтрации и управления доступом мы не успеем за время, отведенное на практикум.

Итак, если мы обнаруживаем метод, который наш прокси реализует, мы должны продолжать обработку. Если же мы не поддерживаем такой метод, нам следует вернуть ошибку 405 и закрыть соединение.

Методы POST и PUT не следует кэшировать ни при каких обстоятельствах. Для них следует открывать сквозное соединение и транслировать все проходящие через нас в обоих направлениях данные, подобно тому, как это надо сделать в упражнении 25.

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

Итак, при получении запроса GET и HEAD нам следует проверить, есть ли такая страница в кэше. На этот вопрос может быть три ответа:

  1. Страницы нет в кэше
  2. Страница есть в кэше и она закачана в кэш полностью.
  3. Страница есть в кэше, но она закачана не полностью.

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

При разборе URL мы можем также обнаружить, что используется протокол, отличный от http. На это нам тоже следует ответить ошибкой.

Итак, большинство ошибок можно обнаружить уже по первой строке запроса.

Но если ошибки нет, то у нас получаются три разные ветви исполнения, которые соответствуют трем разным выходам из состояния "ожидание запроса":

  1. Страницы нет в кэше

    В этом случае надо выделить буфер под весь HTTP-запрос вместе со всеми его дополнительными полями, и послать сообщение системе, что нам нужен обработчик запроса к серверу.

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

    Если же получен ответ 200, обработчик запроса к серверу должен создать новую запись кэша, и сообщить нам об этом. Тогда наше соединение переходит в состояние варианта 3 - запись есть в кэше, но закачана туда не полностью.

  2. Страница есть в кэше и она закачана в кэш полностью

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

  3. Страница есть в кэше, но она закачана не полностью

    В этом случае тоже все просто. У нас есть два источника событий - готовность клиента получать данные и сообщение обработчика событий от сервера, что он получил еще данные. Сообщения от обработчика событий сервера могут быть двух типов - "получены еще данные и страница еще не докачана" и "получены еще данные и страница докачана полностью". В рамках протокола HTTP 1.0 мы не можем отличить второй вариант от аварийного завершения работы с сервером (то и другое выглядит как разрыв соединения).

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

    Наверное, в этом случае нам следует оповестить серверный обработчик событий, что страница нам не нужна… Хотя… есть возможность, что эту же страницу качают другие клиенты, поэтому закрытие соединения одним клиентом, строго говоря, не повод закрывать соединение с сервером. То есть серверный обработчик должен знать, сколько у него клиентов, то есть должны быть какие-то еще сообщения между обработчиками. Чем больше об этом думаешь, тем сложнее все становится. Наверное, нам следует пока что остановиться на варианте, когда серверный обработчик события в любом случае докачивает страницу до конца.

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

Для серверного соединения работа начинается, когда кто-то из клиентов обнаружил, что нужной ему записи нет в кэше. При этом клиент должен предоставить полный текст HTTP запроса со всеми полями заголовка (запросы PUT и POST, мы, как договорились, не рассматриваем). По хорошему, сервер должен добавить к этим полям свой идентификатор, но мы пишем очень простой прокси, который не будет этого делать и просто передаст этот запрос серверу без всяких изменений.

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

Однако тут-то нас ждет подводный камень. Некоторые страницы честно говорят свой размер в заголовке HTTP-запроса. Но HTTP 1.0 допускает страницы без размера в заголовке. Размер такой страницы определяется косвенно по разрыву соединения сервером. Но мы, разумеется, не знаем, когда сервер его разорвет, поэтому, наверное, нам придется переразмещать буфер при помощи realloc(3C). Но ведь клиент читает данные из этого буфера - то есть нам надо предусмотреть сообщение клиенту -всем клиентам! - что местоположение буфера изменилось. Это все немного усложняет для клиента - впрочем, не фатально. Наверное, клиенту вместо указателя на текущую позицию в буфере надо было бы хранить смещение текущей позиции. Либо нам надо реализовать запись кэша в виде списка буферов, а не в виде одного линейного буфера. Ну и в многопоточной версии прокси надо бы озаботиться защитой этого места чем-то вроде мутексов… Но пока что все это выглядит разрешимым, хотя, конечно, придется написать некоторое количество дополнительного кода…

К сожалению, в Интернете немало страниц, на которых лежат очень большие объекты.

Например, на сайтах, на которых раздают дистрибутивы свободно распространяемых Unix-систем, часто можно найти файлы с расширением . iso размером от 600 мегабайт до четырех гигабайт, а по мере распространения HD-DVD и BluRay, наверное, можно будет обнаружить файлы и большего размера.. На 32-разрядной машине вам под такое не дадут памяти никогда. Да и на 64-разрядных машинах настройки ресурсных квот могут вам не позволить получить такой объем памяти.

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

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

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

Еще несколько замечаний по прокси

Важной проблемой при реализации однопоточного прокси являются запросы к DNS. Стандартизованного API для асинхронных обращений к DNS не существует, да и интеграция нестандартных API с событийно-ориентированной архитектурой на select/poll достаточно сложна, поэтому с этим недостатком придется смириться.

Для асинхронного установления соединения, напротив, существует стандартный API, так называемые неблокирующиеся сокеты. Сокет объявляется неблокирующимся при помощи fcntl(2) с флагом O_NONBLOCK. Connect(3SOCKET) с таким сокетом возвращает ошибку EINPROGRESS. Обнаружить факт установления соединения можно при помощи select(3C) или poll(2) проверкой готовности сокета для записи. Неблокирующийся connect(3SOCKET) целесообразно использовать и в однопоточных, и в многопоточных версиях прокси.

После установления соединения флаг O_NONBLOCK следует снять, во всяком случае для отладки. Иначе есть риск, что некорректная проверка этого сокета в select(3C)/poll(2) выродится в холостой цикл.

Для стресс-тестирования вашего прокси можно использовать wget(1) с ключом -- mirror.

< Лекция 8 || Лекция 9: 12 || Лекция 10 >
Dima Puvovarov
Dima Puvovarov
Россия
Святослав Песенко
Святослав Песенко
Украина, Кривой Рог, КГПУ, 2006