Стриминг в Rails 4

  • Tutorial


Что такое стриминг?


Стриминг крутился около Rails начиная с версии 3.2, но он был ограничен исключительно стримингом шаблонов. Rails 4 же вышел с более зрелым функционалом стриминга в реальном времени. По сути это значит что Rails сейчас способен нативно обрабатывать I/O объекты и посылать данные клиенту в риалтайме.

Streaming и Live — два отдельных модуля, реализованных внутри ActionController'а. Streaming включен по умолчанию, в то время как Live должен быть явно добавлен непосредственно в контроллере.

Основной api стриминга использует класс Fiber (доступен с версии ruby 1.9.2). Файберы предоставляют инструментарий для потоко-подобного параллелизма в ruby. Fiber дает возможность потокам приостанавливаться и возобновлять работу по желанию программиста, а не быть по сути упреждающими.

Стриминг шаблонов


Стриминг инвертирует обычный процесс рендеринга лэйаута и шаблона. По умолчанию Rails рендерит сначала шаблон, а потом лэйаут. Первое что он делает, запускает yield и загружает шаблон. После этого, рендерятся ассеты и лэйаут.

Рассмотрим action который делает много запросов, например:

class TimelineController
  def index
    @users = User.all
    @tickets = Ticket.all
    @attachments = Attachment.all
  end
end

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

Давайте добавим стриминг:

class TimelineController
  def index
    @users = User.all
    @tickets = Ticket.all
    @attachments = Attachment.all
    render stream: true
  end
end

Метод render stream: true лениво загрузит все запросы и даст им возможность выполняться после того как ассеты и лэйаут будут отрендерены. Стриминг работает с шаблонами и только с ними (но не с json или xml). Это дает хороший способ передать приоритет шаблонам основываясь на типе страницы и её содержимом.

Добавим чего-нибудь внутрь


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

Ведь так как база данных еще не вернула ответ на момент рендеринга шаблонов, попытка обратиться к инстансным переменным приведет к ошибке.

Следовательно, чтобы загрузить такие аттрибуты как title и meta нужно использовать content_for вместо привычного yield'а. Тем не менее, yield все еще будет работать для body.

Ранее наш метод выглядел как-то так:

<%= yield :title %>

Теперь же он будет выглядеть так:

<%= content_for :title, "My Awesome Title" %>

Становимся живее с Live API


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

Так как мы работаем в контексте стриминга и параллелизма, WEBrick нам тут не товарищ. Будем использовать Puma ибо она умеет работать с потоками.

Добавим пуму в Gemfile и запустим bundle.

gem "puma"

:~/testapp$ bundle install

Puma хорошо интегрируется с Rails, так что если вы теперь запустите rails s, Puma запустится на том же порту что и WEBRick.

:~/testapp$ rails s
=> Booting Puma
=> Rails 4.0.0 application starting in development on http://0.0.0.0:3000
=> Run `rails server -h` for more startup options
=> Ctrl-C to shutdown server
Puma 2.3.0 starting...
* Min threads: 0, max threads: 16
* Environment: development
* Listening on tcp://0.0.0.0:3000

Давайте по-быстрому сгенерируем контроллер для отправки сообщений.

:~/testapp$ rails g controller messaging

И добавим простой метод для их стриминга.

class MessagingController < ApplicationController
  include ActionController::Live

  def send_message
    response.headers['Content-Type'] = 'text/event-stream'
    10.times {
      response.stream.write "This is a test Message\n"
      sleep 1
    }
    response.stream.close
  end
end

Добавим роут в routes.rb:

get 'messaging' => 'messaging#send_message'

Теперь мы можем получить доступ к стриму например с помощью curl:

:~/testapp$ curl -i http://localhost:3000/messaging
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-UA-Compatible: chrome=1
Content-Type: text/event-stream
Cache-Control: no-cache
Set-Cookie: request_method=GET; path=/
X-Request-Id: 68c6b7c7-4f5f-46cc-9923-95778033eee7
X-Runtime: 0.846080
Transfer-Encoding: chunked
This is a test message
This is a test message
This is a test message
This is a test message

