Статьи

Создайте приложение для чата с Rails 5, ActionCable и Devise

В Rails 5 появилось множество новых замечательных функций, но одна из самых ожидаемых — конечно же ActionCable . ActionCable легко интегрирует WebSockets в ваше приложение и предлагает как клиентские JS, так и серверные платформы Ruby. Таким образом, вы можете писать функции в реальном времени в тех же стилях, что и остальные приложения, что действительно здорово.

Узнайте больше о ruby ​​с нашим учебным пособием Имитация поведения пользователя и протестируйте приложение Ruby на SitePoint.

Несколько месяцев назад я написал серию статей, описывающих, как создавать мини-чат с Rails с использованием AJAX , WebSockets на основе событий Faye и Server-sent . Эти статьи привлекли некоторое внимание, поэтому я решил написать новую часть этой серии, в которой объясняется, как использовать ActionCable для достижения той же цели.

Однако на этот раз мы столкнемся с более сложной задачей и обсудим следующие темы:

  • Подготовка приложения и интеграция Devise
  • Знакомство с чатами
  • Настройка ActionCable
  • Кодирование на стороне клиента
  • Кодирование на стороне сервера с помощью фоновых заданий
  • Представляем базовую авторизацию для ActionCable
  • Подготовка приложения для развертывания в Heroku

Исходный код можно найти на GitHub .

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

Подготовка заявки

Начните с создания нового приложения. Поддержка ActionCable была добавлена ​​только в Rails 5, поэтому вам придется использовать эту версию (в настоящее время 5.0.0.rc1 является последней):

$ rails new CableChat -T 

Теперь добавьте пару драгоценных камней:

Gemfile

 [...] gem 'devise' gem 'bootstrap', '~> 4.0.0.alpha3' [...] 

Devise будет использоваться для аутентификации и авторизации (вы можете прочитать эту статью, чтобы узнать больше) и Bootstrap 4 — для стилизации.

Бегать

 $ bundle install 

Добавьте стили Bootstrap:

таблицы стилей / application.scss

 @import "bootstrap"; 

Выполните следующие команды для установки Devise, создания новой модели User и копирования представлений для дальнейшей настройки:

 $ rails generate devise:install $ rails generate devise User $ rails generate devise:views $ rails db:migrate 

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

application_controller.rb

 [...] before_action :authenticate_user! [...] 

Чаты

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

 $ rails g model ChatRoom title:string user:references $ rails db:migrate 

В чат-комнате должен быть создатель, поэтому убедитесь, что вы установили связь один-ко-многим между chat_rooms и users :

модели / chat_room.rb

 [...] belongs_to :user [...] 

модели / users.rb

 [...] has_many :chat_rooms, dependent: :destroy [...] 

Код контроллера, чтобы вывести список и создать комнаты чата:

chat_rooms_controller.rb

 class ChatRoomsController < ApplicationController def index @chat_rooms = ChatRoom.all end def new @chat_room = ChatRoom.new end def create @chat_room = current_user.chat_rooms.build(chat_room_params) if @chat_room.save flash[:success] = 'Chat room added!' redirect_to chat_rooms_path else render 'new' end end private def chat_room_params params.require(:chat_room).permit(:title) end end 

Теперь куча действительно простых представлений:

просмотров / chat_rooms / index.html.erb

 <h1>Chat rooms</h1> <p class="lead"><%= link_to 'New chat room', new_chat_room_path, class: 'btn btn-primary' %></p> <ul> <%= render @chat_rooms %> </ul> 

просмотров / chat_rooms / _chat_room.html.erb

 <li><%= link_to "Enter #{chat_room.title}", chat_room_path(chat_room) %></li> 

просмотров / chat_rooms / new.html.erb

 <h1>Add chat room</h1> <%= form_for @chat_room do |f| %> <div class="form-group"> <%= f.label :title %> <%= f.text_field :title, autofocus: true, class: 'form-control' %> </div> <%= f.submit "Add!", class: 'btn btn-primary' %> <% end %> 

Сообщения

Главная звезда нашего приложения — это, конечно, сообщение в чате. Он должен принадлежать как пользователю, так и чату. Чтобы попасть туда, запустите следующее:

 $ rails g model Message body:text user:references chat_room:references $ rails db:migrate 

Обязательно установите правильные отношения:

модели / chat_room.rb

 [...] belongs_to :user has_many :messages, dependent: :destroy [...] 

модели / users.rb

 [...] has_many :chat_rooms, dependent: :destroy has_many :messages, dependent: :destroy [...] 

Модели / message.rb

 [...] belongs_to :user belongs_to :chat_room [...] 

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

