ЧАТ

Мне дали вот такую интересную задачку:

Реализовать клиент-серверное асинхронное приложение, реализующие многопользовательский чат с комнатами и приватными сообщениями. Каждый запрос от клиента или сервера должен валидироваться проверкой целостности сообщения. Описать протокол взаимодействия и схему запуска сервера. Код выложить на GitHub или Bitbucket в открытом репозитории и прислать ссылку на него.

Сказали, что плюсом будет, если я реализую с помощью Tornado, которым я еще ни разу не пользовался. Одновременно я писал диссертацию, и вот уже через несколько дней ее защищаю.

Я скачал на читалку документацию к Tornado, тут надо заметить, что я ожидал в Tornado лапшу из callbackов, но нет. Можно даже мост настроить с asyncio.

Итак, задача стоит следующая - нужно реализовать чат, причем каждый запрос должен быть проверен на целостность.

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

Плюсы:

  • Полнодуплесный (одновременная передача и прием)
  • Связь между веб-сервером и браузером. HTML5 дает гарантию кросплатформенности
  • Полностью асинхронен и симметричен
  • Во время передачи сообщений передаются именно сообщения, а не тонны HTTP-заголовков => Малый размер передаваемых данных, который иногда даже будет помещаться в один TCP-пакет
  • Защита от поддельных запросов
  • Гарантирует целостность передаваемых данных (есть поле контрольной суммы)

Благодаря последнему пункту не надо будет реализовывать проверку checksum или что-то еще.

Tornado поддерживает работу с Websocket - доки.

Tornado

Все достаточно просто - нам надо создать класс, наследуясь от WebSocketHandler, после чего переписать методы open() и on_message(). исходник

Что будет:

  • Общий чат. В него могут писать только зарегистрированные пользователи
  • Комнаты (создание и подписка)
  • Личные сообщения - с этим все ясно

Далее надо определиться, как вообще будем принимать и доставлять сообщения до клиентов. Веб-сокеты - это лишь транспортная часть.

Если создадим следующий атрибут класса:

connections = set()

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

Для поставленной задачи логичнее будет использовать шаблон проектирования Издатель-подписчик.

Впервые я познакомился с ним при использовании RabbitMQ, а именно в этом туториале.

Все бы хорошо, но pika до сих пор не поддерживает Python 3 (вышедший 3 декабря 2008). Есть форк, решающий эту проблему, есть аналогичная библиотека от Celery.

Но дело не в RabbitMQ. Данные о пользователе (айди, логин, пароль и тд) надо где-то хранить, иметь к ним быстрый доступ.

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

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

Redis

Я ни разу не пользовался Redis, но прочитав The Little Redis Book мне захотелось попробовать использовать его для вышепоставленных задач.

Почему:

  • Есть поддержка Pub/Sub
  • Redis достаточно быстрый. В презентации Amir Salihefendic говорится о 110 000 операций set в секунду и 81 000 get за то же время.
  • Удобная структура данных, по сравнению с Memcached (в последней только строки)

brew install redis

Теперь надо выбрать библиотеку, которая нам поможет в работе с Redis.

В интернетах часто упоминали brukva. Последний коммит в мастер был 4 года назад, код и правда хорошо написан, читается легко, но поддерживает только Python 2.

Потом мне пришла в голову идея настроить мост с asyncio и использовать asyncio_redis, тут соблазн вообще отказаться от Tornado стал слишком велик, да и смешно было бы использовать его, если есть websockets. Tornado - хороший молоток общего назначения.

В конце поисков был найден tornado-redis. Все, что нужно, есть внутри, даже третий Питон.

Что мне не понравилось, находится здесь.

Проще говоря, это список подписок, но мы хотим все хранить в Redis. В этом и минус - чем больше подписок, тем больше забиваетлся оперативная память самой машины, на которой исполняется код.

self.subscribed = set()

Этот атрибут дергается везде и очень часто. Например, тут. Это к тому, что под что-то большое придется переписать логику.

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

Безопасность

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

Однако, используются secure_cookie, защита от межсайтовой подделки запросов на странице авторизации + при отправке сообщений через веб-сокеты постоянно проверяется наличие кук и не поддельные ли они.

По-сути, сессии пишутся в Redis, чтобы можно было использовать несколько серверов.

