Ruby - Distribution d'objets à travers le réseau

Publié le 12 août 2015 par Nicolas Cavigneaux | back

Cet article est publié sous licence CC BY-NC-SA

Il existe de nos jours de nombreuses façons de distribuer des objets à travers des programmes. On pense pour cela à des technologies comme RPC, COM, CORBA ou encore RMI de Java.

Chacune de ces technologies varie en complexité mais permet finalement de faire à peu près la même chose. Elles mettent à disposition un moyen de communication transparent entre des objets dans le contexte d’un réseau. On est donc en mesure d’utiliser des objets distants comme s’ils étaient locaux.

Ok… Mais quel est l’intérêt de faire ça ? Il y a en fait des dizaines d’applications possibles mais la plus commune reste de partager de lourds calculs entre plusieurs machines.

L’idée générale est de découper un problème en petites parties pour répartir le traitement grâce à une solution distribuée.

En Ruby, la réponse à cette problématique s’appelle DRb pour “distributed Ruby”. Ce n’est pas la seule façon de faire mais c’est à ma connaissance la plus simple à mettre en œuvre.

C’est une bibliothèque assez simpliste comparée à d’autres solution nommées plus haut mais elle offre sans aucun doute toutes les fonctionnalités basiques dont vous pourriez avoir besoin pour créer votre système d’objets distribués.

DRb, le principe de fonctionnement

Une application basée sur DRb présentera toujours au moins deux composants, un serveur et un client.

Le serveur aura la responsabilité de :

  • démarrer un serveur TCP
  • écouter les connexions sur un port donné
  • attacher un objet à l’instance du serveur DRb
  • accepter les connexions des clients
  • répondre aux messages des clients
  • éventuellement gérer des contrôles d’accès

Le client quant à lui devra :

  • établir une connexion sur le serveur
  • attacher un objet local à l’objet distant géré par le serveur
  • envoyer des messages au serveur
  • récupérer les réponses

La bibliothèque DRb met à notre disposition la méthode start_service qui permet de démarrer un serveur TCP sur un port donné. Cette méthode prend deux arguments qui sont :

  • une URI qui spécifie un port, si cet argument est nil alors le port sera choisi automatiquement
  • l’objet que nous voulons attacher à l’instance du serveur

L’objet attaché deviendra accessible au client à distance. Il lui sera donc possible d’appeler toutes les méthodes de l’objet comme s’il avait été défini et instancié localement.

Les détails du fonctionnement interne

DRb est implémenté autour de trois composants principaux :

  • marshaller/unmarshaller pour les appels de méthodes
  • un protocole de transport
  • un mapper ID vers objet

Marshalling

Le marshalling / unmarshalling des appels de méthodes distantes est géré par une instance de DRb::DRbMessage. En interne cette classe utilise le module Marshal pour transformer l’appel en un flux de bytes avant d’effectuer l’appel réseau. Côté client, la même classe reconstruit le message.

Protocole de transport

La couche de transport doit elle ouvrir les connexions réseau côté client et serveur pour y transporter les requêtes DRb. C’est la classe DRb::DRbProtocol qui se charge de ça.

Plusieurs protocoles peuvent cohabiter au sein de cette classe, c’est le schéma de l’URI qui détermine le protocole à utiliser. Par défaut, c’est le schéma “druby:” qui est utilisé et qui est implémenté par la classe DRb::DRbTCPSocket. Les communications de ce protocole passent par une simple socket TCP/IP.

Un protocole alternatif utilisant les sockets UNIX est implémenté par la classe DRb::DRbUNIXSocket, son schéma correspondant est “drbunix:”.

Les exemples livrés avec DRb proposent également une implémentation utilisant le protocole HTTP. Vous pouvez donc étendre cette couche pour utiliser le protocole qui vous convient le mieux.

Mapping des objets

Le composant de mapping “ID vers objet” permet d’associer l’id d’un objet DRb à l’objet auquel il fait référence et inversement.

L’implémentation par défaut est fournie par la classe Drb::DRbIdConv. Cette implémentation utilise l’object_id de l’objet comme id DRb. Par conséquent, cette référence n’est valide que pour la durée de vie du processus serveur ainsi que la durée de vie de l’objet dans ce processus. Si l’objet est garbage collecté alors la référence devient inutile.

Une autre implémentation est fournie par la classe DRb::TimerIdConv qui conserve une référence locale de tous les objets exportés par DRb pour une période donnée. Cette période est configurable et définie à dix minutes par défaut. Les objets ne seront donc pas garbage collectés pendant cette période.

