Сгенерировать SSL-сертификат для локального тестирования можно на сайте
www.cert-depot.com
Django CMS Обучение
среда, 12 ноября 2014 г.
вторник, 14 октября 2014 г.
Python - Веб-сервер своими руками
Часть 1 — Простейший сервер
Многие уже писали себе «сайты» или планируют ими заняться, но при этом плохо представляют себе что же это такое - сайт. Серия постов посвящена построению с нуля учебного (а не супер-быстрого или супер-фичастого) сервера, на примере которого, постараемся разобрать разные практические вопросы из жизни разработчика. Первая, вступительная, часть публичная т.к. возможно многим будет интересно «как это всё устроено». Код на питоне, но это практически псевдокод на английском, поэтому ни у кого тут сложностей возникнуть не должно. Остальные части скорее всего будут показываться только для участников питоноблога.
Сайт это такой многослойный торт, напичканый самыми разными видами крема кода. Давайте посмотрим, что происходит, когда пользователь набирает в браузере http://example.com/ и зачем.
Протокол HTTP работает поверх другого протокола - TCP, в котором никаких example.com нет, а есть 2.50.203.49, поэтому шаг 0 — используя службы DNS браузер получает IP-адрес хоста. Сервер об этом ничего не знает, поэтому и никакого кода этому этапу не соответствует.
Теперь можно установить соединение до сервера. IP нам сообщили, а порт берётся из схемы или задаётся вручную. Обычно это 80. Зная адрес и порт клиент создаёт сокет и открывает соединение, которое сервер принимает.
Для организации канала необходимо две стороны, два сокета. Клиент будет подключаться, а сервер находиться в состоянии ожидания:
(Если любопытно, можно заходить на сервер через telnet или netcat или, собственно, браузером и смотреть что происходит)
Когда клиент делает connect, а сервер accept они получают объект-соединение из которго можно читать и писать. Фактически это пара FIFO каналов.
Канал организован, теперь можно сообщить другой стороне о своих намерениях согласно протоколуHTTP.
Первым делом посылается запрос:
1 |
Потом идут заголовки:
1 |
Запрос оканчивается пустой строкой:
1 |
Клиент своё дело сделал, дальше в игру вступает сервер. От него потребуется разобрать запрос на сегменты: собственно запрос, заголовки и тело.
Имея на руках URL, адрес ресурса**, канал для ответа и остальные запчасти мы можем ответить. Ответ сервера клиенту состоит из тех же трёх частей:
Устроив такое «короткое замыкание» браузеру и прогулявшись по стеку протоколов обеспечивающих транспорт, мы теперь можем разобрать саму начинку сервера. Тут уже пойдёт варенье вместо крема и мы вольны писать что угодно, не заглядывая в RFC 1945.
Сервер, в том виде, как сейчас обрабатывает всего один запрос и выходит. У такого поведения есть определённая полезность, но Настоящие Сервера так не делают. Продолжим.
Во время и после того, как соединение было принято, обработано и закрыто, с оригинальным сокетом ничего не произошло, он так и оставался в готовности принять новые соединения. И возможно даже уже что-то принял. Код, который будет повторяться завернём в вечный while:
1 2 3 4 |
Каждый раз пересоздавать сокет и выставлять его в режим ожидания не надо. Более того, не получится т.к. порт будет занят предыдущим сокетом и сервер навернётся с соответствующим исключением.
Итого. От сервера требуется:
создать сокет (socket.socket)
настроить его (bind, listen)
принять соединение (accept)
считать и распарсить запрос
придумать ответ
завернуть ответ в протокол
выдать его и закрыть соединение
Больше всего времени он проводит на этапе 5, и по большому счёту, это вообще не его дело, что там будет происходить. А происходить там может много чего. Например:
Тупо ответ как в примере какой-нибудь захардкоденой фигни. Малоприменимо, но в качестве упражнения сойдёт. Реальное применение - всякие empty_gif; у nginx.
Отдача файла с диска. Не барское это дело. Такими вещами должны заниматься очень сильно оптимизированые сервера типа того же nginx. Но мы всё равно попробуем, заодно разберём вопросы безопасности.
Обратное проксирование (сервер сам делает запрос и передаёт результат клиенту). Тоже работа для nginx, но делается просто, поэтому возьмём всё равно.
Выполнение другого скрипта (CGI). Морально устаревший метод вызова на каждый чих подпроцесса. Как-нибудь потом.
Передача обработчику (в стиле modphp, asp). Конёк Apache - в сервер вкорячивается интерпретатор чего угодно, который на каждый урл запускает скрипты. Попытка починить CGI, который «починить» невозможно т.к. он предназначен для другого.
Обработка запроса сервером приложений. Любимое дело Java серверов. Весь код 5го пункта живёт как часть сервера и запускается только один раз, всасывает всё в кэши, после чего как турбовеник только раскидывает запросы.
Кроме того, можно сразу выделить несколько интересных применений протокола, помимо «сайтиков». Фактичски, сайт это API для употребления человеком. Есть ещё несколько классов задач, которые часто встречаются на практике:
RPC, «удалённый вызов процедур». HTTP, благодаря своей минималистичности очень хорошо подходит в качестве транспорта для реализации службы доступа к удалённым объектам. Немного class-ной питонской магии и whatever.you_wish(to="do") начинает выполнятся на сервере, а может даже и сразу на нескольких.
Обёртка вокруг сложных протоколов. Если есть какая-нибудь хитрая библиотека, которая там как-то сложно работает, а её нужно вызывать из кучи языков и сред то, передав в адресе все необходимые параметры, сервер выполнит всю грязную работу и не надо будет переписывать библиотеку для очередного недоязычка. HTTP-клиенты обычно есть везде.
Виртуальные файловые системы. Используя протокол WebDAV (расширение HTTP) можно подключить в виде сетевого диска что угодно - базу данных, поисковый индекс, список рассылки или форум. Что-то типа кроссплатформенного FUSE.
В следующем заходе приведём код в порядок и сделаем большой задел для реализации возможностей в пункте 5.
Часть 2 — наводим порядок.
Поэтому сейчас мы поделим сервер на части и упакуем всё в коробку с бантиком, чтобы модуль можно было использовать в своих целях, не изменяя код самого сервера.
Для начала, применим принцип инкапсуляции и объединим всё барахло сервера и его код в один класс:
1 2 3 4 5 |
И заодно допишем к модулю специальный режим, чтобы он мог запускаться как скрипт и делать что-нибудь (условно) полезное:
1 2 3 |
Этот код будет выполняться только если модуль будет запущен напрямую (python s02.py) или через специальный режим для запуска модулей, лежащих где-то в недрах библиотеки (python -m s02).
Если(когда) вы столкнётесь с ошибкой «Address already in use» это значит, что ОС ещё не освободила адрес и ждёт завершения каких-то своих операций. Такое бывает если сервер обслуживал подключения, а потом крашнулся. В таком случае надо просто подождать несколько секунд.
Теперь можно втащить сюда и рабочий код сервера.
self — обязательный параметр для всех методов класса через который передаётся конкретный егоэкземпляр.
Код делится на несколько стадий чтобы была возможность их заменять по требованию или вызывать по частям, например для тестирования.
Всё как и раньше, просто это один из логических участков работы с запросом и потенциально может быть переопределён самыми разными способами. Обратите внимание, что не смотря на то, что self передаётся, туда ничего не складывается — никаких соединений, никаких заголовков и всего такого. Оно просто передаётся следующему «работнику конвейера». Причин тут сразу несколько.
Во-первых, это нарушает принципиальное устройство класса: он называется «сервер», а прицеплять туда детали каждого конкретного соединения получается ни к селу ни к городу.
Во-вторых, сервер у нас один, а соединений много. Если обслуживаются сразу несколько соединений, то они будут постоянно перезаписывать разные участки данных друг друга. Это FAIL.
И, в любом случае, как подсказывает нам практика функционального программирования, чем меньше код создаёт побочных эффектов во время своей работы, тем меньше вероятность возникновения и распространения ошибок.
1 2 3 4 5 6 7 8 9 10 |
Тут совершенно ничего фантастического пока что нет. Но скоро будет (:
Что же у нас на данный момент получилось? Сервер на любое соединение, на любой запрос без обработки просто по-быстрому отдаёт результат. Интересно, кстати, насколько быстро? Давайте проверим. Воспользуемся утилитой ab из apache-utils:
1 |
На нормальном десктопе 5к запросов пролетает за пару секунд даже на таком «медленном» языке, как python. Из отчёта ab нам будут интересны несколько строк:
Requests per second: 4228.88 <#/sec> (mean)
Количество запросов в секунду, которое сервер может через себя пропустить. Если подавать на него меньшее количество, то он будет часть времени простаивать, а если больше - то запросы будут скапливаться в очереди в конце концов отваливаться с ошибкой, о чём станет извествно из соответствующих строк отчёта:
Complete requests: 5000
Failed requests: 0
Ещё хорошим показателем является то, что после окончаний пытаний сервера бенчмарком он остаётся работать и не вылетает на пол пути (=
А как оно будет себя если валить на него сразу много одновременных соединений? Мы ведь никакой явной параллелизации не делали. Давайте посмотрим:
ab -c 10 -n 5000 'http://localhost:8000/hellow/orld/?whatever=stuff&spam;=eggs'
...
Complete requests: 5000
Failed requests: 0
...
Requests per second: 11333.29 <#/sec> (mean)
Опаньки! При десяти (опция -c 10) одновременных соединений он выдаёт даже больше «попугаев» - аж в два раза. Это связано с тем, что ОС действует независимо от сервера. И пока сервер там делает свои дела, она в ядре обрабатывает установку соединений и все эти штучки на более низких уровнях стека протоколов. Готовые к употреблению соединения ОС укладывает в очередь, размер которой задаётся при переводе сокета в режим сервера: sock.listen(50)
Впрочем даже указав -c 1000 мне не удалось завалить свой сервер и ни один запрос небыл потерян :3
Запомним эти цифры, это базовый уровень скорости нашего сервера. В дальнейшем он будет работать всё тормознее и тормознее (8
(Полный код после упаковки в класс.)
А как быстро проверить, что оно вообще работает и будет работать после внесения дальнейших изменений? Для этого мы используем модульное тестирование с помощью nose, который надо `pip install`.
Сами тесты будут лежать в tests.py (так то!) и представлять собой несколько классов, содержащих код, проверяющий работоспособность собственно рабочего кода.
Проверяем:
1 2 3 4 5 6 |
Oops! Сервер начинает слушать порт и пока его вечный цикл не завершится, код дальше не пойдёт. Халявы не вышло...
Давайте внимательно посмотрим что делает метод serve. Он создаёт сокет и ждёт... Ждёт он пока появится доступное соединение, которое он передаст дальше. Больше ничего полезного или хотя бы интересного тут не происходит. И по большому счёту, никакого нашего кода тут нет — все эти операции на сокетах делаются стандартной библиотекой питона, которая протестирована вдоль и поперёк. Попробуем обойтись без этого.
1 |
Судя по сигнатуре, метод работы с соединением принимает что-то и ещё кое-что. Тоесть ему глубого фиолетово что там будут передавать. Давайте этим и воспользуемся.
1 2 3 |
Да, это полная фигня и не должно работать в принципе. Но запустив тесты (nosetests tests.py) мы хотя бы узнаем что именно от нас требуется предоставить в качестве «сокета».
1 2 |
Как минимум, объект должен иметь метод «recv», который получает размер буфера и возвращает строку (split это метод объектов-строк). Пробежися сразу по коду в поисках других обращений к этому conn. Это встречается аж в самом конце последнего метода, но сделаем всё сразу:
1 2 |
Итак, нам понадобится сделать объект, который будет эмулировать соединение и обладать тремя методами: recv, send и close. Такая методика называется «Mock Objects», «липовые объекты».
Такой-вот примитивчик. Для тестов нам хватит, а там дальше что-нибудь придумаем...
Закорачиваем наш сервер на тестовое «соединение» и смотрим что получится:
1 2 3 4 |
Получается, совсем не внезапно, а вполне ожидаемо, ошибка — ведь мы ещё ничего не отправили по соединению:
method, url, proto = data<0>.split(' ', 2)
ValueError: need more than 1 value to unpack
Пустую строку разделили и получили список из одной пустой строки (можете проверить в интерпретаторе: ''.split('\r\n')). Там, где по протоколу HTTP идёт 3 параметра, split вернул опять один и поломался код распаковки списка по переменным, который очень строго подсчитывает сколько куда должно попасть значений.
Давайте теперь подсунем туда реальный запрос от настоящего клиента. Для этого есть полезная UNIX-утилита netcat:
1 2 3 4 5 6 |
То, что надо! Теперь мы знаем как представиться клиентом.
Но каждый раз вручную составлять все эти строки будет очень неудобно, поэтому сразу же напишем «липовый клиент», который сам «установит соединение с сервером» и вызовет его обработчик:
Лично я стараюсь сразу делать код, удобный в использовании. Кому-то не нравится магия-шмагия, а мне сильно приятней писать среди питонского кода в питонском же стиле. Поэтому заголовки передаются как именованые аргументы метода и кодом преобразовываются из «some_header_name="value"» в каноничныйъ «Some-Header-Name: value». Можно было бы передавать туда сразу готовый словарь или даже список, но лично мне такое близкое общение с чужими протоколами не по нраву.
Теперь написание тестов будет попроще.
1 2 3 |
Запускаем тесты, оно работает!
1 2 3 |
Ну... Во всяком случае не вылетает. Но что конкретно работает? Об этом — в следующей части, а то и так уже несколько человек до сюда не дочитало (:
Часть 3 — фиксим фичи, добавляем баги
Как мы помним из кода, липовое соединение содержит в себе буфер отправленного, в котором оказывается ответ сервера. Можно было бы его сравнить с эталонной строкой, но каждый раз её составлять неудобно и муторно. Поэтому неплохо было бы его распарсить.
Но один раз у нас уже кто-то что-то парсит, а именно — сервер, при получении запроса от клиента. Внимательно посмотрев на траффик можно обнаружить, что протокол практически симметричен. И клиент и сервер обмениваются «сообщениями», состоящими из одних и тех же элементов: строка запроса или ответа (формат одинаковый, немного отличается содержимое), заголовки (формат одинаковый) и тело (необязательное для клиента при GET и для сервера при всяких хитрых статусах).
В то же время, наш тестовый клиент уже содержит генератор запросов, преобразующий аргументы функции согласно протоколу.
Вынесем эти две части как из клиента, так и из сервера:
Прежде чем менять актуальный и уже рабочий код, сделаем простенькие тесты конкретно для этого элемента. Они будут лежать в своём отдельном контейнере:
Это обычные заготовки тестов, которые при запуске будут фейлиться и сообщать, что получено не то, что ожидалось:
1 |
Убедившись «на глаз», что на выходе получается ровно то, что там должно быть согласно входным данным, копипастим (вдумчиво и внимательно!) значения в тесты.
Теперь, когда мы уверены, что всё работает, можно убрать дублирующийся код из клиента и сервера, а заодно добавить функционала.
Сервер сворачивается в один вызов parse и один encode и теперь готов к дальнейшему расширению без лишних усилий на ручное де/кодирование ответов:
Тест-клиент делает тоже самое, только в обратном порядке:
1 2 3 4 |
Для новичков в питоне сразу поясню, что за странные «**» в сигнатуре функции и последующем вызове.Показать
Теперь, пнув сервер в лабораторных условиях, мы можем точно узнать его реакцию, дописав соответствующий тест:
1 2 3 4 5 6 7 8 |
Уже заполненые тесты лежат на гуглькоде, но я считаю, что намного полезней и интересней поиграться и изучить всё это самим.
Заодно обратите внимание, что в отчёте nosetests функции, имеющие строки документации теперь отображаются в человечьем виде вместо «ехал гитлер^W тест через тест…»:
1 2 3 |
Вот теперь мы готовы запилить что-нибудь полезное. Сервер выдающий один и тот же ответ мало интересен, поэтому надо сделать возможность расширения функционала. Тоесть потребуются какие-то обработчики и возможность их встраивать в код без наследования и перезаписи кусков кода сервера.
Можно было бы вешать код просто на URL, но это малоинтересно и не позволяет сделать какие-нибудь более продвинутые схемы. Сразу разделим обработчики на две фазы: pattern и handler. Первый занимается определением, надо ли вообще вызывать обработчик - получает всё, что сервер знает о запросе и выдаёт своё веское решение. Второй собственно знает, что его вызывают не просто так и пора заняться своей непосредственной работой - ответом.
Но сервер знает много чего, и это много передавать в виде аргументов очень неудобно. Поэтому завернём всё наше хозяйство в объект Request:
1 2 3 4 5 6 7 8 9 |
Пропишем сразу серверу в on_connect, чтобы он его использовал и передавал дальше уже всё готовенькое:
1 |
Сам же on_request теряет всю свою кучу аргументов и получает один (два, если вместе с self):
1 2 3 |
Хм.. При запросе сервер выводит какую-то нечитабельную лабуду в консоль. Это легко исправить. print пытается все свои аргументы привести сначала строковому виду, тоесть к типу str. Посмотреть что будет выводиться можно в терминале, сделав это вручную:
1 2 |
В питоне всё-это-объект™ и у всех объектов может быть определён «волшебный» метод __str__ который будет в таких случаях вызываться. Там есть ещё много других интересных и странных методов, позволяющих сделать объект функцией или словарём или чёрти чем ещё. Пока что ограничимся просто читабельностью нашего контейнера и покажем пользователю немного содержимого:
1 2 |
Время разработчика очень ценно, а дублирование кода очень вредно. Поэтому, чтобы поймать сразу двух зайцев, скроем работу с соединением за функцией-помощником reply:
Она сразу выставит дефолтные заголовки, которые при желании можно передать самому, но они практически обязательны и совершенно нет смысла их формировать каждый раз вручную. При очень большом желании, обработчик может взять request.conn и ответить так, как ему надо. Но такое требуется редко.
Посылка готова, можно отправлять. Но ещё надо составить список возможных получателей. Добавим в конструктор сервера инициализацию списка обработчиков:
1 |
И метод их регистрации, в котором просто добавляем пару шаблон-обработчик в этот список:
1 2 |
Теперь on_request может стать диспетчером:
1 2 3 4 5 6 7 |
Обновим тесты, с учётом всех нововведений. Класс, содержащий сценарии тестирования будет иметь несколько методов, каждый из которых будет создавать сервер, клиент для него и дальше делать свои дела. Дублирование кода детектед! К счастью, методика модульного тестирования уже давно решила эту задачу. Собственно для этого мы и используем тут классы, а не просто функции test_something. Специальный метод setup позволяет делать одинаковую настройку для каждого последующего запуска серии тестов:
1 2 3 4 |
Попробуем теперь протестировать поведение пустого сервера без обработчиков. Клиент уже создан и настроен, поэтому сразу выстреливаем запрос:
1 2 3 4 |
Всё в порядке, можем продолжать. Зарегистрируем пару обработчиков и попробуем наш API на вкус:
1 2 3 4 5 6 7 |
Одна из самых удобных возможностей питона — передавать функции в качестве аргументов, укладывать их в списки и назначать в переменные. Безо всяких if/case/goto и подобной чертовщины. lambda это выражение для создания анонимной функции; сжатый аналог def, которую можно создавать на ходу и передавать дальше не отвлекаясь от структуры кода.
Как и обещалось, проверять можно не только урл, но и всё, что доступно в запросе:
Тесты работают и можно приступать к реализации модулей, описаных в первой части.
PS: Специальный бонус для осиливших весь пост целиком \o/
Показать
Тесты это хорошо, очень хорошо. Но по ходу разрастания проекта хочется знать какие участки нотариально™ заверены, а какие ещё только предстоит покрыть.
У nose есть плагин, позволяющий оценить процент покрытия и отметить строки кода, в которые никто не заходил во время работы юниттестов. Ставится он из pip и называется nose-cov. При запуске с опцией --with-cover помимо отчётов об успешности будет выведена ещё таблица покрытия:
70-76 это строки, где создаётся сокет и запускается вечный цикл обработки подключений.
99-100 это запуск дефолтного сервера, там тоже ничего интересного нет.
У nose есть плагин, позволяющий оценить процент покрытия и отметить строки кода, в которые никто не заходил во время работы юниттестов. Ставится он из pip и называется nose-cov. При запуске с опцией --with-cover помимо отчётов об успешности будет выведена ещё таблица покрытия:
1 2 3 4 |
70-76 это строки, где создаётся сокет и запускается вечный цикл обработки подключений.
99-100 это запуск дефолтного сервера, там тоже ничего интересного нет.
(Полный код тестов и сервера)
Часть 4 — раздача файлов
Я пока смутно представляю какой должна быть реализация, но зато примерно знаю как можно проверить её правильность. Поэтому эти знания мы сейчас выразим в виде теста, к несуществующему пока коду. Такой подход называется Test-driven Development или TDD. Тоесть мы сначала строим измеритель выхлопа, а потом уже на другом конце собираем карбюратор, соответствующий заданым параметрам (= Контринтуитивный манёвр, но частенько помогает определиться с API ещё до написания кода, который потом, во время изменения задачи придётся переписывать. А зачем делать двойную работу?
Обработчики являются как бы плагинами к серверу, он от них никак не зависит, и поэтому должны лежать в отдельном модуле (handlers.py).
Функцию serve_static надо будет импортировать в начале тестов, а в handlers.py сделать заглушку:
1 2 |
Тесты начнут дружно валиться, но теперь понятно что должно быть внутри функции:
Теперь всё проходит. Можно сделать «однострочный веб сервер» для раздачи файлов из текущего каталога в баше, который можно вызывать через `python -m serve`. Пока оно не установлено в пути python из любого каталога конечно не прокатит, но из возле самого сервера работать вполне будет.
1 2 3 4 5 |
Запускаем, пробуем:
1 2 3 4 |
Работает. Пробуем дальше:
1 2 3 4 5 6 |
Ой! К счастью сервер запущен не от рута и /etc/shadow в безопасности. Но сервер при этом жёстко крашнется:
Сразу допишем в test_handlers вредный тест, который будет рушить «сервер» в комфортной обстановке:
1 2 |
Тесты начали фэйлиться с «NameError: global name 'no_you' is not defined». Замечательно, то что нужно.
В каждом хэндлере всех ошибок не отловишь, да и полагаться на их будущих авторов тоже не стоит. «Хочешь чтобы было хорошо — сделай это сам!». Где у нас есть место, в котором можно раз и навсегда защитить сервер от крашей по причине ошибок хэндлеров? Они вызываются из диспетчера on_request, пристегнём его try-мнями безопасности:
Конечно, это не защитит от всяких фатальных ошибок типа вызывающих core dump, но уже что-то. Тесты теперь ловят свой законный «груз 500», но человек, заглянувший браузером останется в непонятках и будет зол. Особенно если это сам разработчик в поисках проблемы. Питон позволяет не просто отлавливать код и сообщение ошибки, но и ситуацию, в которой она возникла. А заполучить это нам поможет штатный модуль traceback.
Сделаем более детальный тест:
1 2 3 |
Выражение assert это способ языка проверить очень-важное-условие. assert False — всегда будет вызывать исключение AssertionError. А чтобы сразу было видно, что не понравилось условию, вторым «аргументом» assert идёт тело ответа. Прямо сейчас нам оттуда просто выражают сожаление, но ничего конкретного не сообщают.
Немного изменим обработку ошибки:
1 |
curl стал показывать трейсбек и ошибку, что значительно облегчит написание и отладку обработчиков:
1 2 3 4 |
Этому хаку уже сто лет 21 год в обед и нам ещё повезло, что open не умеет выполнять команды внутри скобок типа `mail hacker@dot.org < /etc/shadow` и прочие шелловские штучки, которым были подвержены в детском возрасте многие демоны. Но всё же, даже такая штука весьма неприятна даже если сервер запущен от nobody:nogroup.
Сегодня — день TDD, поэтому сразу заготавливаем проверку. Это уже не для сервера в целом, а для конкретного хэндлера, поэтому и отправляется в его набор test_static:
1 |
Убедившись, что nosetests выдаёт наш «законный» AssertionError: '404' != '200', отправляемся писать фикс.
В библиотеке os.path есть много интересных функций, поэкспериментировав с которыми можно найти одну, которая выдаёт, что указаный путь выходит за уровень начального:
1 2 3 4 5 6 |
Что-то я уже подзадолбался писать «return request.reply()», тем более, что сервер в итоге и так ещё потом пытается ловить ошибки. А ведь 4хх и 5хх это именно ошибки и есть. Вынесем их в отдельный класс исключений, которые затем можно будет везде бросать и ловить:
1 2 |
И всё. Остальные могут его импортировать и пользоваться. Сделаем для него специальный способ обработки в диспетчере:
После перевода ошибок на рельсы HTTPError оставшиеся ошибки из файловой системы транслируются в коды HTTP достаточно тривиально.
Выдавать файлы из текущего каталога это забавно, но хочется всё же указать привычный /var/www, а может даже не один. Тоесть понадобится система альясов url ? path, а значит нужна связь между паттерном и хэндлером. Можно было бы сделать специальный класс с конструктором (url, root) и методами pattern & handler, которые через экземпляр класса бы знали свои начальные параметры. Но «объекты это замыкания для бедных» © Norman Adams. Действительно, зачем городить целый класс, потом создавать его экземпляр, потом брать его методы и засовывать в сервер, когда нам нужно всего лишь объединить область видимости уже готовой функции с паттерном, который отловит соответствующий URL.
Первым делом, первым делом — юнит тесты... Завернём раздатчик статики так, чтобы функция выдавала две других функции и передадим это хозяйство через развёртывание позиционных аргументов:
1 |
Ровно тоже самое, что и было: все урлы раздаются из текущего каталога.
Нынешнюю serve_static переименуем в handler и завернём внутрь новой serve_static(url, root):
1 2 3 |
Теперь у нас есть создание функции внутри другой функции. При этом изнутри handler будут доступны также ещё url и root из «родительской функции». Добавим паттерн и возврат двух свежесозданных функций:
Замыкания потому так и называются, что, после того, как возврат произведён, всё состояние как бы перестаёт существовать, как река, в которую нельзя войти дважды. Но функции, созданные (они на самом деле создаются на ходу - можете проверить их id) продолжают иметь доступ к этому «висящему в пустоте» контексту и всем его переменным. Поэтому единожды посчитаная длина базового URL продолжает оставаться доступной для всего кода порождённого внутри.
Тесты как обычно показывают, что код в порядке и при переезде ничего не отломилось.
Другой важной задачей файлого сервера, помимо отдавания файлов, является... не-отдавание файлов когда это возможно. Это называется «кэш на стороне клиента» и обеспечивается с помощью пары заголовков If-Modified-Since/Last-Modified и кода HTTP 304: Not modified.
Для того чтобы договориться о том, что надо или не надо передавать содержимое файла сервер посылает заголовок Last-Modified с датой последнего изменения по GMT в формате RFC1123: '%a, %d %b %Y %H:%I:%S GMT'. Чтобы получить эту дату (и ещё другие вещи, которые тоже пригодятся) используем os.stat:
1 2 |
Отсюда нам интересны st_mtime (время изменения), st_size (размер) и st_mode (вдруг это не файл вообще). Если такого узла в файловой системе не будет, возникнет исключение OSError/2 (а не, IOError, как в случае с open). С каталогами мы разберёмся в следующей части, а дата и размер берутся достаточно легко.
Клиент, увидев дату, запомнит её и в следующий раз отправит вместе с запросом в заголовке If-Modified-Since, который надо распарсить и сравнить с датой файла. Сразу оформим это в виде теста:
1 2 3 4 |
И сделаем закорачивание обработки сразу после stat:
Вот и всё. Осталось починить дефолтный сервер, а заодно добавить немного настроек.
В python 2.7 появился очередной модуль разбора аргументов командной строки argparse, который довольно неплох. Для предыдущих версий его можно поставить из pip. А можно и не ставить и сделать так, чтобы сервер запускался и без него. import ничем не хуже других инструкций питона и бросает самые обычные исключения, которые можно просто отловить и обойти.
Код теперь попытается разобрать командную строку и скромно откажется принимать какие-либо аргументы кроме --help/-h. Допишем разбор порта и корневого каталога для раздачи:
Формат очень простой: полное имя переменной (короткое он сам сделает), количество аргументов (? - один необязательный, * - много необязательных или фиксированое число), в какой тип преобразовывать (если надо) и значение по-умолчанию (None, если не указывать). Там есть ещё другие интересные опции, но этих нам хватит. Если полное имя написать без «--», то получится позиционный аргумент.
Результаты регистрации аргументов можно посмотреть вызывав справку:
1 2 3 4 5 6 7 |
Даже такая, казалось бы, простейшая задача содержит предостаточно подводных граблей и возможностей хвастнуть питоном. В следующей статье мы обкатаем ещё несколько важных особенностей сервера, протоколов и питона.
handlers.py
# -*- coding: utf-8 -*- |
import os, time |
from os.path import relpath |
from serve import HTTPError |
def serve_static(url, root): |
cut = len(url) |
DATE_RFC1123 = '%a, %d %b %Y %H:%I:%S GMT' |
def pattern(request): |
return request.url.startswith(url) |
def handler(request): |
path = "%s/%s" % (root, request.url[cut:]) |
if relpath(path).startswith('..'): |
raise HTTPError(404) |
try: |
stat = os.stat(path) |
if 'IF-MODIFIED-SINCE' in request.headers: |
try: |
request_mtime = time.mktime(time.strptime(request.headers['IF-MODIFIED-SINCE'], DATE_RFC1123)) |
except ValueError: |
request_mtime = None # в заголовках мусор |
if request_mtime and request_mtime < stat.st_mtime: |
return request.reply('304', 'Not modified', '') |
mod_time = time.strftime(DATE_RFC1123, time.gmtime(stat.st_mtime)) |
data = open(path).read() |
except (OSError, IOError) as err: |
if err.errno == 2: |
raise HTTPError(404) # not found |
if err.errno == 13: |
raise HTTPError(403) # no access |
if err.errno == 21: |
raise HTTPError(403) # is a directory |
raise |
request.reply(body=data, content_length=stat.st_size, last_modified=mod_time) |
return pattern, handler |
serve.py
# -*- coding: utf-8 -*- |
import socket |
import traceback |
from datetime import datetime |
class HTTPError(Exception): |
pass |
def parse_http(data): |
lines = data.split('\r\n') |
query = lines[0].split(' ', 2) |
headers = {} |
for pos, line in enumerate(lines[1:]): |
if not line.strip(): |
break |
key, value = line.split(': ', 1) |
headers[key.upper()] = value |
body = '\r\n'.join(lines[pos+2:]) |
return query, headers, body |
def encode_http(query, body='', **headers): |
data = [" ".join(query)] |
headers = "\r\n".join("%s: %s" % |
("-".join(part.title() for part in key.split('_')), value) |
for key, value in sorted(headers.iteritems())) |
if headers: |
data.append(headers) |
data.append('') |
if body: |
data.append(body) |
return "\r\n".join(data) |
class Request(object): |
"""Контейнер с данными текущего запроса и средством ответа на него""" |
def __init__(self, method, url, headers, body, conn): |
self.method = method |
self.url = url |
self.headers = headers |
self.body = body |
self.conn = conn |
def __str__(self): |
return "%s %s %r" % (self.method, self.url, self.headers) |
def reply(self, code='200', status='OK', body='', **headers): |
headers.setdefault('server', 'OwnHands/0.1') |
headers.setdefault('content_type', 'text/plain') |
headers.setdefault('content_length', len(body)) |
headers.setdefault('connection', 'close') |
headers.setdefault('date', datetime.now().ctime()) |
self.conn.send(encode_http(('HTTP/1.0', code, status), body, **headers)) |
self.conn.close() |
class HTTPServer(object): |
def __init__(self, host='', port=8000): |
"""Распихиваем по карманам аргументы для старта""" |
self.host = host |
self.port = port |
self.handlers = [] |
def serve(self): |
"""Цикл ожидания входящих соединений""" |
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
sock.bind(('', 8000)) |
sock.listen(50) |
while True: |
conn, addr = sock.accept() |
self.on_connect(conn, addr) |
def on_connect(self, conn, addr): |
"""Соединение установлено, вычитываем запрос""" |
(method, url, proto), headers, body = parse_http(conn.recv(1024)) |
self.on_request(Request(method, url, headers, body, conn)) |
def on_request(self, request): |
"""Обработка запроса""" |
print request |
try: |
for pattern, handler in self.handlers: |
if pattern(request): |
handler(request) |
return True |
except HTTPError as error: |
code = error.args[0] |
reply = { |
404: 'Not found', |
403: 'Permission denied', |
}[code] |
request.reply(str(code), reply, "%s: %s" % (reply, request.url)) |
return False |
except Exception as err: |
request.reply('500', 'Infernal server error', traceback.format_exc()) |
return False |
# никто не взялся ответить |
request.reply('404', 'Not found', 'Письмо самурай получил\nТают следы на песке\nСтраница не найдена') |
def register(self, pattern, handler): |
self.handlers.append((pattern, handler)) |
if __name__ == '__main__': |
from handlers import serve_static |
port, root = 8000, '.' |
try: |
import argparse |
parser = argparse.ArgumentParser() |
parser.add_argument('--port', nargs='?', type=int, default=port) |
parser.add_argument('--root', nargs='?', type=str, default=root) |
options = parser.parse_args() |
port, root = options.port, options.root |
except ImportError: |
pass |
server = HTTPServer(port=port) |
server.register(*serve_static('/', root)) |
server.serve() |
tests.py
# -*- coding: utf-8 -*- |
from nose.tools import eq_ |
from serve import HTTPServer, parse_http, encode_http |
from handlers import serve_static |
class MockConnection(object): |
def __init__(self, data=''): |
"""Создаём буферы для приёма и передачи""" |
self.read = data |
self.sent = '' |
def recv(self, buf_size=None): |
"""HTTP читает всё сразу, поэтому на буфер пофиг""" |
return self.read |
def send(self, data): |
"""Просто накапливаем отправленое""" |
self.sent += data |
def close(self): |
"""Закрывать нечего, просто заглушка""" |
pass |
class MockClient(object): |
def __init__(self, server): |
self.server = server |
def __call__(self, url, method="GET", body='', **headers): |
conn = MockConnection(encode_http((method, url, "HTTP/1.0"), body, **headers)) |
self.server.on_connect(conn, None) |
return parse_http(conn.sent) |
class TestHTTP(object): |
def test_request(self): |
"""Тестирование в режиме запроса: клиент сериализует, сервер разбирает""" |
eq_(encode_http(('GET', '/', 'HTTP/1.0'), user_agent="test/shmest"), |
'GET / HTTP/1.0\r\nUser-Agent: test/shmest\r\n') |
eq_(encode_http(('POST', '/', 'HTTP/1.0'), 'post=body', user_agent="test/shmest"), |
'POST / HTTP/1.0\r\nUser-Agent: test/shmest\r\n\r\npost=body') |
eq_(parse_http('POST / HTTP/1.0\r\nUser-Agent: test/shmest\r\n\r\npost=body'), |
(['POST', '/', 'HTTP/1.0'], {'USER-AGENT': 'test/shmest'}, 'post=body')) |
def test_response(self): |
"""Тестирование в режиме ответа: сервер сериализует, клиент разбирает""" |
data = 'HTTP/1.0 200 OK\r\nSpam: eggs\r\nTest-Me: please\r\n\r\nHellow, orld!\n' |
eq_(data, encode_http(('HTTP/1.0', '200', 'OK'), 'Hellow, orld!\n', test_me='please', spam="eggs")) |
reply, headers, body = parse_http(data) |
eq_(reply, ['HTTP/1.0', '200', 'OK']) |
eq_(headers, {'TEST-ME': 'please', 'SPAM': 'eggs'}) |
eq_(body, 'Hellow, orld!\n') |
class TestServer(object): |
def setup(self): |
self.server = HTTPServer() |
self.client = MockClient(self.server) |
def test_404(self): |
reply, headers, body = self.client('/you/cant/find/me/?yet') |
eq_(reply, ['HTTP/1.0', '404', 'Not found']) |
eq_(headers['SERVER'], 'OwnHands/0.1') |
def test_handlers(self): |
self.server.register(lambda r: r.url.startswith('/hello/'), |
lambda r: r.reply(body='hi')) |
reply, headers, body = self.client('/hello/world/') |
eq_(reply[1], '200') |
eq_(body, 'hi') |
self.server.register(lambda r: r.method == 'POST', |
lambda r: r.reply(body=r.body)) |
reply, headers, body = self.client('/looking/for/a/POST/', 'POST', 'any url') |
eq_(reply[1], '200') |
eq_(body, 'any url') |
self.server.register(lambda r: r.url == '/crash/me/', lambda r: no_you) |
request, headers, body = self.client('/crash/me/') |
eq_(request[1], '500') |
assert 'NameError' in body, body |
class TestHandlers(object): |
def setup(self): |
self.server = HTTPServer() |
self.client = MockClient(self.server) |
def test_static(self): |
"""Раздача файлов с диска""" |
self.server.register(*serve_static('/', '.')) # обслуживать все запросы как файл-сервер |
eq_('404', self.client('/give-me-nice-404')[0][1]) |
reply, headers, body = self.client('/handlers.py') # файл из каталога сервера |
eq_('200', reply[1]) |
data = open('handlers.py').read() |
eq_(body, data) |
eq_(int(headers['CONTENT-LENGTH']), len(data)) |
reply, headers, body = self.client('/handlers.py', if_modified_since=headers['LAST-MODIFIED']) |
eq_(reply, ['HTTP/1.0', '304', 'Not modified']) |
eq_(body, '') |
eq_('404', self.client('/../../../../../../../../../../../etc/passwd')[0][1]) |
Подписаться на:
Сообщения (Atom)