Aller au contenu

Applications réparties

Client-serveur, appels distants et serialisation — les briques elementaires de la communication entre composants.


Le modèle client-serveur

Le modèle client-serveur est la base de toute architecture distribuée. Un client envoie une requête, un serveur la traite et retourné une réponse. Simple en apparence, mais les implications en système distribué sont profondes.

Variantes du modèle

Variante Description Exemple
2-tiers Client communique directement avec le serveur Application desktop + base SQL
3-tiers Présentation, logique métier, données sur des couches séparées Web app + API + base de données
N-tiers Decomposition fine en services specialises Microservices derriere un gateway
Peer-to-peer Chaque nœud est à la fois client et serveur BitTorrent, blockchain, gossip

Le passage de 2-tiers a 3-tiers a été motive par la séparation des responsabilités : le client ne parle plus directement à la base de données. Le passage a N-tiers suit la même logique — chaque couche devient un service indépendant avec son propre cycle de vie.

Problèmes inhérents

Distribuer introduit huit erreurs classiques (Peter Deutsch, "Fallacies of Distributed Computing") :

  1. Le réseau est fiable
  2. La latence est nulle
  3. La bande passante est infinie
  4. Le réseau est sécurisé
  5. La topologie ne change pas
  6. Il y a un seul administrateur
  7. Le coût de transport est nul
  8. Le réseau est homogène

Chaque architecture distribuée doit traiter explicitement ces réalités : timeouts, retries, circuit breakers, serialisation efficace, authentification inter-services.


Remote Procédure Call (RPC)

L'idée du RPC : appeler une procédure distante comme si elle etait locale. Le programmeur écrit un appel de fonction normal, l'infrastructure se charge de la communication réseau.

Architecture du RPC

graph LR
    subgraph Client
        CC[Code client]
        CS[Client stub]
    end

    subgraph Reseau
        M["Marshalling<br/>(serialisation)"]
    end

    subgraph Serveur
        SS[Server skeleton]
        SC[Code serveur]
    end

    CC --"appel local"--> CS
    CS --"serialise + envoie"--> M
    M --"deserialise"--> SS
    SS --"appel local"--> SC
    SC --"retour"--> SS
    SS --"serialise + envoie"--> M
    M --"deserialise"--> CS
    CS --"retour"--> CC

Stub (côté client) : proxy local qui à la même signature que la méthode distante. Il serialise les parametres, envoie la requête sur le réseau, attend la réponse et la deserialise.

Skeleton (côté serveur) : reçoit la requête, deserialise les parametres, appelle la méthode réelle, serialise le résultat et le renvoie.

Marshalling : processus de conversion des objets en mémoire en une représentation transmissible sur le réseau (octets). Le demarshalling fait l'inverse. C'est la serialisation appliquee au contexte RPC.

Semantiques d'appel

Le réseau peut perdre des messages. Comment le client sait-il si son appel a été traite ?

Semantique Garantie Risque
At-most-once L'appel est exécuté 0 ou 1 fois Le client ne sait pas si exécuté
At-least-once L'appel est exécuté au moins 1 fois (retry auto) Effet de bord duplique
Exactly-once L'appel est exécuté exactement 1 fois Très coûteux a garantir

En pratique, at-least-once + idempotence est le choix standard. Le serveur reçoit potentiellement le même appel plusieurs fois et produit le même résultat. Exactly-once nécessité un protocole de commit en deux phases, rarement justifie.

gRPC

gRPC (Google, 2015) est le standard moderne du RPC. Il utilisé HTTP/2 comme transport et Protocol Buffers comme format de serialisation.

syntax = "proto3";

service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc ListUsers(ListUsersRequest) returns (stream User);
  rpc CreateUser(CreateUserRequest) returns (User);
}

message GetUserRequest {
  string id = 1;
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
}

Les quatre modes de communication gRPC :

Mode Client Serveur Cas d'usage
Unary 1 requête 1 réponse CRUD classique
Server streaming 1 requête N réponses Feed d'événements, pagination
Client streaming N requêtes 1 réponse Upload de fichier, batch
Bidirectional streaming N requêtes N réponses Chat, collaboration temps réel