Une autre implémentation fournie en tant qu’exemple est la classe DRbNamedIdConv qui quant à elle permet aux objets de définir leur propre identifiant unique. De ce fait une référence DRb permanente devient possible même à travers différents processus.

L’implémentation à utiliser est paramétrable au travers de la configuration de DRb::DRbServer. Une fois encore vous pouvez écrire et utiliser votre propre implémentation si le besoin s’en fait sentir.

Un exemple simpliste

Mettons en place un client / serveur DRb ultra-simpliste pour comprendre la logique de mise en place.

Le serveur

Commençons par le serveur :

require "drb"

obj = Array.new
DRb.start_service(nil, obj)

puts "Server running at #{DRb.uri}"

trap("INT") { DRb.stop_service }
DRb.thread.join

En premier lieu, il faut charger DRb, on crée ensuite une instance de l’objet que nous voulons distribuer.

On peut enfin démarrer le serveur DRb pour lequel on ne précise pas de port, il sera donc choisit dynamiquement. On passe l’instance de l’objet à distribuer en deuxième argument.

On pense ensuite à afficher l’URI puisqu’elle est déterminée automatiquement. Il faut donc un moyen de la connaître pour la spécifier dans le client.

Pour finir, on s’assure d’attraper le signal “INT” pour pouvoir stopper proprement le serveur lors d’un “Ctrl-C” en console.

On fait également un join car DRb fait utilisation des threads pour faire son travail. On s’assure donc que le serveur ne pourra pas être quitté prématurément en plein milieu d’un traitement.

Le client

Passons maintenant au client dont le rôle va être d’utiliser l’objet distant.

require "drb"

DRb.start_service
remote_obj = DRbObject.new_with_uri("druby://localhost:50350")

puts "Before size: #{remote_obj.size}"

remote_obj << "foo added from process #{Process.pid}"

puts "After size: #{remote_obj.size}"
puts "Content: #{remote_obj.join(", ")}"

Dans le client, nous initialisons le service DRb puis nous invoquons la méthode DRbObject#new_with_uri avec en paramètre l’url du serveur qui doit nous servir l’objet distribué.

Cette méthode nous retourne une instance de DRbObject qui est en quelque sorte un proxy vers notre objet distant.

Nous pouvons ensuite manipuler l’objet, ici un tableau, de manière tout à fait transparente.

Pour l’exemple, on affiche la taille de notre tableau, puis on y ajoute un élément qui est une chaîne contenant le pid du processus client.

Finalement nous affichons à nouveau la taille du tableau puis l’ensemble de son contenu.

Voici ce que ça donnerait si on lance notre serveur, puis deux processus client :

$ ruby server.rb
Server running at druby://imac.local:50350

$ ruby client.rb

Before size: 0
After size: 1
Content: foo added from process 7468

$ ruby client.rb

Before size: 1
After size: 2
Content: foo added from process 7468, foo added from process 7503

Il est donc très clair que l’objet attaché côté serveur est un objet unique, une seule instance qui répondra à toutes les requêtes client qu’il recevra.

À ce titre, s’il peut y avoir plusieurs clients en simultané, et ça sera très certainement le cas, il faudra vous assurer que votre code côté serveur est “thread-safe” pour éviter de vous retrouver avec un objet laissé dans un état incohérent.

Pour être plus précis, ce n’est un souci que si les clients peuvent à la fois lire et écrire sur l’objet distant.

Pour palier à ce problème, la solution la plus simple est d’utiliser des Mutex pour coordonner l’accès aux informations partagées.

Définition des contrôles d’accès

Hormis pour les services publiques, vous aurez certainement besoin de mettre en place une politique de sécurité.

Bien sûr vous pouvez passer par un filtrage au niveau de votre pare-feu mais DRb est livré avec une petite couche ACL qui peut se révéler pratique. Ces ACL vous permettent de définir directement depuis le code du serveur la liste des hôtes (ou IP) qui sont autorisés ou interdits d’accès.

La méthode ACL#new permet de définir cette liste, elle attend en premier argument un tableau de tableaux.

Chaque tableau est constitué de deux chaînes, la première est une des directives allow ou deny qui permettent respectivement d’autoriser ou d’interdire un client. La deuxième chaîne quant à elle est un nom d’hôte, une ip, un masque ou encore le mot-clé all.

Le deuxième argument, optionnel, de la méthode ACL#new est une constante, DENY_ALLOW ou ALLOW_DENY qui permet de dire si la politique par défaut est d’interdire tout ce qui n’est pas spécifié ou au contraire d’autoriser tout ce qui n’est pas spécifié. Par défaut c’est la politique DENY_ALLOW qui est utilisée.

On peut donc créer des ACL de la manière suivante :

require "drb/acl"

acl = ACL.new([
  ["allow", "192.168.0.*"],
  ["allow", "192.0.2.128/26"],
  ["allow", "localhost"]
])

Il faut ensuite demander à notre instance DRb d’utiliser ces ACL :

DRb.install_acl(acl)
DRb.start_service(nil, obj)

Désormais, si un client non-autorisé essaie de se connecter au serveur, une exception RuntimeError sera levée.

Cas d’étude avancé

Nous allons mettre en place un exemple plus représentatif d’un cas réel d’utilisation.

Dans le premier exemple nous nous sommes cantonnés à comprendre comment fonctionne DRb et à le mettre en place en utilisant un objet simple, un tableau.

Dans ce nouvel exemple nous allons implémenter une simulation de suivi de prix d’articles.

En plus d’implémenter notre propre classe qui sera distribuée sur le réseau, nous allons aussi créer un module d’observation des fluctuations qui permettra d’avoir un système à la pub / sub plutôt que de récupérer inutilement et sur une base régulière les informations depuis le serveur.

L’ensemble du code de cet exemple est disponible sur un dépôt GitHub dédié.

Je vais expliciter les parties les plus intéressantes.

Le module de pub / sub

module DRbObservable

  def add_observer(observer)
    raise(NameError, "observer must respond to 'update'") unless observer.respond_to?(:update)

    @observers ||= []
    @observers << observer
  end

  def delete_observer(observer)
    @observers.delete(observer) if @observers
  end

  def notify_observers(*args)
    return unless @observers

    @observers.each { |o| o.update(*args) rescue delete_observer(o) }
  end
end

Le code de ce module est très court mais pourtant redoutablement utile à l’usage. Il permet d’injecter, dans une classe, des fonctionnalités basiques de pub / sub.

Une classe qui inclura ce module pourra être notifiée de divers événements et n’aura donc pas besoin de scruter elle même les changements.

Ce module va nous servir côté serveur à envoyer une notification à tous les clients à chaque changement de prix de l’article.

Le module ajoute trois méthodes, la première add_observer permet d’enregistrer un observateur, c’est à dire une classe qui pourra être notifiée des changements. On s’assure d’ailleurs que la classe en question répond bien à nos contraintes, à savoir répondre à la méthode update que nous allons explicitement appeler à chaque notification à envoyer.

La deuxième méthode permet de supprimer un observateur existant.

La troisième méthode quant à elle permet d’envoyer une notification à tous les souscripteurs. Cette notification appelle donc la méthode update de la classe cible en lui passant l’ensemble des arguments passés à notify_observers. Si pour une raison ou une autre, la méthode update de la classe cible venait à planter, on la supprimerait aussitôt des observateurs connus pour éviter les erreurs.

Le serveur DRb

Notre serveur DRb, disponible dans le fichier server.rb commence par charger DRb mais aussi notre module DRbObservable.

Ensuite il y a la définition de la classe MockPrice qui ne sert qu’à générer des prix aléatoirement. Elle respecte quelques règles simples pour éviter les grosses fluctuations sur le prix d’un article. En pratique, votre serveur DRb irait chercher cette information en base ou via une API.

Puis vient la définition de la classe Ticker qui a pour but de créer une boucle infinie dont le travail est de récupérer le dernier prix connu pour l’article. Si ce prix a changé depuis la dernière vérification, on notifie tous les souscripteurs :

class Ticker

  include DRbObservable

  def initialize(price_feed)
    @feed = price_feed
    Thread.new { run }
  end

  def run
    last_price = nil

    loop do
      price = @feed.price
      puts "Current price: #{price}"

      if price != last_price
        last_price = price
        notify_observers(Time.now, price)
      end

      sleep 1
    end
  end
end

Cette classe inclut notre module, ce qui permet d’envoyer des notifications.

La méthode initialize ne fait rien d’exceptionnel. Elle stocke le flux de prix (notre objet MockPrice) dans une variable d’instance puis lance la boucle principale (méthode run) dans un thread.

