6 сентября 2016 в 14:52

WebSockets в Scorocode или чат своими руками за 15 минут



Недавно мы добавили поддержку WebSockets в разрабатываемый нами backend as a service Scorocode. Теперь вы можете полноценно использовать эту технологию при создании приложений, требующих безопасного и универсального способа передачи данных.

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

Подробности под катом.

Архитектура


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

1. Ноды используют общий брокер


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

Плюсы:

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

Минусы:

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

2. Ноды имеют общую шину для обмена системными сообщениями


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

Плюсы:

Нам не нужны дополнительные зависимости в виде отдельного ПО, упрощается архитектура и поддержка всей инфраструктуры.

Минусы:

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

Выбор сделан


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

Для обмена сообщениями между нодами решили использовать ZeroMQ или Nanomsg. Данные библиотеки представляют из себя высокоуровневую абстракцию для обмена сообщениями между процессами, нодами, кластерами, приложениями. При этом вам не нужно беспокоиться за состояние соединения, обработку ошибок и т.д. Все это уже реализовано внутри. Мы остановились на Nanomsg.

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

Тесты показали, что данная схема прекрасно справляется с поставленными задачами.

В итоге мы получили:

1) Отдельный микросервис для работы с WebSocket написанный на Go.
2) Простое масштабирование добавлением нод.
3) Отсутствие зависимостей.

Пример использования WebSocket


Один из самых распространенных примеров использования WebSocket — чат. Ниже будет описан пример создания простейшего чата, с использованием Scorocode, React и WebSockets.

Наша страница чата:

<!doctype html>
<html lang="ru">
    <head>
        <meta charset="UTF-8">
        <title>My Chat</title>
        <link rel="stylesheet" type="text/css" href="dist/bundle.css">
    </head>
    <body>
        <div id="app"></div>
        <script src="dist/bundle.js"></script>
    </body>
</html>

Разобьем наш чат на три составляющие: каркас чата, список участников и история чата.

Начнем с каркаса чата:

appView.js
import React from 'react';
import UserList from './../components/userList'
import History from './../components/history'

// Подключаем SDK
import Scorocode from './../scorocode.min'

// Инициализируем SDK
Scorocode.Init({
    ApplicationID: '<appId>',
    WebSocketKey: '<websocketKey>',
    JavaScriptKey: '<javascriptKey>'
});
var WS = new Scorocode.WebSocket('scorochat');
class AppView extends React.Component{
    constructor () {
        super();
        this.state = {
            userList: {},
            user: {
                id: '',
                name: ''
            },
            history: []
        };
    }
    guid () {
        function s4() {
            return Math.floor((1 + Math.random()) * 0x10000)
                .toString(16)
                .substring(1);
        }
        return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
            s4() + '-' + s4() + s4() + s4();
    }
    onOpen () {
        setInterval(() => {
            this.getUserList();
        }, 10000);
        this.getUserList ();
    }
    onError (err) {}
    onClose () {}
    updateUserList (user) {
        let now = new Date().getTime();
        let userList = this.state.userList;
        if (!userList[user.id]) {
            userList[user.id] = {
                name: user.name
            };
        }
        userList[user.id].expire = now;
        for (let id in userList) {
            if (now - userList[id].expire > 10000) {
                delete userList[id];
            }
        }
        this.setState({
            userList: userList
        });
    }
    getUserList () {
        var data = JSON.stringify({
            cmd: 'getUserList',
            from: this.state.user,
            text: ''
        });
        WS.send(data);
    }
    onMessage (data) {
        var result = JSON.parse(data);
        switch (result.cmd) {
            case 'message':
                let history = this.state.history.slice();
                history.push(result);
                this.setState({history: history});
                break;
            case 'getUserList':
                WS.send(JSON.stringify({
                    cmd: 'userList',
                    from: this.state.user,
                    text: ''
                }));
                break;
            case 'userList':
                this.updateUserList(result.from);
                break
        }
    }
    send (msg) {
        var data = JSON.stringify({
            cmd: 'message',
            from: this.state.user,
            text: msg
        });
        WS.send(data);
    }
    keyPressHandle(ev) {
        let value = ev.target.value;
        if (ev.charCode === 13 && !ev.shiftKey) {
            ev.preventDefault();
            if (!ev.target.value) {
                return;
            }
            this.send(value);
            ev.target.value = '';
        }
    }
    componentWillMount () {
        let userName = prompt('Укажите свое имя?');
        userName = (userName || 'New User').substr(0, 30);
        this.setState({
            user: {
                name: userName,
                id: this.guid()
            }
        });
    }
    componentDidMount () {

        // Добавляем обработчики событий
        WS.on("open", this.onOpen.bind(this));
        WS.on("close", this.onClose.bind(this));
        WS.on("error", this.onError.bind(this));
        WS.on("message", this.onMessage.bind(this));
    }
    render () {
        return (
            <div className="viewport">
                <div className="header">
                    <h1>ScoroChat</h1>
                </div>
                <div className="main">
                    <div className="left_panel">
                        <UserList userList={this.state.userList}/>
                    </div>
                    <div className="content">
                        <div className="history">
                            <History history={this.state.history} />
                        </div>
                        <div className="control">
                            <div className="container">
                                <textarea placeholder="Введите сообщение" onKeyPress={this.keyPressHandle.bind(this)}></textarea>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        )
    }
}
export default AppView;


