Основы Python и Django. / Чат сервер с использованием Tornado фреймфорка. / Создание проекта и окружения. Установка библиотек. Запуск веб сервера. Работа с сообщениями

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

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

Репозиторий

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).

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