ActionCable est certainement la nouveauté la plus importante de Rails 5. ActionCable va vous permettre d’utiliser facilement les WebSockets dans votre application afin de faire une communication bidirectionnelle entre le client et le serveur.

Utiliser les sockets était déjà possible en utilisant par exemple faye mais ActionCable apporte une interface plus haut niveau qui nécessite moins de configuration, que ce soit côté serveur ou côté client.

Installation de Rails 5

Nous allons utiliser la version “Beta 2” sortie le 1er février 2016.

$ gem install rails -v 5.0.0.beta2

Si vous avez plusieurs versions de Rails, pour éxécuter une version spécifique:

$ rails _numero-de-version_ -v

par exemple:

$ rails _5.0.0.beta2_ -v
Rails 5.0.0.beta2

Rails 5

Vous devrez également installer Redis car ActionCable utilise le système Pub/Sub de Redis.

Création du projet

$ rails _5.0.0.beta2_ new actioncable-exemple
$ cd actioncable-exemple

Créez un contrôleur et sa vue:

app/controllers/home_controller.rb

class HomeController < ApplicationController
  def show
  end
end

views/home/show.html.erb

<h1>ActionCable</h1>

Puis, dans config/routes.rb:

Rails.application.routes.draw do
  root 'home#show'
end

Activer ActionCable

Par défaut, ActionCable n’est pas activé. Pour l’activer il suffit de décommenter quelques lignes:

config/routes.rb

Rails.application.routes.draw do
  root 'home#show'
  mount ActionCable.server => '/cable'
end

assets/javascripts/cable.coffee

@App ||= {}
App.cable = ActionCable.createConsumer()

Création d’un channel

La première chose à faire sera de créer un channel grâce à un nouveau générateur. Un channel est un flux de données. On peut s’y abonner pour écouter les événements ou y diffuser des événements (broadcast).

Créez un channel qui comportera une action notify:

$ rails generate channel notifications notify

Vous pouvez ajouter autant d’actions que vous voulez et les nommer comme vous voulez.

Deux fichiers sont alors générés:

app/channels/notifications_channel.rb

# Be sure to restart your server when you modify this file. Action Cable runs in an EventMachine loop that does not support auto reloading.
class NotificationsChannel < ApplicationCable::Channel
  def subscribed
    # stream_from "some_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def notify
  end
end

app/assets/javascripts/channels/notifications.coffee

App.notifications = App.cable.subscriptions.create "NotificationsChannel",
  connected: ->
    # Called when the subscription is ready for use on the server

  disconnected: ->
    # Called when the subscription has been terminated by the server

  received: (data) ->
    # Called when there's incoming data on the websocket for this channel

  notify: ->
    @perform 'notify'

Tout d’abord, le premier fichier (app/channels/notifications_channel.rb) est la partie côté serveur du channel. Deux callbacks sont ajoutés automatiquement: subscribed et unsubscribed. Le premier sera appelé quand un client va s’abonner au channel, le second sera appelé quand le client va se désabonner. Nous avons également notre unique action: notify.

Décommentez la ligne dans subscribed pour que l’écoute des événements débute quand le client s’abonne (c’est ici qu’ActionCable utilise Redis):

def subscribed
  stream_from "notifications"
end

Le deuxième fichier géneré est la partie client du channel (app/assets/javascripts/channels/notifications.coffee). Il comporte 4 méthodes:

  • connected: appelée quand la connection au socket a été effectuée.
  • disconnected: appelée quand le serveur ferme la connexion.
  • received: appelée quand des données sont émises dans le channel.
  • notify: l’action que nous avons crée, quand on appellera cette méthode côté client, le notify de NotificationsChannel côté serveur sera appelé. La “magie” de Rails.

Tester notre cable

Lancez votre serveur, puis ouvrez votre navigateur avec une console et entrez:

App.notifications.notify()

ActionCable

Rien de spécial ne se passe dans la console du navigateur, par contre si vous regardez dans la console de Rails, vous trouverez:

NotificationsChannel#notify

Cela signifie que la méthode notify de NotificationsChannel a été appelée. Vous pourriez penser qu’un appel Ajax aurait suffit pour arriver à ce résultat. Détrompez-vous, cela ne s’arrête pas là. Tout l’interêt des sockets est que l’on peut également faire l’inverse: le serveur peut envoyer des messages aux clients.

Changez votre méthode notify:

def notify(data)
  ActionCable.server.broadcast 'notifications', message: data['message']
end

ActionCable.server.broadcast prend deux paramètres:

  • Le nom du stream dans lequel envoyer le message.
  • Un hash de données que l’on veut envoyer et qui sera sérialisé. Vous êtes libre de choisir le contenu de votre hash.

(vous devrez relancer le serveur à chaque fois que vous modifiez un channel)

Et côté client:

received: (data) ->
  console.log data['message']

notify: (message) ->
  @perform 'notify', message: message

Maintenant dans la console de votre navigateur:

App.notifications.notify("bonjour")

ActionCable

Le message a fait un aller/retour:

  • App.notifications.notify('bonjour') va exécuter @perform 'notify', message: 'bonjour'.
  • NotificationsChannel#notify sera alors appelé avec message: 'bonjour' en paramètre.
  • ActionCable.server.broadcast envoie un message qui contient message: data['message'] dans le channel “notifications”.
  • Pour tous les abonnés du channel, la méthode received se déclenche.
  • “Bonjour” est alors affiché dans la console de tous les clients abonnés, y compris celui qui a envoyé le message.

Essayons avec plusieurs clients:

Multi

Ici, nous avons fait au plus simple et utilisé qu’un seul stream pour toutes les notifications. En revanche, dans une application réelle, on pourrait par exemple scoper les streams en fonction de l’ID de l’utilisateur pour éviter que tous les utilisateurs reçoivent les notifications de tout le monde:

def subscribed
  stream_from "users:#{current_user.id}:notifications"
end

De cette manière, quand le client va s’abonner au channel NotificationsChannel, il recevra uniquement ses propres notifications.

ActionCable et ActiveRecord

ActionCable devient vraiment puissant quand on l’associe avec nos objets ActiveRecord. Par exemple, on peut notifier les clients quand un objet ActiveRecord est crée et sauvegardé dans la base de données.

Nous allons prendre l’exemple d’une liste de statuts dans le même esprit que Twitter ou n’importe quel réseau social.

Pour faire simple et rapide, nous n’allons pas faire de gestion des utilisateurs. On aura uniquement la liste des statuts et un formulaire pour poster un statut.

Créez le modèle (app/models/status.rb):

$ rails generate model status content:text

Le contrôleur (app/controllers/statuses_controller.rb):

class StatusesController < ApplicationController
  def index
    @statuses = Status.order('created_at DESC')
  end

  def new
    @status = Status.new
  end

  def create
    @status = Status.new(status_params)

    if @status.save
       redirect_to statuses_path
    else
      render :new
    end
  end

  private
    def status_params
      params.require(:status).permit(:content)
    end
end

La vue qui liste les statuts (app/views/statuses/index.html.erb):

<h1>Statuts</h1>

<div id="statuses">
  <%= render @statuses %>
</div>

Le formulaire pour poster un statut (app/views/statuses/new.html.erb):

<%= form_for(@status) do |f| %>
  <%= f.label :content %>
  <%= f.text_area :content %>
  <%= f.submit %>
<% end %>

Un partial qui représente un statut (app/views/statuses/_status.html.erb):

<div class="status">
  <i><%= time_ago_in_words(status.created_at) %></i>
  <%= status.content %>
</div>

Créez aussi un nouveau channel pour les statuts:

$ rails generate channel statuses

app/channels/statuses_channel.rb

class StatusesChannel < ApplicationCable::Channel
  def subscribed
    stream_from "statuses"
  end

  def unsubscribed
  end
end

app/assets/javascripts/channels/statuses.coffee

App.statuses = App.cable.subscriptions.create "StatusesChannel",
  connected: ->

  disconnected: ->

  received: (data) ->
    console.log data['status']

Enfin, ajoutez un hook after_create_commit dans le modèle Status:

class Status < ApplicationRecord
  after_create_commit { ActionCable.server.broadcast 'statuses', status: content }
end

Une fois qu’un statut a été crée et sauvegardé dans la base de données, un événement qui contient le contenu du statut sera émis dans le channel statuses. Le statut s’affichera alors dans la console de tous les clients.

Status console

Afficher ce statut dans la console n’a pas grand interêt, nous allons maintenant voir comment l’insérer dans le DOM de la page.

Une des autres nouveautés de Rails 5 est ApplicationController.renderer. Grâce à ça il sera possible de render des templates en dehors des contrôleurs.

Plutôt que d’envoyer le contenu du statut dans le stream comme nous l’avons fait, nous pouvons envoyer le partial qui représente ce statut. Les clients pourront alors l’insérer dans le DOM.

Modifiez votre modèle:

class Status < ApplicationRecord
  after_create_commit { ActionCable.server.broadcast 'statuses', status: render_status }

  private

    def render_status
      ApplicationController.renderer.render(partial: 'statuses/status', locals: { status: self })
    end
end

Et le côté client (app/assets/javascripts/channels/statuses.coffee):

App.statuses = App.cable.subscriptions.create "StatusesChannel",
  connected: ->

  disconnected: ->

  received: (data) ->
    $('#statuses').prepend(data['status'])

Status render

Plutôt que d’utiliser ApplicationController.renderer dans les modèles, David Heinemeier Hansson suggère d’utilser des jobs. En effet, ApplicationController.renderer concerne les vues: ça n’a pas vraiment sa place dans un modèle. De même pour émettre des événements dans un channel, mieux vaut le faire dans un job pour respecter le principe de separation of concerns.

$ rails generate job statuses

app/jobs/statuses_job.rb

class StatusesJob < ApplicationJob
  queue_as :default

  def perform(status)
    ActionCable.server.broadcast 'statuses', status: render_status(status)
  end

  private

    def render_status(status)
      ApplicationController.renderer.render(partial: 'statuses/status', locals: { status: status })
    end
end

Adaptez le modèle en conséquence:

class Status < ApplicationRecord
  after_create_commit { StatusesJob.perform_later self }
end

Cela ne change rien au résultat, mais le code est mieux organisé.

Conclusion

Cet article est inspiré de la vidéo de présentation d’ActionCable par David Heinemeier Hansson, que je vous suggère de regarder.

Rails 5 n’est pas encore sorti en version finale, ce qui signifie qu’il peut y avoir des changements d’ici là. Cela reste peu probable mais il vaut mieux regarder régulièrement la documentation pour vous tenir informé des éventuels modifications.

Resources