Статьи

Мини-чат с Rails и событиями, отправленными на сервер

Концепция совместного использования облаков

Недавно я написал пару статей о создании приложения для чата с Rails ( Мини-чат с Rails и Мини-чат в реальном времени с Rails и Faye — ознакомьтесь с рабочей демонстрацией здесь ). В этих статьях я объяснил, как создать простое приложение для мини-чата и сделать его действительно асинхронным, используя две альтернативы: опрос AJAX (менее предпочтительно) и веб-сокеты.

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

Мы будем использовать то же приложение Rails из предыдущих статей, поэтому, если вы хотите следовать, клонируйте его из соответствующего репозитория GitHub . Окончательная версия кода находится в ветке SSE .

Темы, которые будут рассмотрены в этой статье:

  • Общий обзор отправленных сервером событий
  • Использование Rails 4 ActionController::Live для потоковой передачи
  • Базовая настройка веб-сервера Puma
  • Использование PostgreSQL LISTEN / NOTIFY для отправки уведомлений
  • Рендеринг новых комментариев с помощью JSON и HTML (с помощью шаблонов underscore.js)

Время начинать!

Главная идея

HTML5 представил API для работы с событиями, отправленными сервером . Основная идея SSE проста: веб-страница подписывается на источник событий на веб-сервере, который передает обновления. Веб-странице не нужно постоянно опрашивать сервер, чтобы проверить наличие обновлений (как мы сделали с опросом AJAX) — они приходят автоматически. Обратите внимание, что сценарий на стороне клиента может только прослушивать обновления, он не может ничего публиковать (сравните это с веб-сокетами, где клиент может подписываться и публиковать). Поэтому все функции публикации выполняются сервером.

Вероятно, одним из основных недостатков SSE является отсутствие поддержки Internet Explorer вообще (вы удивлены?). Тем не менее , есть несколько доступных заполнителей .

Использовать технологию SSE проще, чем веб-сокеты, и для этого есть варианты использования, такие как обновления Twitter или обновление статистики для футбольного (баскетбольного, волейбольного и т. Д.) Матча в режиме реального времени. Возможно, вас заинтересует это обсуждение SO о сравнении веб-сокетов и SSE.

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

  • Создать источник событий на сервере (маршрут и действие контроллера)
  • Добавьте функциональность потоковой передачи (к счастью, существует ActionController::Live , о котором мы вскоре поговорим)
  • Отправляйте уведомление каждый раз при создании комментария, чтобы источник события отправлял обновление клиенту (мы будем использовать PostgreSQL LISTEN / NOTIFY, но есть и другие возможности)
  • Рендерит новый комментарий каждый раз при получении уведомления

Как видите, для выполнения задачи не так много шагов, но некоторые из них были довольно хитрыми. Но мы не боимся трудностей, так что давайте начнем!

Настройка приложения и сервера

Если вы sse клоном исходного кода из этого sse GitHub и переключитесь на новую ветку с именем sse :

 $ git checkout -b sse 

Теперь сделайте некоторую очистку, удалив Faye, которая предоставила нам функциональность Web Sockets. Удалить эту строку из Gemfile

Gemfile

 [...] gem 'faye-rails' [...] 

и из application.js :

application.js

 [...] //= require faye [...] 

Удалите весь код из файла comments.coffee и удалите конфигурацию Faye из application.rb :

конфиг / application.rb

 [...] require File.expand_path('../csrf_protection', __FILE__) [...] config.middleware.delete Rack::Lock config.middleware.use FayeRails::Middleware, extensions: [CsrfProtection.new], mount: '/faye', :timeout => 25 [...] 

Также полностью удалите файл config / csrf_protection.rb . Нам это больше не нужно, потому что клиенты не могут публиковать обновления с SSE.

Добавьте новый метод в модель комментариев, форматируя дату создания комментария:

модели / comment.rb

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

и назовите это из частичного:

комментарии / _comment.html.erb

 [...] <h4 class="media-heading"><%= link_to comment.user.name, comment.user.profile_url, target: '_blank' %> says <small class="text-muted">[at <%= comment.timestamp %>]</small></h4> [...] 

Наконец, упростите действие create в CommentsController :

comments_controller.rb

 [...] def create if current_user @comment = current_user.comments.build(comment_params) @comment.save end end [...] 

Отлично, мы готовы продолжить. Следующим шагом является настройка веб-сервера, поддерживающего многопоточность, который требуется для SSE. Сервер WEBrick по умолчанию буферизует вывод, чтобы он не работал. Для этой демонстрации мы будем использовать Puma — веб-сервер, созданный для скорости и параллелизма Эваном Фениксом и другими. Замените эту строку в вашем Gemfile

