Thursday, November 29, 2012

WebSocket-чат на Tornado для вашего Django-проекта

TornadoНедавно я запустил сайт backgrounddating.com, одной из возможностей которого является чат с другими пользователями в режиме реального времени. Документации (как на русском, так и на английском), позволяющей быстро вникнуть в написание подобных приложений с помощью технологии WebSocket пока что мало, поэтому я решил написать данное руководство. Итак, задача состоит в том, чтобы любой пользователь мог отправлять другим пользователям сообщения, и, если у получателя сообщения открыт чат с этим пользователям, то он сразу же видел входящие сообщения (а в ином случае он мог прочитать сообщения позже: то есть при открытии чата загружается история последних сообщений).

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

Сразу уточню, что это не единственный способ реализовать подобное. Вы можете использовать другой асинхронный веб-сервер (например node.js), можете использовать другую очередь сообщений (или вообще её не использовать, если вам подходят особенности такого варианта: с пользователями одного канала обязательно общается один и тот же worker веб-сервера). Я даже не утверждаю, что этот вариант самый лучший (но в данном случае он подошёл лучше всех). В конце концов, мы здесь вообще не будем рассматривать костыли (long polling, Flash) для старых браузеров (а это почти все версии IE, например), не поддерживающих веб-сокеты, и даже не будем рассматривать возможность подключаться из тех браузеров, которые уже поддерживают протокол WebSocket, но не стандартизированную версию (RFC 6455), а одну из устаревших. О том, как можно включить поддержку устаревшей версии «draft 76» (она же «hixie-76»), смотрите в документации Tornado.

Тем не менее, что можно сказать точно — этот способ работает хорошо, причём не в одном проекте, а во многих (описанный способ реализации уже давно применяется, хоть о нём пока и не очень много информации). Например, сервер, на котором работает Background Dating — это на данный момент самый младший VPS от Linode (512 MiB памяти), но нагрузка на процессор не поднималась более 20—40 процентов, а использование оперативной памяти — около 30%. Причём ресурсы используют в основном gunicorn (веб-сервер, на котором работает Django) и PostgreSQL. Но тут нет абсолютно ничего удивительного, поскольку ни для кого не секрет, что Tornado достаточно лёгкий, чтобы не просто работать быстро, но даже справляться с C10k (о чём уже было на Хабрахабре).

Итак, мы будем использовать Django 1.4.2, Tornado 2.4, Redis 2.6.5 и PostgreSQL 9.2.1 (на самом деле вы можете использовать и другую реляционную СУБД — у Django есть много разных бэкэндов). Для подключения к Redis будет использоваться стандартный для Python клиент redis-py, а также brükva (асинхронный Redis-клиент для использования с Tornado). Для того, чтобы развернуть всё это на сервере, мы будем использовать haproxy и nginx, для запуска Django-проекта в production будем использовать веб-сервер gunicorn, а управлять запуском веб-серверов для Django и Tornado будет Supervisor.

Если вы чувствуете, что вам не хватает теоретических знаний (например, вы не вполне понимаете, что такое асинхронный неблокирующий веб-сервер, очередь сообщений и так далее), то рекомендую прежде всего прочитать соответствующую документацию, и, если останутся вопросы, то написать в комментариях (или мне по почте). Также рекомендую обратить внимание на несколько обсуждений по теме (1, 2, 3, 4, 5, 6, 7) на прекрасном форуме Python-гуру Ивана Сагалаева, а также на слайды доклада «Связываем синхронный фреймворк с асинхронным (на примере django)», который ещё один Python-гуру Михаил Коробов читал на DevConf 2011.

Исходники рассматриваемого решения присутствуют как в этой статье, так и на GitHub.

Настройка Django


Создаём Django-проект и переходим в появившуюся директорию:

django-admin.py startproject myproject
cd myproject/

Теперь отредактируем файл myproject/settings.py.

По-первых, очень хорошей идеей будет сразу переключиться на использование Redis-бэкэнда для хранения информации о сессиях пользователей. Это стоит делать во всех проектах (кроме тех, где вы по какой-то причине не можете использовать Redis, а также тех, где просто не планируется много пользователей). Для этого установите django-redis-sessions (pip install django-redis-sessions) и просто добавьте в настройках:

SESSION_ENGINE = 'redis_sessions.session'

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

Кстати, такие настройки лучше всего сохранять в отдельном файле, который вы добавляете в gitignore — обычно это local_settings.py.

Туда же вы можете перенести секретный ключ проекта, например (SECRET_KEY), а также настройки базы данных. Смысл тут в том, что во-первых, вы можете сохранять в local_settings.py настройки, специфические для конкретного сервера (база данных, бэкэнд кэширования, бэкэнд сессий, настройки режима отладки), а во-вторых Git (или другая система контроля версий) не будет хранить информацию о ключах и паролях.

Соответственно, чтобы добавлять такие настройки в local_settings.py, вам достаточно просто дописать в конце settings.py следующее:

try:
    from local_settings import *
except ImportError:
    pass

Далее не забудьте настроить базу данных (DATABASES), указать директорию шаблонов, директорию статических файлов и ключ API (Tornado при получении нового сообщения от пользователя будет асинхронно отправлять запрос к Django и Django будет сохранять это сообщение в базе данных), а также URL, по которому нужно отправлять запросы.

Для того, чтобы при развёртывании сайта на другом сервере (или при размещении проекта в другом месте в ФС) не нужно было менять путь к директориям шаблонов и статических файлов, лучше всего определять местонахождение проекта при запуске, добавив сверху в настройках следующее:

import os

PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))

А далее, соответственно, получать другие пути на основе PROJECT_ROOT.

Статические файлы:

STATICFILES_DIRS = (
    os.path.join(PROJECT_ROOT, "static"),
)

Шаблоны:

TEMPLATE_DIRS = (
    os.path.join(PROJECT_ROOT, "templates"),
)

Ключ API и адрес:

API_KEY = '$0m3-U/\/1qu3-K3Y'

SEND_MESSAGE_API_URL = 'http://127.0.0.1:8000/messages/send_message_api'

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

< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c${1:-32};echo;

Теперь остаётся создать в директории myproject (где находятся settings.py и local_settings.py) каталоги static и templates и приступить к написанию приложения-чата.

Чат (Django)


Добавим новое приложение:

python manage.py startapp privatemessages

И напишем модели (privatemessages/models.py):

from django.db import models
from django.db.models.signals import post_save

from django.contrib.auth.models import User

# Create your models here.

class Thread(models.Model):
    participants = models.ManyToManyField(User)
    last_message = models.DateTimeField(null=True, blank=True, db_index=True)

class Message(models.Model):
    text = models.TextField()
    sender = models.ForeignKey(User)
    thread = models.ForeignKey(Thread)
    datetime = models.DateTimeField(auto_now_add=True, db_index=True)

def update_last_message_datetime(sender, instance, created, **kwargs):
    """
    Update Thread's last_message field when
    a new message is sent.
    """
    if not created:
        return

    Thread.objects.filter(id=instance.thread.id).update(
        last_message=instance.datetime
    )

post_save.connect(update_last_message_datetime, sender=Message)

Тут ничего сложного — есть сообщения и треды (нити). Для каждых двух пользователей будет создаваться свой тред, и все их сообщения будут относиться к этому треду. Когда создаётся новое сообщение, то дата и время последнего сообщения для соответствующего треда обновляются.

Теперь самое время добавить приложение в INSTALLED_APPS в settings.py и синхронизировать модели с базой данных:

python manage.py syncdb

Давайте откроем privatemessages/views.py и напишем 4 представления:
  • send_message_view — для отправки сообщения через Django (например, если это первое сообщение между двумя людьми)
  • send_message_api_view — для отправки сообщений через Tornado
  • messages_view — для просмотра списка собеседников (с сортировкой по убыванию даты и времени последнего сообщения)
  • chat_view — для чата (Django будет просто возвращать страницу с теми сообщениями, которые уже есть в базе данных, а дальше бразуер будет подключаться к Tornado-серверу через WS и получать/отправлять новые сообщения в реальном времени, без перезагрузки страницы и без дополнительных HTTP-запросов)

# Create your views here.

import json

import redis

from django.shortcuts import render_to_response, get_object_or_404
from django.http import HttpResponse, HttpResponseRedirect
from django.template import RequestContext
from django.core.urlresolvers import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings

from django.contrib.auth.models import User

from privatemessages.models import Thread, Message

from privatemessages.utils import json_response, send_message

def send_message_view(request):
    if not request.method == "POST":
        return HttpResponse("Please use POST.")

    if not request.user.is_authenticated():
        return HttpResponse("Please sign in.")

    message_text = request.POST.get("message")

    if not message_text:
        return HttpResponse("No message found.")

    if len(message_text) > 10000:
        return HttpResponse("The message is too long.")

    recipient_name = request.POST.get("recipient_name")

    try:
        recipient = User.objects.get(username=recipient_name)
    except User.DoesNotExist:
        return HttpResponse("No such user.")

    if recipient == request.user:
        return HttpResponse("You cannot send messages to yourself.")

    thread_queryset = Thread.objects.filter(
        participants=recipient
    ).filter(
        participants=request.user
    )

    if thread_queryset.exists():
        thread = thread_queryset[0]
    else:
        thread = Thread.objects.create()
        thread.participants.add(request.user, recipient)

    send_message(
                    thread.id,
                    request.user.id,
                    message_text,
                    request.user.username
                )

    return HttpResponseRedirect(
        reverse('privatemessages.views.messages_view')
    )

@csrf_exempt
def send_message_api_view(request, thread_id):
    if not request.method == "POST":
        return json_response({"error": "Please use POST."})

    api_key = request.POST.get("api_key")

    if api_key != settings.API_KEY:
        return json_response({"error": "Please pass a correct API key."})

    try:
        thread = Thread.objects.get(id=thread_id)
    except Thread.DoesNotExist:
        return json_response({"error": "No such thread."})

    try:
        sender = User.objects.get(id=request.POST.get("sender_id"))
    except User.DoesNotExist:
        return json_response({"error": "No such user."})

    message_text = request.POST.get("message")

    if not message_text:
        return json_response({"error": "No message found."})

    if len(message_text) > 10000:
        return json_response({"error": "The message is too long."})

    send_message(
                    thread.id,
                    sender.id,
                    message_text
                )

    return json_response({"status": "ok"})

def messages_view(request):
    if not request.user.is_authenticated():
        return HttpResponse("Please sign in.")

    threads = Thread.objects.filter(
        participants=request.user
    ).order_by("-last_message")

    if not threads:
        return render_to_response('private_messages.html',
                                  {},
                                  context_instance=RequestContext(request))

    r = redis.StrictRedis()

    user_id = str(request.user.id)

    for thread in threads:
        thread.partner = thread.participants.exclude(id=request.user.id)[0]

        thread.total_messages = r.hget(
            "".join(["thread_", str(thread.id), "_messages"]),
            "total_messages"
        )

    return render_to_response('private_messages.html',
                              {
                                  "threads": threads,
                              },
                              context_instance=RequestContext(request))