Каждый раз когда запускается метод send_message, Puma создает новый поток, в котором производит стриминг данных для отдельного клиента. По умолчанию Puma сконфигурирована чтоб обрабатывать до 16 параллельных потоков, что значит 16 одновременно подключенных клентов. Конечно же это количество можно увеличить, но это несколько увеличит расход памяти.

Давайте-ка создадим форму и посмотрим можем ли мы послать какие-нибудь данные на view:

def send_message
  response.headers['Content-Type'] = 'text/event-stream'
  10.times {
    response.stream.write "#{params[:message]}\n"
    sleep 1
  }
  response.stream.close
end

Делаем форму для посылки данных в стрим:

<%= form_tag messaging_path, :method => 'get' do %>
  <%= text_field_tag :message, params[:message] %>
  <%= submit_tag "Post Message" %>
<% end %>

И настроим роут:

get  'messaging' => 'messaging#send_message', :as => 'messaging'

Как только вы введете сообщение и нажмете «Post Message», браузер получит потоковый ответ в виде загружаемого текстового файла, который содержит ваше сообщение, залогированное 10 раз.



Здесь, однако, стрим не знает куда посылать данные и в каком формате, поэтому то он их и пишет в текстовый файл на сервере.

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

:~/testapp$ curl -i http://localhost:3000/messaging?message="awesome"
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-UA-Compatible: chrome=1
Content-Type: text/event-stream
Cache-Control: no-cache
Set-Cookie: request_method=GET; path=/
X-Request-Id: 382bbf75-7d32-47c4-a767-576ec59cc364
X-Runtime: 0.055470
Transfer-Encoding: chunked
awesome
awesome

События на стороне сервера (Server Side Events)


HTML5 предоставляет метод, называемый Server Side Events (SSE). SSE это метод доступный браузеру, который распознает и инициирует события каждый раз когда сервер присылает данные.

Мы можем использовать SSE в сочетании с Live API чтобы наладить двухстороннюю связь сервера с клиентом.

По умолчанию Rails предоставляет только одностороннюю комуникацию — позволяет потоково посылать клиенту данные как только они становятся доступны. Однако если мы добавим SSE то сможем использовать события и ответы в двухстороннем режиме.

Простой SSE выглядит приблизительно так:

require 'json'

module ServerSide
  class SSE
    def initialize io
      @io = io
    end

    def write object, options = {}
      options.each do |k,v|
        @io.write "#{k}: #{v}\n"
      end
      @io.write "data: #{object}\n\n"
    end

    def close
      @io.close
    end
  end
end

Данный модуль получает объект I/O стрима в хэш и конвертирует его в пару ключ-значение так, чтоб его можно было легко читать, хранить и отсылать обратно в формате JSON.

Теперь можно обернуть наш стрим-объект в SSE. Во-первых, подключим SSE модуль к контроллеру. Теперь открытие и закрытие стрима регулируется SSE модулем. Также, если не завершить явно, цикл будет повторяться бесконечно и подключение будет открыто всегда, поэтому мы добавим условие ensure, чтобы удостовериться что стрим таки закроется.

require 'server_side/sse'

class MessagingController < ApplicationController
  include ActionController::Live

  def stream
    response.headers['Content-Type'] = 'text/event-stream'
    sse = ServerSide::SSE.new(response.stream)
    begin
      loop do
        sse.write({ :message => "#{params[:message]}" })
        sleep 1
      end
    rescue IOError
    ensure
      sse.close
    end
  end
end

Этот код выдаст ответ вроде этого:

:~/testapp$ curl -i http://localhost:3000/messaging?message="awesome"
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-UA-Compatible: chrome=1
Content-Type: text/event-stream
Cache-Control: no-cache
Set-Cookie: request_method=GET; path=/
X-Request-Id: b922a2eb-9358-429b-b1bb-015421ab8526
X-Runtime: 0.067414
Transfer-Encoding: chunked
data: {:message=>"awesome"}
data: {:message=>"awesome"}

Подводные камни


Будьте внимательны, есть пара подводных камней (куда ж без них то):

  1. Все стримы должны быть закрыты явно, иначе они будут открыты всегда.
  2. Вы должны удостовериться в том что ваш код потокобезопасен, так как контроллер всегда порождает новый поток при запуске метода.
  3. После первой порции ответа хедеры нельзя изменить в write или close


Заключение