Gemfile

 [...] gem 'thin' [...] 

с gem 'puma' и беги

 $ bundle install 

Если вы используете Windows, для установки Puma необходимо выполнить несколько дополнительных шагов:

  • Скачайте и установите пакет DevKit, если по какой-то причине у вас его нет
  • Загрузите и распакуйте пакет разработчика OpenSSL (например, в c: \ openssl )
  • Скопируйте dll-файлы OpenSSL (из openssl / bin ) в каталог ruby / bin
  • Установите Puma, выполнив gem install puma -- --with-opt-dir=c:\openssl

Узнайте больше здесь , если это необходимо.

Пора конфигурировать Пуму. Heroku предоставляет хорошее руководство, объясняющее, как настроить Puma, поэтому давайте использовать его. Создайте новый файл puma.rb в каталоге config и добавьте следующее содержимое:

конфиг / puma.rb

 workers Integer(ENV['PUMA_WORKERS'] || 3) threads Integer(ENV['MIN_THREADS'] || 1), Integer(ENV['MAX_THREADS'] || 16) preload_app! rackup DefaultRackup port ENV['PORT'] || 3000 environment ENV['RACK_ENV'] || 'development' on_worker_boot do # worker specific setup ActiveSupport.on_load(:active_record) do config = ActiveRecord::Base.configurations[Rails.env] || Rails.application.config.database_configuration[Rails.env] config['pool'] = ENV['MAX_THREADS'] || 16 ActiveRecord::Base.establish_connection(config) end end 

Все эти цифры должны быть скорректированы для реального приложения, особенно если вы ожидаете, что много пользователей получат доступ к вашему сайту одновременно (в противном случае доступные соединения будут слишком быстро разряжаться).

Также замените содержимое Procfile (расположенного в корне приложения) на:

PROCFILE

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

Теперь беги

 $ rails s 

чтобы убедиться, что сервер загружается без ошибок. Здорово!

Нам еще предстоит внести еще несколько изменений. Сначала установите для config.eager_load и config.cache_classes в config / environment / development.rb значение true чтобы проверить потоковую передачу и SSE на компьютере разработчика:

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

 [...] config.cache_classes = true config.eager_load = true [...] 

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

Наконец, измените вашу базу данных разработки на PostgreSQL (мы собираемся использовать ее функцию LISTEN / NOTIFY, а SQLite ее не поддерживает). Посетите раздел загрузок, если на вашем компьютере не установлена ​​эта СУБД. Установка PostgreSQL не так уж плоха .

конфиг / database.yml

 [...] development: adapter: postgresql encoding: unicode database: database_name pool: 16 username: username password: password host: localhost port: 5432 [...] 

Установите значение pool , равное значению Puma MAX_THREADS чтобы каждый пользователь мог получить соединение с базой данных.

На данный момент мы закончили со всей необходимой конфигурацией и готовы продолжить!

Потоковый

Следующим шагом является добавление функциональности потоковой передачи для нашего сервера. Для этой задачи необходим метод маршрута и контроллера. Давайте использовать маршрут /comments в качестве источника событий, поэтому изменим файл маршрутов следующим образом:

конфиг / routes.rb

 [...] resources :comments, only: [:new, :create, :index] [...] 

Действие index должно быть оснащено функцией потоковой передачи, и, к счастью, в Rails 4 появился ActionController :: Live, разработанный специально для этой задачи. Все, что нам нужно сделать, это включить этот модуль в наш контроллер

comments_controller.rb

 class CommentsController < ApplicationController include ActionController::Live [...] 

и установите тип ответа в text/event-stream

comments_controller.rb

 [...] def index response.headers['Content-Type'] = 'text/event-stream' [...] 

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

comments_controller.rb

 def index response.headers['Content-Type'] = 'text/event-stream' sse = SSE.new(response.stream) begin Comment.on_change do |data| sse.write(data) end rescue IOError # Client Disconnected ensure sse.close end render nothing: true end 

Мы используем локальную переменную sse для потоковой передачи обновлений. on_change — это метод для прослушивания уведомлений, и мы напишем это через несколько минут. rescue IOError блок rescue IOError предназначен для обработки ситуации, когда пользователь отключился.

Блок ensure здесь жизненно важен — мы должны закрыть поток, чтобы освободить поток.

Метод write принимает несколько аргументов, но он может быть вызван одной строкой:

 sse.write('Test') 

Здесь Test будет передан клиентам. Также может быть предоставлен объект JSON:

 sse.write({name: 'Test'}) 