def chat_view(request, thread_id):
    if not request.user.is_authenticated():
        return HttpResponse("Please sign in.")

    thread = get_object_or_404(
        Thread,
        id=thread_id,
        participants__id=request.user.id
    )

    messages = thread.message_set.order_by("-datetime")[:100]

    user_id = str(request.user.id)

    r = redis.StrictRedis()

    messages_total = r.hget(
        "".join(["thread_", thread_id, "_messages"]),
        "total_messages"
    )

    messages_sent = r.hget(
        "".join(["thread_", thread_id, "_messages"]),
        "".join(["from_", user_id])
    )

    if messages_total:
        messages_total = int(messages_total)
    else:
        messages_total = 0

    if messages_sent:
        messages_sent = int(messages_sent)
    else:
        messages_sent = 0

    messages_received = messages_total-messages_sent

    partner = thread.participants.exclude(id=request.user.id)[0]

    tz = request.COOKIES.get("timezone")
    if tz:
        timezone.activate(tz)

    return render_to_response('chat.html',
                              {
                                  "thread_id": thread_id,
                                  "thread_messages": messages,
                                  "messages_total": messages_total,
                                  "messages_sent": messages_sent,
                                  "messages_received": messages_received,
                                  "partner": partner,
                              },
                              context_instance=RequestContext(request))

И вот privatemessages/utils.py:

import json

import redis

from django.utils import dateformat

from privatemessages.models import Message

def json_response(obj):
    """
    This function takes a Python object (a dictionary or a list)
    as an argument and returns an HttpResponse object containing
    the data from the object exported into the JSON format.
    """
    return HttpResponse(json.dumps(obj), content_type="application/json")

def send_message(thread_id,
                 sender_id,
                 message_text,
                 sender_name=None):
    """
    This function takes Thread object id (first argument),
    sender id (second argument), message text (third argument)
    and can also take sender's name.

    It creates a new Message object and increases the
    values stored in Redis that represent the total number
    of messages for the thread and the number of this thread's
    messages sent from this specific user.

    If a sender's name is passed, it also publishes
    the message in the thread's channel in Redis
    (otherwise it is assumed that the message was
    already published in the channel).
    """

    message = Message()
    message.text = message_text
    message.thread_id = thread_id
    message.sender_id = sender_id
    message.save()

    thread_id = str(thread_id)
    sender_id = str(sender_id)

    r = redis.StrictRedis()

    if sender_name:
        r.publish("".join(["thread_", thread_id, "_messages"]), json.dumps({
            "timestamp": dateformat.format(message.datetime, 'U'),
            "sender": sender_name,
            "text": message_text,
        }))

    for key in ("total_messages", "".join(["from_", sender_id])):
        r.hincrby(
            "".join(["thread_", thread_id, "_messages"]),
            key,
            1
        )

О том, что такое tz.activate(), можно почитать в документации Django по работе с часовыми поясами. Поскольку браузеры не предоставляют информацию о часовом поясе (в отличие от информации о языке, например), нам необходимо самостоятельно создавать на клиентской стороне cookie с названием timezone — таким образом Django сможет отобразить дату и время каждого сообщения в том часовом поясе, в котором на данный момент находится пользователь.

Для того, чтобы вычислить часовой пояс на клиентской стороне, мы будем использовать библиотеку jstz (не забудьте разместить jstz.min.js в директории myproject/static). Есть и другие решения, позволяющие определить часовой пояс пользователя: например можно брать наиболее вероятный часовой пояс на основе базы GeoIP или даже сравнивать локальное время на компьютере пользователя с локальным временем на сервере (поскольку нам известно. что на сервере время установлено правильно) — такое решение позволяет узнать фактический часовой пояс пользователя в том случае, если у него в системе выбран какой-то совершенно другой часовой пояс, но время было вручную переведено для соответствия поясному.

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

Также обратите внимание, что вам потребуется установить pytz (pip install pytz) — это нужно для того, чтобы получать часовой пояс из строки в формате базы данных Олсона (IANA time zone database).

Когда функция send_messages добавляет в базу данных новое сообщение, она также обновляет hash с количеством сообщений для данного треда в Redis. В этом hash инкрементируются два ключа — один из них представляет общее количество сообщений в треде, а другой представляет количество сообщений от данного пользователя. Эти ключи используются при отображении общего количества сообщений, а также количества принятых и отправленных сообщений (количество принятых — это просто общее количество минус количество отправленных).

Когда пользователь открывает чат и его браузер открывает WS-соединение с Tornado-сервером, Tornado-сервер подписывается на канал треда в Redis, и при появлении новых сообщений тут же отправляет их пользователю.

Если вы не знакомы с реализацией Pub/Sub в Redis (команды SUBSCRIBE, UNSUBSCRIBE и PUBLISH), то вы можете прочитать о ней в документации по Redis.

Когда функцию send_messages вызывает send_message_view и передаёт, в частности, имя отправителя, функция публикует сообщение на канале данного треда в Redis. В случае с send_message_api_view имя отправителя не передаётся, и предполагается, что сообщение уже было отправлено на канал данного треда, ещё до вызова send_message_api_view (так сделано для того, чтобы сообщения максимально быстро передавались между пользователями, общающимися в чате — сохранение в базу данных происходит асинхронно, уже после публикации на канале).

Теперь создадим файл privatemessages/urls.py и зададим urlpatterns для Django-методов:

from django.conf.urls import patterns, url

urlpatterns = patterns('privatemessages.views',
    url(r'^send_message/$', 'send_message_view'),
    url(r'^send_message_api/(?P<thread_id>\d+)/$', 'send_message_api_view'),
    url(r'^chat/(?P<thread_id>\d+)/$', 'chat_view'),
    url(r'^$', 'messages_view'),
)

И, конечно, включим их в root URLconf (myproject/urls.py):

from django.conf.urls import patterns, include, url

# something else

urlpatterns = patterns('',
    # something else

    url(r'^messages/', include('privatemessages.urls')),

    # something else
)

В директории с шаблонами нужно разместить chat.html и private_messages.html. Также добавим базовый шаблон base.html.

base.html

<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" type="text/css" href="/static/privatemessages.css">
<script type="text/javascript" src="http://yandex.st/jquery/1.8.3/jquery.min.js"></script>
<script type="text/javascript" src="/static/jstz.min.js"></script>
<script type="text/javascript" src="/static/privatemessages.js"></script>
{% block head %}{% endblock %}
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{% block title %}Личные сообщения{% endblock title %}</title>
</head>
<body>
{% block content %}{% endblock content %}
</body>
</html>

chat.html

{% extends "base.html" %}

{% block title %}{{ partner.username }}{% endblock %}

{% block head %}
<script type="text/javascript">
$(document).ready(function() {
    activate_chat({{ thread_id }}, "{{ user.username }}", {
        "total": {{ messages_total }},
        "sent": {{ messages_sent }},
        "received": {{ messages_received }}
    });
});
</script>
{% endblock %}

{% block content %}
{% load pluralize %}
<div class="chat">
    <div class="partner">
        <p class="name">{{ partner.username }}</p>
        <p class="messages"><span class="total">{{ messages_total }}</span> {{ messages_total|rupluralize:"сообщение,сообщения,сообщений" }} (<span class="received">{{ messages_received }}</span> получено, <span class="sent">{{ messages_sent }}</span> отправлено)</p>
    </div>
    <div class="conversation">
        {% for message in thread_messages reversed %}
        <div class="message"> 
            {% if message.sender == user %}<p class="author we"><span class="datetime">{{ message.datetime|date:"d.m.Y H:i:s" }}</span> {{ user.username }}:</p>{% else %}<p class="author partner"><span class="datetime">{{ message.datetime|date:"d.m.Y H:i:s" }}</span> {{ partner.username }}:</p>{% endif %}
            <p class="message">{{ message.text|linebreaksbr }}</p>
        </div>
        {% endfor %}
    </div>
    <form class="message_form">
        <div class="compose">
            <textarea rows="1" cols="30" id="message_textarea"></textarea>
        </div>
        <div class="send">
            <button class="btn" type="button">Отправить</button>
            <p>Вы также можете отправлять сообщения с помощью клавиш Ctrl + Enter.</p>
        </div>
    </form>
</div>
{% endblock content %}

private_messages.html

{% extends "base.html" %}

{% block content %}
{% load pluralize %}
<div class="private_messages">
    <h1>Собеседники</h1>
    <div class="partners">
    {% for thread in threads %}
        <p><a href="{% url privatemessages.views.chat_view thread.id %}">{{ thread.partner.username }} ({{ thread.total_messages|default_if_none:"0" }} {{ thread.total_messages|rupluralize:"сообщение,сообщения,сообщений" }})</a></p>
    {% empty %}
        <p>Пока что собеседников нет.</p>
    {% endfor %}
    </div>
    <h1>Отправить сообщение</h1>
    <form action="{% url privatemessages.views.send_message_view %}" method="post" class="new_message">
        {% csrf_token %}
        <p class="name"><input name="recipient_name" placeholder="Имя получателя"></p>
        <p><textarea name="message" placeholder="Сообщение"></textarea></p>
        <p><input type="submit" value="Отправить"></p>
    </form>
</div>
{% endblock content %}

И ещё создадим каталог privatemessages/templatetags с файлами __init__.py и pluralize.py.

privatemessages/templatetags/pluralize.py (автор фильтра — V@s3K):

from django import template

register = template.Library()

@register.filter
def rupluralize(value, arg):
    args = arg.split(",")
    try:
        number = abs(int(value))
    except TypeError:
        number = 0

    a = number % 10
    b = number % 100

    if (a == 1) and (b != 11):
        return args[0]
    elif (a >= 2) and (a <= 4) and ((b < 10) or (b >= 20)):
        return args[1]
    else:
        return args[2]

Добавим стили (myproject/static/privatemessages.css):

html, body {
    height: 100%;
    margin: 0;
}

body {
    font-family: Geneva, Arial, Helvetica, sans-serif;
    font-size: 14px;
    color: #000;
    background: #fff;
}

textarea, form p.name input {
    border: 1px #d4d4d4 solid;
}

form.new_message p {
    margin: 4px 0;
}

form.new_message p.name input, form.new_message textarea {
    width: 300px;
}

form.new_message textarea {
    height: 100px;
}

textarea:focus, input.name:focus {
    border-color: #cacaca;
    -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 3px rgba(150, 150, 150, 0.5);
       -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 3px rgba(150, 150, 150, 0.5);
            box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 3px rgba(150, 150, 150, 0.5);
}