Это возможность, которую многие давно искали в Rails, потому как она может существенно увеличить производительность приложений (стриминг шаблонов) и составить серьезную конкуренцию node.js (Live).

Некоторые уже проводят бенчмарки, сравнивают, но я думаю что это только самое начало и должно пройти время (читай несколько релизов) чтобы эта фича, так сказать, дозрела. Сейчас же это хороший старт и волнующая возможность попробовать всё в деле.

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

Оригинал тут.
Сможет ли Rails через n месяцев/лет стать полноценной заменой Node.js в реал-тайм приложениях?

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

Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 30
  • 0
    Спасибо! Для веб-приложений это одна из фундаметальных фитчий (а рельсы для RIA во-многом и сделаны)
    Долго ждал поста на эту тему на хабре, я перешел с nodejs на rails. И использую sse с live для организации COMET'a. Но т.к. для того чтобы это работало нормально нужно добавить в конфиг

    config.action_controller.perform_caching = false

    и при каждом изменении даже JS перезагружать сервер (это очень сильно замедляет процесс разработки)
    Кто-нибудь с этим сталкивался? Что делать в подобной ситуации? Я уже писал на stackoverflow
    stackoverflow.com/questions/17778517/caching-js-with-config-cache-classes
    Но там все молчат…
    • 0
      Ну вы же наверное не меняете js прям на сервере и не разрабыватываете прям там, так ведь?
      А все отлаживаете на локальной машине и потом через capistrano деплоите, да?
      • 0
        Я деплою на heroku, но это сейчас не важно.
        Я же сервер запускаю локально и работающий SSE через live streaming мне тоже нужен локально.
        И перезапускать мне нужно локальный сервер. (в данном случае puma)
        • +1
          puma не кеширует. перезапускать сервер не нужно. так что вариант один, вы что-то не то сделали с настройками development
          • 0
            Очень странно!
            Вот development.rb: gist.github.com/Timopheym/6096257
            Я был бы Вам очень благодарен, потому работать просто невозможно…
            • 0
              config.cache_classes = false
              ...
              config.cache_classes = true

              Вы не находите это странным?
              • –1
                Какая разница? Просто перезаписывается поле объекта. Я убрал

                config.cache_classes = false

                Это не помогло…
                • 0
                  Может стоит все-таки убрать кеширование в девелопменте? Зачем его оставлять? На config.cache_classes завязано так же config.action_view.cache_template_loading.

                  • 0
                    config.cache_classes нужно оставить потому что без него будет висеть sse соединение.
                    это необходимо для live streaming.
      • 0
        Всем спасибо, я нашел ответ, и отписался на stackoverflow.
      • 0
        Поправьте, пожалуйста, если я не прав.
        1. Из коробки нормально(держа нагрузку) стриминг работать если и должен, то только на jruby/rubinius, где есть настоящие треды, причём если верить последнему выпуску rubynoname подкаста, то у jruby с puma всё не совсем гладко, а rubinius в продакшене и вовсе мало кто использует.
        2. На mri для стриминга нужно выносить стримящий сервер отдельно от основного приложения, чтобы он был в отдельном процессе, который не будет принимать прочие http запросы. И даже так, непонятно будет ли он держать хоть какую-то нагрузку.

        Было бы конечно очень интересно посмотреть на бенчмарки, если кто-то их делал.
        Голосовал за «Никогда», хотя если бы был вариант «Возможно, когда-нибудь», то выбрал бы его.

        Только вот если рельсы не могут предложить достойной альтернативы ноде, это не значит, что в руби мире нет ничего подобного. Ведь есть eventmachine, celluloid(тут может совсем не прав, про него лишь мельком слышал) и очень простой в использовании как минимум для простеньких задач faye, основанный на том же eventmachine.
        • 0
          На сколько я себе это представляю, пума запускает для каждого клиента, который требует стриминга, отдельный инстанс rails-приложения. Поэтому настоящие треды тут вроде не нужны, каждое приложение работает в однопоточном режиме, многопоточная только сама пума. Главное чтобы приложение было потокобезопасным, то есть все операции должны быть атомарными (но это уже тема отдельной статьи).
          • 0
            Отдельный инстанс не создается, создается отдельный поток.
            Настоящие треды нужны, если много ruby-кода. Если же большую часть поток занят внешними операциями (io, сеть) то сойдут и MRI зеленые потоки.
            • 0
              Отдельный инстанс не создается, создается отдельный поток.
              Настоящие треды нужны, если много ruby-кода.

              По этим же соображениям и написал, что для стриминга под MRI скорее всего нужно запускать отдельный процесс веб-сервера пумы, чтобы получить хоть какую-то производительность, иначе все ресурсы должны быть заняты обработкой обычных http запросов.
              • 0
                Диспетчер выдаёт кванты времени потокам независимо от того, занимается ли поток обработкой «обычных» запросов или stream'ает, то есть ресурсы делятся честно.
              • 0
                В MRI начиная с 1.9 нет зелёных потоков.
          • 0
            Преподносится как какое-то чудо. В Sinatra это уже около трёх лет как есть, причём отдельный thread для каждого stream'а не создаётся, а засчёт использования stream do stream закрывается сам.
            • 0
              А как обеспечивается параллельность? или Вы про async_sinatra?
              • 0
                А откуда вообще параллельность в MRI? Почитайте про GIL.
                async_sinatra вообще ни при чём. Как только вы послали что-то в сокет, текущий обработчик отдаёт управление eventmachine'е, которая передаёт управление следующему желающему, который тоже что-то посылает или принимает.
                • 0
                  Я Вам это же могу посоветовать почитать, т.к. Вы не правы. Кратко: MRI не выполняет одновременно более одного рубишного кода. Как только в поток заходит в C-extension или в IO операцию — MRI передает управление другому потоку.
                  Вот тут и проявляется параллельность.
                  Поэтому если в приложении медленный IO, то потоки в MRI имеют смысл.
                  • 0
                    Вы опять же ошибаетесь. Теперь как минимум уже в трёх вещах.

                    Первое — проверка на флаг прерывания исполнения проверяется после того, как C код вычислил и готов вернуть значение в Ruby код.

                    Второе — все операции IO в stdlib Ruby — блокирующие, и IO операция никак не отличается по поведению от случая с вызовом любого другого метода, написанного на C. Специально для решения этой проблемы и написан EventMachine, который, используя свой диспетчер переключает на следующего желающего при IO операциях. При этом вытесняющей многозадачностью EM не занимается, то есть он не может отнять управление у потока, как это делает Ruby диспетчер потоков.

                    Третье — параллельность и многопоточность — это далеко не одно и то же в MRI Ruby. Точка исполнения всегда одна, и если у вас исполняется какой-то Ruby код, или C код, который вызван из Ruby, то больше ровным счётом ничего в тот самый момент времени не исполняется в рамках процесса. Это собственно хорошо объясняет тот факт, что даже если запустить чтение файлов (например, STDIN, который теоретически может быть бесконечно быстр) в разных потоках, то нагрузка будет только на одно ядро процессора, и никак не затронет другие ядра.

                    Заметьте, тему процессов, которые из себя представляют совершенно другую вещь, мы здесь не затрагиваем.
                    • 0
                      Вы заблуждаетесь:

                      Давайте разберемся с самого начала:
                      1. В MRI 1.9 применяются нативные threads. Каждый раз когда создается экземпляр класса Thread — создается нативный thread OS.
                      2. При выполнении ruby кода (или C-кода внутри ruby) блокируется GIL. Чтобы выполнялся только один thread.
                      3. IO операции: операции с файлами, сетевые операции — действительно блокируемые.
                      4. Но до входа в IO операции освобождается GIL (или как Вы называете флаг прерывания), благодаря этому спящие потоки получают управление.
                      5. Кастомные C-extensions (nokogiri, json parser, etc) которые напрямую не работают с структурами MRI также освобождают GIL
                      6. В EM совсем другой принцип — есть всего один поток, но все (в идеале) IO операции неблокируемые.
                      7. EM использует паттерн Reactor — используются средства OS (epoll,select,etc) для опроса дескрипторов, готовых для чтения/записи.
                      8. В этом случае ruby код должен быть как можно более быстрым, чтобы быстрей вернуть управление реактору.
                      9. В EM есть еще thread pools, но мы их не рассматриваем, т.к. являются workaround для блокируемых операций (типа клиента mysql).

                      Ваши заблуждения:
                      1. C-код (C-extendsions) который не работает с MRI структурами — может освобождать GIL и будет задействовано более одного ядра.
                      2. EM переключает не на следующего желающего — а на тот блок кода (callback), для дескриптора которого OS сообщает, что готовы данные для чтения, или запись завершена и тп.

                      Т.к. C-код как правило выполняется достаточно быстро — профит получается небольшой.

                      Вот ссылки для дальнейшего чтения:
                      merbist.com/2011/02/22/concurrency-in-ruby-explained/
                      stackoverflow.com/questions/1203565/native-threads-in-ruby-1-9-1-whats-in-it-for-me
                      yehudakatz.com/2010/08/14/threads-in-ruby-enough-already/
                      www.igvita.com/2008/11/13/concurrency-is-a-myth-in-ruby/

                      Процессы тут совершенно ни причем, и мы не рассматриваем их.
                      • 0
                        В данном случае я не заблуждаюсь, а опустил детали. Да, действительно, GVL можно отпускать, но в контексте http серверов этого не происходит, так как nokogiri не участвует в процессе. В крайнем случае это может делать redcarpet, но чтобы кто-то из шаблонизаторов отпускал GVL я не слышал (в slim, haml и их подспудном tilt'е вообще нет C кода).
                        По поводу EM 1) не только callback'и на чтение, есть ещё события по таймеру. 2) EM не переключает на обработчик, запись по требованию которого была завершена, пока не дойдёт его очередь, в конец которой он был помещён при начале записи.
                        • 0
                          Вы «опускаете» очень важные детали — именно те детали, из-за которых у нас и завязалась дискуссия.
                          Другими словами, Вы пытаетесь вывернуться, и сами себе противоречите:

                          Выше Вы писали:
                          Точка исполнения всегда одна, и если у вас исполняется какой-то Ruby код, или C код, который вызван из Ruby, то больше ровным счётом ничего в тот самый момент времени не исполняется в рамках процесса. Это собственно хорошо объясняет тот факт, что даже если запустить чтение файлов (например, STDIN, который теоретически может быть бесконечно быстр) в разных потоках, то нагрузка будет только на одно ядро процессора, и никак не затронет другие ядра.

                          я разъяснил Вам ваше заблуждение — нагрузка может распределятся на разные ядра, это очень важная деталь, без которой MRI потоки (а тем более что они реализованы через native threads) теряют смысл.

                          Т.е. Вы же не будете отрицать, что в ~99% случаев web-приложениях используются внешние данные — запросы к БД, внешним источникам (IO операции) и тп? Причем, чаще всего эти операции выполняются дольше чем ruby-код. Именно здесь и идет выигрыш, и задействуются дополнительные ядра.
                          Да и с чего Вы взяли что в приложениях не применяются C-extensions наподобие nokogiri? JSON-parser/encoder'ы? посмотрите Yajl например. Но больше обратите внимание на IO операции.
                  • 0
                    Причем тут sinatra и eventmachine. Вы путаете разные понятия. sinatra некоим образом не завязана на EM.
                    Какая eventmachine может быть, если sinatra запущена, например из под passenger?

                    Я использую в нагруженном проекте async_sinatra + em + fibers + thin и знаю о чем говорю.
                    • 0
                      Sinatra не завязан на EM? Да ну?
                      def stream(keep_open = false)
                            scheduler = env['async.callback'] ? EventMachine : Stream
                      


                      Вы удивитесь, когда узнаете, что async_sinatra в этой связке лишний.
                      • 0
                        Хорошо, согласен. Скажем так: sinatra пытается использовать EM если запущена под EM сервером, но EM не обязателен
                        Если вернуться к первоначальному вопросу — то sinatra+em и rails+threads разные инструменты, которые нельзя сравнивать напрямую, именно поэтому он у меня и возник, т.к. Вы не озвучили, что имеете в виду именно EM.
                        • 0
                          Ну сейчас-то всё ясно для обоих случаев?
                  • +1
                    нееененекропост:
                    Это вам надо прочитать про GIL. yehudakatz.com/2010/08/14/threads-in-ruby-enough-already/
                    Другое дело, что в Rails до 4 версии был Rack::Lock.

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