WebSocket RPC или как написать живое WEB приложение для браузера



    В статье речь пойдет о технологии WebSocket. Точнее не о самой технологии, а о том, как ее можно использовать. Я давно слежу за ней. Еще когда в 2011 году один мой коллега прислал мне ссылку на стандарт, пробежав глазами, я как-то расстроился. Выглядело настолько круто, и я думал, что в момент, когда это появится в популярных браузерах, я уже буду планировать, на что потратить свою пенсию. Но все оказалось не так, и как гласит caniuse.com WebSocket не поддерживается только в Opera Mini (надо бы провести голосование, как давно кто-либо видел Opera Mini).

    Кто трогал WebSocketы руками, тот наверняка знает, что работать с API тяжело. В Javascript API достаточно низкоуровневый (принять сообщение — отправить сообщение), и придется разрабатывать алгоритм, как этими сообщениями обмениваться. Поэтому и была предпринята попытка упростить работу с вебсокетами.

    Так и появился WSRPC. Для нетерпеливых вот простое демо.

    Идея


    Основная идея в том, чтобы дать разработчику простой API на Javascript вроде:

    var url = (window.location.protocol==="https:"?"wss://":"ws://") + window.location.host + '/ws/';
    RPC = WSRPC(url, 5000);
    
    // Инициализируем объект
    RPC.call('test').then(function (data) {
        // посылаем аргументы как *args
        RPC.call('test.serverSideFunction', [1,2,3]).then(function (data) {
            console.log("Server return", data)
        });
    
        // Объект как аргументы **kwargs
        RPC.call('test.serverSideFunction', {size: 1, id: 2, lolwat: 3}).then(function (data) {
            console.log("Server return", data)
        });
    });
    
    // Если с сервера придет вызов 'whoAreYou', вызовем следующую функцию
    // ответим на сервер то, что после return
    RPC.addRoute('whoAreYou', function (data) {
        return window.navigator.userAgent;
    });
    
    RPC.connect();
    

    И на python:

    import tornado.web
    import tornado.httpserver
    import tornado.ioloop
    import time
    from wsrpc import WebSocketRoute, WebSocket, wsrpc_static
    
    class ExampleClassBasedRoute(WebSocketRoute):
        def init(self, **kwargs):
            return self.socket.call('whoAreYou', callback=self._handle_user_agent)
    
        def _handle_user_agent(self, ua):
            print ua
    
        def serverSideFunction(self, *args, **kwargs):
            return args, kwargs
    
    WebSocket.ROUTES['test'] = ExampleClassBasedRoute
    WebSocket.ROUTES['getTime'] = lambda: time.time()
    
    if __name__ == "__main__":
        http_server = tornado.httpserver.HTTPServer(tornado.web.Application((
            # Генерирует url со статикой q.min.js и wsrpc.min.js
            # (подключать в том же порядке)
            wsrpc_static(r'/js/(.*)'),
            (r"/ws/", WebSocket),
            (r'/(.*)', tornado.web.StaticFileHandler, {
                 'path': os.path.join(project_root, 'static'),
                 'default_filename': 'index.html'
            }),
        ))
        http_server.listen(options.port, address=options.listen)
        WebSocket.cleapup_worker()
        tornado.ioloop.IOLoop.instance().start()
    


    Особенности


    Поясню некоторые моменты того, как это работает.

    JavaScript

    Браузер инициализирует новый объект RPC, после этого мы вызываем методы, но WebSocket еще не соединился. Не беда, вызовы стали в очередь, которую мы разгребаем при удачном соединении, или отвергаем все обещания (promises), очищая очередь при следующем неудачном соединении. Библиотека все время пытается соединиться с сервером (на события соединения и отсоединения тоже можно подписаться RPC.addEventListener(«onconnect», func)). Но пока мы не запустили RPC.connect(), мы мирно складываем вызовы в очередь внутри RPC.

    После соединения сериализуем в JSON наши параметры и отправляем на сервер сообщение вида:

    {"serial":3,"call":"test","arguments": null}
    

    На что сервер отвечает:

    {"data": {}, "serial": 3, "type": "callback"}
    

    где serial — это номер вызова.

    После получения ответа библиотка на JS разрешает обещание (resolve promise), и мы вызываем то, что за then. После этого делаем еще один вызов и так далее…

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

    Python

    На Python регистрируются вызовы в объекте WebSocket. Атрибут класса (class-property) ROUTES это словарь (dict), который хранит ассоциацию того, как называется вызов, и какая функция или класс его обслуживает.

    Если указана функция, она просто вызывается, и ее результат передается клиенту.

    Когда мы указываем класс, и клиент хоть раз вызывает его, мы создаем экземпляр этого класса и храним его вместе с соединением до самого его разрыва. Это очень удобно, можно сделать statefull соединение с браузером.

    Доступ к методам осуществляется через точку. Если метод называется с подчеркивания (_hidden), то доступ из Javascript к нему не получить.

    Еще от клиента к серверу, и от сервера к клиенту пробрасываются исключения. Когда я это реализовал, а был просто ошарашен. Увидеть Javascript traceback в питонячих логах — гарантированный когнтивный диссонанс. Ну, а про питонячьи Exceptions в JS я молчу.

    Итог


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

    Вместо заключения


    Спасибо моим коллегам и друзъям за то, что помогали находить ошибки и иногда присылали патчи. Ну, и тебе, читатель. Если ты это читаешь, с учетом сухости статьи, тогда тебе уж точно интересна эта тема.

    upd 1: Добавил WebSocket.cleapup_worker() в примеры.
    Поделиться публикацией

    Комментарии 64

      0
      Эх, как раз недавно закончил проект на WS и Tornado. Реализация во многом похожа. Если бы знал про эту либу — скорее всего использовал бы её.
        0
        Ага, а я три месяца не мог выложить это… руки не доходили. В итоге в pypi название какой-то парень занял, пршлось назвать wsrpc-tornado.
        0
        Я тоже занимаюсь работой с WS и хотел бы предупредить.
        Если открывать соединение с WS на каждой странице то может случится так что один пользователь попытается открыть ваш сайт сразу в нескольких вкладках и тогда он может исчерпать лимит активных соединений с сервером, в хроме это 6 соединений. После исчерпания лимита новые соединения включая простые ajax запросы будут ждать пока не закроется одно из открытых соединений.

        Для этого надо держать одно общее соединение на все вкладки, я уже почти закончил статью о реализации этого функционала в js думаю на следующей неделе опубликую.
          0
          Как альтернативный вариант — можно «раскидать» вебсокет-запросы по поддоменам.
            0
            Я проверял такой вариант в crome и выявил что таким образом ограничение вместо 6 запросов на домен расширяется на 255 запросов на все домены в сумме.
            Гораздо правильнее использовать коммуникацию между вкладками браузера. К примеру вот моя реализация github.com/Levhav/signal.js для передачи сообщений от вкладки к вкладке.
              0
              Тут возникает целая куча проблем. Например, если я закрою вкладку в которой у меня открыт коннекшн, что делать остальным, кому установить соединение, как они выберут мастера???

              ИМХО: * в DNS A записи и генерация субдомена при соединении из случайной строки, и 255 соединений куда реалистичнее.
            0
            У меня на сайте так реализовано, в Хроме открываем хоть 20 вкладок и никаких проблем нету.
            Т.е. выше указанного бага не наблюдаю.
            0
            Спасибо за статью, присматриваюсь краем глаза к этой теме, а тут вот. Сразу полез в пример для нетерпеливых и вдруг не обнаружил trace'а запросов между браузером и сервером. Я в WebSoсket'ах не копенгаген, но при разработке web-приложений мне очень полезна бывает информация о том, что за данные ходят между клиентом и сервером, ходят ли вообще, а если ходят, то как выглядят.


            Увидел ничего, испугался и полез для проверки на www.websocket.org/echo.html — там меня успокоили тем, что trace обнаружился. В связи с чем вопрос — простое демо работает, как и задумывалось или не совсем? Потому что ответ «I'm delayed 2 seconds» я получаю, а вот в логах этого не вижу. Раз в минуту браузер отправляет запрос на сервер:
            GET ws://wsrpc.mosquito.su/ws/ HTTP/1.1
            Host: wsrpc.mosquito.su
            Connection: Upgrade
            Pragma: no-cache
            Cache-Control: no-cache
            Upgrade: websocket
            Origin: http://wsrpc.mosquito.su
            Sec-WebSocket-Version: 13
            User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.99 Safari/537.36
            Accept-Encoding: gzip, deflate, sdch
            Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.6,en;q=0.4
            Sec-WebSocket-Key: cp5b9j20tvZtX5n70kyYXw==
            Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
            


            и получает ответ:
            HTTP/1.1 101 Switching Protocols
            Server: nginx
            Date: Thu, 22 Jan 2015 05:17:53 GMT
            Connection: upgrade
            Upgrade: websocket
            Sec-WebSocket-Accept: iWSx/PeUVE8B3ti9ZjYa2WjiSIQ=
            


            И так — каждую минуту. Похоже, пример не рабочий.
              0
              Каждую минуту такой запрос идет скорее всего из-за того что время сессии в nginx ограничено минутой, что вполне нормально при реверс-проксировании к приложению.

              proxy_read_timeout 60;
              proxy_send_timeout 60;
              


              На www.websocket.org/echo.html тоже нет ничего на вкладке DevTools/Network. Единственное что там сделано — «логирование» данных по эвентам в dom-элемент средствами самого JS.

              А пример, как по мне, рабочий, так как он отвечает на ваши запросы и помещает ответ в отведенное «окно».
                0
                В точку. Так как демо это демо а не полноценное приложение то я выставил таймауты.
                  0
                  А разве их не придется выставлять в любом случае? Просто для продакшен варианта их можно будет увеличить до 10-60 минут, чтобы не использовались стандартные, тем более что библиотека сама умеет реконнектиться.
                    0
                    Зачем их выставлять? Если ping-pong фреймы сервер будет посылать, то соединение может жить сколько угодно.
                +1
                Всё общение при помощи ws в chrome отображается во вкладке network, после того как произошла смена протокола 101 нужно откртыть этот запрос и увидите пакеты, единственно вкладка с пакетами сама не обновляется — нужно руками нажимать на запрос 101
                image
                0
                Извесный баг в Chromium, в Chrome под мак все ок.
                image
                  0
                  Это не известный баг. Это «сначала открыл сайт, потом отладчик и начал слушать эфир».
                    0
                    Ну у одного моего приятеля, chromium себя ведет именно так, не показывает фреймы в WS. Может он в Gentoo собрал что-то не так.
                      0
                      Открыл хромиум на Gentoo, все работает как надо. Есть шанс что Хром поставили и забыли, а стоило бы обновить, там в последних версиях dev tools хорошо причесали.
                        +1
                        Спасибо за тест. За отсутствием аргументов соглашусь с вами. Да и дело было пару месяцев назад. Плюс гента у каждого своя ;-)
                +1
                Как раз ищу нечто похожее. Прямо вот вчера обнаружил wamp. Почему не он?
                  0
                  Не помню, разрабатывалось это где-то пол года назад. На WAMP я смотел, и что-то мне не понравилось, убей не помню что. Сейчас, очень похоже, они сильно выросли.
                  +1
                  Уже год используем в компании SignalR как весьма неплохой враппер для вебсокетов, очень удобная поддержка на уровне JS, конечно годно только для .Net сайтов, но весьма грамотно реализовано (не PR).

                  Минусы только порты, каждое соединение отъедает порт на сервере, т.е. есть ограничения по пользователям, есть поддержка LoadBalancing но пока все еще весьма в зачаточном состоянии.
                    0
                    А почему на сервере отъедается порт? Разве сервер не слушает один порт, а клиенты на него соединяются? Чую какой-то подвох и костыль в стиле запуск приложения в отдельном воркере и проксирование на него входящих соединений.
                      +1
                      Вот что пишут

                      It's important to understand that this application will be used by a small number of administrative users, so it's not going to be used by thousands of users simultaneously. At most we figure there may be 10 people connected at a time. However, connections are something to consider with SignalR. As cool as this technology is, it's connected technology meaning that each client connected through SignalR is using a persistent and dedicated connection on the Web Server. 10 users or 100 are probably not much of a problem, but thousands of users may bump up against the Web server connection and Windows thread and resource limits eventually. SignalR also includes some scalability features, but these get very complex quickly and if this becomes an issue I personally think that one should reconsider whether SignalR or a real-time connection based interface is the right choice…

                      там еще память жрется от каждой коннекции, на самомо деле в реальной жизни мы не сталкивались с этой проблемой, так как сайт с этой технологией только для внутреннего использования, однако понятно что для коммерческого использования весьма сыровато еще.
                        0
                        that this application will be used by a small number of administrative users, so it's not going to be used by thousands of users simultaneously.

                        Ну по моим тестам, топик тянет сотню соединений на воркер не напрягаясь. Больше не тестил. Вебсокет достаточно сложно протестировать тем же питоном. Была мысль написать на NodeJS клиента который будет открывать сокеты, но руки не дошли как-то.
                          0
                            0
                            Не, не так ;)

                            Гляньте tcpkali — генератор нагрузки для TCP и WebSockets серверов!

                            image
                    0
                    и как гласит caniuse.com WebSocket не поддерживается только в Opera Mini (надо бы провести голосование, как давно кто-либо видел Opera Mini)

                    К сожалению, caniuse.com гласит еще и IE 8, 9, а для поиска таких клиентов не надо голосование — это все люди, вынужденные пользоваться IE и работающие с XP. И в гос. организациях таких людей немеряно.

                    Да, я знаю, что с помощью флэша это обходится, но и говорить, что любой браузер поддержит веб-сокет из коробки, к сожалению, можно будет с абсолютно спокойной совестью еще лет через пару.
                      0
                      Есть же websocket.js это flash плагин который эмулирует браузеру websocket API.
                        0
                        Ну никто не мешает поставить Firefox на Windows XP, а IE89 уже не поддерживаются Microsoft.
                          0
                          Ох, если бы не мешали…
                          Если есть крупная организация с большинством терминалов под XP, что означает IE8 максимум + достаточно упёртый безопасник + админ, которому не интересно ставить всем новый браузер == любимая проверка на IE в яваскрипте и использование флешеобразного websocket.js. Это реальная история. Прям сейчас происходит, да.

                          Я первый буду прыгать от радости, когда эти проблемы вместе с IE до 10-ки канут в Лету, мне нрвятся веб-сокеты в некоторых задачах. Единственное, ради чего я написал свой первый комментарий тут — предостеречь людей, что такое может случиться и иметь в виду, что если они не пропишут в требованиях проекта IE10+, а поставят «любой браузер», то могут сразу же начинать осваивать websocket.js и писать проверки на текущий браузер.
                            0
                            Я на самом деле думал сделать работу через long polling при не поддерживаемости Websocket. Но пока без надобности просто.
                              0
                              Это уже будет повторение Socket.IO или SockJS — тогда какой смысл?
                                0
                                Так потому, и не переделал. Вообще я считаю что у библиотеки своя ниша. И отлично что комментарии содержат столько отсылок к другим продуктам. Это прекрасное хранилище информации.
                      0
                      Вы очень вовремя, спасибо за библиотеку. Прям сейчас ковыряю и со старта возникло несколько вопросов:

                      1. Чтоб завести в require.js пришлось немного похачить wsrpc.js (не видел Q и работал в глобальной области видимости)

                      2. С запросами к серверу всё понятно, а вот наоборот — не очень. В документации примера не нашёл, поэтому на данный момент всё, что я придумал — это делать запрос к серверу, который инициализирует PeriodicCallback, а на клиенте уже слушать ответы через addRoute. Можно как-то более изящно решить этот вопрос?

                      Реализую свой stock board — ждите pull-реквестов :)
                        0
                        С запросами к серверу всё понятно, а вот наоборот — не очень.


                        тут же прямо написано
                                return self.socket.call('whoAreYou', callback=self._handle_user_agent)
                        

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

                        PS пишу сюда потому что автор куда охотнее отвечает на комментарии тут, чем на пулл-реквесты там :)
                          0
                          Нигде не сказано, что обратные идут синхронно. С точки зрения браузера все асинхронно, на торнаде, как напишет разработчик.

                          Забыл написать одну особенность, при старте WebSocket инициализирует thread pool, и выполняет все методы в тредах, именно поэтому можно не бояться писать sleep в методах.
                            0
                            Да неужели?
                            ws.onmessage = function (message) {
                            				log('WSRPC: ONMESSAGE CALLED (' + self.public.state() + ')');
                            				trace(message);
                            				var data = null;
                            				if (message.type == 'message') {
                            					try {
                            						data = JSON.parse(message.data);
                            						log(data.data);
                            						if (data.hasOwnProperty('type') && data.type === 'call') {
                            							if (!self.routes.hasOwnProperty(data.call)) {
                            								throw Error('Route not found');
                            							}
                            
                            							out = {
                            								serial: data.serial,
                            								type: 'callback',
                            								data: self.routes[data.call](data.arguments)
                            							};
                            							self.socket.send(JSON.stringify(out));
                            

                            Мне одному кажется, что callback, записанный в словаре self.routes, в этом месте вызывается синхронно?
                              0
                              Так, а что вы предлагаете?
                              Сделать setTimeout(0, func) какой в этом смысл, эвент пришел асинхронно с сервера. Или как?
                              В браузере появились треды?

                              Я открыт к pull реквестам.
                                0
                                Я предлагаю дать возможность обработчику вернуть не значение, а обещание (promise). PS pull request вы игнорируете уже 15 часов.
                                  0
                                  Согласен. У меня просто такого кейса не возникало. Заводите issue, сделаю.
                                    +1
                                    (цензура), вы издеваетесь? Я это уже сделал вчера утром.

                                    PS кто-нибудь еще кроме меня видит мой pull request? А то у меня такое ощущение, что на гитхабе появился спам-фильтр…
                                      –1
                                      Нет, я не издеваюсь, но вы сломали обратную совместимость. Теперь вызов может быть только promise.
                                        0
                                        Почему он может быть только обещанием? Я специально обернул его в Q, чтобы сохранить обратную совместимость.
                                          0
                                          ок, тогда в чем смысл? как это обещание обернутое в обещание сресолвить?
                                            +1
                                            Функция Q работает по-разному в зависимости от того, что ей подано на вход.

                                            Если ей подано «родное» обещание — она его и возвращает. Если ей подано «чужое» обещание — она возвращает свою обертку над ним. Если ей подана константа — она возвращает обертку над константой.

                                            В итоге, конструкция Q(result).then(reply), выполнит reply сразу же, если result — константа, и дождется result, если result — обещание.
                                              0
                                              Спасибо за наводку, принято.
                                    –1
                                    Потому что promise это один из 5 асинхронных паттернов в JS. И ограничивать разработчика в выборе паттерна не хорошо.
                                      +1
                                      Ну вы можете написать свою реализацию клиента, на любом другом из оставшихся 4х. Просто там все в основном написано на promise
                                        0
                                        Неужели отсутствие асинхронности лучше, чем присутствие хотя бы одного паттерна? Да и библиотека не может поддерживать все 5 сразу. В этой библиотеке уже использовались обещания — вот я и предложил очевидный вариант.
                                          0
                                          callback это тоже асинхронный паттерн. Не суть. Я скорее о том, что причина появления таких решений вообще она именно в том, что callback это фундаментальный примитив в JS и все «асинхронности» рано или поздно вернутся к нему. И имеяя возможность закинуть callback можно городить что угодно, имея что-то другое, уже сложнее и менее правильно.

                                          Что до конкретно этой библиотеки, то ничего не скажу, даже не вижу в ней смысла.
                                            0
                                            Ответьте честно, вы попадали когда-нибудь в ситуацию именуемую «callback hell»?
                                              0
                                              Лично я нет. Зато видел как другие мучаются.
                                              А вы попадали когда-нибудь в ситуацию именуемую «ReferenceError: myVar is not defined»?
                                              0
                                              В данном случае callback — это не асинхронный паттерн, а название для RPC, инициируемого сервером. До моей модификации этот вызов обязан был завершиться синхронно, что не позволяло, к примеру, по запросу с сервера показать диалог, запрашивающий у пользователя некоторую информацию (точнее, диалог-то показать было можно, но вот отправить введенную пользователем информацию в качестве ответа на этот RPC — уже нет).
                                                0
                                                Ага, следовательно это такое хитрое название.
                                0
                                Гм, а чем не подошло обычный JSON-RPC over WS? Только обертку осталось сделать, если хотеть с промисами работать на клиенте
                                  0
                                  Можно переделать на JSON RPC там по сути тоже самое. Да велосипед, но гораздо менее многословный, нежели json rpc.
                                    0
                                    Гмм., вы уверенны? json-rpc.org/wiki/specification у себя вы повторили большую часть из этого, насколько я бегло просмотрел.
                                      0
                                      Ок давайте переделаем ;-)
                                  0
                                  А мне понравился стандартный интерфейс WS в JS. И ИМХО и хорошо, что это не RPC…
                                    0
                                    Согласен с вами. Но у сеня ничего больше чата сделать не удалось вот и заверте…
                                      0
                                      Но у сеня ничего больше чата сделать не удалось вот и заверте…

                                      Простите, что то я вас не понял…
                                        +1
                                        Надо на время коммента смотреть. Видимо в 00:00 компьютер топикстартера постит всё, что не оттправлено, закрывает все вкладки и выключается. :)

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

                                  Самое читаемое