Pull to refresh

Сказ о том как я свой REST фреймворк с веб-сокетами писал

Reading time 14 min
Views 22K
Эта статья посвящена очередному REST фреймворку (для Python 3), особенностью которого является использование веб-сокетов для обмена данными между клиентом и сервером. О том откуда пришла идея, с чем мне пришлось столкнулся при написании своей первой библиотеки для Python и что из этого в итоге получилось, я расскажу далее.


Для тех, кому интересна эта статья — пожалуйста, заходите под кат.

1. Идея проекта


Идея зародилась примерно в середине Апреле 2015, когда я задержался с коллегой на работе, с которым мы числимся на одном проекте в своей конторе. Чтобы как-то минимально себя развлечь, пока занимались непосредственно программированием, мы решили поговорить о различных интересных питоновских проектах. В процессе общения как-то спонтанно подошли к теме о собственных проектах и того, что можно было бы интересно использовать далее в своих проектах (не обязательно связанных с работой). При обсуждении непосредственно и возникла идея того, что было бы классно иметь достаточно «гибкий» фреймворк, который использует веб-сокеты, через которые можно передавать данные в обе стороны без каких-либо проблем. Каждый запрос приходит в JSON формате и содержит некоторые заголовки, которые привычны при использовании REST и HTTP протокола. И в качестве приятного дополнения предоставляет возможность передачи уведомлений (нотификаций) со стороны сервера клиенту из коробки по какому-то событию/тайм-ауту.

Естественно после столь продолжительного обсуждения я решился воплотить эту идею в жизнь (а почему бы и да?). Собственный интерес, энтузиазм и желание сделать что-нибудь полезное для развития экосистемы третьего Python'а только давало лишнюю мотивацию побыстрее приступить к делу.

2. Постановка целей


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

  • Постараться использовать asyncio при обработке клиентских запросов
  • Не более 1-2 зависимых модулей (чем меньше, тем лучше)
  • Не должна быть слишком сложной для понимания
  • Легкость в использовании (см. фреймворки Django REST, Flask, которые достаточно простые и гибкие)
  • Программист может заменить практически любой компонент, тогда, когда ему это необходимо

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

3. Подготовка к разработке: выбор между Aiohttp vs Gevent vs Autobahn.ws


Разработка началась примерно в конце Апреля 2015. Чтобы как-то облегчить себе работу при реализации проекта, начались поиски каких-либо уже готовых решений (или уже существующих библиотек, о которых ранее не предполагал). Библиотек, которые бы имели схожую идею с моей или хотя бы минимально имели из коробки то, что предполагается сделать – не нашлось. Это привело к усложнению задачи, поскольку большую часть необходимых компонентов потребуется написать самостоятельно, исходя из собственного понимания всех происходящих процессов.

Я решил начать непосредственно с библиотек, которые дают возможность использовать веб-сокеты. На тот момент времени было найдено несколько таких пакетов: aiohttp, gevent и autobahn.ws. У каждой библиотеки есть свои достоинства и недостатки, но, в первую очередь, я исходил из их возможностей и возможного дальнейшего переиспользования кода, чтобы не приходилось в очередной раз городить свои велосипеды, особенно там, где это не нужно.

Aiohttp – библиотека для веб-разработки, базирующая на стандартной библиотеке asyncio и разработанная svetlov. Не сказать, что у меня был какой-то большой реальный опыт использования этой библиотеки, хотя стоит отметить, что сделано множество вещей очень классно. Однако, предлагаемое решение с веб-сокетами показалось мне несколько низкоуровневым (хотя, в ряде случаев это действительно может быть удобно). Хотелось какого-то большего уровня абстракции (например, как в gevent-websocket или autobahn.ws, где в клиенте/сервере есть методы вроде onMessage и sendMessage, столь похожие на методы из событийно-ориентированного фреймворка Twisted). В остальном же – библиотека прекрасна.

Gevent при первом рассмотрении был одним из тех первых пакетов, на которые было заострено внимания. И также быстро идея о использовании её была отклонена: на момент времени начала проекта (Апрель 2015) gevent не был портирован под третью ветку языка Python. Хотя, если бы все же она была портирована, то я использовал бы именно её, взяв при этом еще расширение gevent-websocket и все могло бы выйти очень даже неплохо. На момент написания статьи данная библиотека уже имеет поддержку третьей ветки, но переходить на нее сейчас я не вижу никакого смысла.