chat_rooms_controller.rb

 [...] def show @chat_room = ChatRoom.includes(:messages).find_by(id: params[:id]) end [...] 

Обратите внимание на метод include, используемый здесь для быстрой загрузки.

Теперь мнения:

просмотров / chat_rooms / show.html.erb

 <h1><%= @chat_room.title %></h1> <div id="messages"> <%= render @chat_room.messages %> </div> 

просмотров / сообщений / _message.html.erb

 <div class="card"> <div class="card-block"> <div class="row"> <div class="col-md-1"> <%= gravatar_for message.user %> </div> <div class="col-md-11"> <p class="card-text"> <span class="text-muted"><%= message.user.name %> at <%= message.timestamp %> says</span><br> <%= message.body %> </p> </div> </div> </div> </div> 

В этой части используются три новых метода: user.name , message.timestamp и gravatar_for . Чтобы создать имя, давайте просто удалим часть домена из электронной почты пользователя (конечно, в реальном приложении вы бы хотели, чтобы они вводили имя при регистрации или на странице «Редактирование профиля»):

модели / user.rb

 [...] def name email.split('@')[0] end [...] 

timestamp использует strftime для представления даты создания сообщения в удобном для пользователя формате:

Модели / message.rb

 [...] def timestamp created_at.strftime('%H:%M:%S %d %B %Y') end [...] 

gravatar_for — это помощник для отображения граватара пользователя:

application_helper.rb

 module ApplicationHelper def gravatar_for(user, opts = {}) opts[:alt] = user.name image_tag "https://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(user.email)}?s=#{opts.delete(:size) { 40 }}", opts end end 

Последние две вещи, которые нужно сделать, это немного стилизовать контейнер сообщений:

 #messages { max-height: 450px; overflow-y: auto; .avatar { margin: 0.5rem; } } 

Добавить маршруты:

конфиг / routes.rb

 [...] resources :chat_rooms, only: [:new, :create, :show, :index] root 'chat_rooms#index' [...] 

Наконец, подготовка завершена, и мы можем приступить к кодированию основных функций нашего чата.

Добавление ActionCable

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

Прежде чем продолжить, установите Redis на свой компьютер, если у вас его еще нет. Redis доступен также для nix , через Homebrew и для Windows .

Затем настройте Gemfile:

Gemfile

 [...] gem 'redis', '~> 3.2' [...] 

и беги

 $ bundle install 

Теперь вы можете изменить файл config / cable.yml, чтобы использовать Redis в качестве адаптера:

конфиг / cable.yml

 [...] adapter: redis url: YOUR_URL [...] 

Или просто используйте adapter: async (значение по умолчанию).

Кроме того, измените ваш route.rb для монтирования ActionCable на некоторых URL:

конфиг / routes.rb

 [...] mount ActionCable.server => '/cable' [...] 

Убедитесь, что внутри каталога javascripts есть файл cable.js с таким содержимым:

JavaScripts / cable.js

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

Этот файл должен быть обязательным внутри application.js :

JavaScripts / application.js

 [...] //= require cable [...] 

Consumer — это клиент подключения к веб-сокету, который может подписаться на один или несколько каналов. Каждый сервер ActionCable может обрабатывать несколько соединений. Channel похож на контроллер MVC и используется для потоковой передачи. Вы можете прочитать больше о терминологии ActionCable здесь .

Итак, давайте создадим новый канал:

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

 App.global_chat = App.cable.subscriptions.create { channel: "ChatRoomsChannel" chat_room_id: '' }, 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) -> # Data received send_message: (message, chat_room_id) -> @perform 'send_message', message: message, chat_room_id: chat_room_id 

Здесь мы в основном подписываем потребителя на ChatRoomsChannel и передаем идентификатор текущей комнаты (на данный момент мы ничего не передаем, но это будет исправлено в ближайшее время). Подписка имеет ряд самоочевидных обратных вызовов: connected , disconnected и received . Также подписка определяет основную функцию ( send_message ), которая вызывает метод с тем же именем сервера и передает ему необходимые данные.

Конечно, нам нужна форма, позволяющая пользователям отправлять свои сообщения:

просмотров / chat_rooms / show.html.erb

 <%= form_for @message, url: '#' do |f| %> <div class="form-group"> <%= f.label :body %> <%= f.text_area :body, class: 'form-control' %> <small class="text-muted">From 2 to 1000 characters</small> </div> <%= f.submit "Post", class: 'btn btn-primary btn-lg' %> <% end %> 

@message экземпляра @message должна быть установлена ​​внутри контроллера:

chat_rooms_controller.rb

 [...] def show @chat_room = ChatRoom.includes(:messages).find_by(id: params[:id]) @message = Message.new end [...] 

