Python — Веб-сервер своими руками. Часть 3 — фиксим фичи, добавляем баги
В предыдущей части мы сделали инстурменты для тестирования серверного кода без участия сокетов. Но это получился самый тривиальный из видов тестов — Smoke Test. Сервер запрос обработал, но что именно произошло остаётся загадкой.
Как мы помним из кода, липовое соединение содержит в себе буфер отправленного, в котором оказывается ответ сервера. Можно было бы его сравнить с эталонной строкой, но каждый раз её составлять неудобно и муторно. Поэтому неплохо было бы его распарсить.
Но один раз у нас уже кто-то что-то парсит, а именно — сервер, при получении запроса от клиента. Внимательно посмотрев на траффик можно обнаружить, что протокол практически симметричен. И клиент и сервер обмениваются «сообщениями», состоящими из одних и тех же элементов: строка запроса или ответа (формат одинаковый, немного отличается содержимое), заголовки (формат одинаковый) и тело (необязательное для клиента при GET и для сервера при всяких хитрых статусах).
В то же время, наш тестовый клиент уже содержит генератор запросов, преобразующий аргументы функции согласно протоколу.
Вынесем эти две части как из клиента, так и из сервера:
Прежде чем менять актуальный и уже рабочий код, сделаем простенькие тесты конкретно для этого элемента. Они будут лежать в своём отдельном контейнере:
Это обычные заготовки тестов, которые при запуске будут фейлиться и сообщать, что получено не то, что ожидалось:
Убедившись «на глаз», что на выходе получается ровно то, что там должно быть согласно входным данным, копипастим (вдумчиво и внимательно!) значения в тесты.
Теперь, когда мы уверены, что всё работает, можно убрать дублирующийся код из клиента и сервера, а заодно добавить функционала.
Сервер сворачивается в один вызов parse и один encode и теперь готов к дальнейшему расширению без лишних усилий на ручное де/кодирование ответов:
Тест-клиент делает тоже самое, только в обратном порядке:
Теперь, пнув сервер в лабораторных условиях, мы можем точно узнать его реакцию, дописав соответствующий тест:
Уже заполненые тесты лежат на гуглькоде, но я считаю, что намного полезней и интересней поиграться и изучить всё это самим.
Заодно обратите внимание, что в отчёте nosetests функции, имеющие строки документации теперь отображаются в человечьем виде вместо «ехал гитлер^W тест через тест…»:
Вот теперь мы готовы запилить что-нибудь полезное. Сервер выдающий один и тот же ответ мало интересен, поэтому надо сделать возможность расширения функционала. Тоесть потребуются какие-то обработчики и возможность их встраивать в код без наследования и перезаписи кусков кода сервера.
Можно было бы вешать код просто на URL, но это малоинтересно и не позволяет сделать какие-нибудь более продвинутые схемы. Сразу разделим обработчики на две фазы: pattern и handler. Первый занимается определением, надо ли вообще вызывать обработчик - получает всё, что сервер знает о запросе и выдаёт своё веское решение. Второй собственно знает, что его вызывают не просто так и пора заняться своей непосредственной работой - ответом.
Но сервер знает много чего, и это много передавать в виде аргументов очень неудобно. Поэтому завернём всё наше хозяйство в объект Request:
Пропишем сразу серверу в on_connect, чтобы он его использовал и передавал дальше уже всё готовенькое:
Сам же on_request теряет всю свою кучу аргументов и получает один (два, если вместе с self):
Хм.. При запросе сервер выводит какую-то нечитабельную лабуду в консоль. Это легко исправить. print пытается все свои аргументы привести сначала строковому виду, тоесть к типу str. Посмотреть что будет выводиться можно в терминале, сделав это вручную:
В питоне всё-это-объект™ и у всех объектов может быть определён «волшебный» метод __str__ который будет в таких случаях вызываться. Там есть ещё много других интересных и странных методов, позволяющих сделать объект функцией или словарём или чёрти чем ещё. Пока что ограничимся просто читабельностью нашего контейнера и покажем пользователю немного содержимого:
Время разработчика очень ценно, а дублирование кода очень вредно. Поэтому, чтобы поймать сразу двух зайцев, скроем работу с соединением за функцией-помощником reply:
Она сразу выставит дефолтные заголовки, которые при желании можно передать самому, но они практически обязательны и совершенно нет смысла их формировать каждый раз вручную. При очень большом желании, обработчик может взять request.conn и ответить так, как ему надо. Но такое требуется редко.
Посылка готова, можно отправлять. Но ещё надо составить список возможных получателей. Добавим в конструктор сервера инициализацию списка обработчиков:
И метод их регистрации, в котором просто добавляем пару шаблон-обработчик в этот список:
Теперь on_request может стать диспетчером:
Обновим тесты, с учётом всех нововведений. Класс, содержащий сценарии тестирования будет иметь несколько методов, каждый из которых будет создавать сервер, клиент для него и дальше делать свои дела. Дублирование кода детектед! К счастью, методика модульного тестирования уже давно решила эту задачу. Собственно для этого мы и используем тут классы, а не просто функции test_something. Специальный метод setup позволяет делать одинаковую настройку для каждого последующего запуска серии тестов:
Попробуем теперь протестировать поведение пустого сервера без обработчиков. Клиент уже создан и настроен, поэтому сразу выстреливаем запрос:
Всё в порядке, можем продолжать. Зарегистрируем пару обработчиков и попробуем наш API на вкус:
Одна из самых удобных возможностей питона — передавать функции в качестве аргументов, укладывать их в списки и назначать в переменные. Безо всяких if/case/goto и подобной чертовщины. lambda это выражение для создания анонимной функции; сжатый аналог def, которую можно создавать на ходу и передавать дальше не отвлекаясь от структуры кода.
Как и обещалось, проверять можно не только урл, но и всё, что доступно в запросе:
Тесты работают и можно приступать к реализации модулей, описаных в первой части.
PS: Специальный бонус для осиливших весь пост целиком \o/
(Полный код тестов и сервера)
Как мы помним из кода, липовое соединение содержит в себе буфер отправленного, в котором оказывается ответ сервера. Можно было бы его сравнить с эталонной строкой, но каждый раз её составлять неудобно и муторно. Поэтому неплохо было бы его распарсить.
Но один раз у нас уже кто-то что-то парсит, а именно — сервер, при получении запроса от клиента. Внимательно посмотрев на траффик можно обнаружить, что протокол практически симметричен. И клиент и сервер обмениваются «сообщениями», состоящими из одних и тех же элементов: строка запроса или ответа (формат одинаковый, немного отличается содержимое), заголовки (формат одинаковый) и тело (необязательное для клиента при 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 33 |
|
Прежде чем менять актуальный и уже рабочий код, сделаем простенькие тесты конкретно для этого элемента. Они будут лежать в своём отдельном контейнере:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class TestHTTP(object): |
Это обычные заготовки тестов, которые при запуске будут фейлиться и сообщать, что получено не то, что ожидалось:
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): |
Тест-клиент делает тоже самое, только в обратном порядке:
1 2 3 4 |
def __call__(self, url, method="GET", body='', **headers): |
Для новичков в питоне сразу поясню, что за странные «**» в сигнатуре функции и последующем вызове.
Это вовсе не указатель на указатель, как могли бы подумать бывалые сишники, а словарь необязательных именованых аргументов. Что это значит можно быстро проверить в интерпретаторе. Сначала набросаем несколько тривиальных функций:
Попробуйте передать им {'whatever': "yeah"} и whatever="yeah" и посмотреть что будет лежать в переменных.
При вызове функции ситуация ровно обратная. spam({'sausage': 'bacon'}) пройдёт как и ожидалось, а eggs потребует «развёртывания словаря» - eggs(**{'salad': 'cheese'})
В общем рекомендую поиграться созданием и вызовом функций и почитать что-нибудь на эту тему.
1 2 3 4 5 |
def spam(kwargs): |
Попробуйте передать им {'whatever': "yeah"} и whatever="yeah" и посмотреть что будет лежать в переменных.
При вызове функции ситуация ровно обратная. spam({'sausage': 'bacon'}) пройдёт как и ожидалось, а eggs потребует «развёртывания словаря» - eggs(**{'salad': 'cheese'})
В общем рекомендую поиграться созданием и вызовом функций и почитать что-нибудь на эту тему.
Теперь, пнув сервер в лабораторных условиях, мы можем точно узнать его реакцию, дописав соответствующий тест:
1 2 3 4 5 6 7 |
server = HTTPServer() |
Уже заполненые тесты лежат на гуглькоде, но я считаю, что намного полезней и интересней поиграться и изучить всё это самим.
Заодно обратите внимание, что в отчёте nosetests функции, имеющие строки документации теперь отображаются в человечьем виде вместо «ехал гитлер^W тест через тест…»:
1 2 3 |
Тестирование в режиме запроса: клиент сериализует, сервер разбирает ... ok |
Вот теперь мы готовы запилить что-нибудь полезное. Сервер выдающий один и тот же ответ мало интересен, поэтому надо сделать возможность расширения функционала. Тоесть потребуются какие-то обработчики и возможность их встраивать в код без наследования и перезаписи кусков кода сервера.
Можно было бы вешать код просто на URL, но это малоинтересно и не позволяет сделать какие-нибудь более продвинутые схемы. Сразу разделим обработчики на две фазы: pattern и handler. Первый занимается определением, надо ли вообще вызывать обработчик - получает всё, что сервер знает о запросе и выдаёт своё веское решение. Второй собственно знает, что его вызывают не просто так и пора заняться своей непосредственной работой - ответом.
Но сервер знает много чего, и это много передавать в виде аргументов очень неудобно. Поэтому завернём всё наше хозяйство в объект Request:
1 2 3 4 5 6 7 8 9 |
class Request(object): |
Пропишем сразу серверу в on_connect, чтобы он его использовал и передавал дальше уже всё готовенькое:
self.on_request(Request(method, url, headers, body, conn))
Сам же on_request теряет всю свою кучу аргументов и получает один (два, если вместе с self):
1 2 3 |
def on_request(self, request): |
Хм.. При запросе сервер выводит какую-то нечитабельную лабуду в консоль. Это легко исправить. print пытается все свои аргументы привести сначала строковому виду, тоесть к типу str. Посмотреть что будет выводиться можно в терминале, сделав это вручную:
1 2 |
>>> str(Request()) |
В питоне всё-это-объект™ и у всех объектов может быть определён «волшебный» метод __str__ который будет в таких случаях вызываться. Там есть ещё много других интересных и странных методов, позволяющих сделать объект функцией или словарём или чёрти чем ещё. Пока что ограничимся просто читабельностью нашего контейнера и покажем пользователю немного содержимого:
1 2 |
def __str__(self): |
Время разработчика очень ценно, а дублирование кода очень вредно. Поэтому, чтобы поймать сразу двух зайцев, скроем работу с соединением за функцией-помощником reply:
1 2 3 4 5 6 7 8 9 |
def reply(self, code='200', status='OK', body='', **headers): |
Она сразу выставит дефолтные заголовки, которые при желании можно передать самому, но они практически обязательны и совершенно нет смысла их формировать каждый раз вручную. При очень большом желании, обработчик может взять request.conn и ответить так, как ему надо. Но такое требуется редко.
Посылка готова, можно отправлять. Но ещё надо составить список возможных получателей. Добавим в конструктор сервера инициализацию списка обработчиков:
self.handlers = []
И метод их регистрации, в котором просто добавляем пару шаблон-обработчик в этот список:
1 2 |
def register(self, pattern, handler): |
Теперь on_request может стать диспетчером:
1 2 3 4 5 6 7 |
for pattern, handler in self.handlers: |
Обновим тесты, с учётом всех нововведений. Класс, содержащий сценарии тестирования будет иметь несколько методов, каждый из которых будет создавать сервер, клиент для него и дальше делать свои дела. Дублирование кода детектед! К счастью, методика модульного тестирования уже давно решила эту задачу. Собственно для этого мы и используем тут классы, а не просто функции test_something. Специальный метод setup позволяет делать одинаковую настройку для каждого последующего запуска серии тестов:
1 2 3 4 |
class TestServer(object): |
Попробуем теперь протестировать поведение пустого сервера без обработчиков. Клиент уже создан и настроен, поэтому сразу выстреливаем запрос:
1 2 3 4 |
def test_404(self): |
Всё в порядке, можем продолжать. Зарегистрируем пару обработчиков и попробуем наш API на вкус:
1 2 3 4 5 6 7 |
def test_handlers(self): |
Одна из самых удобных возможностей питона — передавать функции в качестве аргументов, укладывать их в списки и назначать в переменные. Безо всяких if/case/goto и подобной чертовщины. lambda это выражение для создания анонимной функции; сжатый аналог def, которую можно создавать на ходу и передавать дальше не отвлекаясь от структуры кода.
Как и обещалось, проверять можно не только урл, но и всё, что доступно в запросе:
1 2 3 4 5 6 |
self.server.register(lambda r: r.method == 'POST', # отлавливать все посты |
Тесты работают и можно приступать к реализации модулей, описаных в первой части.
PS: Специальный бонус для осиливших весь пост целиком \o/
Тесты это хорошо, очень хорошо. Но по ходу разрастания проекта хочется знать какие участки нотариально™ заверены, а какие ещё только предстоит покрыть.
У nose есть плагин, позволяющий оценить процент покрытия и отметить строки кода, в которые никто не заходил во время работы юниттестов. Ставится он из pip и называется nose-cov. При запуске с опцией --with-cover помимо отчётов об успешности будет выведена ещё таблица покрытия:
70-76 это строки, где создаётся сокет и запускается вечный цикл обработки подключений.
99-100 это запуск дефолтного сервера, там тоже ничего интересного нет.
У nose есть плагин, позволяющий оценить процент покрытия и отметить строки кода, в которые никто не заходил во время работы юниттестов. Ставится он из pip и называется nose-cov. При запуске с опцией --with-cover помимо отчётов об успешности будет выведена ещё таблица покрытия:
1 2 3 4 |
Name Stmts Miss Cover Missing |
70-76 это строки, где создаётся сокет и запускается вечный цикл обработки подключений.
99-100 это запуск дефолтного сервера, там тоже ничего интересного нет.
(Полный код тестов и сервера)