C’est la méthode run qui fait tout le travail. Elle crée la boucle infinie, se charge de récupérer le dernier prix (@feed.price) et l’affiche côté serveur. Si le prix récupéré a changé depuis la dernière version connue alors on notifie l’ensemble des clients qui ont souscrit grâce à la méthode notify_observers à laquelle on passe l’heure courante ainsi que le nouveau prix.

On attend ensuite une seconde et la boucle reprend.

Les clients ne sont donc notifiés que lorsqu’un prix change. Si le prix est le même pendant plusieurs boucles, le client n’aura été notifié qu’une seule fois.

Le reste du code est identique à ce qu’on a pu voir dans le premier exemple :

ticker = Ticker.new(MockPrice.new(:awesome_guitar))

DRb.start_service("druby://localhost:4000", ticker)

puts "Server running at #{DRb.uri}."
puts "Press Ctrl-C to exit."

trap("INT") { DRb.stop_service }
DRb.thread.join

On crée l’objet à distribuer, on lance le service DRb pour partager cet objet. On affiche l’URI du serveur, on donne un moyen à l’utilisateur de pouvoir couper le serveur (Ctrl-C) et on fait un join sur les threads pour éviter toute surprise.

Le client DRb

Passons maintenant au client dont le but est de consommer le flux du prix d’un article. Il sera averti de chaque changement mais ce qui nous intéresse est d’afficher uniquement les prix qui seront sous ou au-dessus de seuils qu’on aura défini.

require "drb"

class Notification

  include DRbUndumped

  def initialize(ticker, limit)
    @limit = limit
    ticker.add_observer(self)
  end

end

class LowNotification < Notification

  def update(time, price)
    puts "Price below #{@limit}: #{price}" if price < @limit
  end

end

class HighNotification < Notification

  def update(time, price)
    puts "Price above #{@limit}: #{price}" if price > @limit
  end

end

DRb.start_service
ticker = DRbObject.new_with_uri("druby://localhost:4000")

LowNotification.new(ticker, 600)
HighNotification.new(ticker, 700)

puts "Press Ctrl-C to exit."

trap("INT") { exit }
gets

Comme toujours on requiert DRb, ensuite on crée trois classes.

Une classe principale Notification dont le but est de servir de base à d’autres classes plus spécifiques. Elle définit la méthode initialize qui prend en paramètre l’objet à utiliser comme source d’information, ici notre ticker distribué sur le réseau. Le deuxième paramètre définit quant à lui le seuil qui nous intéresse.

On note aussi l’appel à include DRbUndumped qui sert simplement à dire à DRb que cette classe n’a pas vocation a être sérialisée. On évite donc une surcharge inutile.

On déclare ensuite deux autres classes HighNotification et LowNotification qui héritent de la classe Notification. Ces classes spécialisées définissent la méthode update qui sera appelée lors des notifications par le serveur.

Ces méthodes servent simplement à afficher respectivement le prix courant de l’article lorsqu’il dépasse le seuil défini et lorsqu’il est inférieur au seuil défini.

Le code restant est très similaire à ce qu’on a déjà pu voir. On lance le service DRb, on récupère l’objet distribué ticker depuis le serveur.

On instancie un LowNotification et un HighNotification. Le LowNotification se sert du ticker comme source d’information et définit le seuil à 600€. Le HighNotification se sert lui aussi de ticker comme source d’information et définit le seuil à 700€.

On ne sera donc notifié du prix de l’article que lorsqu’il sera inférieur à 600€ ou supérieur à 700€.

Finalement on permet à l’utilisateur de couper via un “Ctrl-C”.

Pour finir

Il est donc très simple de mettre en place cette notion d’objets distribués. Ce concept pourtant puissant ne requiert que quelques lignes de code.

Nous pourrions aller encore plus loin en parlant de Rinda qui nous simplifierait la gestion de tuples synchronisés et apporterait notamment une gestion améliorée des notifications.

On pourrait également parler de Rinda::RingServer qui permet de mettre en place un serveur DRb avec découverte automatique par les clients.

La programmation de systèmes d’objets distribués est vraiment quelque chose de très accessible en Ruby, grâce notamment à des bibliothèques livrées avec l’interpréteur et qui facilitent grandement le travail.

Si vous souhaitez vous lancer dans la programmation de logiciels clients / serveur que ce soit pour des jeux, du chat, des fermes de calcul ou que sais-je encore, je vous conseille vivement de vous pencher sur DRb !


L’équipe Synbioz.

Libres d’être ensemble.