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.
Une application basée sur DRb présentera toujours au moins deux composants, un serveur et un client.
Le serveur aura la responsabilité de :
Le client quant à lui devra :
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 :
nil
alors le port sera choisi automatiquementL’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.
DRb est implémenté autour de trois composants principaux :
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.
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.
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.
Mettons en place un client / serveur DRb ultra-simpliste pour comprendre la logique de mise en place.
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.
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.
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.
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.
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.
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.
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”.
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.