Конечно, вы можете использовать базовый тег form вместо того, чтобы полагаться на конструктор форм Rails, но это позволяет нам позже воспользоваться такими вещами, как переводы I18n.

Давайте также добавим некоторые проверки для сообщений:

Модели / message.rb

 [...] validates :body, presence: true, length: {minimum: 2, maximum: 1000} [...] 

Еще одна проблема, которую необходимо решить, — предоставить нашему сценарию идентификатор комнаты. Давайте решим это с помощью HTML-атрибута data- :

просмотров / chat_rooms / show.html.erb

 [...] <div id="messages" data-chat-room-id="<%= @chat_room.id %>"> <%= render @chat_room.messages %> </div> [...] 

Имея это на месте, мы можем использовать идентификатор комнаты в скрипте:

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

 jQuery(document).on 'turbolinks:load', -> messages = $('#messages') if $('#messages').length > 0 App.global_chat = App.cable.subscriptions.create { channel: "ChatRoomsChannel" chat_room_id: messages.data('chat-room-id') }, 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) -> # Data received send_message: (message, chat_room_id) -> @perform 'send_message', message: message, chat_room_id: chat_room_id 

Обратите внимание на часть jQuery(document).on 'turbolinks:load' . Это должно быть сделано, только если вы используете Turbolinks 5, который поддерживает это новое событие. Можно подумать об использовании jquery-turbolinks для возврата событий jQuery по умолчанию, но, к сожалению, это не совместимо с Turbolinks 5 .

Логика сценария довольно проста: проверьте, есть ли на #messages блок #messages и, если да, подпишитесь на канал, #messages идентификатор комнаты. Следующим шагом является прослушивание события submit формы:

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

 jQuery(document).on 'turbolinks:load', -> messages = $('#messages') if $('#messages').length > 0 App.global_chat = App.cable.subscriptions.create # ... $('#new_message').submit (e) -> $this = $(this) textarea = $this.find('#message_body') if $.trim(textarea.val()).length > 1 App.global_chat.send_message textarea.val(), messages.data('chat-room-id') textarea.val('') e.preventDefault() return false 

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

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

Нашей следующей задачей будет внедрение канала на нашем сервере. В Rails 5 есть новый каталог под названием channels для их размещения, поэтому создайте там файл chat_rooms_channel.rb :

Каналы / chat_rooms_channel.rb

 class ChatRoomsChannel < ApplicationCable::Channel def subscribed stream_from "chat_rooms_#{params['chat_room_id']}_channel" end def unsubscribed # Any cleanup needed when channel is unsubscribed end def send_message(data) # process data sent from the page end end 

subscribed — это специальный метод для начала потоковой передачи с канала с заданным именем. Пока у нас несколько комнат, название канала будет отличаться. Помните, мы предоставили chat_room_id: messages.data('chat-room-id') при подписке на канал в нашем скрипте? Благодаря этому chat_room_id можно получить внутри subscribed метода, вызвав params['chat_room_id'] .

unsubscribed является обратным вызовом, который срабатывает, когда потоковая передача остановлена, но мы не будем использовать ее в этой демонстрации.

Последний метод — send_message — срабатывает, когда мы запускаем @perform 'send_message', message: message, chat_room_id: chat_room_id из нашего скрипта. Переменная data содержит хеш отправленных данных, поэтому, например, чтобы получить доступ к сообщению, вы должны ввести data['message'] .

Есть несколько способов передать полученное сообщение, но я собираюсь показать вам очень удобное решение, основанное на демонстрации, предоставленной DHH (я также нашел эту статью с немного другим подходом).

Прежде всего, измените метод send_message :

Каналы / chat_rooms_channel.rb

 [...] def send_message(data) current_user.messages.create!(body: data['message'], chat_room_id: data['chat_room_id']) end [...] 

Как только мы получим сообщение, сохраните его в базе данных. Вам даже не нужно проверять, существует ли предоставленная комната чата — по умолчанию в Rails 5 должен существовать родительский элемент записи, чтобы сохранить ее. Это поведение может быть изменено путем установки belongs_to optional: true для отношения belongs_to (о других изменениях в Rails 5 читайте здесь ).

Однако есть проблема — метод Devise current_user не доступен для нас здесь. Чтобы это исправить, измените файл connection.rb в каталоге application_cable :

Каналы / 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 if verified_user = env['warden'].user verified_user else reject_unauthorized_connection end end end end 

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

Вызов logger.add_tags 'ActionCable', current_user.email используется для отображения отладочной информации в консоли, поэтому вы увидите вывод, подобный следующему:

 [ActionCable] [[email protected]] Registered connection (Z2lkOi8vY2FibGUtY2hhdC9Vc2VyLzE) [ActionCable] [[email protected]] ChatRoomsChannel is transmitting the subscription confirmation [ActionCable] [[email protected]] ChatRoomsChannel is streaming from chat_rooms_1_channel 

