HowTo: Как подружить Django с WebSocket (socket.io, sockjs)

    Version: 0.2

    Возникла у меня потребность атомарного обновления в реальном времени страницы у некоторого количества пользователей в зависимости от действий других пользователей (не гербалайф чат). Понятное дело, можно всё выкинуть в помойку и, по-молодецки, запилить с нуля на tornado/twisted.web, но явно не самый продуктивный путь (да и я не мо́лодец ни разу) когда всё что надо — уже работает на Django и нужно всего-то чуть-чуть… Естественным образом, по сути своей, сюда просится WebSocket. И всё бы ничего но Django WSGI приложение, а этот стандарт не предполагает таких выкрутасов даже близко (пока). Гугления интернетов навели, в очередной раз, на труд известного python-гуру kmike (это без сарказма, т.к. его работы выручали меня лично уже не однократно, за что нижайший ему поклон!).

    Итак если вы хотите скрестить ваш Django проект с websocket посредством js библиотеки socket.io или sockjs — вилькоммен!

    Об обновлении


    Первая версия статьи была посвящена только варианту с Socket.io. В процессе работы возникло странное ощущение, что библиотека не всегда определяет отвалившееся подключение. Т.е. при отвале сокета на долгое время она не пыталась зацепиться обратно. Вполне допускаю, что я сам сделал что-то не так. В комментариях к первой версии статьи люди рекомендовали sockjs (по своей инициативе, т.к. в итоге перешли на него) и я решил проверить этот вариант. В итоге либа держится за коннект всеми зубами, но со стороны сервера возникла странная ситуация, когда он перестаёт отвечать на запросы (также скорее всего по нашей вине, но причина пока ещё в процессе выяснения). В любом случае я решил дополнить статью для полноты картины (не без настоятельной просьбы товарища из гуглоплюса). В общем оставляю выбор реализации за вами.

    Вступление


    Давно хотелось попробовать чего-нибудь асинхронного, да всё не было хорошего повода, Тут появилась необходимость, а откуда стартовать было совершенно не ясно. Собственно здесь я попытаюсь создать эту самую актуальную (мной самим за отправную точку был взят упомянутый выше доку́мент но он довольно стар и уже появились некоторые усовершенствования) отправную точку для старта. Будет знакомый островок Django к которому я покажу как подпустить свежего ветерку…

    Кстати из труда kmike пара функций использована без изменений, надеюсь автор не против.

    Что получим


    В результате мы получим асинхронный сервис, который крутится рядом с основным django сайтом, знает какой django пользователь посылает/получает запросы, и [сервис] может получать команды от django, выполняя на их основе какие-то действия в браузере юзера.

    Пример


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

    До этого у вас все дружно колбасили F5… И, в общем, highload, как таковой, нас интересует не особо.

    Инструменты


    Для работы нам понадобятся:
    pip install redis tornado-redis
    и 
    pip install tornadio2 
    или
    pip install sockjs-tornado
    в зависимости от вашего выбора.
    

    А также библиотека socket.io или sockjs

    Теория


    Для работы с socket.io будем использовать библиотеку tornadio2, а для sockjs — sockjs-tornado которые, естественным образом основаны на асинхронном фреймворке tornado. Запускаться это дело будет как manage команда django (привет supervisor). Особых проблем с исполнением джанговского когда в tornadio нет, а вот в обратку у нас небольшой затык, который решается PubSub возможностями Redis (вкратце это такие каналы или очереди сообщений в которые publisher'ы пихают сообщения, а subscriber'ы их получают).

    Отмазка


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

    Также см. отмазки kmike в документе, на который я всё время ссылаюсь.

    Практика


    Практика будет практичной, потому много объяснений в комментариях в исходниках.

    service.py

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

    Метод on_message обязателен к реализации, но в приведённом примере он не нужен, т.к. всё реализуется на новомодной событийной модели (для socket.io).

    Реализация для socket.io

    # -*- coding: utf-8 -*-
    
    import tornado
    import tornadoredis
    from tornadio2 import SocketConnection
    from tornadio2.conn import event
    import django
    from django.utils.importlib import import_module
    from django.conf import settings
    from django.utils import simplejson
    
    # start of kmike's sources
    _engine = import_module(settings.SESSION_ENGINE)
    
    
    def get_session(session_key):
        return _engine.SessionStore(session_key)
    
    
    def get_user(session):
        class Dummy(object):
            pass
    
        django_request = Dummy()
        django_request.session = session
    
        return django.contrib.auth.get_user(django_request)
    # end of kmike's sources
    
    
    # конфиг для подключения к redis можно хранить в настройках django
    ORDERS_REDIS_HOST = getattr(settings, 'ORDERS_REDIS_HOST', 'localhost')
    ORDERS_REDIS_PORT = getattr(settings, 'ORDERS_REDIS_PORT', 6379)
    ORDERS_REDIS_PASSWORD = getattr(settings, 'ORDERS_REDIS_PASSWORD', None)
    ORDERS_REDIS_DB = getattr(settings, 'ORDERS_REDIS_DB', None)
    
    # немного удобства
    unjson = simplejson.loads
    json = simplejson.dumps
    
    
    class Connection(SocketConnection):
        def __init__(self, *args, **kwargs):
            super(Connection, self).__init__(*args, **kwargs)
            self.listen_redis()
    
        @tornado.gen.engine
        def listen_redis(self):
            """
            Вешаем подписчиков на каналы сообщений.
            """
            self.redis_client = tornadoredis.Client(
                    host=ORDERS_REDIS_HOST,
                    port=ORDERS_REDIS_PORT,
                    password=ORDERS_REDIS_PASSWORD,
                    selected_db=ORDERS_REDIS_DB
                )
            self.redis_client.connect()
    
            yield tornado.gen.Task(self.redis_client.subscribe, [
                'order_lock',
                'order_done'
            ])
            self.redis_client.listen(self.on_redis_queue)  # при получении сообщения
                               #  вызываем self.on_redis_queue
    
        def on_open(self, info):
            """
            Определяем сессию django.
            """
            self.django_session = get_session(info.get_cookie('sessionid').value)
    
        @event  # событие, произошедшее в браузере
        def login(self):
            """
            Определение пользователя и его возможностей
            """
            # это просто для примера входящей команды, определять юзера можно и в on_open
            self.user = get_user(self.django_session)
            self.is_client = self.user.has_perm('order.lock')
            self.is_moder = self.user.has_perm('order.delete')
    
        def on_message(self):
            """
            Обязательный метод.
            """
            pass
    
        def on_redis_queue(self, message):
            """
            Обновление в списке заказов
            """
            if message.kind == 'message':  # сообщения у редиса бывают разного типа, 
                               # много сервисных, нам нужны только эти
                message_body = unjson(message.body)  # разворачиваем сабж, как вы  
                                       #  поняли я передаю данные в JSON
    
                # в зависимости от канала получения распределяем сообщения
                if message.channel == 'order_lock':
                    self.on_lock(message_body)
    
                if message.channel == 'order_done:
                    self.on_done(message_body)
    
        def on_lock(self, message):
            """
            Заказ закреплён
            """
            if message['user'] != self.user.pk:  # юзеру-источнику действия сообщать о нём не надо
                self.emit('lock', message)
    
        def on_done(self, message):
            """
            Заказ выполнен
            """
            if message['user'] != self.user.pk:
                if self.is_client:
                    message['action'] = 'hide'
                else:
                    message['action'] = 'highlight'
    
                self.emit('done', message)
    
        def on_close(self):
            """
            При закрытии соединения отписываемся от сообщений
            """
            self.redis_client.unsubscribe([
                'order_lock',
                'order_done'
            ])
            self.redis_client.disconnect()
    

    Реализация для sockjs

    # -*- coding: utf-8 -*-
    
    import tornado
    import tornadoredis
    from sockjs.tornado import SockJSConnection
    import django
    from django.utils.importlib import import_module
    from django.conf import settings
    from django.utils import simplejson
    
    # start of kmike's sources
    _engine = import_module(settings.SESSION_ENGINE)
    
    
    def get_session(session_key):
        return _engine.SessionStore(session_key)
    
    
    def get_user(session):
        class Dummy(object):
            pass
    
        django_request = Dummy()
        django_request.session = session
    
        return django.contrib.auth.get_user(django_request)
    # end of kmike's sources
    
    
    # конфиг для подключения к redis можно хранить в настройках django
    ORDERS_REDIS_HOST = getattr(settings, 'ORDERS_REDIS_HOST', 'localhost')
    ORDERS_REDIS_PORT = getattr(settings, 'ORDERS_REDIS_PORT', 6379)
    ORDERS_REDIS_PASSWORD = getattr(settings, 'ORDERS_REDIS_PASSWORD', None)
    ORDERS_REDIS_DB = getattr(settings, 'ORDERS_REDIS_DB', None)
    
    # немного удобства
    unjson = simplejson.loads
    json = simplejson.dumps
    
    
    class Connection(SocketConnection):
        def __init__(self, *args, **kwargs):
            super(Connection, self).__init__(*args, **kwargs)
            self.listen_redis()
    
        @tornado.gen.engine
        def listen_redis(self):
            """
            Вешаем подписчиков на каналы сообщений.
            """
            self.redis_client = tornadoredis.Client(
                    host=ORDERS_REDIS_HOST,
                    port=ORDERS_REDIS_PORT,
                    password=ORDERS_REDIS_PASSWORD,
                    selected_db=ORDERS_REDIS_DB
                )
            self.redis_client.connect()
    
            yield tornado.gen.Task(self.redis_client.subscribe, [
                'order_lock',
                'order_done'
            ])
            self.redis_client.listen(self.on_redis_queue)  # при получении сообщения
                               #  вызываем self.on_redis_queue
    
        def send(self, msg_type, message):
            """
            Оправка сообщений.
            """
            return super(Connection, self).send({
                    'type': msg_type,
                    'data': message,
                })
    
        def on_open(self, info):
            """
            Определяем сессию django.
            """
            self.django_session = get_session(info.get_cookie('sessionid').value)
            self.user = get_user(self.django_session)
            self.is_client = self.user.has_perm('order.lock')
            self.is_moder = self.user.has_perm('order.delete')
    
        def on_message(self):
            """
            Обязательный метод.
            """
            pass
    
        def on_redis_queue(self, message):
            """
            Обновление в списке заказов
            """
            if message.kind == 'message':  # сообщения у редиса бывают разного типа, 
                               # много сервисных, нам нужны только эти
                message_body = unjson(message.body)  # разворачиваем сабж, как вы  
                                       #  поняли я передаю данные в JSON
    
                # в зависимости от канала получения распределяем сообщения
                if message.channel == 'order_lock':
                    self.on_lock(message_body)
    
                if message.channel == 'order_done:
                    self.on_done(message_body)
    
        def on_lock(self, message):
            """
            Заказ закреплён
            """
            if message['user'] != self.user.pk:  # юзеру-источнику действия сообщать о нём не надо
                self.send('lock', message)
    
        def on_done(self, message):
            """
            Заказ выполнен
            """
            if message['user'] != self.user.pk:
                if self.is_client:
                    message['action'] = 'hide'
                else:
                    message['action'] = 'highlight'
    
                self.send('done', message)
    
        def on_close(self):
            """
            При закрытии соединения отписываемся от сообщений
            """
            self.redis_client.unsubscribe([
                'order_lock',
                'order_done'
            ])
            self.redis_client.disconnect()
    

    models.py

    Источник изменений. Пускай это будет модель.

    # -*- coding: utf-8 -*-
    
    import redis
    from django.conf import settings
    from django.db import models
    
    
    ORDERS_FREE_LOCK_TIME = getattr(settings, 'ORDERS_FREE_LOCK_TIME', 0)
    ORDERS_REDIS_HOST = getattr(settings, 'ORDERS_REDIS_HOST', 'localhost')
    ORDERS_REDIS_PORT = getattr(settings, 'ORDERS_REDIS_PORT', 6379)
    ORDERS_REDIS_PASSWORD = getattr(settings, 'ORDERS_REDIS_PASSWORD', None)
    ORDERS_REDIS_DB = getattr(settings, 'ORDERS_REDIS_DB', 0)
    
    # опять удобства
    service_queue = redis.StrictRedis(
        host=ORDERS_REDIS_HOST,
        port=ORDERS_REDIS_PORT,
        db=ORDERS_REDIS_DB,
        password=ORDERS_REDIS_PASSWORD
    ).publish
    json = simplejson.dumps
    
    class Order(models.Model)
        …
    
        def lock(self):
            """
            Закрепление заказа
            """
            …
    
            service_queue('order_lock', json({
                    'user': self.client.pk,
                    'order': self.pk,
                }))
    
        def done(self):
            """
            Завершение заказа
            """
            …
    
            service_queue('order_done', json({
                    'user': self.client.pk,
                    'order': self.pk,
                }))
    

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

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

    client.js

    Не забывайте загрузить в html socket.io.js или sockjs.js в зависимости от вашего выбора (ссылки в начале статьи)!

    Собственно апофигей всего этого действа — работа на клиентской стороне.

    Реализация для socket.io

        var socket = io.connect('http://' + window.location.host + ':8989');  // ваш порт для асинхронного сервиса
        // при соединении вызываем событие login, которое будет выполнено на серверной стороне
        socket.on('connect', function(){
            socket.emit('login');
        });
    
        // при дисконнекте - пытаемся вернуть соединение
        socket.on('disconnect', function() {
            setTimeout(socket.socket.reconnect, 5000);
        });
    
        // при возникновении события "lock" вызываем "ws_order_lock" с полученным сообщением в качестве параметра
        socket.on('lock', function(msg){
            ws_order_lock(msg);
        });
    
        socket.on('done', function(msg){
            ws_order_done(msg);
        });
    
    function ws_order_lock(msg){
        if (msg.action == 'highlight'){
            $('.id_order_row__' + msg.order).addClass('order-row_is_locked');
        }else{
            $('.id_info_renew_orders').addClass('hidden');
        }
    }
    
    …
    

    Реализация для sockjs

    socket_connect();
    
    function socket_connect() {
        socket = new SockJS('http://' + window.location.host + ':8989/orders');  // ваш порт для асинхронного сервиса
        // при соединении вызываем событие login, которое будет выполнено на серверной стороне
    
        socket.onmessage = function(msg){
            window['ws_order_' + msg.data.type](msg.data.data);  // роутер, выполняет функцию согласно типу сообщения
        }
    
        socket.onclose = function(e){
            setTimeout(socket_connect, 5000);
        };
    }
    
    function ws_order_lock(msg){
        if (msg.action == 'highlight'){
            $('.id_order_row__' + msg.order).addClass('order-row_is_locked');
        }else{
            $('.id_info_renew_orders').addClass('hidden');
        }
    }
    
    …
    

    async_server.py

    Это manage команда, файл надо класть в папку myProject/orderApp/management/commands не забываем также, в каждой из подпапок файлик __init__.py.

    Реализация для socket.io

    
    # -*- coding: utf-8 -*-
    
    import tornado
    import tornadio2 as tornadio
    from django.core.management.base import NoArgsCommand
    
    from myProject.order.tornado.service import Connection
    
    
    class Command(NoArgsCommand):
        def handle_noargs(self, **options):
            router = tornadio.TornadioRouter(Connection)
            app = tornado.web.Application(router.urls, socket_io_port=8989)  # ваш порт для асинхронного сервиса
            tornadio.SocketServer(app)
    

    Реализация для sockjs

    
    # -*- coding: utf-8 -*-
    
    import tornado
    import tornadio2 as tornadio
    from django.core.management.base import NoArgsCommand
    
    from myProject.order.tornado.service import Connection
    
    
    class Command(NoArgsCommand):
        def handle_noargs(self, **options):
            router = SockJSRouter(Connection, '/orders')  # sockjs не захотел работать с корнем :(
            app = tornado.web.Application(router.urls)
            app.listen(8989)
            tornado.ioloop.IOLoop.instance().start()
    


    Теперь можно стартовать сервис python manage.py async_server.

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 22

      0
      class Dummy(object):
              pass
      
          django_request = Dummy()
          django_request.session = session
      


      вот почему-то мне всегда не нравится такой код, тем более есть namedtuple для таких вещей

        +1
        Не слишком сложно с асинхронной частью? Имхо можно было бы заменить redis+tornadio на один RabbitMQ
        www.rabbitmq.com/blog/2012/05/14/introducing-rabbitmq-web-stomp/
          +2
          В emit можно просто передать dict и на клиенте получить объект, и не надо будет делать msg = JSON.parse(msg);
            0
            Действительно, спасибо!
            0
            В начале вы говорите, что не хотите писать на торнадо, а потом берете и юзаете его :(
              0
              Нет, я говорю, что не хочу переписывать всё на торнадо. Т.е. всю биржу. Здесь же на нём лишь та часть которая нужна и её раньше не было.
              +2
              какой из меня гуру, ну спасибо конечно) По поводу слайдов небольшая история. Я в проекте занимался примерно тем, что там на слайдах; было интересно, и примерно за месяц до начала конференции заявку на DevConf отправил — думал, как раз за месяц в тонкостях доразбираюсь. Но как отправил, проект сразу накрылся (по нетехническим причинам), и в итоге ничего в продакшн не пошло, а я другими вещами занялся. Так что на практике не знаю, хороший ли там подход, или плохой, и доклад пришлось рассказывать по теме, в которой опыта почти нет (доклад был ужасным). В слайдах есть пара штук, которые точно лучше поправить (вместо порта лучше использовать поддомен + вместо socket.io лучше socks.js + магия там экспериментальная). Авторы socket.io, похоже, больше самопиаром занимаются (успешно), чем тем, чтоб код работал.

              С другой стороны, основная идея (что не нужно все переписывать, и лучший вариант часто — это синхронный фреймворк + асинхронный сервер отдельно) мне все еще кажется правильной и заслуживающей бОльшего внимания. Недавно вот Armin Ronacher про то же самое писал: lucumr.pocoo.org/2012/8/5/stateless-and-proud/
                0
                Спасибо, ознакомлюсь — Армин знает что пишет. В любом случае и этот метод вполне рабочий оказался… И на питоне — что особенно удобно.
                  0
                  Прочитал Армина, собственно он это и описал, только тут Django вместо flask
                    0
                    В pdf-ке на выбор идёт node.js и tornado. Что в итоге в дело пошло?
                      0
                      упс, это был вопрос не ко мне, сорри…
                        0
                        Ваш ответ ещё любопытнее, учитывая, что вся статья про торнадо. Получается вы всё соорудили на нём, а потом переписали на node.js? Почему?
                          0
                          А, так вопрос всё-таки мне. Нет соорудили на том и так как написано в статье. Собственно я не понял откуда сомнения?
                      0
                      Добавил вариант с sockjs
                      0
                      А не использовали ли вы WSS (WebSocketSecure) с поддержкой SSL
                      У меня проблемы с самоподписанным сертификатом в Safari он там выдает странную ошибку и переходит в режим XHR.

                      Я настраивал через Nginx Tcp proxy это дело, т.е. Nginx у меня занимался SSL шифрованием и после проксированием к tornadio2

                      Спасибо за статью.
                        0
                        Нет, с шифрованием не заморачивался, т.к. у меня через сокеты ходит несущественная инфа, а все манипуляции и текст идут по https. Возможно проблема в nginx т.к. он вроде не особо дружит с websocket ещё… У меня, как видно, сокеты на отдельном порту, чтоб nginx обойти как раз.
                        +1
                        Прошли похожую эволюцию от чистого Pyramid к Pyramid + Tornado + TornadIO2 + Redis как общая шина. Потом отказались от socket.io в пользу sockjs и sockjs-tornado. Не могу указать конкретно, в чём корень проблем socket.io, но со стабильностью соединения и задержками стало гораздо лучше. К тому же sockjs не настолько node-центричен, протокол проще и прозрачнее.
                        Ещё я в процессе понял, как ненавижу программирование на коллбэках (асинхронный сервер у нас содержит приличный объём логики), но переписывать этот кусок на Erlang пока не велит bus factor, хотя прототип был сваян и опробован.
                          0
                          Спасибо за комментарий! Уже не первый человек говорит о недостатках socket.io — значит будем менять. Мне показалось у него какое-то странное поведение после несанкционированного разрыва соединения…
                            0
                            А зачем в торнадо писать на callback'ах если есть tornado.gen?

                            Единственное что надо не забывать вызывать callback, но я вот написал себе такой вот велосипед и он отлично работает: gist.github.com/3924669

                            #1 — где лежит исходник
                            #2 — как писать
                            #3 — аналог с текущим gen.engine
                            #4 — как можно писать в python 3.3+ (return в генераторе кидает StopIteration)

                            Реально используется в боевом приложении. Единственный минус — надо не забывать асинхронные вызовы оборачивать в gen.Task, а то иначе не происходит вызова.
                              0
                              Писали мы конечно с tornado.gen и это отличная обёртка, но абстракция все равно течёт. Ладно, можно один раз проковырять исходники tornado и понять, как здесь реализовали кооперативную многозадачность на этот раз. Но больше всего напрягает, что синхронный и асинхронный код в Питоне — разные миры. Да, иногда можно забить на блокирующие операции сторонних библиотек. Можно вынести их в отдельный поток/процесс и общаться через очередь. Только после всего этого невольно возникает вопрос: а за тот ли молоток я вообще взялся?
                                0
                                Да, согласен, абстракция течет.

                                А с эрлангом та же проблема — очень тяжело найти людей.

                                Везде приходится искать какие-то компромиссы.
                              0
                              Добавил вариант с sockjs.

                            Only users with full accounts can post comments. Log in, please.