gRPC vs REST

gRPC excelle pour la communication inter-services : typage fort, performance binaire, streaming natif, génération de code automatique. REST reste preferable pour les APIs publiques : universalite, outils de debug (curl, navigateur), documentation lisible.


RMI Java

Java RMI (Remote Method Invocation) est l'implémentation Java du RPC. Moins utilise aujourd'hui que gRPC, il illustre les concepts fondamentaux avec clarte.

Architecture RMI

graph TD
    subgraph Client JVM
        CL[Client]
        ST[Stub<br/>genere automatiquement]
    end

    subgraph RMI Registry
        REG[Registry<br/>port 1099]
    end

    subgraph Server JVM
        SK[Skeleton]
        SV[Service impl]
    end

    CL --"1. lookup(name)"--> REG
    REG --"2. retourne stub"--> CL
    CL --"3. appel methode"--> ST
    ST --"4. serialise + envoie"--> SK
    SK --"5. appel local"--> SV
    SV --"6. retour"--> SK
    SK --"7. serialise + envoie"--> ST

Le RMI Registry est un annuaire ou les services s'enregistrent avec un nom. Le client fait un lookup pour obtenir le stub, puis appelle les méthodes comme si l'objet etait local.

Serialisation Java

RMI utilise la serialisation Java native (Serializable). Chaque objet est converti en flux d'octets avec ses metadonnees de classe. C'est pratique mais problematique :

  • Version : le serialVersionUID doit correspondre entre client et serveur
  • Sécurité : la deserialisation peut exécuter du code arbitraire (vulnérabilités de deserialisation)
  • Performance : le format est verbeux (metadonnees de classe incluses)
  • Interopérabilité : lie au langage Java

Ces limitations ont motive la migration vers des formats de serialisation indépendants du langage.


Formats de serialisation

Le choix du format de serialisation impacte la performance, l'interopérabilité et l'évolution du schéma.

Comparaison des formats

Format Type Lisible Taille Vitesse Schéma Langages
JSON Texte Oui Moyenne Moyenne Optionnel Tous
XML Texte Oui Grande Lente XSD Tous
Protobuf Binaire Non Petite Rapide Obligatoire Principaux
Avro Binaire Non Petite Rapide Obligatoire JVM, Python
MessagePack Binaire Non Petite Très rapide Non Tous
FlatBuffers Binaire Non Petite Zero-copy Obligatoire Principaux

JSON

Standard de facto pour les APIs REST. Lisible, universel, supporte par tous les langages sans librairie externe.

{
  "id": "usr-001",
  "name": "Alice Martin",
  "email": "alice@example.com",
  "roles": ["admin", "reviewer"],
  "created_at": "2025-03-15T10:30:00Z"
}

Limites : pas de schéma natif (JSON Schéma existe mais est optionnel), types limites (pas de date, pas de binaire natif, pas de distinction int/float), verbeux pour les gros volumes.

XML

Predecesseur de JSON pour les APIs web (SOAP). Schéma puissant (XSD) avec validation, namespaces, et transformations (XSLT). Encore present dans les systèmes legacy, les protocoles bancaires (ISO 20022), et les configurations complexes.

<user xmlns="urn:example:users:v1">
  <id>usr-001</id>
  <name>Alice Martin</name>
  <email>alice@example.com</email>
  <roles>
    <role>admin</role>
    <role>reviewer</role>
  </roles>
</user>

Le ratio signal/bruit est faible : les balises occupent souvent plus de place que les données. Pour les APIs modernes, JSON a gagne.

Protocol Buffers (Protobuf)

Format binaire avec schéma obligatoire. Les champs sont identifiés par numéro, pas par nom — ce qui permet de renommer un champ sans casser la compatibilité.

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  repeated string roles = 4;
  google.protobuf.Timestamp created_at = 5;
}

