Чат сервер с использованием 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
Видим следующую картину.
Добавляем шаблонизатор (загрузчик и парсер шаблонов).
...
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.
Добавим класс обработчика формы.
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),
...
])
После сабмита видим что переменная из формы попала на сервер и вывелась в консоле.
Добавим обработчик веб сокетов.
Определим новый класс - наследник от 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>
Видим что подключение установлено.
Отправим сообщение в сокет соединение с клиента (браузера) на сервер 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)
...
Сообщение приходит после обновления страницы при каждом соединении с веб-сокетом. Однако в виде 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)
Передача сообщения с сервера на клиент.
Передадим произвольную строку назад в браузер после принятия сообщения из него.
Воспользуемся методом 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);
}
Как видно в evt поступает сложный объект события.
Чтобы выудить из него полезную нагрузку (наше строковое сообщение) используем следующую конструкцию:
jdata = JSON.parse(evt.data);
Свойство data и будет содержать наше сообщение.
Передадим вместо строки словарь питона.
self.write_message({'action': 'onmessage', 'message': message })
Как видим в параметре 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())})
Так как каждое соединение с сокет сервером порождает отдельный экземпляр объекта соединения внутри 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 # сохраним в текущем экземпляре.
Определим пустой контейнер (словарь) для хранения текущих (активных) сокет соединений.
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)
...
Осталось удалить соединение при дисконнекте.
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)); // отправка сообщения в соединение
});
Определим элемент 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>');
}
Сейчас каждое сообщение попадает в тот же канал (соединение) где было установлено соединение т.е. все остальные соединения (страницы браузера) эти сообщения получать не будут. Что бы это исправить нам необходимо при поступлении сообщения внутрь 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"]