Autobahn.ws – это та библиотека, с которой мне уже ранее приходилось неоднократно сталкиваться при написании своих небольших pet-проектов и с которой у меня уже имеется некий минимальный опыт использования. Достаточно неплохое коммьюнити, плюс автор библиотеки всегда готов помочь в случае возникших проблем (например, когда у меня не получалось совместить ее с Twisted + wxPython, Тобиас очень хорошо объяснил мне как это можно сделать). Последние версии совместимы с asyncio, достаточно добавить декораторы в требуемых местах. Приятной особенностью еще было соответствие документу RFC6455 и наличие проверки входящих/исходящих данных (поступили/отправлены ли они в UTF-8 кодировке, что я считаю достаточно удобно). Поэтому было принято решение использовать именно её в качестве основы для будущей библиотеки.

4. Проблемы, возникшие при разработке


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

1) Получили запрос
2) Проверили что пришли необходимые данные, на основе которых станет понятно каким образом обработать запрос (тип операции, куда обращаемся, и т.д.)
3) Ищем обработчик, соответствующий поступившему запросу (конкретную точку входа и метод, который будет вызываться). Если ничего не нашли – возвращаем ошибку. Если же все отлично, то выбираем соответствующий обработчик и в него передаем полученные аргументы
4) Сформированный ответ привели к определенному формату (JSON, XML, и т.д.)
5) Вернули ответ клиенту

В теории все звучит довольно просто, на деле все оказалось все в точности наоборот. Единственное, что мне приходило в голову, это идти от высокого уровня абстракции к нижним. То есть я шел следующим образом, когда мы работаем с Autobahn.ws и asyncio loop:

1) Создаем экземпляр «фабрики», который будет использовать asyncio loop и принимать входящие подключения и обслуживать их. После выполненного «процесса рукопожатия» мы готовы получать запросы от клиента и выполнять их обработку.

2) Получили запрос от клиента в определенном формате. В нашем случае мы будем получать его в виде JSON следующим образом:

{
   'method': 'POST', 
   'url': '/users/create',
   'args': {
       'token': 'aGFicmFoYWJyX2FkbWlu'
   },
   'data': {
       'username': 'habrahabr',
       'password': 'mysupersecretpassword',
   }
}

Этот JSON имеет достаточно простую структуру. Клиент достаточно определить несколько важных для нас параметров:

  • method – тип операции над ресурсом (подобно тому, как это сделано в HTTP).
  • url – путь к ресурсу, с которым хотим работать.
  • args (опционально) – набор параметров, отсылаемых серверу. Наиболее близкая аналогия это определяемые параметры в URL'е HTTP запроса с помощью "?" и "&" символов, вроде «habrahabr.ru/?page=2&paginate_by=25». Это может быть какой-то список готовых данных (например, идентификаторы пользователей, которым надо назначить определенную группу) или просто набор аргументов для каких-либо фильтров, используемых на стороне сервера в процессе обработки запроса.
  • data (опционально) – набор данных, используемых при работе с ресурсом. В целом, можете считать, что это некий аналог телу HTTP запроса.
  • event_name (опционально) — некий уникальный идентификатор, с помощью которого можно понять от какого endpoint'а вернулись данные.

Примерно такого вида запросы будет ожидать получить наш сервер. Если какого-либо из обязательных аргументов нету – говорим об этом сразу (например, забыли добавить method). В противном случае идем далее по нашему списку.

3) Итак, запрос доставлен серверу, он правильном формате и корректен. Теперь мы хотим его обработать соответствующим образом и вернуть ответ. Однако, что нам для этого необходимо? С моей точки зрения, на первое время будет достаточно наличие системы роутинга, позволяющей зарегистрировать на определенный URL требуемый обработчик, который бы формировал соответствующий ответ, преобразовывал его в JSON, XML (или любой другой формат) и возвращал его клиенту.

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

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

router = SimpleRouter()
router.register('/auth/login', LogIn, 'POST')
router.register('/users/{pk}', UserDetail, ['GET', 'PATCH'])

