Быстрый в изучении - мощный в программировании
>> Telegram ЧАТ для Python Программистов

Свободное общение и помощь советом и решением проблем с кодом! Заходите в наш TELEGRAM ЧАТ!

>> ВИДЕОКУРС Python Разработчик

Best Practice по решению прикладных задач и освоению инструментов, применяемых при разработке, веб-приложений.

>> ОНЛАЙН ТЕСТ Сможешь обучить робота?

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

Создание своего веб-фреймворка на Python - Часть 1

Создаем web-framework на Python

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

В этом руководстве мы построим наиболее важные части фреймворка. В конце у нас появятся обработчики запросов (к примеру, Django views), и маршрутизации: простая (как /books/ ) и параметризованная (как /greet/{name} ). Если интересно, в разделе комментариев вы можете рассказать о других функциях, которые на ваш взгляд, стоит реализовать в нашем фреймворке.

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

Gunicorn это WSGI HTTP сервер, так что для него нужна особенная точка входа в наше приложение.

Ознакомились с WSGI? Отлично! Давайте продолжим.

Чтобы добиться WSGI совместимости, нам нужен вызываемый объект (функция или класс), который принимает два параметра (environ и start_response), и возвращает совместимый с WSGI ответ. Не волнуйтесь о том, что написанное кажется непонятным. Суть может стать яснее, когда мы перейдем к коду.

VPS для практики

Если вы начинающий программист, то рано или поздно вам придется познакомиться с Linux и запуском своего приложения сразу на рабочий VPS для клиента или для собственного проекта. Мы рекомендуем VPS от Fornex, т.к. данный хостинг отлично подходит для тех кто хочет быстро получить рабочий и надежный VPS.

Какой VPS выбрать?

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

Первым делом нужно завести аккаунт на Fornex.com

Выбор VPS

На данном этапе нашего проекта подойдет и обычный VPS.

Debian 9 VPS

Выбираем SSD CLOUD 1GB и операционную систему Debian 9. Всегда нужно выбирать последнюю самую новую версию. Это избавит вас от проблем со старыми библиотеками.

Подключение по SSH

После заказа нашего VPS, мы получим данные от сервера. Нам понадобиться логин и пароль от SSH. Для того чтобы войти по SSH мы воспользуемся Putty (для Windows) либо в обычный терминал на Linux пишем:

ssh root@IP-нашего-VPS

Обновляемся:

$ apt update
$ apt upgrade

Устанавливаем Python:

$ apt install python3

Для корректной работы, нужно установить необходимые библиотеки:

$ apt install python3-setuptools python3-dev python3-venv
$ apt install libtiff5-dev libjpeg8-dev zlib1g-dev
$ apt install libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python-tk

Ниже мы будем использовать менеджер пакетов pip, устанавливаем его:

$ apt install python3-pip

Создание веб-фреймворка

Придумайте название вашего фреймворка и создайте одноименную папку. Свой я назвал bumbo :

cd /home
mkdir bumbo

Переходим к этой папке, создаем виртуальное окружение и активируем её:

cd bumbo
python3.6 -m venv venv
source venv/bin/activate

Теперь мы создаем файл под названием app.py , где будет находиться наша точка входа для gunicorn:

touch app.py

Внутри нашего файла app.py мы впишем простую функцию, чтобы узнать, будет ли она работать с gunicorn :

# app.py
 
def app(environ, start_response):
    response_body = b"Hello, World!"
    status = "200 OK"
    start_response(status, headers=[])
    return iter([response_body])

Как говорилось ранее, вызываемый точкой входа объект получает два параметра. Один из них - environ , где вся информация о запросах хранится в качестве метода request, url, параметров запроса, и тому подобное. Второй параметр - start_response , который высылает предполагаемый ответ. Теперь, попробуем запустить этот код с gunicorn . Для этого нам нужно его установить и запустить следующим образом:

pip install gunicorn
gunicorn app:app

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

[2019-03-20 17:58:56 +0500] [30962] [INFO] Starting gunicorn 19.9.0
[2019-03-20 17:58:56 +0500] [30962] [INFO] Listening at: http://127.0.0.1:8000 (30962)
[2019-03-20 17:58:56 +0500] [30962] [INFO] Using worker: sync
[2019-03-20 17:58:56 +0500] [30966] [INFO] Booting worker with pid: 30966

Если вы видите такую выдачу, откройте свой браузер и перейдите на http://localhost:8000 . Вы должны увидеть нашего старого доброго друга, сообщение Hello, World! Далее мы будем работать исходя из этого.

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

Создадим файл api.py :

touch api.py

Внутри этого файла создадим следующий класс API . Вкратце объясню, что он делает.

# api.py
 
class API:
    def __call__(self, environ, start_response):
        response_body = b"Hello, World!"
        status = "200 OK"
        start_response(status, headers=[])
        return iter([response_body])

Теперь, удалите все внутри app.py и впишите следующее:

# app.py
from api import API
 
