Rails MVC aan je laars lappen met Mongrel handlers

Geplaatst door Remco van 't Veer ma, 24 dec 2007 11:18:00 GMT

Module View Controller is mooi maar soms moet je heel creatief zijn om een oplossing in die mal te proppen. In dergelijke gevallen is een meer lowlevel aanpak veel doeltreffender. In het geval waar ik tegenaan gelopen ben, wil ik audio streams serveren vanuit een Rails applicatie. Om precies te zijn: ik wil MP3 bestanden automatisch omzetten naar een lager bitrate en deze over het netwerk sturen zodat er aan de andere kant naar geluisterd kan worden. Daar is weinig MVC aan, al is het alleen maar dat er niets te view-en valt!

Nog even in het kort, was is Mongrel? Mongrel is een webserver (grotendeels) geschreven in Ruby. Ruby versie 1.8 is standaard uitgerust met een web server genaamd WEBrick maar deze is niet snel en efficient genoeg voor openbare productie web applicaties. Mongrel daarentegen is een kruising tussen een windhond en een poedel, snel en niet stuk te krijgen. Het beestje is multi-threaded en kan daarmee honderden aanvragen tegenlijk afhandelen.

Waarschijnlijk is Mongrel (of een roedel Mongrels) op dit moment de meest gebruikte webserver om Rails te hosten. Als ik dan toch al Mongrel gebruik, en dus verschrikkelijk veel gelijktijdige aanvragen kan afhandelen, waarom zou ik dan niet gewoon toch m’n audio streams MVC modeleren en m’n data met de send_data methode versturen? Het volgende stukje Mongrel code is het probleem:

@guard.synchronize {
  @active_request_path = request.params[Mongrel::Const::PATH_INFO] 
  Dispatcher.dispatch(cgi, ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS, response.body)
  @active_request_path = nil
}
Bron: mongrel-1.1.2/lib/mongrel/rails.rb

Hier is Dispatcher.dispatch de Rails code welke een verzoek afhandelt. Het probleem zit hem in dat @guard.synchronize statement, deze synchronisatie op een Mutex zorgt er voor dat een enkel Mongrel proces maar één Rails verzoek tegenlijk af kan handelen. Geen situatie waarin je audio streams wilt serveren, vooral omdat veel MP3 spelers maar een paar seconden bufferen en streams dus bijna de hele looptijd een netwerk verbinding open houden.

Simpele handler

Laten we een simpele Mongrel handler maken. We vergeten onze Rails applicatie voorlopig en beginnen met een simpele hello-world variant:
require 'rubygems'
require 'mongrel'

class HelloWorldHandler < Mongrel::HttpHandler
  def process(request, response)
    response.start(200) do |head,out|
      head['Content-Type'] = "text/plain" 
      out << "Hello cruel world!" 
    end
  end
end

Mongrel::Configurator.new do
  listener :port => 3000 do
    uri "/", :handler => HelloWorldHandler.new
  end
  run; join
end

Maak van het bovenstaande een Ruby bestand, draai het met ruby hello_word_handler.rb en wijs je browser naar http://localhost:3000/.

Mooi maar wat gebeurd er allemaal? In het eerste blok wordt een Mongrel handler gedefinieerd. Deze handler heeft een enkele methode genaamd process welke een request en response object aanneemt. Een HTTP aanvraag wordt afgehandeld door deze methode. Onze methode geeft altijd een 200 OK antwoord. Er wordt aangegeven welk type informatie er terug gegeven gaat worden, "text/plain", en het antwoord wordt samengesteld, "Hello cruel world!". In het tweede blok wordt Mongrel duidelijk gemaakt dat er geluisterd moet worden op poortnummer 3000 en dat alle aanvragen die beginnen met "/" moeten worden afgehandeld door een instantie van HelloWorldHandler.

Heel simpel eigenlijk en voor iemand die wel eens een Java Servlet of WEBrick Servlet gemaakt heeft, niet veel nieuws. Wat nog wel interessant is om op te merken: er wordt maar een enkele instantie van de handler gebruikt. Dat maakt de kosten van een afhandeling erg laag. M’n laptopje kan bijvoorbeeld gemakkelijk 700 verzoeken per seconden afhandelen met de bovenstaande code, tegenover 30 per seconden met een soort gelijke Rails controller. Dat is niet mis!

Streaming

Toch is een handler als de bovenstaande niet bruikbaar om streams te serveren. Het probleem van deze standaard aanpak (met de response.start methode) is dat het hele antwoord pas over het netwerk wordt gestuurd als het blok dat aan de start methode meegegeven is, afgerond is. Dat betekent dus dat het gehele antwoord eerst in het geheugen opgebouwd wordt en voor megabytes aan audio stream is dat niet zo’n goed idee. Daarbij komt ook nog eens dat de MP3 eerst omgezet moet worden naar een lager bitrate en we eigenlijk het complete resultaat van die omzetting niet helemaal af wil wachten maar zo snel mogelijk willen streamen.

Laten we eerst het streamen van data eens bekijken. Het volgende voorbeeld laat het antwoord langzaam terug druppelen over het netwerk (gebruik curl of telnet om het effect goed te kunnen zien):

require 'rubygems'
require 'mongrel'

class KoekoekHandler < Mongrel::HttpHandler
  def process(request, response)
    response.status = 200
    response.send_status(nil)

    response.header['Content-Type] = "text/plain" 
    response.send_header

    5.times do
      response.write("Koekoek\r\n")
      sleep 1
    end

    response.done
  end
end

Mongrel::Configurator.new do
  listener :port => 3000 do
    uri "/", :handler => KoekoekHandler.new
  end
  run; join
end

Zoals je ziet moeten we ons wat bewuster zijn van de opbouw van een HTTP response; eerst de status regel sturen (die nil geeft aan dat we niet weten hoeveel data we gaan sturen), dan de headers en vervolgens de data. Allemaal niet zo ingewikkeld, we gaan meteen verder met audio omzetten en streamen:

require 'rubygems'
require 'mongrel'

class StreamHandler < Mongrel::HttpHandler
  def process(request, response)
    response.status = 200
    response.send_status(nil)

    response.header['Content-Type'] = "audio/mpeg" 
    response.send_header

    fn = File.join('/BigFatDisc', request.params['PATH_INFO'])
    IO.popen("lame --quiet --preset 16 #{fn} -") do |trans|
      while data = trans.read(1024)
        response.write(data)
      end
    end

    response.done
  end
end

Mongrel::Configurator.new do
  listener :port => 3000 do
    uri "/", :handler => StreamHandler.new
  end
  run; join
end

Nu hebben we een recoding-audio-stream server die alle MP3 van /BigFatDisc kan om zetten met lame naar een 16kbps stream. Maak van het bovenstaande een Ruby bestand, verander /BigFatDisc naar een directory waar je MP3’s hebt staan, controleer of je lame geïnstalleerd hebt en open een stream met je favoriete MP3 speler (appeltje-U in iTunes); http://localhost:3000/various/some_track.mp3.

Samen met Rails

Nu we een stream kunnen leveren is het tijd om onze StreamHandler in Rails hangen. Ik plaats m’n extra handlers in een Rails applicatie in de lib directory van m’n applicatie. Alles onder app is me namelijk veel te MVC.

Rails met een extra handler opstarten is heel simpel. Het mongrel_rails command heeft een speciale parameter voor extra configuratie scripts; de -S switch. Scripts die met deze parameter meegegeven worden, worden binnen de context van het listener blok uitgevoerd. Dit betekent dat we:

Mongrel::Configurator.new do
  listener :port => 3000 do
    uri "/", :handler => StreamHandler.new
  end
  run; join
end

kunnen vervangen door:

uri "/stream", :handler => StreamHandler.new

Merk op dat ik "/" meteen vervangen heb door "/stream" om te voorkomen dat m’n Rails applicatie niet meer aanbod komt en alle aanvragen aan de StreamHandler aangeboden worden. Nu kan je de StreamHandler meenemen als je je applicatie opstart met:

mongrel_rails start -S lib/stream_handler.rb

Als je je Mongrels in een roedel houdt met Mongrel Cluster, gebruik dan:

  config_script: lib/streamer.rb

in je configuratie file.

Vanuit de Rails applicatie kunnen we nu een URL naar een stream maken door simpelweg de root_url (of wat je dan ook geroute hebt naar ”/”) en er "stream/#{track.filename}" achter te plakken (gegeven dat de filename methode in je track model een relative locatie geeft van de bij behorende MP3 in jouw /BigFatDisc directory). Nu kan je M3U of XSPF playlists opbouwen!

Uiteraard leveren bestandsnamen met tekens daarin die niet im het path onderdeel van een URL passen een probleem. Zie RFC1738 voor de details. Overigens heb je, in je handler, toegang tot je ActiveRecord models en kan je ook met record-identifiers aan de slag. Maar wees een beetje paranoia, de eerder besproken synchronisatie in de Mongrel Rails handler zit er vast niet voor niets..

Klaar?

Dit is nog maar het begin van een volwaardige stream handler; caching van omgezette MP3’s moet nog geïmplementeerd worden, sommige MP3 players willen persé weten hoeveel data er overgezet gaat worden en als je seeking wilt ondersteunen zul je een 206 Partial Content antwoord moeten kunnen geven.

Maar je hebt genoeg voor je public beta! ;)

Geplaatst in , ,  | 5 reacties

Reacties

  1. Lucas zei ongeveer 22 uur later:

    Mischien is merb een idee, daar kun je je controller een Proc object laten returnen. Ofwel, binnen je controller werk je in een Mutex (want ja, ActiveRecord is niet bepaald thread safe), en vervolgens return je een Proc object dat je mp3 data streamed.

    Overigens, mp3 on the fly reencoden naar een lagere bitrate kost een aardige hoeveelheid CPU, gereencode mp3s cachen is aan te raden.

  2. Remco zei ongeveer 23 uur later:

    Uiteraard heb ik merb bekeken en overwogen te gebruiken. In het gegeven voorbeeld, had ik echter al een Rails applicatie en gebruikte een externe streaming applicatie. Deze applicatie was aan vervanging toe.

  3. Lucas zei 1 dag later:

    Ah, ok. Ik had eenzelfde soort probleem, alleen moest het ook nog wat op webradio lijken, dus heb ik de libice bindings gebruikt.

  4. Andy zei 7 dagen later:

    Voor dit soort streaming oplossingen met Rails maak ik vaak gebruik van X-Sendfile support in Apache en/of lighttpd. Zie http://blog.lighttpd.net/articles/2006/07/02/x-sendfile voor een korte maar krachtige uitleg. Wat je in wezen doet is dat je Rails controller de file content prepareert i.e. on-the-fly transcoding of de cached versie opzoekt en vervolgens in een HTTP X-Sendfile header lijntje een lokaal disk pad naar het te streamen bestand opgeeft. Vervolgens pikt Apache danwel lighty dit verzoek tot streamen op en klaar. Schaalt erg goed.

    Los daarvan zijn Mongrel handlers suuuper cool als je toch nets iets meer controle wilt in de HTTP request/response cycle dan Rails je toelaat. Echt schitterend als je de zoveelste 4K regels Java servlet kan vervangen met 100 regels aan Mongrel handler shait :)

  5. Klaas Jan zei 10 dagen later:

    curl werkt alleen met de koekoek alsvolgt:

    curl—ignore-content-length http://localhost:3000/

(Laat url/e-mail achter »)

   Voorvertoning