Список пользователей:

userList.js
import React from "react";
var avatar = require('./../../img/avatar.png');
export default class UserList extends React.Component{
    constructor(props){
        super(props);
    }
    render () {
        const historyIds = Object.keys(this.props.userList);
        return (
            <div id="members">
                {historyIds.map((id) => {
                    return (
                        <div className='userList' key={id}>
                            <div className='userList_avatar'>
                                <img src=http://{avatar} />
                            </div>
                            <div className='userList_info'>
                                <span>{this.props.userList[id].name}</span>
                            </div>
                        </div>
                    )
                })}
            </div>
        )
    }
}


И компонент, отображающий историю переписки:

history.js
import React from 'react'
var avatar = require('./../../img/avatar.png');
class History extends React.Component {
    constructor(props) {
        super(props);
    }
    getDate () {
        let dt = new Date();
        return ('0' + dt.getHours()).slice(-2) + ':' + ('0' + dt.getMinutes()).slice(-2) + ' '
            + ('0' + dt.getDate()).slice(-2) + '.' + ('0' + (dt.getMonth() + 1)).slice(-2) + '.' + dt.getFullYear();
    }
    render () {
        return (
            <div id="msgContainer" className="container">
                {this.props.history.map((item, ind) => {
                    return (
                        <div className="msg_container" key={ind}>
                            <div className="avatar">
                                <img src=http://{avatar} />
                            </div>
                            <div className="msg_content">
                                <div className="title">
                                    <a className="author" href="javascript:void(0)">{item.from.name}</a>
                                    <span>{this.getDate()}</span>
                                </div>
                                <div className="msg_body">
                                    <p>{item.text}</p>
                                </div>
                            </div>
                        </div>
                        )
                })}
            </div>
        );
    }
    componentDidUpdate() {
        var historyContainer = document.getElementsByClassName('history')[0];
        var msgContainer = document.getElementById('msgContainer');
        // Скролим чат
        if (msgContainer.offsetHeight - (historyContainer.scrollTop + historyContainer.offsetHeight) < 200) {
            historyContainer.scrollTop = msgContainer.offsetHeight - historyContainer.offsetHeight;
        }
    }
}
export default History;


Область применения WebSockets довольно широка. Это может быть и обновление контента в режиме реального времени, распределенные вычисления, взаимодействие frontend'а c API, различные интерактивные сервисы и многое другое. С учетом достаточно простой интеграции с платформой Scorocode, разработчики могут не тратить время на реализацию серверной логики, а сконцентрироваться на других частях приложения.

Демо: Ссылка
Исходники: Ссылка
Автор: @watsonstudio
Scorocode
рейтинг 29,23
Готовый облачный backend для любых приложений

