Remote Procedure Call (RPC)

L'appel de fonction est un mécanisme très bien connu des programmeurs, d'où l'idée de programmer des applications distribuées en appelant des fonctions qui sont situées sur une machine distante, ce qui explique le nom de « Remote Procedure Call ».

Vous pouvez utiliser les RPC pour toutes sortes d'applications distribuées, par exemple l'utilisation d'un ordinateur très puissant pour des calculs intensifs (décryptage de données, calculs numériques ...). Cet ordinateur sera donc le serveur. Un autre ordinateur sera le client et appellera la procédure distante pour commander des calculs au serveur et récupérer le résultat.

L'idée de Remote Procedure Call n'est pas nouvelle. De fait, de nombreux systèmes RPC sont disponibles (et incompatibles entre eux). Nous allons étudier ici le système de Sun Microsystems ou « Sun RPC » qui est devenu un standard de fait car ses spécifications sont dans le domaine public. Ce système a été développé pour servir de base au système NFS (Network File System) de Sun, largement utilisé sous Linux. Vous trouverez dans votre distribution préférée tous les fichiers (en-têtes, fonctions RPC) et outils nécessaires, ne serait-ce que pour pouvoir recompiler client et serveur NFS.

Principe de fonctionnement

Le système RPC s'efforce de maintenir le plus possible la sémantique habituelle des appels de fonction, autrement dit tout doit être le plus transparent possible pour le programmeur. Pour que cela ressemble à un appel de fonction local, il existe dans le programme client une fonction locale qui a le même nom que la fonction distante et qui, en réalité, appelle d'autres fonctions de la bibliothèque RPC qui prennent en charge les connexions réseaux, le passage des paramètres et le retour des résultats. De même, côté serveur, il suffira (à quelques exceptions près) d'écrire une fonction comme on en écrit tous les jours, un processus se chargeant d'attendre les connexions clientes et d'appeler votre fonction avec les bons paramètres. Il se chargera ensuite de renvoyer les résultats. Les fonctions qui prennent en charge les connexions réseaux sont des « stub ». Il faut donc écrire un stub client et un stub serveur en plus du programme client et de la fonction distante.

