Blog do Urubatan
msgbartop
Desenvolvedor, Arquiteto, Palestrante, Coordenador do RSJUG, Patinador e Blogger
msgbarbottom

24 Oct 07 Um exemplo de chat com Ruby On Rails e Juggernaut (utilizando AJAX Push)

English Version here
Antes de iniciar a implementação do chat, nos precisamos instalar as dependências do juggernaut, ele precisa das gems json e eventmachine instaladas, para instalar ambas basta executar o seguinte comando:

  • gem install -y json eventmachine

Agora estamos prontos para começar a brincar …
Primeiro, vamos criar uma aplicação rails e instalar o plugin juggernaut com os seguitnes comandos:

  • rails -d sqlite3 chattest
  • cd chattest
  • script/plugin install svn://rubyforge.org//var/svn/juggernaut/trunk/juggernaut

O Juggernaut usa um servidor externo implementando Flash XML Push, e precisamos configurar este servidor, então vamos editar o arquivo: config/juggernaut.yml
Coloquei aqui no blog apenas as configurações que precisei alterar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUSH_PORT: 8080
...
DEFAULT_CHANNELS: 
- "chat"
...
PUSH_HELPER_HOST: "localhost"
...
SECRET: "481516232342edededededed"
...
LOGIN_GET_URL: "http://localhost:3000/session/login"
LOGOUT_GET_URL: "http://localhost:3000/session/logout"
...
SESSION_ID: "_chattest_session_id"
...
BASE64: true

Precisei alterar PUSH_PORT Por que no linux apenas o root pode ouvir em portas inferiores a 1024, e mesmo assim eu não acho que a porta 443 padrão seja uma boa escolha, ja que esta é a porta padrão do protocolo HTTPS.
A linhe com PUSH_HELPER_HOST precisa ser alterada para o mesmo nome do servidor que for utilizado para a acessar a aplicação, em desenvolvimento, localhost deve resolver, mas não esqueça de alterar esta configuração quando for fazer o deploy do seu chat.
LOGIN_GET_URL e LOGOUT_GET_URL são utilizadas para notificar a aplicação quando um usuário conecta ou desconecta, apenas o desconectar é realmente importante para nós.
A SESSION_ID precisa ser igual ao nome do cookie utilizado como marcador de sessão da aplicação, no rails 1.2.x fica no application controller, no rails 2.0 esta configuração vai para o enviroment.rb
e BASE64 é a configuração mágica que habilita o uso dos helpers do rails para geração do Javascript que vai ser utilizado.

Agora vamos começar a criar o layout da aplicação.
Crie um arquivo de nome pubic/stylesheets/public.css com o seguinte conteúdo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
body {
  background-color: white;
}
#users {
  float: left;
  width: 200px;
  height: 400px;
  border-style: inset;
  overflow: auto;
  color: white;
  background-color: gray;
}
#dasd {
  height: 400px;
  margin-left: 5px;
  border-style: inset;
  overflow: auto;
  color: white;
  background-color: gray;
}
#controls {
  clear: both;
  padding: 0 0 0 0;
  height: 55px;
  vertical-align: top;
  border-style: inset;
  overflow: auto;
  color: white;
  background-color: gray;
}

e o arquivo: app/views/layouts/application.rhtml com o seguinte conteúdo.

1
2
3
4
5
6
7
8
9
10
<html>
  <head>
    <title>Chat Test</title>
    <%= stylesheet_link_tag 'public' %>
    <%= javascript_include_tag :defaults %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

Com o layout pronto (sim, esta bem feio, mas eu não sou designer eu sou um desenvolvedor, então solicite ao designer da sua equipe a criação de um novo layout para este chat :D ) vamos criar o código fonte necessário e o banco de dados com estes 4 comandos:

  • script/generate model online_user username:string session_id:string last_seen:date online:boolean
  • script/generate controller session
  • script/generate controller chat index
  • rake db:migrate

Layout pronto, arquivos criados, agora só precisamos escrever o código da aplicação :D
Vamos começar editando o model OnlineUser (app/model/online_user.rb) para adicionar algumas validações, elas não são realmente necessárias, são resquícios da primeira tentativa de implementação do chat, mas mesmo assim mantive elas no código :D

1
2
3
4
class OnlineUser < ActiveRecord::Base
	validates_presence_of :username, :session_id, :last_seen
	validates_uniqueness_of :username, :if => Proc.new {|user| user.online }
end

Agora a view principal da aplicação no arquivo:app/views/chat/index.rhtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- Register with Juggernault -->
<%= listen_to_juggernaut_channels [:generic],session.session_id %>
<!-- The Users List -->
<div id="users">
  <ul id="users_list"></ul>
