Чат сервер с использованием Tornado фреймфорка.

Основы Python и Django. -> Создание проекта и окружения. Установка библиотек. Запуск веб сервера. Работа с сообщениями

Создание папки проекта.

Ссылка на презентацию

Репозиторий

mkdir tornado-chat
cd tornado-chat

Виртуальное окружение

установка в систему нужных команд (установщик python pip и virtualenv)

sudo apt-get install python3-pip virtualenv

установка виртуального окружения в проект

virtualenv -p python3 venv
  • создается папка venv где будут requirements-ы, которую обычно игнорят в git.

Установка Tornado

pip install tornado

Если нужно перечислить все библиотеки в одном текстовом файле то запуск следующий.

pip install -r requirements.txt

Простой веб сервер.

# tornado-server.py

import tornado.ioloop
import tornado.web

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.write("Hello, world")

def make_app():
    return tornado.web.Application([
        (r"/", MainHandler),
    ])

if __name__ == "__main__":
    app = make_app()
    app.listen(8888)
    tornado.ioloop.IOLoop.current().start()

Запуск сервера.

python tornado-server.py

Если нужно запускать короче (без python) то в начале файла пишем

#!/usr/bin/env python
....
import tornado.ioloop
import tornado.web   
.....

Отмечаем как исполняемый и запускаем.

chmod +x tornado-server.py
./tornado-server.py

Видим следующую картину.

Сервер Tornado

Добавляем шаблонизатор (загрузчик и парсер шаблонов).

...
import tornado.web
loader = tornado.template.Loader(".")
...

Создаем простой html шаблон.

<html>
    <head>

    </head>
    <body>
        <h1> {{ myvar }}  </h1>

    </body> 
</html>

Подключаем шаблон в обработчике.

class MainHandler(tornado.web.RequestHandler):
    def get(self):
    self.render("index.html", myvar='Hello world')

Обеспечим отслеживание изменений файлов проекта для автоматической перегрузки сервера.

# tornado-server.py

from tornado import autoreload

...

if __name__ == "__main__":
    print('Starting server on 8888 port')
    autoreload.start()
    autoreload.watch('.')
    autoreload.watch('index.html')

После этих изменений и перегрузки сервера (останавливать по ctrl+c) сервер будет автоматически перегружаться после каждого изменения кода сервера (tornado-server.py) и шаблона (index.html).

Парсинг массива в шаблоне.

Определим и передадим словарь в шаблон.

# tornado-server.py

users = [
    {
      'name': 'Dima',
      'uid': 1
    },

    {
      'name': 'Vovan',
      'uid': 2
    },

]

...

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.render("index.html", myvar='Hello world', users=users)

Шаблон index.html.

    <ul>
        {% for user in users %}
            <li> {{ user['name'] }}  </li>
        {% end %}
    </ul>

Работа с формой. POST запрос.

Добавим форму в шаблон.

<form method="POST" action="/submit">
    <input id="chat_message" type="text" name="message" />
    <input id="submit_button" type="submit" value="Submit" />
</form>

После сабмита ловим 404.

404 Tornado

Добавим класс обработчика формы.

class FormHandler(tornado.web.RequestHandler):
    def post(self):
        print(self.get_argument('message'))
        self.render("index.html",  myvar='Hello world', users=users)

Прицепим этот класс к роутингу с url заканчивающимся на /submit.

def make_app():
    return tornado.web.Application([
        (r"/", MainHandler),
        ...
        (r"/submit", FormHandler),
        ...
    ])

После сабмита видим что переменная из формы попала на сервер и вывелась в консоле.

Submit Tornado

Добавим обработчик веб сокетов.

Определим новый класс - наследник от WebSocketHandler.

from tornado.websocket import WebSocketHandler

...

class WebsocketHandler(tornado.websocket.WebSocketHandler):

    def open(self):
        print('Open connection')

    def on_message(self, message):
        print('got message')


    def on_close(self):
        print('close connection')