Мы будем выполнять анализ пути к такому ресурсу. И создавать endpoint, который будет обрабатывать запросы только определенного типа и только по указанному пути. Когда придет запрос на этот ресурс, нам будет достаточно пройтись по словарю, где ключом будет путь, а значением – обработчик. В случае, если обнаружен динамический путь, в момент получения запроса, и мы нашли требуемый обработчик, то будем пробрасывать обнаруженный динамический параметр в место обработки запроса, чтобы было возможным получить объект по ключу либо сделать какую-то иную операцию с использованием этого параметра.

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

4) Здорово, теперь кое что прояснилось. Умеем находить требуемые пути, обработчики для них, а с помощью регулярок находить и пробрасывать параметры (для случая если попался динамический путь). Далее мы смотрим на параметр method, указанный в JSON и стараемся найти соответствующий метод класса с вьюшки. Если он отсутствует – говорим об этом сразу и не выполняем каких-либо операций. В противном случае делаем вызов обнаруженного метода и формируем ответ.

5) Далее выполняем сериализацию данных (в том числе и для случаев с ошибками) в некоторый формат. По умолчанию все преобразуется в JSON формат.

6) Передаем сформированный ответ клиенту обратно по веб-сокету.

И вот по этому примерному плану я следовал до релиза 1.0. Было достаточно интересно написать свои вьюшки, систему роутинга и прочий интересный функционал. Хотя в процессе написания первого релиза, по ходу развития этого pet-проекта, потребовались модули с конфигурациями (в нашем случае это был модуль аналогичный тому, что есть в Django). Или, например, столь необходимая мне аутентификация медленно привела к реализации поддержки middleware и JSON Web Token модулей. Как и упоминалось ранее – делаем всевозможные модули самостоятельно, не стараемся тянуть что-то лишнее.

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

Если при написании первой версии написание кода и его отладка шла достаточно неплохо, то при реализации версии 1.1 я просто надолго повяз в отладке. Написание и портирование кода не занимало столь много времени, сколько поиск и детальный анализ того что происходит, например:

1) Анализ исходной кодовой базы Django REST фреймворка на предмет того, что и как происходит «под капотом»: что делаем когда хотим записать или прочитать определенный объект; когда и каким образом понимаем, что за поля были получены (и имеют ли они вообще какие-то связи с другими моделями) и во что требуется их сериализовать/десериализовать.

2) Сериализация моделей SQLAlchemy по аналогии с тем, как это происходит между Django REST кодом и Django ORM.

3) Иметь такую возможность работы с роутингом, чтобы можно было получить путь до некоторого объекта через уже написанный API (так, чтобы можно было и прочитать, и записать какие-то данные по полученным URL).

При разработке этой части функционала мне весьма сильно помогли исходные коды библиотеки как Django REST (которая во многом являлась основой для следующей версии), так и исходники SQLAlchemy + marshmallow-sqlalchemy библиотек, которые во многом помогли воплотить все задумки в жизнь.

Хоть и было затрачено очень много ресурсов, но конечный результат полностью оправдал все затраты – теперь мы имеем возможность работать с SQLAlchemy так, как мы привыкли это делать в Django REST. Работа с данными осуществляется одинаково и практически не имеет сильных отличий. Здорово, даже практически переучиваться нет необходимости: доступный API во многом идентичен тому, что используется в Django REST.

5. Текущее состояние проекта


На текущий момент времени библиотека предоставляет следующие возможности:

  • Роутинг
  • Поддержка function- и class-based вьюшек
  • Аутентификация через JSON Web Token (хоть и немного ограничено)
  • Поддержка файла с конфигурацией, подобной той, что есть в Django Framework
  • Сжатие передаваемых сообщений (если поддерживается браузером и установлено нужное расширение)
  • Сериализация моделей Django и SQLAlchemy ORM
  • Поддержка SSL

6. Пример использования


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

# -*- coding: utf-8 -*-
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, validates

Base = declarative_base()