Le travail nécessaire à la construction des stubs client et serveur sera automatisé grâce au programme rpcgen qui produira du code C qu'il suffira alors de compiler. Il ne restera plus qu'à écrire le programme client, qui appelle la fonction et en utilise le résultat (par exemple en l'affichant), et la fonction elle-même.

Interface

Pour comprendre le fonctionnement des RPC, nous allons donc écrire, à titre d'exemple, un programme simple mais néanmoins complet. Il y a en fait deux programmes, un programme client et un programme serveur. La fonction distante prendra deux nombres en paramètres et renverra leur somme ainsi qu'un code d'erreur indiquant s'il y a eu un overflow ou non. Le travail commence donc par la définition de l'interface, celle-ci étant écrire en utilisant l'IDL (Interface Definition Language) du système RPC (proche du C).

struct data {
  unsigned int arg1;
  unsigned int arg2;
};
typedef struct data data;
struct reponse {
  unsigned int somme;
  int errno;
};
typedef struct reponse reponse;
program CALCUL{
  version VERSION_UN{
    void CALCUL_NULL(void) = 0;
    reponse CALCUL_ADDITION(data) = 1;
  } = 1;
} = 0x20000001;

Cette définition est enregistrée dans le fichier calcul.x. Ce fichier décrit notre programme et les fonctions qu'il contient. La définition parle d'elle-même. Notre programme s'appelle CALCUL et dans sa version VERSION_UN contient deux procédures : CALCUL_NULL et CALCUL_ADDITION.

Le programme a un nom (ici CALCUL) et un numéro (ici 0x20000001). Ce numéro identifie ce programme de manière unique dans le monde. C'est pratique pour des programmes comme le daemon NFS. Dans notre cas, le numéro est choisi dans l'intervalle allant de 0x20000000 à 0x3FFFFFFF, réservé pour les utilisateurs, et ne risque pas d'entrer en conflit avec des programmes tournant déjà sur votre machine.

Ensuite, pour un programme donné, on peut avoir plusieurs versions, ceci afin de pouvoir offrir de nouvelles fonctions dans la nouvelle version tout en conservant les versions précédentes (pour les anciens programmes clients). Ici, nous avons donc une version appelée VERSION_UN et qui a pour numéro 1.

Vient ensuite, la liste des procédures que le serveur implémente. Chaque procédure a un nom et un numéro. Une procédure de numéro 0 et qui ne fait rien (ici CALCUL_NULL) est toujours requise. Ceci afin de tester si le système marche (une sorte de ping en quelque sorte). On peut l'appeler afin de vérifier que le réseau fonctionne et que le serveur tourne. La seconde procédure décrite est notre procédure d'addition. Elle prend un argument de type data et renvoie un argument de type reponse. Le système RPC n'autorise qu'un seul argument en paramètre et un seul en retour. Pour passer plusieurs arguments (dans notre cas, les deux nombres à additionner), on utilise donc une structure. De même, pour renvoyer plusieurs valeurs (dans notre cas, le résultat de l'addition et un code d'erreur), on utilise une structure.

Ce fichier de définition est ensuite traité par l'utilitaire rpcgen (RPC program generator). Il suffit de taper sur la ligne de commande :

rpcgen -a calcul.x

L'option -a permet de produire un squelette pour notre programme client (calcul_client.c) et un squelette pour la fonction distante (calcul_server.c). Avec ou sans cette option, les fichiers suivants sont également produits : calcul.h (en-tête), calcul_clnt.c (stub client), calcul_svc.c (stub serveur) et calcul_xdr.c (routines XDR).

Le format XDR (eXternal Data Representation) définit les types utilisés pour l'échange de variables entre le client et le serveur. Il est possible que les processus serveur et client ne tournent pas sur la même plate-forme; il est donc indispensable de parler une langue commune. Ainsi, ce format définit précisément un codage pour les entiers, les flottants ... Les structures plus complexes utilisent les types de base. Ainsi, les types que nous avons nous-mêmes définis nécessitent un filtre XDR. C'est le rôle des fonctions définies dans le fichiers calcul_xdr.c, à compiler puis à lier avec le client et le serveur. La compilation peut être faite maintenant (c'est déjà ça) et produit un calcul_xdr.o :

gcc -c calcul_xdr.c

Les stubs client et serveur sont complets et peuvent déjà être compilés. La seule connaissance de l'interface suffit à rpcgen pour les générer. Donc, nous pouvons compiler pour produire les fichiers calcul_clnt.o et calcul.svc.o :

gcc -c calcul_clnt.c
gcc -c calcul.svc.c

Processus serveur

Ceci étant fait, écrivons maintenant la fonction distante qui effectue réellement le travail. Grâce à l'option -a de rpcgen, nous avons le squelette de fonction suivant dans le fichier calcul_server.c :

/*
 * This is sample code generated by rpcgen.
 * These are only templates and you can use them
 * as a guideline for developing your own functions.
 */

#include "calcul.h"

void *
calcul_null_1_svc(void *argp, struct svc_req *rqstp)
{
  static char* result;

  /*
   * insert server code here
   */

  return((void *) &result);
}

reponse *
calcul_addition_1_svc(data *argp, struct svc_req *rqstp)
{
  static reponse result;

  /*
   * insert server code here
   */

  return(&result);
}

Après examen du fichier produit par rpcgen, quelques explications s'imposent, car on peut remarquer quelques différences par rapport à notre définition. Les fonctions ont deux arguments, le deuxième n'a pas d'utilité dans notre exemple. Au lieu de récupérer une variable du type demandé dans la définition, on doit en fait passer par un pointeur sur cette variable (ceci évite une copie des arguments et donc il y a un gain de temps et de mémoire). Pour le retour, c'est également un pointeur qui est renvoyé. Comme on utilise des pointeurs, la variable dans laquelle on met la valeur de retour est déclarée 'static' car il faut bien évidemment passer l'adresse d'une variable qui existe encore après la fin de la fonction.

Comme vous le voyez, il n'y a pas de fonction main. La fonction main est située dans le stub serveur. Le stub serveur s'occupe de recevoir et de « dispatcher » les appels aux fonctions adéquates. Notre seul travail est d'écrire les fonctions. Donc, après modifications, le fichier calcul_server.c devient :

#include "calcul.h"

void *
calcul_null_1_svc(void *argp, struct svc_req *rqstp)
{
  static char* result;

  /* Ne rien faire */
  return((void *) &result);
}

reponse *
calcul_addition_1_svc(data *argp, struct svc_req *rqstp)
{
  static reponse result;
  unsigned int max;
  result.errno = 0; /* Pas d'erreur */
  
  /* Prend le max */
  max = argp->arg1 > argp->arg2 ? argp->arg1 : argp->arg2;
  
  /* On additionne */
  result.somme = argp->arg1 + argp->arg2;
  
  /* Overflow ? */
  if ( result.somme < max ) {
    result.errno = 1;
  }
  return(&result);
}

Nous pouvons alors le compiler en faisant :

gcc -c calcul_server.c

Puis, pour obtenir le programme serveur complet, il faut lier calcul_svc.o, calcul_server.o et calcul_xdr.o ensemble :

gcc -o server calcul_svc.o calcul_server.o calcul_xdr.o

A ce stade, nous avons terminé la partie serveur de notre application. On peut alors démarrer le serveur, puis utiliser rpcinfo pour vérifier qu'il tourne :

$ ./server &
[1] 2746

$ rpcinfo -p
   program no_version protocole  no_port
    100000    2   tcp    111  portmapper
    100000    2   udp    111  portmapper
 536870913    1   udp    803
 536870913    1   tcp    805

$ rpcinfo -u localhost 536870913
Le programme 536870913 de version 1 est prêt et en attente.

L'option -p de rpcinfo permet de connaître la liste des programmes RPC actuellement enregistrés sur la machine. Pour chaque programme, on obtient le numéro de version, le protocole et le port utilisés. Veuillez noter que les numéros de programmes sont affichés en décimal. Sachant que 536870913 est l'équivalent décimal de 0x20000001, tout va bien, notre programme est bien enregistré. L'option -u, quand à elle, permet de tester le programme indiqué en appelant sa procédure 0 (rappelez-vous qu'il est obligatoire d'avoir au moins une procédure de numéro 0). Pour plus d'information, vous pouvez consulter la page man de rpcinfo.

Processus client

Là encore, pour simplifier, on utilise le squelette que nous a fourni rpcgen en produisant le fichier calcul_client.c :

/*
 * This is sample code generated by rpcgen.
 * These are only templates and you can use them
 * as a guideline for developing your own functions.
 */

#include "calcul.h"

void
calcul_1(char* host)
{
  CLIENT *clnt;
  void  *result_1;
  char *calcul_null_1_arg;
  reponse  *result_2;
  data  calcul_addition_1_arg;
  clnt = clnt_create (host, CALCUL, VERSION_UN, "udp");
  if (clnt == NULL) {
    clnt_pcreateerror(host);
    exit (1);
  }
  result_1 = calcul_null_1((void*)&calcul_null_1_arg, clnt);
  if (result_1 == NULL) {
    clnt_perror (clnt, "call failed:");
  }
  result_2 = calcul_addition_1(&calcul_addition_1_arg, clnt);
  if (result_2 == NULL) {
    clnt_perror (clnt, "call failed:");
  }
  clnt_destroy (clnt);
}
main (int argc, char* argv[])
{
  char *host;
  if (argc < 2) {
    printf ("usage: %s server_host\n", argv[0]);
    exit (1);
  }
  host = argv[1];
  calcul_1 (host);
}

Des variables sont déclarées pour les arguments et les valeurs de retour. Comme vous pouvez le constater. Le programme squelette généré comprend un appel à chacune des fonctions définies dans l'interface (ici CALCUL_NULL et CALCUL_ADDITION). On peut remarquer que chaque appel est suivi d'un test qui détecte les erreurs de niveau « RPC » (serveur ne répondant pas, machine inexistante). L'erreur éventuelle est alors explicitée par la fonction clnt_perror(). Quand une erreur de niveau « RPC » se produit, le pointeur renvoyé est à NULL. Dans vos fonctions, vous ne devez donc pas mettre le pointeur à NULL pour spécifier une erreur. Pour un niveau d'erreur autre que RPC, vous pouvez utiliser une valeur particulière de la valeur de retour (comme un nombre négatif par exemple) et renvoyer tout à fait normalement le pointeur sur cette variable. Dans l'exemple, le choix a été fait d'utiliser une deuxième variable (champ errno de la structure reponse) pour spécifier s'il y a eu une erreur ou non (car l'utilisation de valeurs particulières est discutable).

Pour faire un vrai programme, il nous faut donner des valeurs aux paramètres et il faut utiliser effectivement les résultats des appels distants (par exemple en les affichant à l'écran). C'est ce que nous faisons avec notre programme client (dans lequel il n'y a volontairement pas d'appel à la procédure CALCUL_NULL) :

#include <limits.h>
#include "calcul.h"

CLIENT *clnt;
void
test_addition (uint param1, uint param2)
{
  reponse  *resultat;
  data  parametre;

  /* 1. Preparer les arguments */
  parametre.arg1 = param1;
  parametre.arg2 = param2;
  printf("Appel de la fonction CALCUL_ADDITION avec les paramètres: %u et %u \n", parametre.arg1,parametre.arg2);

  /* 2. Appel de la fonction distante */
  resultat = calcul_addition_1 (¶metre, clnt);
  if (resultat == (reponse *) NULL) {
    clnt_perror (clnt, "call failed");
    clnt_destroy (clnt);
    exit(EXIT_FAILURE);
  }
  else if ( resultat->errno == 0 ) {
    printf("Le resultat de l'addition est: %u \n\n",resultat->somme);
  } else {
    printf("La fonction distante ne peut faire l'addition a cause d'un overflow \n\n");
  }
}
int
main (int argc, char *argv[])
{
  char *host;
  if (argc < 2) {
    printf ("usage: %s server_host\n", argv[0]);
    exit (1);
  }
  host = argv[1];
  clnt = clnt_create (host, CALCUL, VERSION_UN, "udp");
  if (clnt == NULL) {
    clnt_pcreateerror (host);
    exit (1);
  }
  test_addition ( UINT_MAX - 15, 10 );
  test_addition ( UINT_MAX, 10 );
  clnt_destroy (clnt);
  exit(EXIT_SUCCESS);
}

Nous pouvons alors compiler puis lier avec calcul_clnt.o et calcul_xdr.o pour produire le client. Enfin, on peut tester le résultat final (en ayant démarré le processus serveur avant).

gcc -c calcul_client.c
gcc -o client calcul_client.o calcul_clnt.o calcul_xdr.o

$ ./client localhost

Appel de la fonction CALCUL_ADDITION avec les paramètres : 4294967280 et 10
Le résultat de l'addition est : 4294967290

Appel de la fonction CALCUL_ADDITION avec les paramètres : 4294967295 et 10
La fonction distante ne peut faire l'addition à cause d'un overflow

Le client prend en paramètre le nom de la machine serveur (ici localhost car le processus serveur a été démarré sur la même machine). Le nom peut être court (machine) pour une machine du même domaine que le client ou complet (machine.domaine.com).

Conclusion

Avec l'aide de la bibliothèque de fonctions RPC (clnt_create, clnt_destroy ...) et des outils comme rpcgen et rpcinfo, il a été très facile de construire une application simple mais toutefois représentative du mécanisme RPC. Pour aller plus loin, vous pouvez consulter la page man de rpcgen ainsi que la RFC 1057.

Aujourd'hui des systèmes plus perfectionnés ont fait leur apparition, comme par exemple CORBA. Certains diront peut-être que les RPC sont en quelque sorte l'ancêtre de CORBA, dans lequel il ne s'agit plus de fonctions distantes mais d'objets (dans le sens de la programmation orienté objet) distants.

François Crevola email_anti_spam
Linux Magazine France n°20 - Septembre 2000

Article sous licence GNU FDL