div.private_messages {
    padding: 10px;
}

div.private_messages h1 {
    margin-top: 0;
}

div.private_messages div.partners {
    margin-bottom: 30px;
}

div.chat {
    height: 100%;
    min-height: 400px;
    min-width: 600px;
    position: relative;
    background-color: #e0e0e0;
}

div.chat div.partner {
    height: 50px;
    padding: 5px;
}

div.chat div.partner p {
    margin: 0;
}

div.chat div.partner p.name {
    font-weight: bold;
    margin-bottom: 3px;
}

div.chat div.conversation {
    position: absolute;
    top: 50px;
    left: 5px;
    right: 5px;
    bottom: 140px;
    overflow: auto;
    padding: 0 5px;
    border-style: solid;
    border-color: #eee;
    border-width: 10px 0;
    background-color: #fff;
    -webkit-border-radius: 7px;
       -moz-border-radius: 7px;
            border-radius: 7px;
}

div.chat div.conversation div.message {
    padding: 5px 0;
}

div.chat div.conversation div.message p {
    margin: 0;
}

div.chat div.conversation div.message p.author.partner {
    color: #002c64;
}

div.chat div.conversation div.message p.author.we {
    color: #216300;
}

div.chat div.conversation div.message p.author span.datetime {
    font-size: 12px;
}

div.chat form.message_form {
    position: absolute;
    margin: 0;
    left: 0;
    right: 0;
    bottom: 5px;
    height: 130px;
}

div.chat form.message_form div.outdated_browser_message {
    margin: 10px 5px;
}

div.chat form.message_form div.compose {
    float: left;
    height: 100%;
    width: 80%;
    padding: 0 5px;
    -webkit-box-sizing: border-box;
       -moz-box-sizing: border-box;
            box-sizing: border-box;
}

div.chat form.message_form div.compose textarea {
    width: 100%;
    height: 100%;
    margin: 0;
    -webkit-box-sizing: border-box;
       -moz-box-sizing: border-box;
            box-sizing: border-box;
    resize: none;
}

div.chat form.message_form div.send {
    float: left;
    height: 100%;
    width: 20%;
    min-width: 100px;
    padding-right: 5px;
    -webkit-box-sizing: border-box;
       -moz-box-sizing: border-box;
            box-sizing: border-box;
}

div.chat form.message_form div.send p {
    margin-top: 5px;
    color: #333;
}

div.chat form.message_form div.send button {
    width: 100%;
    -webkit-box-sizing: border-box;
       -moz-box-sizing: border-box;
            box-sizing: border-box;
}

И JavaScript (myproject/static/privatemessages.js):

function getCookie(name) {
    var cookieValue = null;
    if (document.cookie && document.cookie != '') {
        var cookies = document.cookie.split(';');
        for (var i = 0; i < cookies.length; i++) {
            var cookie = jQuery.trim(cookies[i]);
            // Does this cookie string begin with the name we want?
            if (cookie.substring(0, name.length + 1) == (name + '=')) {
                cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                break;
            }
        }
    }
    return cookieValue;
}

function setCookie(key, value) {
    document.cookie = escape(key) + '=' + escape(value);
}

function getNumEnding(iNumber, aEndings) {
    var sEnding, i;
    iNumber = iNumber % 100;
    if (iNumber>=11 && iNumber<=19) {
        sEnding=aEndings[2];
    }
    else {
        i = iNumber % 10;
        switch (i)
        {
            case (1): sEnding = aEndings[0]; break;
            case (2):
            case (3):
            case (4): sEnding = aEndings[1]; break;
            default: sEnding = aEndings[2];
        }
    }
    return sEnding;
}

var timezone = getCookie('timezone');

if (timezone == null) {
    setCookie("timezone", jstz.determine().name());
}

function activate_chat(thread_id, user_name, number_of_messages) {
    $("div.chat form.message_form div.compose textarea").focus();

    function scroll_chat_window() {
        $("div.chat div.conversation").scrollTop($("div.chat div.conversation")[0].scrollHeight);
    }

    scroll_chat_window();

    var ws;

    function start_chat_ws() {
        ws = new WebSocket("ws://127.0.0.1:8888/" + thread_id + "/");
        ws.onmessage = function(event) {
            var message_data = JSON.parse(event.data);
            var date = new Date(message_data.timestamp*1000);
            var time = $.map([date.getHours(), date.getMinutes(), date.getSeconds()], function(val, i) {
                return (val < 10) ? '0' + val : val;
            });
            $("div.chat div.conversation").append('<div class="message"><p class="author ' + ((message_data.sender == user_name) ? 'we' : 'partner') + '"><span class="datetime">' + time[0] + ':' + time[1] + ':' + time[2] + '</span> ' + message_data.sender + ':</p><p class="message">' + message_data.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g, '<br />') + '</p></div>');
            scroll_chat_window();
            number_of_messages["total"]++;
            if (message_data.sender == user_name) {
                number_of_messages["sent"]++;
            } else {
                number_of_messages["received"]++;
            }
            $("div.chat p.messages").html('<span class="total">' + number_of_messages["total"] + '</span> ' + getNumEnding(number_of_messages["total"], ["сообщение", "сообщения", "сообщений"]) + ' (<span class="received">' + number_of_messages["received"] + '</span> получено, <span class="sent">' + number_of_messages["sent"] + '</span> отправлено)');
        }
        ws.onclose = function(){
            // Try to reconnect in 5 seconds
            setTimeout(function() {start_chat_ws()}, 5000);
        };
    }

    if ("WebSocket" in window) {
        start_chat_ws();
    } else {
        $("form.message_form").html('<div class="outdated_browser_message"><p><em>Ой!</em> Вы используете устаревший браузер. Пожалуйста, установите любой из современных:</p><ul><li>Для <em>Android</em>: <a href="http://www.mozilla.org/ru/mobile/">Firefox</a>, <a href="http://www.google.com/intl/en/chrome/browser/mobile/android.html">Google Chrome</a>, <a href="https://play.google.com/store/apps/details?id=com.opera.browser">Opera Mobile</a></li><li>Для <em>Linux</em>, <em>Mac OS X</em> и <em>Windows</em>: <a href="http://www.mozilla.org/ru/firefox/fx/">Firefox</a>, <a href="https://www.google.com/intl/ru/chrome/browser/">Google Chrome</a>, <a href="http://ru.opera.com/browser/download/">Opera</a></li></ul></div>');
        return false;
    }

    function send_message() {
        var textarea = $("textarea#message_textarea");
        if (textarea.val() == "") {
            return false;
        }
        if (ws.readyState != WebSocket.OPEN) {
            return false;
        }
        ws.send(textarea.val());
        textarea.val("");
    }

    $("form.message_form div.send button").click(send_message);

    $("textarea#message_textarea").keydown(function (e) {
        // Ctrl + Enter
        if (e.ctrlKey && e.keyCode == 13) {
            send_message();
        }
    });
}