Следует обратить внимание, что куки через веб-сокеты можно брать, только когда соединение зашифровано. Поэтому придется создать самоподписные сертификаты и скормить их Хрому. Сафари у меня почему-то так и не захотел(а) работать с ними - WebSocket network error: OSStatus Error -9807: Invalid certificate chain.

Сертификаты можно создать с помощью Open SSL:

openssl req -new -newkey rsa:2048 -nodes -out localhost.csr -keyout localhost.key -subj "/C=RU/ST=/L=/O=/CN=localhost"`

openssl x509 -req -days 365 -in localhost.csr -signkey localhost.key -out localhost.crt

Поэтому не забываем в браузере открыть именно https://127.0.0.1:8888

Также не предусмотрена защита от DDOS.

Регистрация/авторизация

У каждого пользователя должен быть id, который автоматически увеличивается и был бы уникальным. Для этого в Redis создадим ключ next_user_id и будем увеличивать его каждый раз, когда захотим создать нового юзера:

INCR next_user_id

Пароли в открытом виде хранить не будем. Воспользуемся солью, которую также сохраним в ключе user:id.

Данный ключ будет содержать значение hash, это как словарь в Python. Здесь будем использовать HMSET:

HMSET user:1 username lol password ... salt ... auth ...

Также надо создать ключ users на случай того, если нужно будет по имени вытащить id:

HSET users lol 1

Разница HSET и HMSET очевидна. В последнем мы можем указать сразу несколько ключей hash.

И для еще большего успокоения создадим еще один hash, который будет содержать в себе уникальный идентификатор, который потом будем использовать для куки (и дальнейшее определение пользователя). Своего рода сессии:

HSET auths auth new_user_id

Процесс авторизации очень простой:

  • Получаем имя пользователя и пароль
  • Проверяем, существует ли такой в users и если да, то у нас есть id
  • Проверяем хэш пароль/соль, если не совпадает, то генерим error
  • Выставляем значение куки равное полю auth из user:N

Когда пользователь переходит по /logout то:

  • Создаем новый auth_secret в user:N
  • Удаляем из auths старый auth_secret и создаем запись с указанным выше

Как описывалось раннее, при отправке сообщений через веб-сокеты, мы будем проверять, а реальный ли пользователь отправляет сообщения.

Это подразумевает собой непосредственное обращение к Redis посредством запроса в auths, который при удачном стечении обстоятельтв вернет нам id пользователя, а далее мы уже можем вытащить интересующую нас информацию из user:N.

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

Поэтому надо написать декоратор, который позволял бы сохранять информацию в self.current_user (кэшированная версия self.get_current_user()) асинхронно.

def authenticated_async(method):
    @gen.coroutine
    def wrapper(self, *args, **kwargs):
        self._auto_finish = False
        self.current_user = yield gen.Task(self.get_current_user_async)
        if not self.current_user:
            self.redirect(self.reverse_url('login'))
        else:
            result = method(self, *args, **kwargs)
            if result is not None:
                yield result

    return wrapper


class BaseClass():
    @gen.coroutine
    def get_current_user_async(self, ):  #callback
        "Нужно для того, чтобы могли асинхронно делать запросы"

        auth_cookie = native_str(self.get_secure_cookie(COOKIE_NAME,
                                                        max_age_days=3))
        if auth_cookie:  # если юзер вообще залогинен

            user_id = yield gen.Task(c.hget, 'auths', auth_cookie)

            user_auth = yield gen.Task(c.hget, 'user:%s' % user_id, 'auth')
            if auth_cookie != user_auth:
                return None
            else:
                user_name = yield gen.Task(c.hget, 'user:%s' % user_id,
                                           'username')

                return {'id': user_id, 'name': user_name}

        else:
            return None

Осталось отметить, что если логина юзера нет в Redis, то мы сразу регаем пользователя с указанным логином и паролем.

##О клиенте

Изначально мне хотелось скопировать дизайн TweetDeck. Колонки можно было использовать как комнаты, общий чат в самой первой колонке и личные сообщения в последней.

Проблема в том, что нужно писать много кода на js, основной функционал реализован - по крайней мере можно читать консоль. Но на код javascript лучше не смотреть.

На клиенте мы проверяем только количество символов в сообщении. Ни о каких полях required, ни о reCaptcha и многом другом в задании не было сказано. Не проверяется даже форма авторизации.

Сервер полностью доверяет клиенту, а клиент - серверу.

Так что если кому-нибудь будет нужен чат, то код совершенно открытый, напишите только клиента.

Не забываем открывать консоль!

Ниже несколько скриншотов:

Общий чат

Сообщение в Общий чат

Подписка на канал

Функционал с личными сообщениями очень похож, поэтому здесь не демонстрируется.

Протокол

Клиент и сервер общаются с помощью json. Если в 2х словах, то общий вид сообщения выглядит следующим образом:

{
  type: 'Вид сообщения',
  data: {} // информация
}

При первом соединении сервер отсылает клиенту такое вот сообщение:

{
  'type': 'welcome',
  'data': {
    'protocol_v': PROTOCOL_V
  }
}

Клиент смотрит на версию протокола и если не совпадает, то разрывает соединение.

Сразу после этого сообщения сервер отсылает клиенту:

{
  'type': 'mine_channels',
  'data': {
    'channels': users_channels
  }
}

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

Это можно как-то обработать на клиенте, но я этого делать не стал.

Когда пользователь отправляет сообщение в комнату, оно имеет следующий вид:

{
  'type': 'send_message',
  'data': {
    'channel_id': 1,
    'text': public_message,
    'author': author,
    'current_time': 'время'
  }
}

Автор (self.current_user) и текущее время добавляются на сервере. Про время - добавил, чтобы было.

Сервер отвечает на сообщение клиента вида

{
  'type': 'join',
  'data': {
    'channel_id': 1
  }
}

Сообщением

{
  'type': 'success_join',
  'data': {
    'channel_id': 1
  }
}

Тот же самый функционал и для сообщений типа unjoin.

Забегая вперед, опишем схему для личных сообщений:

{
  'type': 'send_private_message',
  'data': {
    'to_user': to_user,
    'text': direct_message,
    'author': author //self.current_user
  }
}

Если я забыл описать или сделать какие-то вещи касательно протокола, то он настолько прост, что в дальнейшем можно без особых усилий дописать функционал.

Личные сообщения

Как было рассказано выше, мы используем Pub/Sub. Пользователи могут создавать каналы, подписываться на них и отписываться.

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

Но с личными сообщениями у нас есть 2 пути:

  • Создавать уникальный канал для каждого юзера. Таким образом, у нас будет много-много каналов. Именно в такой канал и будут попадать входящие сообщения.
  • Создать один общий канал, подписать на него всех клиентов и фильтровать сообщения - какое кому доставить. Один канал - много клиентов. Слушателей.

Если у нас будет M клиентов, каждый клиент будет подписан минимум на 1 канал (на самом деле на 2 - на публичный и на свой. Публикация N сообщений займет O(N) (не рассматриваем подписку на pattern).

Во втором случае, M клиентов подписаны на один канал, все клиенты получают N сообщений. Сложность публикации и фильтрации займет O(MN).

Более подробно описано в PUBLISH channel message.

Логично, что будем использовать первую схему.

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

Логичнее всего не делать монолитный сервер, где миллионы зависимостей и множество других прелестей.

Про достоинства Docker написано много трудов, я лишь отмечу, как все будет работать.

Главный контейнер с образом Tornado и нашим чатом будет слинкован с контейнером Redis.

Для главного контейнера будем использовать python:3.4.3-slim, чтобы он весил меньше, и потом соберем образ на основе написанного Dockerfile. С Redis все проще - возьмем официальный image.

Теперь надо загрузить сам Docker на какой-нибудь сервер, например, на Digital Ocean, если Ubuntu 14.04 LTS:

sudo apt-get install docker.io

Далее собрать образ для контейнера приложения и запустить сам контейнер с приложением, не забыв перед этим про Redis:

cd tornado-redis-chat
docker build -t chat .
docker run --name some-redis -d redis
docker run --name some-app -p 8888:8888 --link some-redis:redis -d chat

P.S

Код можно найти здесь

Меня очень подпирали сроки и отсутствие свободного времени, поэтому я так и не успел загрузить образ куда-либо. Но в задании это не требуется, а лишь описать схему запуска.

Список использованной литературы: