AlpineLab blog technique

Utiliser les Presences de Phoenix

Chez AlpineLab, on essaye parfois de s'ouvrir l'esprit et de ne pas faire que du Ruby. Depuis quelques temps, on s'essaye à Elixir et son framework web Phoenix. Un des (nombreux) points forts de Phoenix est d'offrir une communication bidirectionnelle entre le serveur et les clients avec une simplicité enfantine grâce à son implémentation des Channels.

Dans sa version 1.2 (actuellement en release candidate) son créateur Chris McChord a introduit la notion de Presence. Comme son nom l'indique assez bien, ça permet de tracker les clients (navigateurs) qui sont connectés au “serveur” (entre guillemets, parce que le “serveur” sous Phoenix est prévu pour être distribué et décentralisé) de manière fiable (consistente, répliquée, temps-réel et sans conflit ni collision). Il explique super bien le fonctionnement des Presences dans son intervention à ElixirConf Europe ci-dessous:

Si vous vous en foutez de savoir comment ça marche (on ne juge pas), laissez tomber la vidéo : on va voir comment utiliser les Presences en quelques lignes de code seulement, tellement c'est facile.

On admet que vous avez déjà une application Phoenix qui tourne et qui s'appelle intelligemment MyApp (mais libre à vous d'adapter à votre cas) et une implémentation basique (serveur et client) des Channels. On va lui ajouter le support des Presences.

1. Installation

Il faut tout d'abord installer Phoenix 1.2+ (au moment où j'écris ces lignes, la version la plus récente est 1.2.0-rc.1). Mettez donc à jour vos dépendances dans mix.exs pour utiliser une version 1.2+:

  defp deps do
    [
      {:phoenix, "~> 1.2.0-rc.1"},
      ... # probablement d'autres dépendences ici
    ]
  end

Puis lancez installer la nouvelle version (et ses nouvelles dépendances) depuis un shell:

$ mix deps.get

D'autre part, npm va télécharger ses dépendances phoenix et phoenix_html non pas sur le serveur npm comme d'habitude, mais directement sur votre disque dur et les copie dans node_modules. C'est pour ça que votre package.json doit probablement déjà contenir ces dépendances:

  "phoenix":      "file:deps/phoenix",
  "phoenix_html": "file:deps/phoenix_html",

Bref, ce qu'il faut donc faire, c'est dire à npm qu'il faut re-“télécharger” ces fichiers, puisqu'ils ont changé et sont maintenant en version 1.2. un simple:

$ npm update phoenix phoenix_html

2. Configuration

Pour utiliser les Presences, il faut tout d'abord démarrer leur superviseur depuis lib/my_app.ex. Ajoutez les lignes suivantes au tableau children (vous verrez, il doit y en avoir déjà au moins un autre):

children = [
  ...
  supervisor(MyApplication.UserPresence, [])
]

Ensuite, il faut créer un module de Presence dans notre application qu'on nommera simplement MyPresence. On créé donc un fichier web/channels/my_presence.ex dans lequel on va écrire:

defmodule MyApp.MyPresence do
  use Phoenix.Presence, otp_app: :my_app,
                        pubsub_server: MyApp.PubSub
end

Voilà, c'est tout. On va enfin pouvoir passer aux choses intéressantes.

3. Implémentation

Le fonctionnement est simple, il se décompose en 3 actions distinctes:

  1. lorsqu'un client se connecte à un channel, il fournit un identifiant et le serveur l'enregistre dans sa liste de Presences (avec éventuellement des meta-données de votre choix)
  2. le serveur lui envoie alors l'état actuel des Presences (i.e. des autres clients connectés au même channel) sous forme d'un message presence_state
  3. périodiquement, le serveur va envoyer une mise à jour de l'état des présences (i.e. une liste de clients s'étant connectés et une liste des clients s'étant déconnectés) sous la forme d'un message presence_diff

C'est tout. La synchronisation entre les différents serveur distribués est faite automatiquement, on n'a pas à s'en occuper.

Côté serveur (Channel)

Tout se passe dans le fichier qui déclare votre module Channel (genre web/channels/my_channel.ex). Pour plus de confort, commencez par aliaser votre module MyPresence:

alias MyApp.MyPresence

Ça permet tout simplement d'utiliser MyPresence plutôt que MyApp.MyPresence dans le reste du module.

Quand un client se connecte, c'est la fonction join/3 qui est appelée avec comme arguments le topic (i.e. le nom) du Channel auquel il se connecte, un Map de paramètres ainsi que le Socket qui gère la connexion.

Dans notre exemple, on va attendre du client qu'il envoie dans les paramètres un user_name pour s'authentifier. On va donc modifier la jonction join/3 pour stocker dans le Socket le user_name fourni (on pourra alors le récupérer de n'importe où, le Socket étant passé à toutes les fonctions liées au Channel). On fait ça grâce à la méthode assign/3 qui stocke des données clef-valeur dans un Socket. On modifie donc la fonction join/3 pour ne pas retourner simplement {:ok, socket} mais ceci:

def join("channel:" <> _channel_id, params, socket) do
  {:ok, assign(socket, :user_name, params["user_name"])}
end

On va ensuite ajouter un callback after_join qui va ajouter le client qui vient de se connecter (authentifié par le user_name stocké dans son Socket) à la liste de Presence du serveur. On utilise pour ça la fonction track/3 de notre module MyPresence (qui le tient lui-même du module Phoenix.Presence) en lui passant le user_name précédemment assigné au Socket, pour obtenir ce qui suit:

def join("channel:" <> _channel_id, params, socket) do
  send(self, :after_join)
  {:ok, assign(socket, :user_name, params["user_name"])}
end

def handle_info(:after_join, socket) do
  {:ok, _} = MyPresence.track(socket, socket.assigns.user_name, %{})
  {:noreply, socket}
end

Notez qu'on doit appeler explicitement after_join dans join/3 et que le 3ème argument de track/3 permet de stocker n'importe quelles données qui seront passées aux clients.

Ça constitue l'étape 1 de notre implémentation décrite ci-dessus (l'enregistrement du client dans les Presences du serveur).

Pour implémenter l'étape 2 (l'envoi au client de la liste actuelle des Presences du serveur), il suffit d'envoyer au client un message presence_state contenant le résultat de la fonction list/1 de MyPresence (qu'il tient une fois de plus de Phoenix.Presence):

def join("channel:" <> _channel_id, params, socket) do
  send(self, :after_join)
  {:ok, assign(socket, :user_name, params["user_name"])}
end

def handle_info(:after_join, socket) do
  {:ok, _} = MyPresence.track(socket, socket.assigns.user_name, %{})
  push socket, "presence_state", MyPresence.list(socket)
  {:noreply, socket}
end

Easy !

Côté client (JS)

Côté client, on va déjà modifier le code qui se connecte au Channel pour lui passer un user_name. Ça doit donc ressembler à ça:

socket.channel("channel:general", {user_name: "Mike"})

Vous aurez pris soin dans la vraie vie de remplacer les valeurs du topic du Channel et du user_name par ce qui vous chante, souvent un truc entré par l'utilisateur… au moins pour le user_name ;-)

Maintenant on va faire en sorte de gérer correctement les messages presence_state et presence_diff que nous envoie le serveur.

Pour gérer les message entrants, on avait déjà la fonction .on() fournie par la classe Channel du package phoenix. On va aussi utiliser les fonctions .syncState() et .syncDiff() de la nouvelle classe Presence du même package pour mettre à jour une liste des clients connectés avec le contenu du message envoyé par le serveur (respectivement presence_state et presence_diff, donc).

Si ce n'est pas très clair, c'est parce que je m'exprime mal, mais vous allez voir que c'est super simple avec du code (ES6):

import { Socket, Presence } from "phoenix"

... // initialisation du socket et du channel

let connectedUsers = []

channel.on("presence_state", payload => {
  Presence.syncState(connectedUsers, payload)
})

channel.on("presence_diff", payload => {
  Presence.syncDiff(connectedUsers, payload)
})

Et paf ! Votre tableau connectedUsers sera automatiquement mis à jour lorsque le serveur vous notifiera qu'il y a eu des changements dans sa liste de Presence (et il contient également toutes les meta-données que vous auriez passées à MyPresence.track/3 dans le Channel côté serveur).

Des helpers pour vérifier la version de Rails

En travaillant sur la gem de Locale, et comme ça arrive souvent quand on développe une gem, on a eu besoin de charger du code spécifique pour certaines versions de Rails. Les petites fonctions toutes bêtes qui suivent servent à savoir dans quelle version de Rails on se trouve au moment de l'exécution du code.

TL;DR : les helpers sont disponibles dans ce Gist

En l'occurrence, il fallait s'adapter à un comportement de Rails que l'on observe dans Rails 3.2.16+ et Rails 4.0.2+ (suite à un patch pour une n-ième faille de sécu dans la gestion des fichiers YAML).

Déjà, il faut savoir que les numéros de version de Rails sont stockés dans le module ::Rails::VERSION qui contient 4 constantes :

  • MAJOR: le numéro de version majeur (pour 4.0.2, c'est 4)
  • MINOR: le numéro de version mineur (pour 4.0.2, c'est 0)
  • TINY: le numéro de patch (pour 4.0.2, c'est 2)
  • STRING: le numéro de version complet sous forme de string (donc pour 4.0.2, c'est “4.0.2”)

La façon bourrine de vérifier qu'on est dans un de ces deux cas est la suivante :

if (::Rails::VERSION::MAJOR == 4 && (::Rails::VERSION::MINOR > 0 || (::Rails::VERSION::MINOR == 0 && ::Rails::VERSION::TINY >= 2)))
|| (::Rails::VERSION::MAJOR == 3 && (::Rails::VERSION::MINOR > 2 || (::Rails::VERSION::MINOR == 2 && ::Rails::VERSION::TINY >= 16)))
  # votre code va ici
end

C'est moche, hein ? Non, je n'en suis pas fier.

C'est con parce qu'il suffit de savoir qu'il y a deux classes Ruby vachement utiles pour comparer les numéros de versions qui sont Gem::Requirement et Gem::Version.

Gem::Requirement permet de créer des matchers, genre ‘~> 4.0.2’ (ça vous dit quelque chose ?). Gem::Version, comme son nom l'indique intelligemment, ça stocke un numéro de version. Et le lien utile entre les deux, c'est la méthode satisfied_by? de Gem::Requirement qui renvoie vrai ou faux selon si l'objet Gem::Version qu'on lui passe en paramètre est conforme au matcher.

Quelques exemples :

Gem::Requirement.new('>= 4.0.2').satisfied_by? Gem::Version.new('3.2')
# => false
Gem::Requirement.new('>= 4.0.2').satisfied_by? Gem::Version.new('4.1')
# => true
Gem::Requirement.new('~> 4.0.2').satisfied_by? Gem::Version.new('4.0.3')
# => true
Gem::Requirement.new('~> 4.0.2').satisfied_by? Gem::Version.new('4.1')
# => false

Voilà, à partir de là, tout est très simple, on créé une fonction qui fait la même chose - non pas par rapport à un numéro de version arbitraire - mais par rapport au numéro de version de Rails (dans lequel le code est exécuté) :

def rails_version_matches?(requirement)
  Gem::Requirement.new(requirement).satisfied_by? Gem::Version::new(::Rails::VERSION::STRING)
end

Il nous suffit dans le code de faire:

if rails_version_matches? '~> 4.0.2'
  # du code custom ici
end

Classe, non ?

Oui, mais nous on veut vérifier plusieurs versions de Rails (souvenez-vous : “3.2.16+ et 4.0.2+”).

Pour ça, on va utiliser, des fonctions similaires mais qui prennent plusieurs matchers en paramètre, et qui vérifie que la version courante de Rails satisfait au moins un de ces matchers (pour la version _any) ou tous ces matchers à la fois (pour la version _all) en utilisant les opérateurs booléens & et | entre leurs résultats :

def rails_version_matches_any?(*requirements)
  requirements.map{ |r| rails_version_matches?(r) }.reduce(:|)
end

def rails_version_matches_all?(*requirements)
  requirements.map{ |r| rails_version_matches?(r) }.reduce(:&)
end

Au final, je teste que ma version courante de Rails est 3.2.16+ ou 4.0.2+ comme ça :

if rails_version_matches_any? '~> 3.2.16', '~> 4.0.2'
  # mon code qui va bien
end

Bien plus propre qu'avant. Là, ça me plait.

SkiWallet à la conférence Blend

Juste un petit mot pour vous dire qu'on a été selectionnés pour présenter SkiWallet au concours de startups de la conférence Blend.

Voilà, on est super contents, d'une parce qu'on a été pris parmis une centaine de projets, de deux parce qu'il y a un lineup interminable et de trois parce qu'on espère rencontrer un paquet de gens motivés.

Blend Web Mix

En plus, on est aux côtés des copains de Simplauto et de Tilkee qui vont essayer de nous mettre la patée, mais on ne va pas se laisser faire :-)

Minute égocentrique

À moitié parce que j'ai envie de croire que ça intéresse qui que ce soit, et à moitié parce que ça me sert de mémo à moi-même, voilà ma sélection de gens que j'ai bien envie de voir :

Alpine Lab