Статьи

Обмен сообщениями в реальном времени с Rails и ActionCable

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

К концу статьи вы узнаете, как:

  • Напишите код на стороне клиента для подписки на веб-сокет / канал
  • Написать код на стороне сервера для широковещательных сообщений
  • Безопасный ActionCable
  • Отображать уведомления о новых сообщениях
  • Динамически обновлять разговоры
  • Реализация функции «онлайн» с использованием Redis
  • Разверните свое приложение в Heroku

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

Рабочая демоверсия доступна по адресу sitepoint-custom-messaging.herokuapp.com .

Интеграция ActionCable

Чтобы наша система обмена сообщениями работала в режиме реального времени, нам потребуется ActionCable , о котором я уже говорил в одной из предыдущих статей . Первый шаг — это монтирование на некоторый путь, обычно просто /cable :

конфиг / routes.rb

 #... mount ActionCable.server => '/cable' 

Далее нам нужно позаботиться как о клиенте, так и о стороне сервера.

Сторона клиента

Внутри каталога javascripts должен быть файл с именем cable.js со следующим содержимым:

cable.js

 //= require action_cable //= require_self //= require_tree ./channels (function() { this.App || (this.App = {}); App.cable = ActionCable.createConsumer(); }).call(this); 

Нам нужен этот файл в нашем манифесте JavaScript:

application.js

 //= require cable 

Затем создайте новый канал, который будет называться Conversations . Давайте также переместим в него весь код из файла messages.coffee:

JavaScripts / каналы / conversations.coffee

 jQuery(document).on 'turbolinks:load', -> messages_to_bottom = -> messages.scrollTop(messages.prop("scrollHeight")) messages = $('#conversation-body') if messages.length > 0 messages_to_bottom() 

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

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

общий / _menu.html.erb

 <% if user_signed_in? %> <div id="current-user"></div> <!-- ... --> 

Теперь CoffeeScript:

conversations.coffee

 jQuery(document).on 'turbolinks:load', -> messages_to_bottom = -> messages.scrollTop(messages.prop("scrollHeight")) messages = $('#conversation-body') if $('#current-user').size() > 0 App.personal_chat = App.cable.subscriptions.create { channel: "NotificationsChannel" }, connected: -> # Called when the subscription is ready for use on the server disconnected: -> # Called when the subscription has been terminated by the server received: (data) -> # Called when data is received if messages.length > 0 messages_to_bottom() 

Я получил много вопросов о ActionCable, и часто читатели испытывают проблемы с CoffeeScript, получая ошибки типа «Неожиданный отступ». Поэтому обратите внимание, что CoffeeScript в значительной степени зависит от отступов и пробелов, поэтому дважды проверьте свой код и сравните его с исходным кодом, опубликованным на GitHub.

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

conversations.coffee

 # ... if messages.length > 0 messages_to_bottom() $('#new_personal_message').submit (e) -> $this = $(this) textarea = $this.find('#personal_message_body') if $.trim(textarea.val()).length > 1 App.personal_chat.send_message textarea.val(), $this.find('#conversation_id').val() textarea.val('') e.preventDefault() return false 

Здесь все довольно просто. Убедитесь, что сообщение содержит не менее 2 символов, и, если оно истинно, вызовите метод send_message который будет закодирован через мгновение. Затем очистите текстовое поле, указывающее пользователю, что сообщение было использовано, и предотвратите действие по умолчанию. Обратите внимание, что даже если наш JavaScript не запускается или пользователь отключил JavaScript для веб-сайта, сообщения все равно будут отправляться синхронно.

Вот метод send_message :

conversations.coffee

 # ... App.personal_chat = App.cable.subscriptions.create { channel: "NotificationsChannel" }, send_message: (message, conversation_id) -> @perform 'send_message', message: message, conversation_id: conversation_id # ... 

@perform 'send_message' означает, что мы @perform 'send_message' метод с этим именем на стороне сервера при передаче объекта, содержащего идентификатор сообщения и диалога. Давайте напишем этот метод сейчас.

Сторона сервера

В приложениях Rails 5 появился новый каталог app / channel . Создайте новый файл внутри этого каталога со следующим кодом:

Каналы / notifications_channel.rb

 class NotificationsChannel < ApplicationCable::Channel def subscribed stream_from("notifications_#{current_user.id}_channel") end def unsubscribed end def send_message(data) conversation = Conversation.find_by(id: data['conversation_id']) if conversation && conversation.participates?(current_user) personal_message = current_user.personal_messages.build({body: data['message']}) personal_message.conversation = conversation personal_message.save! end end end 

subscribed и unsubscribed — это хуки, которые запускаются, когда кто-то запускает или останавливает прослушивание сокета.

send_message — это метод, который мы вызываем со стороны клиента. Этот метод проверяет, существует ли диалог, и у пользователя есть права на доступ к нему. Это очень важный шаг, потому что иначе любой может написать любой разговор. Если это правда, просто сохраните новое сообщение. Обратите внимание, что когда вы вносите какие-либо изменения в файлы внутри каталога каналов , вы должны перезагрузить веб-сервер (даже в разработке).

Еще одна вещь, которая должна быть решена, — это аутентификация, чтобы не дать никому неавторизованному пользователю подписаться на сокет. К сожалению, у нас нет доступа к методу Devise current_user внутри каналов, поэтому добавьте его сейчас:

Каналы / application_cable / connection.rb

 module ApplicationCable class Connection < ActionCable::Connection::Base identified_by :current_user def connect self.current_user = find_verified_user logger.add_tags 'ActionCable', current_user.email end protected def find_verified_user # this checks whether a user is authenticated with devise verified_user = env['warden'].user if verified_user verified_user else reject_unauthorized_connection end end end end 

identified_by :current_user позволяет использовать метод current_user для каналов. connect запускается автоматически, когда кто-то пытается подключиться к сокету. Здесь мы устанавливаем текущего пользователя, только если он аутентифицирован. Поскольку Devise использует Warden для аутентификации, мы можем использовать env['warden'].user для возврата объекта ActiveRecord . Если пользователь не аутентифицирован, просто вызовите reject_unauthorized_connection .

logger.add_tags 'ActionCable', current_user.email выводит информацию о пользователе, который подписался на канал в терминале.

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

модели / personal_message.rb

 # ... after_create_commit do NotificationBroadcastJob.perform_later(self) end 

after_create_commit вызывается только после того, как запись была сохранена и зафиксирована.

Вот фоновое задание для трансляции нового сообщения:

работы / notifications_broadcast_job.rb

 class NotificationBroadcastJob < ApplicationJob queue_as :default def perform(personal_message) message = render_message(personal_message) ActionCable.server.broadcast "notifications_#{personal_message.user.id}_channel", message: message, conversation_id: personal_message.conversation.id ActionCable.server.broadcast "notifications_#{personal_message.receiver.id}_channel", notification: render_notification(personal_message), message: message, conversation_id: personal_message.conversation.id end private def render_notification(message) NotificationsController.render partial: 'notifications/notification', locals: {message: message} end def render_message(message) PersonalMessagesController.render partial: 'personal_messages/personal_message', locals: {personal_message: message} end end 

ActionCable.server.broadcast отправляет данные по каналу. Так как внутри notifications_channel.rb мы сказали stream_from("notifications_#{current_user.id}_channel") , то же имя будет использоваться в фоновом задании.

Здесь мы на самом деле отправляем две трансляции, одна из которых содержит реальное сообщение, а другая — с уведомлением. Идея проста: если пользователь в данный момент просматривает диалог, которому принадлежит сообщение, просто добавьте новое сообщение внизу. Однако, если они находятся на какой-то другой странице, в правом нижнем углу отобразится небольшое сообщение с уведомлением: «Вы получили новое сообщение от…».

Создайте новый контроллер для визуализации частичного уведомления:

notifications_controller.rb

 class NotificationsController < ApplicationController end 

И само частичное:

просмотры / уведомление / _notification.html.erb

 <div id="notification"> <div class="close">close me</div> <p> <%= message.body %><br> from <%= message.user.name %> </p> <p><%= link_to 'Take me there now!', conversation_path(message.conversation) %></p> </div> 

Большой! Следующее, что нам нужно сделать, — это визуализировать разметку, поэтому вернемся к клиентской стороне.

Вернуться на сторону клиента

Настройте received обратный вызов, чтобы отобразить сообщение или уведомление:

conversations.coffee

 received: (data) -> if messages.size() > 0 && messages.data('conversation-id') is data['conversation_id'] messages.append data['message'] messages_to_bottom() else $('body').append(data['notification']) if data['notification'] 

Условие внутри этого обратного вызова основывается на атрибуте data-conversation-id который устанавливается для #conversation-body , поэтому добавьте его сейчас:

просмотров / разговоры / show.html.erb

 <div id="conversation-body" data-conversation-id="<%= @conversation.id %>"> 

Мы почти закончили здесь. Давайте также сделаем ссылку «закрыть меня» внутри уведомления для работы. Поскольку уведомление представляет собой динамически добавляемый контент, мы не можем привязать обработчик события click непосредственно к нему. Вместо этого положитесь на всплытие событий:

conversations.coffee

 $(document).on 'click', '#notification .close', -> $(this).parents('#notification').fadeOut(1000) 

Кроме того, стиль уведомления, чтобы он всегда отображался в правом нижнем углу:

application.scss

 #notification { position: fixed; right: 10px; bottom: 10px; width: 300px; padding: 10px; border: 1px solid black; background-color: #fff; .close {cursor: pointer} } 

Теперь вы можете загрузить сервер, открыть два отдельных окна браузера (или, возможно, использовать два разных браузера) и проверить результат! Разговаривать с самим собой может быть не очень весело, хотя …

Обратите внимание, что для получения обновлений в режиме реального времени вам необходимо посещать один и тот же адрес во всех браузерах, либо localhost:3000 либо 127.0.0.1:3000 .

Обновление разговоров

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

Чтобы заставить это работать, сначала измените обратный вызов:

модели / personal_message.rb

 after_create_commit do conversation.touch NotificationBroadcastJob.perform_later(self) end 

touch просто установит столбец updated_at с текущей датой и временем.

Настроить received обратный вызов:

conversations.coffee

 received: (data) -> if messages.size() > 0 && messages.data('conversation-id') is data['conversation_id'] messages.append data['message'] messages_to_bottom() else $.getScript('/conversations') if $('#conversations').size() > 0 $('body').append(data['notification']) if data['notification'] 

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

Сделайте так, чтобы действие index отвечало на запросы в форматах HTML и JS:

conversations_controller.rb

 def index @conversations = Conversation.participating(current_user).order('updated_at DESC') respond_to do |format| format.html format.js end end 

Наконец, представление .js.erb :

просмотров / разговоры / index.js.erb

 $('#conversations').replaceWith('<%= j render @conversations %>'); 

Это оно!

«Есть онлайн?»

«Кто в сети» — очень распространенная функция для систем обмена сообщениями. Мы реализуем его в довольно простом формате, введя имя пользователя в красную или зеленую маркировку независимо от того, подключены они к сети или нет.

Установка флага «онлайн» для определенных пользователей может быть выполнена с контроллера, но мы собираемся использовать для этого другой канал (который также будет использоваться для уведомления, когда кто-то подключается к сети или отключается). Мы также собираемся использовать Redis, поэтому установите его, чтобы увидеть результат в действии.

Прежде всего, добавьте гем redis-rb

Gemfile

 gem 'redis' 

и установить его

 $ bundle install 

Затем создайте новый канал Appearances :

JavaScripts / каналы / appearances.coffee

 jQuery(document).on 'turbolinks:load', -> App.personal_chat = App.cable.subscriptions.create { channel: "AppearancesChannel" }, connected: -> disconnected: -> received: (data) -> 

По сути, когда пользователь подписывается на этот канал, они онлайн.

Вот реализация на стороне сервера:

Каналы / appearances_channel.rb

 class AppearancesChannel < ApplicationCable::Channel def subscribed redis.set("user_#{current_user.id}_online", "1") stream_from("appearances_channel") ActionCable.server.broadcast "appearances_channel", user_id: current_user.id, online: true end def unsubscribed redis.del("user_#{current_user.id}_online") ActionCable.server.broadcast "appearances_channel", user_id: current_user.id, online: false end private def redis Redis.new end end 

Когда кто-то подписан, установите «user_SOME_ID_online» в «1» в Redis. Затем начните потоковую передачу и отправьте сообщение о том, что пользователь сейчас онлайн.

Когда пользователь отписывается, мы удаляем ключ «user_SOME_ID_online» и снова транслируем сообщение. Довольно просто, правда.

Пока мы кодируем серверную часть, вводим новый online? Метод экземпляра для класса User :

модели / user.rb

 def online? !Redis.new.get("user_#{self.id}_online").nil? end 

Следующий шаг — обернуть все имена пользователей тегом span и присвоить ему правильный класс. Чтобы упростить этот процесс, добавьте следующий вспомогательный метод:

application_helper.rb

 def online_status(user) content_tag :span, user.name, class: "user-#{user.id} online_status #{'online' if user.online?}" end 

Теперь этот помощник можно использовать в любом представлении. Я буду использовать его в части _conversation :

просмотров / разговоры / _conversation.html.erb

 Chatting with <%= online_status conversation.with(current_user) %> 

Теперь код received обратного вызова. Следует просто добавить или удалить online класс:

appearances.coffee

 received: (data) -> user = $(".user-#{data['user_id']}") user.toggleClass 'online', data['online'] 

Либо добавьте, либо удалите класс в зависимости от значения data['online'] .

Конечно, нужно немного стиля:

 .online_status { color: red; transition: color 1s ease; &.online { color: green; } } 

Я добавил transition чтобы сделать вещи немного более плавными. Конечно, вместо того, чтобы стилизовать имена каким-либо цветом, вы можете, например, добавить к ним значок, используя псевдоселекторы :after или :before .

Публикация в Heroku

Конечно, в какой-то момент вы можете поделиться своим приложением со всем миром. Я объясню, как это сделать, используя Heroku в качестве примера.

Прежде всего, вам нужно установить Redis. Heroku предоставляет набор дополнений (или элементов, как они теперь называются), добавляющих поддержку Redis. Я собираюсь использовать Redis Cloud . Установите бесплатную версию, набрав:

 $ heroku addons:create rediscloud 

Теперь скажите ActionCable использовать Redis Cloud:

конфиг / cable.yml

 production: adapter: redis url: <%= ENV["REDISCLOUD_URL"] %> 

Следует также отметить, что когда вы говорите Redis.new , redis-rb попытается подключиться к localhost:6379 ( 6379 — порт по умолчанию). Это нормально для развития, но не особенно хорошо для производства. Поэтому я создал этот простой инициализатор:

конфигурации / инициализирует / set_redis_url.rb

 ENV['REDIS_URL'] = ENV["REDISCLOUD_URL"] if ENV["REDISCLOUD_URL"] 

По умолчанию redis-rb сначала пытается подключиться к URL-адресу, заданному в ENV['REDIS_URL'] и использует localhost:6379 в качестве запасного варианта.

Еще одна вещь, которую необходимо сделать, — это создание Procfile, в котором Heroku будет использовать Puma в качестве веб-сервера:

PROCFILE

 web: bundle exec puma -C config/puma.rb 

Документацию по конфигурации для Puma можно найти здесь .

Последний шаг — установка URL-адреса ActionCable и добавление разрешенных источников для производственной среды. Для моего демонстрационного приложения конфигурация следующая:

конфигурации / среда / production.rb

 config.action_cable.allowed_request_origins = ['https://sitepoint-custom-messaging.herokuapp.com', 'http://sitepoint-custom-messaging.herokuapp.com'] config.action_cable.url = "wss://sitepoint-custom-messaging.herokuapp.com/cable" 

Ваш URL будет другим.

Это все! После выполнения этих шагов вы можете развернуть свое приложение в Heroku и протестировать его.

Вывод

Это была долгая дорога. В этих двух статьях мы создали приложение для обмена сообщениями, позволяющее общаться один на один. Затем мы заставили его работать в режиме реального времени с помощью ActionCable и Redis. Конечно, этот код может быть доработан и может быть введено много новых функций. Если вы создадите что-то интересное на основе моего кода, поделитесь им!

На этом мы заканчиваем статью об обмене сообщениями и ActionCable. Как всегда, я благодарю вас за то, что вы остались со мной до конца и до скорой встречи!