app = API()

Перезапустите gunicorn и проверьте результат в браузере. Он должен быть таким же, как и раньше, так как мы просто конвертировали нашу функцию под названием арр в класс под названием API и переопределили его метод __call__ , который вызывается при вызове экземпляров этого класса:

app = API()
app()   #  здесь вызывается  __call__

Теперь, когда мы создали наш класс, я хочу сделать код более элегантным, так как наши байты (b"Hello World" ) и start_response могут запутать нас.

К счастью, есть замечательный пакет под названием WebOb, который предоставляет объекты для HTTP запросов и ответов, заворачивая среду запросов WSGI и статус, заголовки и тело ответов. Используя данный пакет, мы можем передать environ и start_response классам, которые предоставляются этим пакетом, без необходимости разбираться с этим самостоятельно.

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

Здесь мы приступим к рефакторингу данного кода. Для начала, установим WebOb:

pip install webob

Импортируйте классы Request и Response в начало файла api.py:

# api.py
from webob import Request, Response
 
...

Теперь мы можем использовать их внутри метода __call__ :

# api.py
from webob import Request, Response
 
class API:
    def __call__(self, environ, start_response):
        request = Request(environ)
 
        response = Response()
        response.text = "Hello, World!"
 
        return response(environ, start_response)

Выглядит намного лучше! Перезапустите gunicorn и увидите тот же результат, что и раньше. Лучшая часть в том, что мне не нужно объяснять, что здесь происходит! Всё говорит само за себя. Мы создаем запрос, и возвращаем этот ответ.

Отлично! Хочу обратить внимание на то, что request здесь еще не используется, так как мы ничего для этого не сделали. Итак, давайте используем эту возможность и также используем объект request. Кстати, давайте проведем рефакторинг создания ответа, превратив его в собственный метод. Почем так лучше? Мы узнаем позже:

# api.py
from webob import Request, Response
 
class API:
    def __call__(self, environ, start_response):
        request = Request(environ)
 
        response = self.handle_request(request)
 
        return response(environ, start_response)
 
    def handle_request(self, request):
        user_agent = request.environ.get("HTTP_USER_AGENT", "No User Agent Found")
 
        response = Response()
        response.text = f"Здравствуй, мой друг с браузером: {user_agent}"
 
        return response

Перезапустите gunicorn и увидите новое сообщение в браузере, а мы будем двигаться дальше.

С этого момента, все запросы обработаны общим путем. Вне зависимости от того, какой запрос мы получили, мы просто возвращаем один и тот же ответ, который создан в методе handle_request. В конечном счете, нам нужно быть динамичными. Таким образом, нам нужно обработать запрос от /home/ , иначе чем обработка запроса из /about/ .

Для этого, создадим два метода внутри app.py . Они будут обрабатывать эти два запроса:

# app.py
from api.py import API
 
app = API()
 
 
def home(request, response):
    response.text = "Привет! Это ГЛАВНАЯ страница"
 
 
def about(request, response):
    response.text = "Привет! Это страница О НАС!"

Теперь нам нужно как-то связать эти два метода с упомянутыми ранее путями: /home/ и /about/ . Мне нравится как Flask справляется с данной задачей и я решил вдохновиться от него:

# app.py
from api.py import API
 
app = API()
 
 
@app.route("/home")
def home(request, response):
    response.text = "Привет! Это ГЛАВНАЯ страница"
 
 
@app.route("/about")
def about(request, response):
    response.text = "Привет! Это страница О НАС!"

Что скажете? Выглядит неплохо. Давайте это реализуем.

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

# api.py
class API:
    def __init__(self):
        self.routes = {}
 
    def route(self, path):
        def wrapper(handler):
            self.routes[path] = handler
            return handler
 
        return wrapper
 
    ...

Что было сделано? В методе __init__ мы просто определили словарь под названием self.routes, в котором мы будем хранить пути в качестве ключей, а обработчики - в качестве значений. Это может выглядеть следующим образом:

print(self.routes)
 
{
    "/home": ,
    "/about": 
}

В методе route, мы возьмем путь в качестве аргумента и в методе wrapper просто внесем этот путь в словарь self.routes в качестве ключа, а обработчик - в качестве значения.

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

# api.py
from webob import Request, Response
 
class API:
    ...
 
    def handle_request(self, request):
        response = Response()
 
        for path, handler in self.routes.items():
            if path == request.path:
                handler(request, response)
                return response
 
    ...

Не так уж и сложно, не так ли? Мы просто провели итерацию над self.routes, сравнили пути с путем запроса, и при совпадении, вызвали обработчик, связанный с этим путем.

Перезапустите gunicorn и проверьте эти пути в браузере. Сначала, перейдите по http://localhost:8000/home/ , и затем по http://localhost:8000/about/ . Вы должны увидеть соответствующие сообщения. Удобно, не так ли?