Мы также можем предоставить имя события, установив event :

 sse.write({name: 'Test'}, event: "event_name") 

Это имя затем используется на стороне клиента, чтобы определить, какое событие было отправлено (для более сложных сценариев). Два других параметра — это retry , что позволяет установить время переподключения и id , используемый для отслеживания порядка событий. Если соединение прерывается при отправке SSE в браузер, сервер получит заголовок Last-Event-ID со значением, равным id .

Хорошо, теперь давайте сосредоточимся на уведомлениях.

Уведомления

Сегодня я решил использовать функциональность PostgreSQL LISTEN / NOTIFY для реализации уведомлений. Однако есть несколько руководств, которые объясняют, как использовать механизм Redis Pub / Sub для той же цели.

Чтобы отправить сообщение NOTIFY, можно использовать обратный вызов after_create :

модели / comment.rb

 [...] after_create :notify_comment_added [...] private def notify_comment_added Comment.connection.execute "NOTIFY comments, 'data'" end [...] 

Сырой SQL используется здесь для отправки данных по каналу comments . Пока неясно, какие данные отправлять, но мы вернемся к этому методу в свое время.

Далее нам нужно написать метод on_change который был представлен на предыдущей итерации. Этот метод прослушивает канал comments :

модели / comment.rb

 [...] class << self def on_change Comment.connection.execute "LISTEN comments" loop do Comment.connection.raw_connection.wait_for_notify do |event, pid, comment| yield comment end end ensure Comment.connection.execute "UNLISTEN comments" end end [...] 

wait_for_notify используется для ожидания уведомления на канале. Как только приходит уведомление (и его данные), мы передаем его (хранится в переменной comment ) методу контроллера:

comments_controller.rb

 [...] Comment.on_change do |data| sse.write(data) end [...] 

Итак, data это комментарий.

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

Подписка на источник событий

Подписаться на источник событий очень просто:

comments.coffee

 source = new EventSource('/comments') 

Слушатели событий могут быть подключены к source . Базовый прослушиватель событий называется onmessage и он запускается, когда поступают данные без именованного события. Как вы помните, на стороне сервера мы можем предоставить опцию event для метода write следующим образом:

 sse.write({name: 'Test'}, event: "event_name") 

Итак, если поле event не установлено, наше onmessage очень простое:

comments.coffee

 source = new EventSource('/comments') source.onmessage = (event) -> console.log event.data 

Если вы используете поле event , следует использовать следующую структуру:

comments.coffee

 source = new EventSource('/comments') source.addEventListener("event_name", (event) -> console.log event.data ) 

Хорошо, мы еще не решили, какие данные будут отправлены, но есть еще несколько вещей, которые нужно сделать, прежде чем мы туда доберемся. Отключите кнопку «Опубликовать» после того, как пользователь отправил свой комментарий:

comments.coffee

 [...] jQuery -> $('#new_comment').submit -> $(this).find("input[type='submit']").val('Sending...').prop('disabled', true) 

Затем, после сохранения комментария, включите кнопку и очистите текстовую область:

комментарии / create.js.erb

 <% if !@comment || @comment.errors.any? %> alert('Your comment cannot be saved.'); <% else %> $('#comment_body').val(''); $('#new_comment').find("input[type='submit']").val('Submit').prop('disabled', false) <% end %> 

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

Соедини это все вместе

Настало время решить последнюю проблему — какие данные будут отправлены клиенту для удобной визуализации вновь добавленного комментария? Я покажу вам два возможных решения: использование JSON и использование HTML (с помощью метода render_to_string ).

Передача данных в формате JSON

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

Создайте новый метод для подготовки данных JSON и вызовите его из notify_comment_added :

модели / comment.rb

 [...] def basic_info_json JSON.generate({user_name: user.name, user_avatar: user.avatar_url, user_profile: user.profile_url, body: body, timestamp: timestamp}) end private def notify_comment_added Comment.connection.execute "NOTIFY comments, '#{self.basic_info_json}'" end [...] 

Как видите, просто сгенерируйте объект JSON, содержащий все данные, необходимые для отображения комментария. Обратите внимание, что эти одинарные кавычки необходимы, потому что мы должны отправить эту строку как часть SQL-запроса. Без одинарных кавычек вы получите ошибку «неверное утверждение».

Используйте эти данные в контроллере:

comments_controller.rb

 def index response.headers['Content-Type'] = 'text/event-stream' sse = SSE.new(response.stream) begin Comment.on_change do |comment| sse.write(comment) end rescue IOError # Client Disconnected ensure sse.close end render nothing: true end 