При развёртывании на сервере не забудьте указать адрес, по которому будет доступен Tornado-сервер (или вообще вынесите в отдельную переменную).

Чат (Tornado)


Напишем новое Tornado-приложение и сохраним в privatemessages/tornadoapp.py:

import datetime
import json
import time
import urllib

import brukva
import tornado.web
import tornado.websocket
import tornado.ioloop
import tornado.httpclient

from django.conf import settings
from django.utils.importlib import import_module

session_engine = import_module(settings.SESSION_ENGINE)

from django.contrib.auth.models import User

from privatemessages.models import Thread

c = brukva.Client()
c.connect()

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.set_header('Content-Type', 'text/plain')
        self.write('Hello. :)')

class MessagesHandler(tornado.websocket.WebSocketHandler):
    def __init__(self, *args, **kwargs):
        super(MessagesHandler, self).__init__(*args, **kwargs)
        self.client = brukva.Client()
        self.client.connect()

    def open(self, thread_id):
        session_key = self.get_cookie(settings.SESSION_COOKIE_NAME)
        session = session_engine.SessionStore(session_key)
        try:
            self.user_id = session["_auth_user_id"]
            self.sender_name = User.objects.get(id=self.user_id).username
        except (KeyError, User.DoesNotExist):
            self.close()
            return
        if not Thread.objects.filter(
            id=thread_id,
            participants__id=self.user_id
        ).exists():
            self.close()
            return
        self.channel = "".join(['thread_', thread_id,'_messages'])
        self.client.subscribe(self.channel)
        self.thread_id = thread_id
        self.client.listen(self.show_new_message)

    def handle_request(self, response):
        pass

    def on_message(self, message):
        if not message:
            return
        if len(message) > 10000:
            return
        c.publish(self.channel, json.dumps({
            "timestamp": int(time.time()),
            "sender": self.sender_name,
            "text": message,
        }))
        http_client = tornado.httpclient.AsyncHTTPClient()
        request = tornado.httpclient.HTTPRequest(
            "".join([
                        settings.SEND_MESSAGE_API_URL,
                        "/",
                        self.thread_id,
                        "/"
                    ]),
            method="POST",
            body=urllib.urlencode({
                "message": message.encode("utf-8"),
                "api_key": settings.API_KEY,
                "sender_id": self.user_id,
            })
        )
        http_client.fetch(request, self.handle_request)

    def show_new_message(self, result):
        self.write_message(str(result.body))

    def on_close(self):
        try:
            self.client.unsubscribe(self.channel)
        except AttributeError:
            pass
        def check():
            if self.client.connection.in_progress:
                tornado.ioloop.IOLoop.instance().add_timeout(
                    datetime.timedelta(0.00001),
                    check
                )
            else:
                self.client.disconnect()
        tornado.ioloop.IOLoop.instance().add_timeout(
            datetime.timedelta(0.00001),
            check
        )

application = tornado.web.Application([
    (r"/", MainHandler),
    (r'/(?P<thread_id>\d+)/', MessagesHandler),
])

Почти всё в этом приложении выполняется асинхронно, с использованием tornado.ioloop. Исключение — это авторизация пользователя по идентификатору сессии (и получение его имени из базы данных). Это блокирующая операция, то есть в этот момент другие пользователи, подключённые к этому Tornado-серверу, будут ждать. Если вам потребуется, то это легко изменить, но, во-первых, не факт, что потребуется, а во-вторых, подумайте, правильное ли это решение. По этому поводу ребята из Friendfeed (разработчики Tornado) высказывались не раз. Вот, например, цитата из сообщения Бэна Дарнэлла (Ben Darnell):

