В предыдущей части этой статьи мы создали приложение для обмена сообщениями, которое позволяет пользователям входить в систему через Facebook или Twitter и оставлять свои комментарии. Мы также снабдили форму комментирования AJAX, чтобы страница не перезагружалась при каждом публикации комментария.
Однако в нашем приложении есть проблема — пользователям приходится перезагружать страницу каждый раз, когда они хотят проверить наличие новых комментариев. Это не очень удобно, не так ли? Как мы можем решить эту проблему?
Существует как минимум два решения: опрос AJAX и веб-сокеты. Первый довольно устарел, однако он все еще может использоваться в некоторых сценариях, где мгновенные обновления не требуются. Последнее кажется более подходящим для нашего случая. Однако давайте реализуем их оба, чтобы увидеть, какой из них лучший!
Исходный код доступен на GitHub .
Исходный код для опроса AJAX доступен в ветке comments_polling .
Рабочую демонстрацию можно найти по адресу http://sitepoint-minichat.herokuapp.com/ .
AJAX Polling
Я рекомендую перейти на новую ветку (если вы используете Git), чтобы начать реализацию этой функциональности:
$ git checkout -b polling
Основная идея опроса AJAX заключается в том, что у нас есть какой-то таймер, который срабатывает через пару секунд и отправляет запрос, проверяя, есть ли новые комментарии. Новые комментарии затем отображаются на странице. Цикл повторяется.
Однако есть некоторые проблемы с этой схемой. Прежде всего, мы отправляем эти запросы на опрос постоянно,
даже если нет новых комментариев. Во-вторых, новые комментарии не появятся на странице мгновенно — они будут отображаться только на следующем этапе опроса. Существует также третья проблема с опросом, о которой мы поговорим чуть позже.
Чтобы начать реализацию, создайте новый index
маршрут для проверки новых комментариев:
конфиг / routes.rb
[...] resources :comments, only: [:new, :create, :index] [...]
Если вы используете Turbolinks и еще не включили гем jquery-turbolinks , сделайте это сейчас, иначе jQuery.ready()
по умолчанию не будет работать:
Gemfile
[...] gem 'jquery-turbolinks' [...]
application.js
[...] //= require jquery.turbolinks //= require turbolinks
Теперь создайте новый файл .coffee со следующим кодом:
polling.coffee:
window.Poll = -> setTimeout -> $.get('/comments') , 5000 jQuery -> Poll() if $('#comments').size() > 0
Не забудьте запросить этот файл:
application.js
[...] //= require polling
Мы создаем новую функцию Poll
для window
объекта (иначе она не будет видна
другие скрипты). Эта функция устанавливает время ожидания, которое срабатывает через 5 секунд, и отправляет запрос GET на ресурс /comments
. Вызывайте эту функцию сразу после загрузки DOM, если на #comments
присутствует блок #comments
. Вместо этого мы могли бы использовать setInterval
, но если наш сервер реагирует медленно, мы можем столкнуться с ситуацией, когда мы отправляем запросы слишком быстро.
Теперь создайте метод index
и соответствующее представление:
comments_controller.rb
[...] def index @comments = Comment.order('created_at DESC') end [...]
комментарии / index.js.erb
$('#comments').find('.media-list').html('<%= j render @comments %>'); window.Poll();
Мы заменяем все комментарии новыми и снова включаем таймер. Вы, вероятно, думаете, что это не оптимальное решение, и вы правы. Но пока, запустите сервер, откройте свое приложение в двух отдельных окнах и попытайтесь опубликовать некоторые комментарии. Ваши комментарии должны появиться в обоих окнах.
Отлично, теперь вы можете общаться с самим собой, когда вам скучно!
Давайте оптимизируем наше решение и заставим действие index
возвращать только новые комментарии, а не все. Один из
Для этого нужно указать id
последнего комментария, доступного на странице. Таким образом, ресурс будет загружать только комментарии с id
превышающим предоставленный.
Чтобы реализовать это, нам нужно немного подправить наш комментарий:
комментарии / _comment.html.erb
<li class="media comment" data-id="<%= comment.id %>"> [...]
Теперь у каждого комментария есть атрибут data-id
хранящий его id
. Также измените метод index
:
comments_controller.rb
[...] def index @comments = Comment.where('id > ?', params[:after_id].to_i).order('created_at DESC') end [...]
Единственное, что нам нужно сделать сейчас, это отправить запрос GET и указать id
последнего комментария на странице. Однако, здесь все становится немного сложнее.
Представьте себе такую ситуацию: пользователь A и пользователь B открыли наше приложение для обмена сообщениями. Пользователь A опубликовал свой комментарий с идентификатором 50. Затем пользователь B также разместил комментарий, и ему был присвоен идентификатор 51. Теперь начинается фаза опроса. Со стороны пользователя А все работает нормально — отправляется запрос GET с идентификатором 50, и поэтому отображается новый комментарий с идентификатором 51.
Однако пользователь B не увидит комментарий пользователя A, поскольку будет отправлен запрос GET с идентификатором 51, а 50 меньше 51, поэтому новые комментарии для пользователя B отображаться не будут! Чтобы преодолеть эту проблему, мы можем принудительно инициировать фазу опроса после публикации комментария.
Давайте немного реорганизовать наш JS-код:
polling.coffee
window.Poller = { poll: (timeout) -> if timeout is 0 Poller.request() else this.pollTimeout = setTimeout -> Poller.request() , timeout || 5000 clear: -> clearTimeout(this.pollTimeout) request: -> first_id = $('.comment').first().data('id') $.get('/comments', after_id: first_id) } jQuery -> Poller.poll() if $('#comments').size() > 0
Я заменил функцию Poller
объектом Poller
. Интересная часть здесь — функция poll
. Эта функция принимает один аргумент — timeout
. Если время timeout
равно нулю, инициируйте запрос опроса сразу, без создания таймеров. Вы можете подумать, что это условие не нужно, потому что setTimeout(function() {}, 0);
должен стрелять мгновенно. Однако, это не так. В действительности, перед вызовом функции все равно будет задержка, поэтому нам вообще не нужен setTimeout
.
Если время timeout
не равно нулю, создайте функцию pollTimeout
и присоедините ее к объекту Poller
. Обратите внимание, что мы предоставляем значение по умолчанию 5000 для тайм-аута.
clear
просто очищает pollTimeout
.
request
— это фактический запрос GET, отправленный в /comments
. Он также предоставляет параметр after_id
который равен идентификатору самого нового (первого) комментария на странице.
Нам также нужно настроить представление index.js.erb
:
комментарии / index.js.erb
$('#comments').find('.media-list').prepend('<%= j render @comments %>'); Poller.poll();
Мы изменили метод html
на prepend
а функцию Poll()
на Poller.poll()
.
Наконец, нам также нужно изменить create.js.erb
:
комментарии / create.js.erb
Poller.clear(); Poller.poll(0); <% if @comment.new_record? %> alert('Your comment cannot be saved.'); <% else %> $('#comment_body').val(''); <% end %>
Как только комментарий будет опубликован, очистите текущий таймер и начните фазу опроса. Также обратите внимание, что здесь мы не добавляем комментарий, мы полагаемся на index.js.erb.
сделать это.
Теперь это довольно грязно, не так ли?
Чтобы проверить, работает ли это правильно, откройте свое приложение в двух разных окнах, оставьте комментарий в первом окне, а затем быстро (до фазы опроса) оставьте другой комментарий во втором окне. Вы должны увидеть оба комментария в правильном порядке.
Опрос AJAX теперь работает правильно. Тем не менее, это не кажется лучшим решением. Сохранят ли веб-сокеты день?
Веб-сокеты и Фэй
В этой последней итерации мы будем использовать Faye — систему обмена сообщениями «публикация / подписка», доступную для Ruby и Node.js. Чтобы интегрировать его с Rails, пригодится гем faye-rails от James Coglan .
Если в предыдущей итерации мы регулярно опрашивали наше приложение для проверки новых комментариев, здесь мы вместо этого подписываемся на обновления и предпринимаем какие-либо действия, только если опубликовано обновление (комментарий). Когда комментарий создан, оповестите всех подписчиков, чтобы процесс обновления происходил мгновенно.
Вернитесь в master
ветку и начните с добавления двух новых гемов в Gemfile.
Gemfile
[...] gem 'faye-rails', '~> 2.0` gem 'thin'
и его установка
$ bundle install
Мы должны переключиться на тонкий веб-сервер , потому что Фэй не будет работать с WEBrick. Если вы собираетесь опубликовать свое приложение на Heroku, создайте новый файл в корне вашего проекта:
PROCFILE
web: bundle exec rails server thin -p $PORT -e $RACK_ENV
Теперь добавьте эти строки в application.rb :
конфиг / application.rb
[...] config.middleware.delete Rack::Lock config.middleware.use FayeRails::Middleware, mount: '/faye', :timeout => 25 [...]
Мы используем FayeRails в качестве промежуточного программного обеспечения и монтируем его по пути /faye
.
Теперь требуется два новых файла в application.js :
application.js
[...] //= require faye //= require comments
Файл faye.js предоставлен faye-rails
. Он содержит весь необходимый JS-код для корректной работы Faye.
Создайте файл comments.coffee :
comments.coffee
window.client = new Faye.Client('/faye') jQuery -> client.subscribe '/comments', (payload) -> $('#comments').find('.media-list').prepend(payload.message) if payload.message
Мы создаем новый клиент Faye и прикрепляем его к объекту window
. Подпишите этого клиента на канал /comments
. Как только обновление будет получено, добавьте новое сообщение в список комментариев. Это намного проще, чем опрос AJAX, не так ли?
Последнее, что нужно сделать, это изменить представление create.js.erb :
комментарии / create.js.erb
publisher = client.publish('/comments', { message: '<%= j render @comment %>' });
Здесь client
используется для публикации обновления в канале /comments
с сообщением, которое было опубликовано. Обратите внимание, что нам не нужно использовать метод prepend
потому что он используется в нашем файле comments.coffee .
Запустите сервер и снова пообщайтесь с самим собой (надеюсь, вы не впадаете в депрессию). Обратите внимание, что сообщения появляются мгновенно, в отличие от опроса AJAX. Более того, код проще и короче.
Последнее, что мы можем сделать, это отключить кнопку «Опубликовать» во время отправки комментария:
comments.coffee
[...] jQuery -> client.subscribe '/comments', (payload) -> $('#comments').find('.media-list').prepend(payload.message) if payload.message $('#new_comment').submit -> $(this).find("input[type='submit']").val('Sending...').prop('disabled', true)
Метод prop
используется для установки атрибута disabled
на input
в значение true
.
С помощью обратных вызовов Faye мы можем вернуть кнопку в исходное состояние и очистить текстовую область, как только комментарий будет опубликован. Если при уведомлении подписчиков произошла ошибка, отобразите ее также:
комментарии / create.js.erb
publisher = client.publish('/comments', { message: '<%= j render @comment %>' }); publisher.callback(function() { $('#comment_body').val(''); $('#new_comment').find("input[type='submit']").val('Submit').prop('disabled', false) }); publisher.errback(function() { alert('There was an error while posting your comment.'); });
Как это круто? С веб-сокетами наше приложение похоже на настоящий чат. Сообщения отправляются с минимальной задержкой и обновление страницы не требуется! Конечно, это приложение может быть расширено с помощью бэкенда для пользователей-администраторов, позволяющего им удалять комментарии или блокировать недобросовестных участников чата.
Вы также можете взглянуть на демонстрационное приложение Faye-rails — очень простой чат без какой-либо базы данных или аутентификации.
Безопасность
Я должен сказать пару слов о безопасности. В настоящее время любой желающий может отправить любые данные на канал /comments
, что не очень безопасно. На веб-сайте Faye есть раздел «Безопасность», в котором описано, как ограничить подписки, публикации, обеспечить защиту CSRF и некоторые другие концепции. Я рекомендую вам прочитать его, если вы собираетесь реализовать Фэй в реальном приложении.
Для этой демонстрации мы собираемся обеспечить защиту CSRF — для этого потребуется изменить код как на стороне сервера, так и на стороне клиента. Самый простой способ решить эту задачу — использовать так называемые расширения Фэй:
конфиг / application.rb
require File.expand_path('../boot', __FILE__) require File.expand_path('../csrf_protection', __FILE__) [...] class Application < Rails::Application config.middleware.use FayeRails::Middleware, extensions: [CsrfProtection.new], mount: '/faye', :timeout => 25 [...]
конфиг / csrf_protection.rb
class CsrfProtection def incoming(message, request, callback) session_token = request.session['_csrf_token'] message_token = message['ext'] && message['ext'].delete('csrfToken') unless session_token == message_token message['error'] = '401::Access denied' end callback.call(message) end end
Мы проверяем, установлен ли _csrf_token
в сеансе пользователя, и сравниваем его с токеном, отправленным вместе с сообщением. Если они не совпадают, мы выдаем ошибку «Отказано в доступе».
На клиентском коде:
comments.coffee
window.client = new Faye.Client('/faye') client.addExtension { outgoing: (message, callback) -> message.ext = message.ext || {} message.ext.csrfToken = $('meta[name=csrf-token]').attr('content') callback(message) }
Используя метод addExtension
, сохраните csrfToken
внутри атрибута ext
сообщения. Таким образом, все публикуемые сообщения будут включать токен CSRF. Если вы удалите эту строку client.addExtension
, откройте свое приложение вместе с консолью javascript. Браузер пытается установить соединение, но терпит неудачу, потому что нам нужен токен CSRF.
Вывод
Мы подошли к концу этой статьи из двух частей. Все запланированные функции были реализованы, и мы создали прототип AJAX по сравнению с веб-сокетами, подтверждая то, что мы уже знали: веб-сокеты намного лучше для реального общения.
Вы когда-нибудь реализовывали подобное решение? С какими проблемами вы столкнулись? Поделитесь своим опытом в комментариях!
Спасибо за прочтение!
============================
Обновление (08.12.2014):
Один из читателей заметил, что при переходе в чат, затем на другую страницу и возвращении в чат все отправленные сообщения будут дублироваться. Это происходит потому, что каждый раз, когда вы посещаете страницу, клиент снова подписывается на канал `/ comments`, поэтому, когда вы публикуете сообщение, оно транслируется по всем подпискам. Чтобы решить эту проблему, вы можете просто попробовать отписаться перед подпиской. Добавьте следующий фрагмент кода в файл comments.coffee :
try client.unsubscribe '/comments' catch console?.log "Can't unsubscribe." # print a message only if console is defined end
как раз перед
client.subscribe '/comments', (payload) ->
линия. Смотрите этот diff на GitHub:
Большое спасибо за ваши отзывы!
============================