Следующим нашим действием будет найти ответ на вопрос “Что случится, если путь не будет найден?”. Давайте создадим метод, который возвращает простой HTTP ответ “не найдено” со статусом кода 404:

# api.py
from webob import Request, Response
 
class API:
    ...
 
    def default_response(self, response):
        response.status_code = 404
        response.text = "Not found."
 
    ...

Теперь, используем это в нашем методе handle_request :

# api.py
from webob import Request, Response
 
class API:
    ...
 
    def handle_request(self, request):
        response = Response()
 
        for path, handler in self.routes.items():
            if path == request.path:
                handler(request, response)
                return response
 
        self.default_response(response)
        return response
 
    ...

Перезапустите gunicorn и попробуйте посетить несуществующие пути. Вы должны увидеть страницу “Not found”. Теперь, выполним рефакторинг таким образом, чтобы найти обработчик для его собственного метода ради читаемости:

# api.py
from webob import Request, Response
 
class API:
    ...
 
    def find_handler(self, request_path):
        for path, handler in self.routes.items():
            if path == request_path:
                return handler
 
    ...

Как и в предыдущем случае, он просто итерирует над self.route , сравнивает пути с путем запроса и возвращает обработчик, если пути совпадают. Он возвращает None, если обработчик не был найден. Теперь, мы можем использовать его в нашем методе handle_request:

# api.py
from webob import Request, Response
 
class API:
    ...
 
    def handle_request(self, request):
        response = Response()
 
        handler = self.find_handler(request_path=request.path)
 
        if handler is not None:
            handler(request, response)
        else:
            self.default_response(response)
 
        return response
 
    ...

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

Теперь у нас есть пути и обработчики. Это замечательно, но наши пути достаточно простые. Они не поддерживают сложные параметры ключевых слов в пути URL. Что если нам нужен путь наподобие @app.route("/hello/{person_name}") и иметь возможность использовать значение person_name внутри наших обработчиков, вот так:

def say_hello(request, response, person_name):
    resp.text = f"Hello, {person_name}"

Для этого, если кто-то перейдет по /hello/Matthew/ , нам нужно иметь возможность сопоставить этот путь с зарегистрированным /hello/{person_name}/ и найти надлежащий обработчик. К счастью, есть готовый пакет под названием parse который делает именно то, что нам нужно. Давайте установим его:

pip install parse

И протестируем:

>>> from parse import parse
>>> result = parse("Hello, {name}", "Hello, Matthew")
>>> print(result.named)
{'name': 'Matthew'}

Как вы видите, он проанализировал строку Hello, Matthew и определил, что Matthew соответствует предоставленному {name} .

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

# api.py
from webob import Request, Response
from parse import parse
 
class API:
    ...
 
    def find_handler(self, request_path):
        for path, handler in self.routes.items():
            parse_result = parse(path, request_path)
            if parse_result is not None:
                return handler, parse_result.named
 
        return None, None
 
    ...

Мы все еще итерируем над self.routes, и теперь вместо сравнения пути с путем запроса, мы попытаемся проанализировать его, и если будет результат, мы вернем обработчик и параметры ключевых слов как словарь. Теперь, мы можем использовать наш handle_request для отсылки этих параметров в обработчик вот так:

# api.py
from webob import Request, Response
from parse import parse
 
class API:
    ...
 
    def handle_request(self, request):
        response = Response()
 
        handler, kwargs = self.find_handler(request_path=request.path)
 
        if handler is not None:
            handler(request, response, **kwargs)
        else:
            self.default_response(response)
 
        return response
 
    ...

Единственное, что здесь меняется - это то, что мы получаем обработчик и аргументы ключевых слов kwargs от self.find_handler, и передаем kwargs обработчику вот так: **kwargs.

Давайте напишем обработчик с таким типом пути и испробуем его:

# app.py
...
 
@app.route("/hello/{name}")
def greeting(request, response, name):
    resp.text = f"Hello, {name}"
 
...

Перезапустите gunicorn и перейдите по http://localhost:8000/hello/Matthew/ . Вы увидите замечательное сообщение Hello, Matthew . Шикарно, да? Добавьте немного своих подобных обработчиков.

Вы также можете указать тип заданных параметров. Например, вы можете выполнить @app.route("/tell/{age:d}") , чтобы получить age вашего параметра внутри обработчика в виде цифры.

Вывод

Это был длинный путь, но я думаю он был просто замечательным. Я лично узнал много нового, пока писал это. Если вам понравилось данное руководство, дайте мне знать в комментариях, какие другие функции должны быть реализованы в нашем фреймворке. Лично я подумываю об основанных на классах обработчиках, поддержку шаблонов и статичных файлах.

Комментариев: 2
  1. Хорошая статья, а вторая часть есть?

  2. Питоша | 2019-09-22 в 06:50:23

    Автор, пиши ещё! А если писать свою CMS на Python с нуля, начало будет таким же?

Оставьте комментарий!

Используйте нормальные имена.

Имя и сайт используются только при регистрации

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

(обязательно)