Тут мы должны ОБЯЗАТЕЛЬНО переопределить 3 метода.

Привяжем обработчик к роутингу для урлов заканчивающихся на websocket.

def make_app():
    return tornado.web.Application([
        (r"/", MainHandler),
        (r"/submit", FormHandler),
        (r"/websocket", WebsocketHandler),
    ])

Создадим подключение на клиенте в шаблоне index.html.

<script>
    $( document ).ready(function() {     
        var ws = new WebSocket("ws://localhost:8888/websocket");
    });
</script>

Видим что подключение установлено.

ws connection

Отправим сообщение в сокет соединение с клиента (браузера) на сервер Tornado после подключения.

  $( document ).ready(function() {     
    var ws = new WebSocket("ws://localhost:8888/websocket");

    ws.onopen = function() {
        message = {'action': 'connect', 'message': 'new connection'}
        ws.send(message);
    };
    ...

По параметру action мы будем определять тип сообщения для принятия решения о дальнейших действиях как на стороне сервера так и на клиенте.

Выведем полученное сообщение в терминале Tornado сервера.

def on_message(self, message):
    print('got message')
    print(message)

...

ws onmessage

Сообщение приходит после обновления страницы при каждом соединении с веб-сокетом. Однако в виде js-объекта а нам необходимо распарсить этот объект из json в словарь питона.

Для этого нам нужно на клиенте передавать строку, пропуская данные через ф-цию JSON.stringify() в шаблоне.

def on_message(self, message):
    print('got message')
    print(JSON.stringify(message))

Далее преобразовать на стороне сервера.

import json
.....
def on_message(self, message):
    print('got message')
    json_message = json.loads(message)
    print(json_message)

ws onmessage

Передача сообщения с сервера на клиент.

Передадим произвольную строку назад в браузер после принятия сообщения из него.

Воспользуемся методом write_message(string)

def on_message(self, message):
    print('got message')
    json_message = json.loads(message)
    print(json_message)
    self.write_message('message from socket server')

Теперь необходимо обработать событие поступления сообщения с сервера на стороне JS.

За это отвечает метод on_message().

    ws.onmessage = function (evt) {
        alert(evt);
    }

ws onmessage

Как видно в evt поступает сложный объект события.

Чтобы выудить из него полезную нагрузку (наше строковое сообщение) используем следующую конструкцию:

jdata = JSON.parse(evt.data);

Свойство data и будет содержать наше сообщение.

websocket tornado onmessage

Передадим вместо строки словарь питона.

self.write_message({'action': 'onmessage', 'message': message })

websocket tornado onmessage

Как видим в параметре message зашла строка с экранированными спец символами (результат работы JSON.stringify которая преобразует объект в строку) , которую можно легко преобразовать обратно в json объект на стороне клиента ф-цией JSON.parse(evt.data).

Создание периодической отправки сообщений в цикле.

Организуем на сервере бесконечный цикл с задержкой в 1 сек и отправкой таймстампа.

import datetime
import time
.....
def open(self):
    print('Open connection')
    while True:
        time.sleep(1)
        self.write_message({'action': 'ping', 'message': str(datetime.datetime.now())})

websocket ping message

Так как каждое соединение с сокет сервером порождает отдельный экземпляр объекта соединения внутри Tornado (self), нам необходимо определить место хранения этих объектов внутри сервера для того чтоб иметь возможность:

  • извлекать произвольный объект соединения для передачи сообщения адресно

  • циклично отправлять широковещательное сообщение всем активным клиентам

  • создавать и удалять эти соединенbz из списка при коннекте и разрыве соответственно

Для этих целей более подходит словарь {key1: Object1, key2: Object2} чем список [Object1, Object2] т.к. позволяет извлекать и удалять соединение по ключу.

В качестве ключа будем использовать md5 хешик из таймстампа для уникальности.

Импортируем библиотеку для md5.

import hashlib

Сгенерируем хэш.

def open(self):
    print('Open connection')
    sign = hashlib.md5(str(datetime.datetime.now()).encode('utf-8')).hexdigest()
    self.client_id = sign # сохраним в текущем экземпляре.

websocket ping message

Определим пустой контейнер (словарь) для хранения текущих (активных) сокет соединений.

ws_clients = {}

class WebsocketHandler(tornado.websocket.WebSocketHandler):
    ...

Добавим текущее соединение в словарь при подлючении клиента и выведем их в консоль.

def open(self):
    print('Open connection')
    sign = hashlib.md5(str(datetime.datetime.now()).encode('utf-8')).hexdigest()
    ws_clients[sign] = self 
    print(ws_clients)   
    ...

websocket list connections

Осталось удалить соединение при дисконнекте.

def on_close(self):
    print('close connection')    
    del ws_clients[self.client_id]

Обратите внимание на то что переменная ws_clients объявлена ЗА классом и является глобальной. Поэтому мы легко получаем к ней доступ из любого метода любого соединения (без self).

Таким образо теперь при обрыве соединения (закрытие вкладки браузера, самого браузера или обновления страницы) соединение будет удалено из списка (словаря), либо добавлено в случае установления соединения.

Основы Python и Django. -> Доработки, отправка сообщений из формы.

Создадим обработчик события нажатия на кнопку отправки сообщения.

$('#submit_button').on('click',function(e){
    e.preventDefault(); // чтоб не сабмитить форму
    data = {
        'action': 'message',
        'message': $('#chat_message').val() // достаем значения поля inputbox
    }; 
    ws.send(JSON.stringify(data));  // отправка сообщения в соединение
});

Submit message by click

Определим элемент ul на странице где будут сообщения.

<ul id="message_box"></ul>

Опишем метод onmessage js объекта ws где будем добавлять поступающие сообщения

в элемент ul.

ws.onmessage = function (evt) {
    jdata = JSON.parse(evt.data);    
    $('#message_box').append('<li>'+jdata.message+'</li>');
}

List of messages with adding

Сейчас каждое сообщение попадает в тот же канал (соединение) где было установлено соединение т.е. все остальные соединения (страницы браузера) эти сообщения получать не будут. Что бы это исправить нам необходимо при поступлении сообщения внутрь Tornado сервера в цикле отправлять входящее сообщение каждому активному соединению в глобальном словаре ws_clients.

def on_message(self, message):
    print('got message')
    json_message = json.loads(message)
    for c in ws_clients:
        ws_clients[c].write_message({'action': 'onmessage', 'message': json_message['message']})

Передадим на клиент его идентификатор соединения.

На всякий случай, он может понадобиться в будующем.

def open(self):
    print('Open connection')
    sign = hashlib.md5(str(datetime.datetime.now()).encode('utf-8')).hexdigest()
    self.client_id = sign
    # pass sign to client
    self.write_message(\ # многострочный разделитель для одной команды
        {\
            'action': 'set_sign',\
            'message': sign\
        }\
    )
    ...

Выведем этот идентификатор на странице.

ws.onmessage = function (evt) {
    jdata = JSON.parse(evt.data);    
    if( jdata['action'] == 'set_sign'){
        console.log(`Set sign ${jdata['message']}`);
        localStorage.setItem('chat-connection-id', jdata['message']);
    } else {      
        $('#message_box').append('<li>'+jdata.message+'</li>');
    }
}

Тут мы можем по параметру action принимать решения о том что должно сделать наше сообщение на фронтенде.

Основы Python и Django. -> Использование REDIS сервера.

Установка Redis сервера.

sudo apt-get install redis-server

Установка python библиотеки для работы с сервером.

pip install tornado-redis redis

перед установкой убедитесь что вы активировали виртуальное окружение

Создание тестового скрипта отправки данных в канал redis.

test-redis.py

#!/usr/bin/env python
import redis
print('Testing redis connection')
redis_client = redis.Redis(host='localhost', port=6379, db=0)
print("Sending message to channel")
out = redis_client.publish('chat-channel', 'my data')
print(out)

Клиентская часть

    var ws = new WebSocket("ws://localhost:8888/websocket");

    ws.onopen = function() {
        message = {'action': 'connect', 'message': 'new connection'}
        ws.send(JSON.stringify(message));
    };

    ws.onmessage = function (evt) {
        jdata = JSON.parse(evt.data);    
        if( jdata['action'] == 'set_sign'){
            console.log(`Set sign ${jdata['message']}`);
            localStorage.setItem('chat-connection-id', jdata['message']);
        } else {      
            $('#message_box').append('<li>'+jdata.message+'</li>');
        }
    }

    $('#submit_button').on('click',function(e){
        e.preventDefault(); 
        data = {
            'action': 'message',
            'message': $('#chat_message').val()
        }; 
        ws.send(JSON.stringify(data));     
    });

Использование библиотеки tornado-redis

import tornadoredis

...

class WebsocketHandler(tornado.websocket.WebSocketHandler):

    def __init__(self, *args, **kwargs):
        super(WebsocketHandler, self).__init__(*args, **kwargs)
        self.listen_redis()

    def listen_redis(self):
        self.client = tornadoredis.Client()
        self.client.connect()
        self.client.subscribe('chat-channel')
        self.client.listen(self.on_message)

При таком раскладе работать не будет т.к. процесс подписки на канал должен быть асинхронным в цикле событий Tornado. Для этого будем использовать декоратор @tornado.gen.coroutine. Он создает асинхронную корутину (генератор). Это более удобный способ организации последовательности асинхронных действий по сравнению с созданием цепи коллбеков.

Ссылка на документацию

Пример асинхронной корутины.

class GenAsyncHandler(RequestHandler):
    @gen.coroutine
    def get(self):
        http_client = AsyncHTTPClient()
        response = yield http_client.fetch("http://example.com")
        do_something_with_response(response)
        self.render("template.html")

Как видно код выглядит синхронно (без колбеков), однако выполняется асинхронно, сохраняя последовательность выполнения.

response = yield http_client.fetch("http://example.com")

После оператора yield код приостановится ожидая получить результат в response.

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

@tornado.gen.coroutine
def subscribe_redis(self):
    self.client = tornadoredis.Client()
    self.client.connect()
    yield tornado.gen.Task(self.client.subscribe, 'chat-channel')
    self.client.listen(self.on_message)

Этот же функционал можно реализовать с помощью обычных колбеков так:

class WebsocketHandler(tornado.websocket.WebSocketHandler):

    def __init__(self, *args, **kwargs):
        super(WebsocketHandler, self).__init__(*args, **kwargs)
        self.subscribe_redis()

    def listen_redis(self,rez):
        self.client.listen(self.on_message)

    def subscribe_redis(self):
        self.client = tornadoredis.Client()
        self.client.connect()
        self.client.subscribe('chat-channel',self.listen_redis)

Теперь можно доработать обработчик POST формы и в нем передавать данные в redis канал.

redis_client = redis.Redis(host='localhost', port=6379, db=0)

...

class FormHandler(tornado.web.RequestHandler):
    def post(self):
        data = {"action": "onmessage", "message": self.get_argument('message')}
        redis_client.publish('chat-channel',json.dumps(data))
        self.write("OK")

Далее перепишем код клиента для отправки данных с формы AJAX запросом (без перегрузки страницы).

    $('#submit_button').on('click',function(e){
        e.preventDefault(); 
        data = {
            'action': 'message',
            'message': $('#chat_message').val()
        }; 
        //ws.send(JSON.stringify(data)); 
        $.post('/submit',data,function(r){
            console.log(r);
        })

    });

Ранее мы передавали сообщение в веб сокет.

Сообщение, которое опускается в сервер из redis выглядит таким объектом.

Message(kind='message', channel='chat-channel', body='{"message": "test", "action": "onmessage"}', pattern='chat-channel')

Для доступа к параметру body используется следующий синтаксис:

message.body["message|action"]