Friendfeed uses mysql, with the standard synchronous MySQLdb module (http://bret.appspot.com/entry/how-friendfeed-uses-mysql). Friendfeed's philosophy is that for things that are fast and under your control (i.e. memcache and database — and if your database isn't fast, rethink how you're querying it), you should just call them synchronously because it's not worth the complexity of managing callbacks. Asynchronous code is for things that are likely to be slow in ways you can't do anything about (e.g. external APIs) or when you want to suspend a request for an indefinite amount of time (long polling). A library like adisp shifts the balance somewhat since it makes async interfaces much simpler to use, although there are still issues like the fact that generator coroutines aren't composable like normal function calls.

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

Запускать Tornado-приложение очень удобно через management-команду. В этом случае вам не потребуется конфигурировать рабочую среду Django для доступа к ORM и всему остальному.

Сделать свою management-команду очень легко. Создайте в каталоге privatemessages директорию management, а в ней директорию commands.

Обязательно добавьте в privatemessages/management и в privatemessages/management/commands по файлу __init__.py (если кто-то вдруг не знает, зачем они используются, то посмотрите в документации).

Теперь создадим файл privatemessages/management/commands/starttornadoapp.py:

import signal
import time

import tornado.httpserver
import tornado.ioloop

from django.core.management.base import BaseCommand, CommandError

from privatemessages.tornadoapp import application

class Command(BaseCommand):
    args = '[port_number]'
    help = 'Starts the Tornado application for message handling.'

    def sig_handler(self, sig, frame):
        """Catch signal and init callback"""
        tornado.ioloop.IOLoop.instance().add_callback(self.shutdown)

    def shutdown(self):
        """Stop server and add callback to stop i/o loop"""
        self.http_server.stop()

        io_loop = tornado.ioloop.IOLoop.instance()
        io_loop.add_timeout(time.time() + 2, io_loop.stop)

    def handle(self, *args, **options):
        if len(args) == 1:
            try:
                port = int(args[0])
            except ValueError:
                raise CommandError('Invalid port number specified')
        else:
            port = 8888

        self.http_server = tornado.httpserver.HTTPServer(application)
        self.http_server.listen(port, address="127.0.0.1")

        # Init signals handler
        signal.signal(signal.SIGTERM, self.sig_handler)

        # This will also catch KeyboardInterrupt exception
        signal.signal(signal.SIGINT, self.sig_handler)

        tornado.ioloop.IOLoop.instance().start()

Если вам требуется, чтобы при завершении процесса через сигналы (например, через сигнал SIGINT, который процесс получает при завершении через Ctrl + C) происходило что-либо ещё, это можно задать в соответствующем обработчике. Почитать немного дополнительной информации на эту тему можно здесь.

Проверяем


Теперь перейдите в корневую директорию проекта (в которой находятся директории myproject и privatemessages, а также файл manage.py) и запустите development-сервер Django и сервер Tornado.

python manage.py runserver
python manage.py starttornadoapp

Вы можете запустить эти команды в разных вкладках эмулятора терминала, либо в разных вкладках screen (если, например, вы подключаетесь к серверу по SSH и вам неудобно открывать ещё одну SSH-сессию).

Теперь на порту 8000 у вас запущен сервер разработки Django, а на порту 8888 запущен сервер Tornado.

Список собеседников вы можете посмотреть на следующей странице:

http://127.0.0.1:8000/messages/

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

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

Deployment


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

  • haproxy — балансировщик нагрузки, будет работать на порту 80
  • nginx — раздача статических файлов + реверс-прокси для gunicorn, будет работать на порту 8100
  • Supervisor — запуск сервера Tornado на портах 8000—8003 (4 процесса) и сервера Django (можно использовать gunicorn) на порту 8150

Прежде всего настроим Supervisor. Для этого (на примере Supervisor из репозиториев Ubuntu) создадим в /etc/supervisor/conf.d файлы django.conf и tornadoapp.conf. Убедитесь, что Supervisor будет читать эти файлы (в основном конфигурационном файле должна присутствовать секция include со строкой files = /etc/supervisor/conf.d/*.conf).

django.conf

[program:django]
command=gunicorn_django --workers 4 -b 127.0.0.1:8150
directory=/home/yourusername/myproject
user=yourusername
autostart=true
autorestart=true

tornadoapp.conf

process_name = tornado-%(process_num)s
user = yourusername
directory = /home/yourusername/myproject
command = python manage.py starttornadoapp %(process_num)s
# Increase numprocs to run multiple processes on different ports.
# Note that the chat demo won't actually work in that configuration
# because it assumes all listeners are in one process.
numprocs = 4
numprocs_start = 8000
autostart=true
autorestart=true

Теперь запустите supervisor и посмотрите вывод команды supervisorctl status.

Убедитесь, что у всех 5 процессов (gunicorn будет отображаться как один процесс) выводится состояние RUNNING и uptime совпадает с тем, сколько времени прошло с момента запуска Supervisor.

Также я рекомендую убедиться в том, что и Django-серверы, и Tornado-серверы работают. Для этого мы можете открыть SSH-туннель до сервера и просто попробовать открыть ваш сайт в браузере, обращаясь при этом к выбранному порту на локальном адресе.

То, есть, например:

ssh -L 8000:localhost:8150 someverycoolserver.com

После чего просто открываете в браузере 127.0.0.1:8000 (SSH-сервер передаст ваш запрос на 127.0.0.1:8150 в своей сети — где запущен веб-сервер, который вы хотите проверить).

Аналогичным образом вы можете проверить Tornado-серверы. Они находятся на портах 8000—8003.

Если серверы Django и Tornado работают, то вам остаётся только настроить nginx и haproxy.

Пример конфигурационного файла для nginx:

server {
    listen 127.0.0.1:8100;
    server_name someverycoolserver.com;

    # no security problem here, since / is always passed to upstream
    root /home/yourusername/myproject/myproject/static/;

    ## Compression
    # src: http://www.ruby-forum.com/topic/141251
    # src: http://wiki.brightbox.co.uk/docs:nginx

    gzip on;
    gzip_http_version 1.0;
    gzip_comp_level 2;
    gzip_proxied any;
    gzip_min_length  1100;
    gzip_buffers 16 8k;
    gzip_types text/plain text/html text/css application/x-javascript text/xml application/xml application/xml+rss text/javascript;
    
    # Some version of IE 6 don't handle compression well on some mime-types, so just disable for them
    gzip_disable "MSIE [1-6].(?!.*SV1)";
    
    # Set a vary header so downstream proxies don't send cached gzipped content to IE6
    gzip_vary on;
    ## /Compression

    location /static/admin/ {
        # this changes depending on your python version
        root /usr/local/lib/python2.7/dist-packages/django/contrib/admin/;
    }

    location /robots.txt {
        alias /home/yourusername/myproject/myproject/robots.txt;
    }

    location /favicon.ico {
        alias /home/yourusername/myproject/myproject/img/favicon.ico;
        expires 3d;
    }

    location /static/ {
        root /home/yourusername/myproject/myproject/;
        expires 3d;
    }

    location / {
        proxy_pass_header Server;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Scheme $scheme;
        proxy_connect_timeout 10;
        proxy_read_timeout 10;
        proxy_pass http://localhost:8150/;
    }
}

Обратите внимание, что тут выбрано кэширование статических файлов в течение трёх дней. Делать так стоит либо в том случае, если вы точно знаете, что ничего не будете там менять (а обычно менять всё-таки нужно), либо если вы для всех статических файлов используете так называемую технику static files versioning (обычно это означает, что при запросе статических файлов к их адресу добавляется query string, и если серверу известно, что файл изменился на диске, то query string меняется). В частности, это отлично подходит, если вы используете автоматическое объединение и мимификацию (минификацию) CSS и JS — например, django-assets.

Пример конфигурационного файла haproxy:

global
    maxconn 10000 # Total Max Connections. This is dependent on ulimit
    nbproc 2

defaults
    mode http
    option redispatch
    maxconn 2000
    contimeout 5000
    clitimeout 50000
    srvtimeout 50000
    option httpclose

frontend all 0.0.0.0:80
    timeout client 86400000

    acl is_chat hdr_beg(host) -i chat
    use_backend socket_backend if is_chat

    default_backend www_backend

backend www_backend
    option forwardfor # This sets X-Forwarded-For
    timeout server 30000
    timeout connect 4000
    server server1 localhost:8100

backend socket_backend
    balance roundrobin
    option forwardfor # This sets X-Forwarded-For
    no option httpclose # To match the `Connection` header for the websocket protocol rev. 76
    option http-server-close
    option http-pretend-keepalive
    timeout queue 5000
    timeout server 86400000
    timeout connect 86400000
    server server1 localhost:8000 weight 1 maxconn 5000 check
    server server2 localhost:8001 weight 1 maxconn 5000 check
    server server2 localhost:8002 weight 1 maxconn 5000 check
    server server2 localhost:8003 weight 1 maxconn 5000 check

Здесь мы указываем, что хотим перенаправлять все запросы, Host в которых начинается с chat, на сервера localhost:8000, localhost:8001, localhost:8002 и localhost:8003, а все остальные запросы перенаправлять на localhost:8100.

После запуска haproxy будет работать на 80-м порту на всех имеющихся IPv4-адресах. Если вы хотите поддерживать IPv6 (а это отличная идея, если его поддерживает ваш хостинг/провайдер), то добавьте в секции frontend конфигурационный параметр bind с вашим IPv6-адресом и 80-м портом через двоеточие.

Имейте в виду, что у некоторых пользователей ваш IPv6-адрес может определяться (если, конечно, вы добавите AAAA-запись в DNS), но при этом не работать. Такое бывает в том случае, если системные администаторы их провайдера всё сделали неправильно. Варианты решения — отдельный поддомен для IPv6, либо так называемый DNS whitelisting. Так делает, например, Яндекс — потому что они с одной стороны любят и хотят поддерживать IPv6, но с другой стороны они не могут позволить себе терять 1—2 процента пользователей (для Яндекса это очень много).

Кстати, Tornado-сервер доступен на отдельном поддомене, а не на отдельном порту не просто так — в некоторых компаниях работают очень странные системные администраторы, которые попросту закрывают доступ к любым альтернативным портам (не 80 и не 443).

Вы также можете настроить haproxy таким образом, чтобы он выбирал backend не по заголовку Host, а по наличию заголовка Upgrade — тогда получится, что у вас и Django, и Tornado работают на одном и том же домене (вообще без поддоменов), но WS-соединения открываются с Tornado-серверами, а все остальные — с Django-серверами.

Как видите, в целом всё довольно просто. Чтобы разобраться в новой теме, достаточно читать документацию, а если в документации описано не всё, то читать исходники (в случае с Django и Tornado о многих вещах проще всего узнать именно из исходников), форумы, а также материалы с тематических докладов. Совершенствуйтесь и работайте над интересными проектами! Всем удачи и приятного программирования.

1 comment:

Unknown said...

Имхо, сложноватый зоопарк организован. Несколько вебсерверов, несколько серверов приложений. И все это только ради `function activate_chat`.

Ну, и лично я бы просто ограничился MongoDB и не городил бы SQL+Redis, ибо оно уже старо и вызывает ощущения PHP.