Principes fondamentaux de l'appel de procédure à distance (RPC)

En milieu universitaire, on écrit souvent des programmes simples où le client et le serveur s'exécutent localement sur la même machine, comme un service « Bonjour le Monde ».

En revanche, dans une grande entreprise Internet, les systèmes sont composés de milliers de services distincts, déployés sur des machines différentes et gérés par des équipes variées. Cela soulève deux questions : comment appeler un service distant, et comment publier le nôtre pour qu'il soit accessible ?

1. Appeler un service distant

Lorsque les services sont distribués, chaque invocation nécessite une communication réseau. Réimplémenter la logique réseau pour chaque appel est complexe et source d'erreurs. L'idée est de masquer ces détails pour que l'appel distant ressemble à un appel local. C'est le principe de l'Appel de Procédure à Distance (RPC), utilisé par des systèmes comme Dubbo, Thrift ou gRPC.

Le processus typique d'un appel RPC comprend :

  • L'appelant exécute une méthode localement.
  • Un client stub sérialise l'appel (méthode, paramètres) en un message réseau.
  • Le message est envoyé au serveur.
  • Un stub côté serveur désérialise le message et invoque le service local.
  • Le résultat suit le chemin inverse jusqu'à l'appelant.

L'objectif de RPC est d'automatiser les étapes de sérialisation, transport et désérialisation.

1.1 Rendre l'appel distant transparent

En Java, on peut utiliser des proxies dynamiques pour intercepter les appels. Le proxy enveloppe la logique de communication réseau dans une méthode invoke.

Interface du service :

public interface DemoService {
    String saluer(String nom);
}

Implémentation :

public class DemoServiceImpl implements DemoService {
    @Override
    public String saluer(String nom) {
        return "Bonjour " + nom;
    }
}

Client avec proxy dynamique :

public class RpcClient implements InvocationHandler {
    private final Class<?> serviceInterface;

    public RpcClient(Class<?> serviceInterface) {
        this.serviceInterface = serviceInterface;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // Ici, la logique réseau : sérialisation, envoi, attente de réponse
        // Simulation d'un appel réseau
        System.out.println("Appel RPC intercepté pour: " + method.getName());
        return "Réponse simulée du serveur";
    }

    public <T> T createProxy() {
        return (T) Proxy.newProxyInstance(
                serviceInterface.getClassLoader(),
                new Class[]{serviceInterface},
                this);
    }
}

Utilisation :

DemoService proxyService = new RpcClient(DemoService.class).createProxy();
String resultat = proxyService.saluer("Alice");
System.out.println(resultat);

1.2 Format des messages

La communication nécessite une structure de message standardisée. Une requête inclut typiquement :

  • Nom de l'interface et de la méthode.
  • Types et valeurs des paramètres.
  • Identifiant unique de la requête (requestID) et délai d'expiration.

La réponse contient :

  • La valeur de retour.
  • Un code de statut.
  • Le requestID correspondant.

1.3 Sérialisation

La sérialisation convertit les structures de données en une suite d'octets pour le transport réseau. Le choix d'un format de sérialisation (comme Protocol Buffers, Hessian, ou Thrift) dépend de critères de performance, d'extensibilité et de compatibilité.

1.4 Modèle de communication

Le transport réseau repose sur des modèles d'E/S comme BIO ou NIO. Les frameworks modernes comme Netty fournissent une couche d'abstraction efficace pour gérer les connexions asynchrones.

1.5 Gestion des requêtes asynchrones avec requestID

Les appels RPC sont souvent asynchrones. Pour faire correspondre une réponse à la requête originale, chaque requête est associée à un requestID unique. Côté client, un callback peut être mis en attente via un wait/notify sur un objet dédié, identifié par le requestID.

public class ReponseCallback {
    private Object resultat;
    private boolean pret = false;

    public synchronized Object attendreResultat() throws InterruptedException {
        while (!pret) {
            wait();
        }
        return resultat;
    }

    public synchronized void definirResultat(Object resultat) {
        this.resultat = resultat;
        this.pret = true;
        notifyAll();
    }
}

2. Publier son service

Pour permettre à d'autres de consommer un service, il faut communiquer son adresse (IP et port). Une gestion manuelle (« human wiring ») est inefficace en production.

Une solution automatisée consiste à utiliser un annuaire de services comme ZooKeeper. Les fournisseurs de services s'enregistrent dans ZooKeeper à un chemin structuré (par exemple /nom-du-service/version/ip:port). ZooKeeper effectue un contrôle de santé (« heartbeat ») et met à jour dynamiquement la liste des instances disponibles. Les consommateurs de services sont notifiés des changements et peuvent se rééquilibrer automatiquement.

Cette approche offre découverte de service automatique, tolérance aux pannes et mise à l'échelle dynamique.

Étiquettes: RPC Java ZooKeeper sérialisation communication réseau

Publié le 8 juin à 16h44