wiz 01.02.2011 17:40

PythonВеб-сервер своими руками. Часть 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



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

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


Тэги: http mockups nose ownhands python tutorial unittest
+ 16 -
Похожие Поделиться

zz 02.02.2011 00:56 #
Да, в этой части статьи материал стал заметно более сложным)) Но все же принципы работы понятны и по-прежнему интересно (трудности по большей части возникают из-за незнания Python'а)
kstep 02.02.2011 01:51 #
Спасибо за статью! Жирный от меня плюс. Жаль е? оценило так немного человек. Хотя я и пишу на питоне уже года три, и то было интересно почитать =) Редко встретишь такой слог у собрата-программиста. С нетерпением жду продолжения =)
digiwhite 02.02.2011 07:31 #
Как доберусь до нормального компа почитаю и поробую :)

Статьи в кассу, как говориться, ибо щас опять засел за питон.
wiz 02.02.2011 17:00 #
Продолжение! Ещё больше питона, но градус мозговыноса уже поменьше.
blackraven 02.02.2011 18:40 #
s/конвеера/конвейера/
wiz 02.02.2011 19:13 #
ок (: