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