Règles d'évolution du schéma :

  • Ajouter un champ : nouveau numéro, les anciens clients ignorent le champ inconnu
  • Supprimer un champ : marquer le numéro reserved, ne jamais le réutiliser
  • Renommer un champ : libre, seul le numéro compte en binaire
  • Changer un type : interdit (sauf compatibles comme int32int64)

Avro

Utilisé principalement dans l'écosystème Kafka et Hadoop. Le schéma est envoye avec les données ou stocke dans un Schéma Registry. Supporte l'évolution de schéma avec résolution automatique entre le schéma d'écriture et celui de lecture.

Choisir son format

Pour les APIs REST publiques : JSON. Pour la communication inter-services haute performance : Protobuf via gRPC. Pour les pipelines de données Kafka : Avro avec Schéma Registry. Pour les systèmes legacy : XML si impose par le protocole. Le format est un choix d'infrastructure, pas de préférence personnelle.


Découverte de services

Dans un système distribué, les instances de services apparaissent et disparaissent dynamiquement. La découverte de services permet à un client de trouver les instances disponibles sans coder en dur les adresses.

DNS classique

Le DNS reste la méthode la plus simple : un nom de domaine pointe vers les adresses IP des instances. Le load balancer (nginx, HAProxy, ALB) reçoit le trafic et le distribué.

Limite : le TTL DNS introduit un délai de propagation. Une instance tombee continue de recevoir du trafic jusqu'à l'expiration du cache DNS.

Service registry

Un registre centralise ou chaque instance s'enregistre au démarrage et se desenregistre à l'arrêt. Les clients interrogent le registre pour obtenir la liste des instances saines.

graph TD
    S1[Service A<br/>instance 1] --"register"--> R[(Service Registry<br/>Consul / etcd / Eureka)]
    S2[Service A<br/>instance 2] --"register"--> R
    S3[Service A<br/>instance 3] --"register"--> R

    R --"health check"--> S1
    R --"health check"--> S2
    R --"health check"--> S3

    C[Client] --"lookup: Service A"--> R
    R --"[instance 1, instance 3]"--> C
Outil Protocole Écosystème
Consul HTTP/DNS/gRPC HashiCorp, multi-DC
etcd gRPC Kubernetes, CNCF
Eureka HTTP Netflix, Spring Cloud
ZooKeeper TCP custom Apache, Kafka ancien

Client-side vs server-side discovery

Client-side : le client interroge le registre et choisit l'instance. Le client à le contrôle du load balancing (round-robin, least-connections, weighted). Utilisé par Netflix Ribbon, gRPC avec service mesh.

Server-side : le client envoie au load balancer qui interroge le registre et route. Le client ne sait pas combien d'instances existent. Utilisé par Kubernetes (kube-proxy), AWS ALB.

graph LR
    subgraph Client-side
        C1[Client] --"1. lookup"--> R1[Registry]
        R1 --"2. instances"--> C1
        C1 --"3. appel direct"--> S1[Instance]
    end

    subgraph Server-side
        C2[Client] --"1. appel"--> LB[Load Balancer]
        LB --"2. lookup"--> R2[Registry]
        LB --"3. route"--> S2[Instance]
    end

Health checks

Le registre doit savoir si une instance est reellement opérationnelle. Deux types :

  • Liveness : le processus tourne-t-il ? (TCP connect, HTTP 200 sur /healthz)
  • Readiness : le service est-il prêt à recevoir du trafic ? (connexion DB établie, cache charge)

Une instance live mais pas ready ne doit pas recevoir de trafic. Kubernetes distingue les deux avec des probes séparées. En cas de startup lent (chargement de modèle ML, prechauffage de cache), une startup probe evite que le liveness check tue le pod avant qu'il soit pret.

Timeouts de health check

Des health checks trop agressifs (intervalle de 1s, timeout de 500ms) peuvent déclarer des instances mortes pendant un pic de charge temporaire. Des health checks trop laxistes (intervalle de 30s) laissent du trafic aller vers des instances mortes. Commencer avec un intervalle de 5s et un timeout de 3s, puis ajuster selon les observations.


Chapitre suivant : Synchrone et asynchrone — patterns de communication, brokers de messages et backpressure.