Недавно мы добавили поддержку 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, разработчики могут не тратить время на реализацию серверной логики, а сконцентрироваться на других частях приложения.

Демо: Ссылка
Исходники: Ссылка
Поделиться с друзьями
-->

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


  1. PaulMaly
    06.09.2016 17:47

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

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

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

    Удачи! Извините, если оффтоп.


    1. watsonstudio
      06.09.2016 18:08

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

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


  1. SerafimArts
    06.09.2016 20:51

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


    1. watsonstudio
      06.09.2016 21:45

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


      1. SerafimArts
        06.09.2016 22:12

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


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


        1. PaulMaly
          07.09.2016 01:16

          В целом, думаю вы поняли правильно))) 20 строчек на PHP, 10 строчек на NodeJS и 0 строчек на Hoziron или любой другой self-hosted backend platform.)))


        1. watsonstudio
          07.09.2016 12:16

          Функционал, который сейчас предоставляет платформа, намного шире одних вебсокетов.
          Дополнительно к функционалу реализована отказоустойчивость, репликация, балансировка нагрузки. Для каждого приложения выделяется кластер БД, 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 руб. в месяц

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


          1. SerafimArts
            07.09.2016 12:21

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


      1. RubaXa
        07.09.2016 11:16
        +1

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

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


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


        1. watsonstudio
          07.09.2016 11:35

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

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

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

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

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


          1. RubaXa
            07.09.2016 11:41

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

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


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

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