class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    name = Column(String(50), unique=True)
    fullname = Column(String(50), default='Unknown')
    password = Column(String(512))
    addresses = relationship("Address", back_populates="user")

    @validates('name')
    def validate_name(self, key, name):
        assert '@' not in name
        return name

    def __repr__(self):
        return "<User(name='%s', fullname='%s', password='%s')>" % (self.name, self.fullname, self.password)


class Address(Base):
    __tablename__ = 'addresses'
    id = Column(Integer, primary_key=True)
    email_address = Column(String, nullable=False)
    user_id = Column(Integer, ForeignKey('users.id'))
    user = relationship("User", back_populates="addresses")

    def __repr__(self):
        return "<Address(email_address='%s')>" % self.email_address

Теперь опишем соответствующие сериализаторы для этих двух моделей:

# -*- coding: utf-8 -*-
from app.db import User, Address
from aiorest_ws.db.orm.sqlalchemy import serializers

from sqlalchemy.orm import Query


class AddressSerializer(serializers.ModelSerializer):

    class Meta:
        model = Address
        fields = ('id', 'email_address')


class UserSerializer(serializers.ModelSerializer):
    addresses = serializers.PrimaryKeyRelatedField(queryset=Query(Address), many=True, required=False)

    class Meta:
        model = User

Как многие из успели заметить, в месте, где мы определили класс для сериализации пользователей, указано поле addresses, с аргументом queryset=Query(Address) в конструкторе класса PrimaryKeyRelatedField. Это сделано для того, чтобы сериализатор для SQLAlchemy ORM мог выстроить связь между полем addresses и таблицей, передавая в этот класс при сериализации первичные ключи. В какой-то степени это аналогично QuerySet из Django фреймворка.

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

# -*- coding: utf-8 -*-
from aiorest_ws.conf import settings
from aiorest_ws.db.orm.exceptions import ValidationError
from aiorest_ws.views import MethodBasedView

from app.db import User
from app.serializers import AddressSerializer, UserSerializer


class UserListView(MethodBasedView):

    def get(self, request, *args, **kwargs):
        session = settings.SQLALCHEMY_SESSION()
        users = session.query(User).all()
        data = UserSerializer(users, many=True).data 
        session.close()
        return data

    def post(self, request, *args, **kwargs):
        if not request.data:
            raise ValidationError('You must provide arguments for create.')

        if not isinstance(request.data, list):
            raise ValidationError('You must provide a list of objects.')

        serializer = UserSerializer(data=request.data, many=True)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return serializer.data


class UserView(MethodBasedView):

    def get(self, request, id, *args, **kwargs):
        session = settings.SQLALCHEMY_SESSION()
        instance = session.query(User).filter(User.id == id).first()
        data = UserSerializer(instance).data 
        session.close()
        return data

    def put(self, request, id, *args, **kwargs):
        if not request.data:
            raise ValidationError('You must provide an updated instance.')

        session = settings.SQLALCHEMY_SESSION()
        instance = session.query(User).filter(User.id == id).first()
        if not instance:
            raise ValidationError('Object does not exist.')

        serializer = UserSerializer(instance, data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        session.close()
        return serializer.data


class CreateUserView(MethodBasedView):

    def post(self, request, *args, **kwargs):
        serializer = UserSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return serializer.data


class AddressView(MethodBasedView):

    def get(self, request, id, *args, **kwargs):
        session = settings.SQLALCHEMY_SESSION()
        instance = session.query(User).filter(User.id == id).first()
        session.close()
        return AddressSerializer(instance).data


class CreateAddressView(MethodBasedView):

    def post(self, request, *args, **kwargs):
        serializer = AddressSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        session.close()
        return serializer.data

На текущий момент времени мы пишем отдельно вьюшки для работы с объектами и отдельно со списком объектов. В каждом из таких подклассов, унаследованных от MethodBasedView, реализуются конкретные обработчики, которые будут использоваться. Для каждого типа запроса (get/post/put/patch/ и т.п.) пишется свой обработчик.

Последним шагом является регистрация этого API, и чтобы он был доступен нам извне:

# -*- coding: utf-8 -*-
from aiorest_ws.routers import SimpleRouter

from app.views import UserListView, UserView, CreateUserView, AddressView, \
    CreateAddressView

router = SimpleRouter()
router.register('/user/list', UserListView, 'GET')
router.register('/user/{id}', UserView, ['GET', 'PUT'], name='user-detail')
router.register('/user/', CreateUserView, ['POST'])
router.register('/address/{id}', AddressView, ['GET', 'PUT'], name='address-detail')
router.register('/address/', CreateAddressView, ['POST'])

Вообщем-то здесь все готово, остается только запустить сервер и подключиться через какой-нибудь клиент (Python + Autobahn.ws, используя JavaScript, и так далее, вариантов множество). Для примера я просто покажу парочку простых запросов с использованием Python + Authobahn.ws (оговорюсь заранее, пример с клиентом не идеален, здесь задача просто продемонстировать как мы можем это делать):

# -*- coding: utf-8 -*-
import asyncio
import json

from hashlib import sha256
from autobahn.asyncio.websocket import WebSocketClientProtocol, \
    WebSocketClientFactory


def hash_password(password):
    return sha256(password.encode('utf-8')).hexdigest()


class HelloClientProtocol(WebSocketClientProtocol):

    def onOpen(self):
        # Create new address
        request = {
            'method': 'POST',
            'url': '/address/',
            'data': {
                "email_address": 'some_address@google.com'
            },
            'event_name': 'create-address'
        }
        self.sendMessage(json.dumps(request).encode('utf8'))

        # Get users list
        request = {
            'method': 'GET',
            'url': '/user/list/',
            'event_name': 'get-user-list'
        }
        self.sendMessage(json.dumps(request).encode('utf8'))

        # Create new user with address
        request = {
            'method': 'POST',
            'url': '/user/',
            'data': {
                'name': 'Neyton',
                'fullname': 'Neyton Drake',
                'password': hash_password('123456'),
                'addresses': [{"id": 1}, ]
            },
            'event_name': 'create-user'
        }
        self.sendMessage(json.dumps(request).encode('utf8'))

        # Trying to create new user with same info, but we have taken an error
        self.sendMessage(json.dumps(request).encode('utf8'))

        # Update existing object
        request = {
            'method': 'PUT',
            'url': '/user/6/',
            'data': {
                'fullname': 'Definitely not Neyton Drake',
                'addresses': [{"id": 1}, {"id": 2}]
            },
            'event_name': 'partial-update-user'
        }
        self.sendMessage(json.dumps(request).encode('utf8'))


    def onMessage(self, payload, isBinary):
        print("Result: {0}".format(payload.decode('utf8')))


if __name__ == '__main__':
    factory = WebSocketClientFactory("ws://localhost:8080")
    factory.protocol = HelloClientProtocol

    loop = asyncio.get_event_loop()
    coro = loop.create_connection(factory, '127.0.0.1', 8080)
    loop.run_until_complete(coro)
    loop.run_forever()
    loop.close()

Более детально посмотреть весь исходный код примера можно здесь.

7. Дальнейшее развитие


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

  • Поддержка уведомлений
  • Просмотр через браузер документации к API (возможно в виде плагина для Swagger)
  • Модули для тестирования API
  • Клиенты для Python и JavaScript
  • Поддержка Pony и Peewee ORM'ов

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

8. И в заключении...


Мне кажется получилось достаточно неплохо для первого раза, не смотря на отсутствие какого-либо опыта в написании собственных библиотек. А внести свой вклад (пусть даже и небольшой) в развитие языка Python – хочется достаточно сильно. Не удивляйтесь тому, сколько времени было на это было затрачено: все делалось (и продолжает делаться) в свободное время и периодическими перерывами (поскольку регулярная работа с одним проектом очень утомляет, а развиваться хочется в нескольких направлениях одновременно).

Так или иначе, буду рад услышать все ваши предложения, идеи и улучшения по данной библиотеке в комментариях (или в виде пул реквестов у меня на GitHub). Не стесняйтесь задавать какие-либо вопросы относительно библиотеки и каких-то особенностей реализации – буду рад любому фидбеку.

Весь вышеприведенный код, а также исходники библиотеки aiorest-ws, можно посмотреть на GitHub. Примеры расположены в корне проекта, в каталоге examples. Документацию можно посмотреть здесь.
Tags:
Hubs:
+13
Comments 34
Comments Comments 34

Articles