Комментарии (14)

  • 0
    Как уже достали «chat app» статьи… такое ощущение, что все вокруг просто таки мечтают написать свой чат. Да и вообще только этим и занимаются. ))))

    >… разработчики могут не тратить время на реализацию серверной логики, а сконцентрироваться на других частях приложения.

    Ну чтож, круто! Теперь ждем что-то вроде: «Мы реализовали все части вашего софта, теперь разработчики могут не тратиться время на реализацию серверной логики. Да и клиентской логики. Да и вообще не тратиться время, а сконцентрироваться на печеньках с чаем» )))))

    Удачи! Извините, если оффтоп.
    • 0
      Спасибо за комментарий. Целью статьи было не создание чата, а возможность рассказать немного о внутреннем устройстве websocket'ов в сервисе Scorocode, а так же показать пример, как эту технологию можно использовать в рамках нашего сервиса.

      По поводу «не тратиться», мы уже думаем над кодогенерацией :)
  • 0

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

    • 0
      Магия в том, что нет никакой серверной логики. Т.е. Вы подключаете SDK, указываете канал на который нужно подписаться, и он работает в режиме broadcast. В приведенном примере чата всего один общий канал. Но никто не мешает использовать разные каналы для создания тех же комнат.
      Платформа Scorocode хранит данные о подписчиках на каналы и при поступлении данных рассылает их. Вы можете использовать эти возможности например для синхронизации корзины на разных вкладках, используя в качестве названия канала id пользователя, где каждый клиент будет подписываться на свой канал, либо для отправки каких-либо обновлений определенному пользователю.
      • 0

        Т.е. получается вы продаёте за цену в 3к рублей, примерно 20 строчек кода (на любом языке, например на php http://socketo.me/docs/hello-world#logic). Ну и сервер сам, цены на хостинги можно нагуглить, в принципе.


        Я верно понял смысл?

        • 0
          В целом, думаю вы поняли правильно))) 20 строчек на PHP, 10 строчек на NodeJS и 0 строчек на Hoziron или любой другой self-hosted backend platform.)))
        • 0
          Функционал, который сейчас предоставляет платформа, намного шире одних вебсокетов.
          Дополнительно к функционалу реализована отказоустойчивость, репликация, балансировка нагрузки. Для каждого приложения выделяется кластер БД, API.

          Согласно нашей тарифной политике, пользователю доступно бесплатно:
          • 20 запрсов к API в секунду
          • 10Гб для хранения файлов
          • 200 одновременных подключений к websockets
          • 3 секунды на исполнение серверного кода


          Давайте проведем небольшой расчет. Для реализации простого приложения, которому достаточно вышеуказанных лимитов, потребуется БД, app server, балансировщик. Расчет будем вести с учетом не самых мощных машин. Для небольшого отказоустойчивого проекта нам потребуется:

          • БД реплика (арбитер поставим на машинку с application server) — 2 машины по 4 ядра, 16 Гб Озу
          • Балансировщик — 2 машины (на случай отказа) по 8 ядер, 8 Гб Озу
          • App server — (в целях экономии поставим на те же машинки, что и балансировщики)


          Берем стоимость ресурсов с сайта известного российского облачного хостера:
          4 ядра, 16 Гб Озу — 5 980р
          8 ядер, 8 Гб Озу — 3 900р

          (3900 * 2) + (5980 * 2) = 19760

          Итого стоимость: 19760 руб. в месяц

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

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

      • +1
        возможности например для синхронизации корзины на разных вкладках

        Это через localStorage проще и дешевле.


        Лучше напишите, что с правами (подписка/получение/отправка)?
        Есть ли возможность подписки по маске?

        • 0
          Лучше напишите, что с правами (подписка/получение/отправка)?

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

          Есть ли возможность подписки по маске?

          В данный момент нет, но мысль интересная, подумаем.

          p.s. Хотелось бы получить больше информации по поводу плюшек, которых не хватает и были бы востребованы.
          Например: подписка по маске, буферизация и т.д.
          • 0
            Доступ к вебсокетам регулируется ключом websocketKey, который можно поменять в настройках приложения.

            Я не про ключ, а каналы, как сделать их приватными? Или предлагаете генерировать уникальные имена? Так это фиии.


            Хотелось бы получить больше информации по поводу плюшек, которых не хватает и были бы востребованы.

            Ну, вот, например чтобы сделать нормальный чат, нужно хотя бы все возможности Faye

  • 0

    В документации беда с описанием метода Messenger.sendSms() — тынц

    • 0
      Спасибо, поправим!

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

Самое читаемое Разработка