</div>
<!-- The messages pane -->
<div id="dasd"></div>
<!-- The controls pane (login and send messages) -->
<div id="controls"><%= render :partial => 'login' %></div>
<!-- An util javascript to scroll the messages window -->
<script type="text/javascript">
  function scrollMessages(){
    $('dasd').scrollTop = $('dasd').scrollHeight;
  }
</script>

Como podem ver é bem simples, apenas 3 DIVs, um javascript para fazer o scroll da tela de mensagens e um comando para inicializar o juggernaut no canal “generic” e utilizando o session_id como identificador individual do usuário.
A DIV de mensagens esta com o id dasd por que eu estava testando algumas possibilidades de conflito e esqueci de mudar o ID depois :D

Como pode ser visto na página index, precisaremos de um partial de nome “login” e vamos também precisar de um de nome “controls”, então vamos começar codificando o partial “controls”: app/views/chat/_controls.rhtml

1
2
3
4
5
6
<% form_remote_tag(
      :url => { :action => :say },
      :complete => "$('message').value = '';$('message').focus();" ) do %>
      <%= text_field_tag( 'message', '', { :size => 90, :id => 'message'} ) %>
      <%= submit_tag "Send" %>
  <% end %>

Bastante simples, possui apenas uma tag remote_form_tag que fara um post para a action “say”, um campo de texto para a mensagem e um botão de submit, quando o formulário é enviado o foco volta para o campo message para que o usuário possa digitar a próxima mensagem …

E o partial de login: app/views/chat/_login.rhtml

1
2
3
4
5
6
7
<%= "#{@message}<br/>" if @message %><% form_remote_tag(
      :url => { :action => :login },
      :complete => "$('username').value = ''",
      :after => "$('login').disabled = true" ) do %>
      <%= text_field_tag( 'username', '', { :size => 90, :id => 'username'} ) %>
      <%= submit_tag "Join", :id => 'login' %>
  <% end %>

Bastante parecido com o anterior, mas faz o post para uma action de nome “login”, e o javascript agora desabilita o botão quando o formulário é enviado, e também possui uma mensagem que deve aparecer para o usuário informando que o apelido ja esta sendo utilizado.

Agora todas as views ja estão prontas, podemos passar para a codificação da lógica da aplicação.
Como podemos ver nas views até agora, precisaremos de um controller de nome “chat” com dois métodos: login e say
vamos analisar um pouco o código do chat controller: app/controllers/chat_controller.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class ChatController < ApplicationController
  #this method does not need to exist, but I like to see it here, it only needs to render the index.rhtml view
  def index
  end
  def login
    #creates a new OnlineUser record, this is used to store who are the users that are online now
	@user = OnlineUser.new
	@user.username = Juggernaut.html_and_string_escape params[:username]
	@user.session_id = session.session_id
	@user.online = true
	@user.last_seen = Time.now
        #if we can save, it means that there is no other user with the same nick online, so this user can join the chat
	if @user.save
          #let's save the username in the session for future reference
	  session[:username] = @user.username
          #if there are online users, fill the users box for the new user know who is online
	  @users = OnlineUser.find(:all, :conditions => ["online = true and id != ?", @user.id]) 
          if @users.size >0 
            data = render_to_string(:update) do |page|
              @users.each {|u|
                page.insert_html :bottom, :users_list, %Q{<li id="user_#{u.username}">#{u.username}</li>}
              }
	    end
            #send the javascript only to the new user
            Juggernaut.send_to(@user.session_id, data)
          end
          #create a javascript call to add the new user to the end of the online users list
          data = render_to_string(:update) do |page|
            page.insert_html :bottom, :users_list, %Q{<li id="user_#{@user.username}">#{@user.username}</li>}
            page.insert_html :bottom, :dasd, "<b>user #{@user.username} just joined the chat</b><br/>"
          end
          #add the new user to the chat channel
          Juggernaut.add_channel(@user.session_id, 'chat')
          #send the javascript to all users in the chat channel
          Juggernaut.send_data(data, 'chat')
	  render(:update) do |page|
	    page.replace_html 'controls', :partial => "controls"
          end
	else
          @message = 'This nick name is already in use, please choose another'
	  render(:update) do |page|
	    page.replace_html 'controls', :partial => "login"
	  end
	end
  end
 
  def say
    #escape the message, that way the user can not harm others sending HTML ot JavaScript commands
    message = "#{session[:username]}: #{Juggernaut.html_and_string_escape(params[:message])}"
    #create a javascript to add the new message to the end of the messages screen and scroll the div to the bottom
    data = render_to_string(:update) do |page|
      page.insert_html :bottom, :dasd, "#{message}<br/>"
      page.call "scrollMessages"
    end
    #send the message to all users
    Juggernaut.send_data(data, 'chat')
    render :nothing => true
  end
end

O método say é bastante simples, então vamos começar analisando este:
Primeiro a mensagem é reconstruída adicionando o nome do usuário no inicio da mensagem enviada removendo todos os possíveis códigos HTML ou Javascript para garantir a segurança dos outros usuários.
Então é construido um Javascript utilizando o JavaScriptBuilder do rails e o método render_to_string para adicionar a nova mensagem no final da div “dasd” e fazer o scroll caso necessário.
Então este Javascript é enviado para todos os clientes que estão ouvindo o canal “chat”.

Agora um pouco sobre o método login:

  • nas linhas 7 a 11 apenas criamos e configuramos um model OnlineUser.
  • Se o novo usuário puder ser salvo no banco de dados então não existe nenhum outro usuário online com o mesmo apelido, e podemos continuar, caso contrário mostramos uma mensagem para o usuário para que este escolha outro apelido
  • nas linhas 17 a 23 é criado um javascript para popular a DIV users do novo usuário com a lista dos usuários que ja estavam online
  • E na linha 25 este código é enviado apenas para este usuário.
  • entre a linha 28 e 31 é criado um javascript que vai adicionar o novo usuário a div users de todos os usuários e enviar uma mensagem para todos dizendo que o chat possui um novo participante.
  • na linha 33 o novo usuário é adicionado ao canal “chat”
  • na linha 35 este script é enviado para todos os usuários
  • E entre as linhas 36 e 38 é utilizado o helper de ajax padrão do rails para substituir o conteúdo da DIV controls com a partial controls que possui o formulário que permite que o usuário envie mensagens para outros integrantes do chat.

Esta é quase toda a lógica que precisamos, a única parte que falta é remover um usuário da lista de usuários online de todos os participantes quando este fechar o browser ou deixar o chat por qualquer motivo, para isto vamos ao método logout do controller session: app/controllers/session_controller.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class SessionController < ApplicationController
  #Called when a user disconnect (a refresh in the browser causes this to be called too)
  def logout
    #search for the user record using the session_id
    @u = OnlineUser.find_by_session_id(session.session_id)
    reset_session
    #if a user was found
    if @u
      username = @u.username
      #remove it from the database
      @u.destroy
      #remove from the online users list from all users, and tell others this user left the chat
      data = render_to_string(:update) do |page|
        page.remove "user_#{username}"
        page.insert_html :bottom, :dasd, "<b>User #{username} left the chat</b><br/>"
      end
      Juggernaut.send_data(data,'chat')
    end                
    render :nothing => true
  end
 
  def login
    render :nothing => true
  end
end

O método login não faz nada, pois não precisamos desta notificação para o chat, então vamos a descrição do método logout …

  • Na linah 5 procuramos o usuário no banco de dados usando o id da sessão
  • se um usuário é encontrado
  • ele é removido do banco de dados na linha 8
  • e entre as linhas 13 e 17 é criado um javascript que remove o usuário da lista de usuários online e adiciona uma mensagem avisando que o usuário deixou o chat, e no final este script é enviado para todos os usuários ainda ativos

E isto é tudo pessoal! (isto só fica engraçado em ingles :( )
Agora só falta executar a aplicação.
Para executar esta aplicação, precisamos iniciar o servidor do Rails como padrão, e então iniciar o push_server, para fazer isto basta executar os seguintes dois comandos.

  • script/server
  • script/push_server

E acessar o seu chat novo em folha no endereço: http://localhost:3000/chat
Eu criei este exemplo emquanto estudava um pouco sobre o Juggernaut, é possível que existam maneiras mais fáceis de implementar isto, mas eu acredito que seja um bom ponto de partida.

Quaisquer dicas para melhorar o exemplo são muito bem vindas.

Se você gostou deste post, lembre-se de assinar o RSS feed do blog, para ser notificado de novos posts!

Tags: , , , , , , , , , ,

Reader's Comments

  1. |

    [...] Comments Um exemplo de chat com Ruby On Rails e Juggernaut (utilizando AJAX Push) | Blog do Urubatan on A cool chat example created with Ruby On Rails and JuggernautUrubatan on It is getting easier to [...]

    Reply to this comment
  2. |

    É um otimo exemplo usando o Juggernaut. Mas esse tipo de ajax, eu ja ouvi falar outros nomes também, como, “Comite” e “Ajax Inverso”.
    Sabe me dizer se esse “push_server” é leve para o servidor?

    Abraços, Claudio

    Reply to this comment
  3. |

    Claudio,
    não é comite e sim Commet, mas Comment é uma outra forma de implementar isto.
    e este push_server é bem leve sim, ele foi criado por que se fosse utilizar um processo rails para cada cliente ficaria pesado demais.

    Reply to this comment
  4. |

    [...] Leia o tutorial, que desta vez tem uma versão em português. [...]

    Reply to this comment
  5. |

    hehe, tem razão, “Commet”, eu que estava atrasado para o trabalho e escrevi errado.
    Valeu pelo toque ai. ;)
    Abraços

    Reply to this comment
  6. |

    Legal. Tentei com o NetBeans mas o modulo de plugins deu erro (que nao vou colar aqui). Aconteceu com mais alguem?

    Reply to this comment
  7. |

    Max, eu usei o GVim como editor de código, mas não deveria dar problema algum com o NetBeans …

    Reply to this comment
  8. |

    Bom, com o JRuby (Netbeans e linha de comando, no Windows) deu essa:

    …\chattest>jruby script/plugin install svn://rubyforge.org//var/svn/juggernaut/trunk/juggernaut
    …/NetBeans6.0/ruby1/jruby-1.0.1/lib/ruby/1.8/pathname.rb:4
    20:in `realpath_rec’: No such file or directory - …/chattest/C: (Errno::ENOENT)
    from …/NetBeans6.0/ruby1/jruby-1.0.1/lib/ruby/1.8/
    pathname.rb:453:in `realpath’
    from …/NetBeans6.0/ruby1/jruby-1.0.1/lib/ruby/gems
    /1.8/gems/rails-1.2.5/lib/initializer.rb:543:in `set_root_path!’
    from …/NetBeans6.0/ruby1/jruby-1.0.1/lib/ruby/gems
    /1.8/gems/rails-1.2.5/lib/initializer.rb:509:in `initialize’
    from ./script/../config/boot.rb:38:in `new’
    from ./script/../config/boot.rb:38:in `run’
    from ./script/../config/boot.rb:38
    from :1:in `require’
    from :1

    Nao sei bem pq adiciona esse :drive (no caso, :C) no final do path.

    Com o Ruby nao deu problemas. Nao testei no Linux, mas pelo visto funciona.

    Reply to this comment
  9. |

    Olá

    Estou com problema no meu Juggernaut, aparentemente esta funcionando, mas as mensagem não chegam.
    Verifiquei o log, e as mensagem estão chegando no servidor de juggernaut, porém aparece que a seguinte mensagem:

    No such channel: chat

    Verifiquei no juggernaut.yml e esta assim:
    DEFAULT_CHANNELS:
    - ‘chat’

    O que poderia ser?

    Clodonil

    Reply to this comment
  10. |

    Clodonil,

    que browser esta usando? Testei no Linux e com o Firefox funciona legal, mas o Konqueror por exemplo envia e recebe mas nao faz o refresh da pagina, entao nao pode se ver os usuarios e as mensagens (mesmo existindo).

    Reply to this comment
  11. |

    Na linha 39 o elsif deveria ser um else.

    Pelo que vi, o Konqueror aceita o render(:update) mas nao o data = render_to_string(:update). Talvez seja assim para outros browsers tambem.

    Tem uns probleminhas ai com a gestao das sessoes etc. acredito seja coisa do plugin, ainda nao sei :D

    Reply to this comment
  12. |

    Max, verdade, tem um erro na linha 39, já arrumei aqui :D
    Eu não havia testado no Konkeror, muito obrigado por avisar do problema …

    Reply to this comment
  13. |

    Gostaria de poder baixar o arquivo por completo já funcionando. agradeço desde já

    Reply to this comment
  14. |

    Ehh, eu to vendo que preciso implentar algo parecido com isso… Queria ser igual o Ed-Novoplano ai em cima, cara de pau e pedir o código pronto!
    ehehehehe

    []s

    Reply to this comment
  15. |

    Olá,

    Esse foi meu primeiro projeto de teste usando gems e plugins.. tive alguma dificuldades mas consegui intalar eles.

    Porém depois que instalei o plugin, nao apareceu o arquivo config/juggernaut.yml, esse arquivo foi gravado na pasta: C:\Ruby\testes\chattest\vendor\plugins\juggernaut\media e nao tem as propriedades que vc citou.

    Ele gravou esse arquivo em config: juggernaut_hosts.yml

    que possui:
    # You should list any juggernaut hosts here.
    # You need only specify the secret key if you’re using that type of authentication (see juggernaut.yml)
    #
    # Name: Mapping:
    # :port internal push server’s port
    # :host internal push server’s host/ip
    # :public_host public push server’s host/ip (accessible from external clients)
    # :public_port public push server’s port
    # :secret_key (optional) shared secret (should map to the key specified in the push server’s config)
    # :environment (optional) limit host to a particular RAILS_ENV

    :hosts:
    - :port: 8080
    :host: localhost
    :public_host: localhost
    :public_port: 8080
    :secret_key: 481516232342edededededed
    :environment: :development

    Mesmo assim criei os arquivos e deu esse erro:
    NoMethodError in Chat#index

    Showing chat/index.html.erb where line #2 raised:

    undefined method `listen_to_juggernaut_channels’ for #

    Fiz algo de errado?

    Vlw pelo artigo, muito bacana.

    Abraço

    Reply to this comment

Leave a Comment