В контроллере отправьте полученные данные клиенту.

На стороне клиента мы должны проанализировать данные и использовать их для визуализации комментария. Однако, как вы помните, шаблон нашего комментария довольно сложный. Конечно, мы могли бы просто создать переменную JS, содержащую эту разметку HTML, но это было бы утомительно и неудобно. Поэтому вместо этого давайте использовать шаблоны underscore.js (вы можете выбрать другие альтернативы — например, Handlebars.JS ).

Сначала добавьте underscore.js в ваш проект:

Gemfile

 [...] gem 'underscore-rails' [...] 

Бегать

 $ bundle install 

application.js

 [...] //= require underscore [...] 

Затем создайте новый шаблон:

шаблоны / _comment.html

 <script id="comment_temp" type="text/template"> <li class="media comment"> <a href="<%%- user_profile %>" target="_blank" class="pull-left"> <img src="<%%- user_avatar %>" class="media-object" alt="<%%- user_name %>" /> </a> <div class="media-body"> <h4 class="media-heading"> <a href="<%%- user_profile %>" target="_blank"><%%- user_name %></a> says <small class="text-muted">[at <%%- timestamp %>]</small></h4> <p><%%- body %></p> </div> </li> </script> 

Я дал этому шаблону идентификатор comment_temp чтобы легко ссылаться на него позже. Я просто скопировал все содержимое из файла comment / _comment.html.erb и использовал отмечать места, где переменное содержимое должно быть интерполировано.

Мы должны включить этот шаблон на странице:

комментарии / new.html.erb

 [...] <%= render 'templates/comment' %> 

Теперь мы готовы использовать этот шаблон:

comments.coffee

 source = new EventSource('/comments') source.onmessage = (event) -> comment_template = _.template($('#comment_temp').html()) comment = $.parseJSON(event.data) if comment $('#comments').find('.media-list').prepend(comment_template( { body: comment['body'] user_name: comment['user_name'] user_avatar: comment['user_avatar'] user_profile: comment['user_profile'] timestamp: comment['timestamp'] } )) [...] 

comment_template содержит содержимое шаблона, а comment - данные, отправленные сервером. Мы добавляем новый комментарий к списку комментариев, передавая все необходимые данные в шаблон. Brilliant!

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

Передача данных как HTML

Теперь давайте посмотрим на другое решение: - используя метод render_to_string . В этом случае нам нужно будет только отправить идентификатор нового комментария в контроллер, извлечь комментарий, отобразить его шаблон и отправить сгенерированную разметку клиенту. На стороне клиента эту разметку просто нужно вставить на страницу без изменений.

Настройте свою модель:

модели / comments.rb

 [...] private def notify_comment_added Comment.connection.execute "NOTIFY comments, '#{self.id}'" end [...] 

и контроллер:

comments_controller.rb

 def index response.headers['Content-Type'] = 'text/event-stream' sse = SSE.new(response.stream) begin Comment.on_change do |id| comment = Comment.find(id) t = render_to_string(partial: 'comment', formats: [:html], locals: {comment: comment}) sse.write(t) end rescue IOError # Client Disconnected ensure sse.close end render nothing: true end 

Метод render_to_string похож на метод render , но он не отправляет результат в качестве тела ответа в браузер - он сохраняет этот результат в виде строки. Обратите внимание, что мы должны предоставить formats , в противном случае Rails будет искать партиал с форматом text/event-stream (потому что мы установили этот заголовок ответа ранее).

И, наконец, на стороне клиента:

 source = new EventSource('/comments') source.onmessage = (event) -> $('#comments').find('.media-list').prepend($.parseHTML(event.data)) 

[...]

Это легко. Просто добавьте новый комментарий, проанализировав полученную строку.

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

Вывод

При замене веб-сокетов событиями Server-Send мы рассмотрели использование ActionController::Live Puma, ActionController::Live , PostgreSQL LISTEN / NOTIFY и шаблонов underscore.js. В общем, я думаю, что это решение менее предпочтительно, чем веб-сокеты с Faye.

Прежде всего, это связано с дополнительными издержками и работает только с PostgreSQL (или Redis, если вы используете его механику Pub / Sub). Кроме того, кажется, что использование веб-сокетов больше подходит для наших нужд, так как нам нужен механизм подписки и публикации на стороне клиента.

Надеюсь эта статья была вам интересна! Вы когда-нибудь использовали Server-Send Events в своих проектах? Вы бы предпочли использовать веб-сокеты для создания аналогичного чат-приложения? Поделитесь своим мнением, хотелось бы узнать!