Под капотом Devise использует Warden для аутентификации, поэтому env['warden'].user пытается выбрать текущего вошедшего в систему пользователя. Если он не найден, reject_unauthorized_connection запрещает трансляцию.

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

Модели / message.rb

 [...] after_create_commit { MessageBroadcastJob.perform_later(self) } [...] 

В этом обратном вызове self является сохраненным сообщением, поэтому мы в основном передаем его на работу. Напишите работу сейчас:

вакансии / message_broadcast_job.rb

 class MessageBroadcastJob < ApplicationJob queue_as :default def perform(message) ActionCable.server.broadcast "chat_rooms_#{message.chat_room.id}_channel", message: 'MESSAGE_HTML' end end 

Метод execute осуществляет фактическую трансляцию, но как насчет данных, которые мы хотим транслировать? Еще раз, есть несколько способов решить эту проблему. Вы можете отправить JSON с данными сообщения, а затем на стороне клиента использовать шаблонизатор, такой как Handlebars . В этой демонстрации, однако, давайте отправим разметку HTML из партиала messages / _message.html.erb, который мы создали ранее. Эта часть может быть визуализирована с помощью контроллера:

вакансии / message_broadcast_job.rb

 class MessageBroadcastJob < ApplicationJob queue_as :default def perform(message) ActionCable.server.broadcast "chat_rooms_#{message.chat_room.id}_channel", message: render_message(message) end private def render_message(message) MessagesController.render partial: 'messages/message', locals: {message: message} end end 

Для того чтобы это работало, вам нужно создать пустой MessagesController :

messages_controller.rb

 class MessagesController < ApplicationController end 

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

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

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

 [...] App.global_chat = App.cable.subscriptions.create { channel: "ChatRoomsChannel" chat_room_id: messages.data('chat-room-id') }, 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) -> messages.append data['message'] send_message: (message, chat_room_id) -> @perform 'send_message', message: message, chat_room_id: chat_room_id [...] 

Единственное, что мне здесь не очень нравится, это то, что по умолчанию пользователь видит старые сообщения, а новые размещаются внизу. Вы можете использовать метод order для их правильной сортировки и заменить append на prepend внутри received обратного вызова, но я бы хотел, чтобы наш чат вел себя как Slack. В Slack новые сообщения также размещаются внизу, но окно чата автоматически прокручивается к ним. Этого легко добиться с помощью следующей функции, которая вызывается после загрузки страницы:

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

 jQuery(document).on 'turbolinks:load', -> messages = $('#messages') if $('#messages').length > 0 messages_to_bottom = -> messages.scrollTop(messages.prop("scrollHeight")) messages_to_bottom() App.global_chat = App.cable.subscriptions.create # ... 

Давайте также перейдем к нижней части, как только появится новое сообщение (потому что по умолчанию оно не будет сфокусировано):

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

 [...] received: (data) -> messages.append data['message'] messages_to_bottom() [...] 

Большой! Проверьте полученный скрипт на GitHub .

Толчок к Героку

Если вы хотите отправить новый блестящий чат в Heroku, необходимо предпринять некоторые дополнительные действия. Прежде всего, вам нужно будет установить дополнение Redis. Есть много дополнений на выбор: например, вы можете использовать Rediscloud . Когда аддон установлен, настройте cable.yml, чтобы предоставить правильный URL-адрес Redis. Для Rediscloud он хранится в переменной среды ENV["REDISCLOUD_URL"] :

конфиг / cable.yml

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

Следующим шагом является список разрешенных источников для подписки на каналы:

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

 [...] config.action_cable.allowed_request_origins = ['https://your_app.herokuapp.com', 'http://your_app.herokuapp.com'] [...] 

Наконец, вы должны предоставить URL-адрес ActionCable. До тех пор, пока в файлеways.rb есть mount ActionCable.server => '/cable' , соответствующий параметр должен быть следующим:

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

 [...] config.action_cable.url = "wss://sitepoint-actioncable.herokuapp.com/cable" [...] 

Имея это, вы можете отправить свой код в Heroku и наблюдать за результатом. Ура!

Вывод

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

Надеюсь, вы нашли эту статью полезной и интересной. Вы уже пробовали использовать ActionCable? Тебе понравилось это? Поделитесь своим мнением в комментариях. Следите за мной в Twitter, чтобы быть первым, кто узнает о моих статьях, и до скорой встречи!

Узнайте больше о ruby ​​с нашим учебным пособием Имитация поведения пользователя и протестируйте приложение Ruby на SitePoint.