Введение в Websockets в связке с Ruby (Sinatra)

В этой статье мы рассмотрим введение в WebSockets и реализацию базового приложения для чата с использованием EventMachine-WebSocket.

WebSockets — одна из фич в спецификации HTML5, которая позволяет клиенту и серверу взаимодействовать без использования AJAX или long-polling.

Что такое вебсокеты?

Согласно спецификации, вебсокеты это:

API, который позволяет веб-страницам использовать протокол WebSocket (определенный IETF) для двусторонней связи с удаленным хостом.

То есть, websockets могут использоваться вместо существующих решений для HTTP поллинга. Вебсокеты позволяют установить одно длительное TCP-соединение между клиентом и сервером. Это позволяет осуществлять полно дуплексный (двунаправленный) обмен сообщениями между обеими сторонами с очень небольшой задержкой.

Спецификация websockets определяет две новые схемы URI, ws: и wss:, для не зашифрованных и зашифрованных соединений соответственно.

Итак, начнем

Чтобы сделать чат-приложение, для начала создадим очень простое приложение Sinatra, работающее в EventMachine. Также используем сервер thin, так как он поддерживает EventMachine:

# Gemfile source 'https://rubygems.org' gem 'thin' gem 'sinatra' gem 'em-websocket'
# app.rb require 'thin' require 'sinatra/base' require 'em-websocket' EventMachine.run do class App < Sinatra::Base get '/' do erb :index end end # our WebSockets server logic will go here App.run! :port => 3000 end
# views/index.erb <!doctype html> <html> <head> </head> <body> # WebSockets Chat App <div id='chat-log'> <input type='text' id='message'> <button id='disconnect'>Disconnect</button> <script src='//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js'></script> <script> // where our WebSockets logic will go later </script> </body> </html>

Запускаем:

bundle exec ruby app.rb

Начало положено! Далее, разберемся со всем со стороны сервера.

WebSockets сервер

Для начала, настроим сервер для работы с EventMachine и обработки websocket соединения. Это делается довольно просто с использованием гема em-websocket, нам даже не нужно беспокоиться о хедерах для websocket handshake:

EventMachine.run do # ... [previous Sinatra stuff] @clients = [] EM::WebSocket.start(:host => '0.0.0.0', :port => '3001') do |ws| ws.onopen do |handshake| @clients &lt;&lt; ws ws.send 'Connected to #{handshake.path}.' end ws.onclose do ws.send 'Closed.' @clients.delete ws end ws.onmessage do |msg| puts 'Received Message: #{msg}' @clients.each do |socket| socket.send msg end end end # ... [run Sinatra server] end

Этот код позаботится о handshake вебсокета с клиентами, а также о бэкенде чата. Далее настроим клиентскую часть:

WebSockets клиент

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

Для начала добавим базовую функцию для отображения сообщений на странице:

function addMessage(msg) { $('#chat-log').append('' + msg + ''); }

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

var socket, host; host = 'ws://localhost:3001'; function connect() { try { socket = new WebSocket(host); addMessage('Socket State: ' + socket.readyState); socket.onopen = function() { addMessage('Socket Status: ' + socket.readyState + ' (open)'); } socket.onclose = function() { addMessage('Socket Status: ' + socket.readyState + ' (closed)'); } socket.onmessage = function(msg) { addMessage('Received: ' + msg.data); } } catch(exception) { addMessage('Error: ' + exception); } } $(function() { connect(); });

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

function send() { var text = $('#message').val(); if (text == '') { addMessage('Please Enter a Message'); return; } try { socket.send(text); addMessage('Sent: ' + text) } catch(exception) { addMessage('Failed To Send') } $('#message').val(''); } $('#message').keypress(function(event) { if (event.keyCode == '13') { send(); } });

В конце, добавим возможность пользователям отключаться от сервера, если необходимо:

$('#disconnect').click(function() { socket.close() });

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

Заключение

Иии..Теперь у нас есть рабочий чат сервис! Чтобы его запустить, просто запускаем сервер:

bundle exec ruby app.rb

Теперь можно зайти на http://localhost:3000/, и увидеть чат в действии. Можно открыть в нескольких браузерах чтобы потестировать.

1
4 комментария
\n \n \n","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Запускаем:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"bundle exec ruby app.rb","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Начало положено! Далее, разберемся со всем со стороны сервера.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"WebSockets сервер"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для начала, настроим сервер для работы с EventMachine и обработки websocket соединения. Это делается довольно просто с использованием гема em-websocket, нам даже не нужно беспокоиться о хедерах для websocket handshake:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"EventMachine.run do\n # ... [previous Sinatra stuff]\n\n @clients = []\n\n EM::WebSocket.start(:host => '0.0.0.0', :port => '3001') do |ws|\n ws.onopen do |handshake|\n @clients << ws\n ws.send 'Connected to #{handshake.path}.'\n end\n\n ws.onclose do\n ws.send 'Closed.'\n @clients.delete ws\n end\n\n ws.onmessage do |msg|\n puts 'Received Message: #{msg}'\n @clients.each do |socket|\n socket.send msg\n end\n end\n end\n\n # ... [run Sinatra server]\nend","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Этот код позаботится о handshake вебсокета с клиентами, а также о бэкенде чата. Далее настроим клиентскую часть:

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"WebSockets клиент"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для начала добавим базовую функцию для отображения сообщений на странице:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function addMessage(msg) {\n $('#chat-log').append('' + msg + '');\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"var socket, host;\nhost = 'ws://localhost:3001';\n\nfunction connect() {\n try {\n socket = new WebSocket(host);\n\n addMessage('Socket State: ' + socket.readyState);\n\n socket.onopen = function() {\n addMessage('Socket Status: ' + socket.readyState + ' (open)');\n }\n\n socket.onclose = function() {\n addMessage('Socket Status: ' + socket.readyState + ' (closed)');\n }\n\n socket.onmessage = function(msg) {\n addMessage('Received: ' + msg.data);\n }\n } catch(exception) {\n addMessage('Error: ' + exception);\n }\n}\n\n$(function() {\n connect();\n});","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function send() {\n var text = $('#message').val();\n if (text == '') {\n addMessage('Please Enter a Message');\n return;\n }\n\n try {\n socket.send(text);\n addMessage('Sent: ' + text)\n } catch(exception) {\n addMessage('Failed To Send')\n }\n\n $('#message').val('');\n}\n\n$('#message').keypress(function(event) {\n if (event.keyCode == '13') { send(); }\n});","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В конце, добавим возможность пользователям отключаться от сервера, если необходимо:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"$('#disconnect').click(function() {\n socket.close()\n});","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Заключение"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Иии..Теперь у нас есть рабочий чат сервис! Чтобы его запустить, просто запускаем сервер:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"bundle exec ruby app.rb","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Теперь можно зайти на http://localhost:3000/, и увидеть чат в действии. Можно открыть в нескольких браузерах чтобы потестировать.

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":4,"favorites":2,"reposts":0,"views":3,"hits":1683,"reads":null,"online":0},"dateFavorite":0,"hitsCount":1683,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id1186869/457765-vvedenie-v-websockets-v-svyazke-s-ruby-sinatra","author":{"id":1186869,"name":"Aleksandr Ulanov","nickname":null,"description":"Full Stack Software Developer","uri":"","avatar":{"type":"image","data":{"uuid":"da795448-a09b-5176-bf2d-9da0eaa6c0e5","width":800,"height":800,"size":69048,"type":"jpg","color":"333333","hash":"6373f1432b3f6d63","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":4257220,"userId":1186869,"count":0,"shareImage":"https://api.vc.ru/achievements/share/4257220"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":633686,"userId":1186869,"count":0,"shareImage":"https://api.vc.ru/achievements/share/633686"}],"lastModificationDate":1764937332,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":1186869,"name":"Aleksandr Ulanov","nickname":null,"description":"Full Stack Software Developer","uri":"","avatar":{"type":"image","data":{"uuid":"da795448-a09b-5176-bf2d-9da0eaa6c0e5","width":800,"height":800,"size":69048,"type":"jpg","color":"333333","hash":"6373f1432b3f6d63","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":4257220,"userId":1186869,"count":0,"shareImage":"https://api.vc.ru/achievements/share/4257220"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":633686,"userId":1186869,"count":0,"shareImage":"https://api.vc.ru/achievements/share/633686"}],"lastModificationDate":1764937332,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[{"id":1,"count":1}],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null,"keywords":["chat","message","disconnect"],"media":null,"customCover":null,"robotsTag":null,"categories":[],"isAnonymized":true}};