La lliçó anterior va acabar amb un servidor TCP d'inventari que entenia ESTOC formatge-curat\n i amb una incomoditat: per a cada operació nova calia inventar un format de text, escriure un parser a cada banda i traduir errors a mà. El que un desenvolupador de comandes voldria escriure és inventari.reservar_estoc("formatge-curat", 2) i obtenir-ne un resultat, sense pensar en bytes ni en sockets. Aquesta aspiració té nom des del 1984: crida a procediment remot (RPC, Remote Procedure Call), i la seva versió orientada a objectes en Java és RMI (Remote Method Invocation).

En aquesta lliçó entendrem què hi ha dins d'una crida remota (stubs, marshalling, IDL), per què "com si fos local" és una promesa que la xarxa no pot complir del tot (reprenent les fal·làcies de 01-04), quines semàntiques d'invocació existeixen i per què "exactament una vegada" és un mite. Implementarem inventari com a servidor XML-RPC en Python, el cridarem des de comandes i tractarem els errors que travessen la xarxa; després veurem el mateix servei en Java RMI, per entendre què canvia quan el que és remot són objectes. Acabarem comparant RPC, RMI i REST. gRPC, l'RPC modern que farà servir Quilòmetre Zero, queda per a la lliçó següent: aquí assentem els conceptes que gRPC dona per sabuts.

Contingut

  1. La idea: cridar un procediment com si fos aquí
  2. Anatomia d'una crida remota: stubs, marshalling i IDL
  3. Semàntiques d'invocació: què passa quan alguna cosa falla
  4. Transparència i els seus límits
  5. RPC clàssic en Python: inventari amb XML-RPC
  6. Errors que travessen la xarxa
  7. RMI: objectes remots en Java
  8. RPC contra RMI contra REST
  9. Errors comuns i consells
  10. Exercicis
  11. Conclusió

  1. La idea: cridar un procediment com si fos aquí

Al monòlit de Quilòmetre Zero, confirmar una comanda és una crida de funció Python: inventari.reservar_estoc(linies) (lliçó 01-06). El mòdul comandes no sap ni li importa com està implementada. RPC proposa conservar exactament aquesta experiència quan inventari passa a ser un altre procés en una altra màquina: el programador escriu la mateixa crida i una capa intermèdia (el middleware de la lliçó 01-01) s'encarrega de convertir els arguments en bytes, enviar-los, esperar, i convertir la resposta en un valor.

La proposta original és de Birrell i Nelson (1984) i ha tingut descendents a cada dècada: Sun RPC (la base d'NFS), DCE RPC (la base de les crides remotes de Windows), CORBA (objectes remots multillenguatge als anys 90), Java RMI (1997), XML-RPC i SOAP (RPC sobre HTTP amb XML, 1998-2000), i finalment Thrift i gRPC (RPC binari amb esquemes, 2007-2015). Canvien els formats i els transports; l'estructura interna és sempre la mateixa, i és el que disseccionarem.

  1. Anatomia d'una crida remota: stubs, marshalling i IDL

Una crida RPC passa per deu passos, cinc a cada extrem:

sequenceDiagram
    participant P as comandes (codi de negoci)
    participant SC as Stub client
    participant XARXA as Xarxa (TCP/HTTP)
    participant SK as Skeleton servidor
    participant I as inventari (implementació)
    P->>SC: reservar_estoc("formatge-curat", 2)
    SC->>SC: marshalling: args → bytes
    SC->>XARXA: enviar petició
    XARXA->>SK: rebre petició
    SK->>SK: unmarshalling: bytes → args
    SK->>I: reservar_estoc("formatge-curat", 2)
    I-->>SK: {"restant": 3}
    SK->>SK: marshalling del resultat
    SK-->>XARXA: enviar resposta
    XARXA-->>SC: rebre resposta
    SC->>SC: unmarshalling
    SC-->>P: {"restant": 3}

Cada peça té un nom que convé fixar, perquè els retrobarem a gRPC:

  • Stub client (o proxy): un objecte local que té la mateixa signatura que el procediment remot. comandes el crida com si fos la implementació real. La seva feina és empaquetar la crida i enviar-la.
  • Marshalling (empaquetatge) i unmarshalling: conversió d'arguments i resultats entre la representació en memòria del llenguatge i una seqüència de bytes que pugui viatjar per la xarxa. Inclou resoldre diferències de representació entre màquines (ordre de bytes dels enters, codificació de cadenes) i decidir què fer amb punters o referències, que no tenen sentit en una altra màquina. El terme serialització n'és pràcticament sinònim, i els formats concrets (JSON, XML, Protocol Buffers) són el tema de la lliçó 02-03.
  • Skeleton servidor (o dispatcher): rep els bytes, els desempaqueta, identifica quin procediment es demana, l'invoca i empaqueta la resposta.
  • Transport: el protocol de la lliçó anterior (TCP cru, HTTP...). A l'RPC no li importa quin sigui, però les seves propietats (timeouts, ordre, fiabilitat) sí que en condicionen les garanties.
  • IDL (Interface Definition Language): un llenguatge neutre per descriure el contracte, és a dir, quins procediments existeixen, amb quins paràmetres i tipus, i què retornen. A partir de l'IDL, un generador produeix automàticament els stubs i skeletons en cada llenguatge. Sun RPC tenia .x, CORBA tenia el seu propi IDL, gRPC fa servir .proto. XML-RPC i Java RMI no tenen IDL separat: el contracte és la mateixa signatura de les funcions Python o la interfície Java.

L'IDL és la peça conceptualment més important. Sense ell, el contracte entre comandes i inventari existeix només al cap dels desenvolupadors i al codi de cada banda, i es trenca en silenci quan un dels dos canvia. Amb ell, el contracte és un fitxer versionat que tots dos equips comparteixen i del qual es genera codi: l'enfocament contract-first que la lliçó 02-03 desenvolupa.

  1. Semàntiques d'invocació: què passa quan alguna cosa falla

Una crida local o s'executa o llança una excepció; no hi ha més opcions. Una crida remota té un tercer resultat, que ja ens vam trobar a la lliçó 01-04: no se sap. Hi ha tres punts on un missatge es pot perdre, i són indistingibles per al client:

flowchart LR
    A[1. Es perd la petició] --> X[El client només veu: no arriba resposta]
    B[2. inventari cau després d'executar] --> X
    C[3. Es perd la resposta] --> X

En el cas 1 la reserva no s'ha fet; en els casos 2 i 3 sí que s'ha fet. El client veu el mateix: silenci fins al timeout. El que faci l'stub davant d'aquest silenci defineix la semàntica d'invocació:

Semàntica Què fa l'stub Resultat possible a reservar_estoc Quan és acceptable
Maybe (potser) Envia una vegada, no reintenta, no espera confirmació Executat 0 o 1 vegades; el client no sap quina Telemetria, notificacions sense importància
At-least-once (almenys una vegada) Reintenta fins a obtenir resposta Executat 1 o més vegades: l'estoc es pot descomptar dues vegades Operacions idempotents (consultar estoc, fixar un valor absolut)
At-most-once (com a molt una vegada) Reintenta, però el servidor filtra duplicats per identificador de petició Executat 0 o 1 vegades; si hi ha resposta, és de l'única execució Operacions amb efectes (reservar, cobrar) quan es tolera que no passin
Exactly-once (exactament una vegada) — Executat 1 vegada, garantit No existeix d'extrem a extrem; vegeu més avall

Per què exactly-once no existeix a la pràctica. Per garantir que una operació amb efectes s'executa exactament una vegada passi el que passi, caldria que el servidor executés l'operació i registrés "ja l'he executada" de manera atòmica, que aquest registre sobrevisqués a la seva pròpia caiguda, i que el client pogués saber sempre en quin estat ha quedat. S'hi pot arribar molt a prop (registre durable de peticions processades, identificadors únics, reintents), però sempre hi ha una finestra (el servidor cau entre executar i registrar; el disc falla) en què la garantia es converteix en "almenys una vegada" o "com a molt una vegada". El que sí que es pot aconseguir és que l'operació sigui efectivament una vegada: que executar-la dues vegades tingui el mateix resultat que executar-la una (idempotència). Aquest és l'enfocament pràctic, i la lliçó 02-05 l'implementa amb una taula de peticions processades a inventari. Aquí n'hi ha prou d'entendre que la pregunta "s'ha executat la meva crida?" no sempre té resposta, i que el disseny del contracte ho ha d'assumir.

Un detall útil: at-most-once se sol implementar amb un identificador de petició generat pel client i una memòria cau de respostes al servidor. Si arriba una petició repetida, el servidor no la reexecuta: retorna la resposta desada. Guarda aquest mecanisme a la memòria; és el mateix que reapareixerà a 02-05 amb un altre nom.

  1. Transparència i els seus límits

RPC ven transparència d'accés (lliçó 01-01): la crida remota s'escriu igual que la local. És una promesa útil i alhora perillosa. El 1994, Waldo, Wyant, Wollrath i Kendall (els dissenyadors del que seria Java RMI) van publicar "A Note on Distributed Computing", la tesi del qual és que hi ha quatre diferències entre el que és local i el que és remot que cap middleware no pot amagar, i que fingir que no existeixen produeix sistemes fràgils:

  1. Latència. Una crida local costa nanosegons; una de remota, mil·lisegons: entre quatre i sis ordres de magnitud. Un bucle que crida consultar_estoc per a cadascun dels 40 productes d'un cistell és innocu al monòlit i desastrós amb RPC (40 viatges d'anada i tornada). Fal·làcia 2 de 01-04. Conseqüència de disseny: interfícies remotes de gra gruixut (consultar_estoc(llista_de_productes)), no de gra fi.
  2. Accés a memòria. Un punter o una referència a un objecte no vol dir res en una altra màquina. Els arguments viatgen per valor (copiats); modificar la còpia al servidor no modifica l'original. Això canvia la semàntica del codi de maneres subtils.
  3. Fallades parcials. Ja ho hem vist: "no se sap". Localment, si la funció no retorna és perquè el procés sencer ha mort. Remotament, el client continua viu i ha de decidir què fer sense informació. Fal·làcia 1.
  4. Concurrència. El servidor atén molts clients alhora sense que el client ho vegi; l'estat compartit al servidor necessita protecció (el cadenat de l'exercici 1 de la lliçó anterior), i l'ordre d'arribada de crides de clients diferents no està definit.

La conclusió de Waldo i companyia, que gRPC i tota la indústria han acabat adoptant, és que les interfícies remotes s'han de dissenyar com a remotes: signatures explícites, tipus que viatgin bé, timeouts obligatoris, errors de xarxa distingibles dels errors d'aplicació, i sense fingir que un objecte remot és un objecte local. Tingues-ho present en llegir el codi dels apartats següents: XML-RPC i RMI fan que la crida sembli local, però a cada punt veurem on treu el cap la xarxa.

  1. RPC clàssic en Python: inventari amb XML-RPC

XML-RPC és l'RPC més simple que existeix: les crides es codifiquen en XML i viatgen com a cos d'un POST HTTP. Python l'inclou a la biblioteca estàndard (xmlrpc.server i xmlrpc.client), cosa que el fa perfecte per veure els conceptes sense instal·lar res. No el faríem servir en producció (és lent, verbós i sense esquema), però cada peça té el seu equivalent a gRPC.

El servidor

# km0/serveis/inventari/rpc_servidor.py
import threading
from socketserver import ThreadingMixIn
from xmlrpc.client import Fault
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer

# Codis d'error del contracte d'inventari. Formen part de la interfície:
# el client els necessita per distingir causes sense parsejar text.
ERR_PRODUCTE_DESCONEGUT = 100
ERR_ESTOC_INSUFICIENT = 101
ERR_QUANTITAT_INVALIDA = 102


class Inventari:
    """Implementació del servei. Tots els seus mètodes públics són remots."""

    def __init__(self):
        self._estoc = {
            "tomaquet-rosa": 120, "carbasso": 80, "formatge-curat": 5,
            "formatge-fresc": 30, "vi-crianca": 200,
        }
        self._cadenat = threading.Lock()

    def consultar_estoc(self, producte):
        with self._cadenat:
            unitats = self._estoc.get(producte)
        if unitats is None:
            raise Fault(ERR_PRODUCTE_DESCONEGUT, f"producte desconegut: {producte}")
        return unitats

    def reservar_estoc(self, producte, quantitat):
        if not isinstance(quantitat, int) or quantitat <= 0:
            raise Fault(ERR_QUANTITAT_INVALIDA, "la quantitat ha de ser un enter positiu")
        with self._cadenat:
            disponible = self._estoc.get(producte)
            if disponible is None:
                raise Fault(ERR_PRODUCTE_DESCONEGUT, f"producte desconegut: {producte}")
            if disponible < quantitat:
                raise Fault(ERR_ESTOC_INSUFICIENT,
                            f"només queden {disponible} unitats de {producte}")
            self._estoc[producte] = disponible - quantitat
            restant = self._estoc[producte]
        print(f"[inventari] reservades {quantitat} de {producte}, en queden {restant}")
        return {"producte": producte, "reservat": quantitat, "restant": restant}


class ServidorAmbFils(ThreadingMixIn, SimpleXMLRPCServer):
    """SimpleXMLRPCServer atén en sèrie; amb ThreadingMixIn, un fil per petició."""
    daemon_threads = True


class Gestor(SimpleXMLRPCRequestHandler):
    rpc_paths = ("/rpc",)     # només accepta POST a aquesta ruta


if __name__ == "__main__":
    servidor = ServidorAmbFils(("0.0.0.0", 8001), requestHandler=Gestor,
                               allow_none=True, logRequests=False)
    servidor.register_introspection_functions()   # system.listMethods, etc.
    servidor.register_instance(Inventari())        # exposa els seus mètodes públics
    print("[inventari] XML-RPC escoltant a :8001/rpc")
    servidor.serve_forever()

Punts importants:

  • register_instance fa d'skeleton: rep l'XML, busca un mètode amb aquest nom a la instància, l'invoca amb els arguments desempaquetats i empaqueta el resultat. No hi ha IDL: el contracte és la llista de mètodes públics d'Inventari. Els mètodes que comencen per _ no s'exposen (per això _estoc i _cadenat porten guió baix, i no només per convenció).
  • Fault és l'excepció que forma part del protocol: XML-RPC defineix com es codifica a l'XML de resposta (un faultCode enter i un faultString). Definim codis propis perquè el client necessita saber per què ha fallat sense analitzar el text. Si el mètode llancés una excepció Python qualsevol (ValueError), el servidor la convertiria en un Fault genèric amb codi 1 i un text com <class 'ValueError'>:...: útil per depurar, inútil per programar-hi en contra.
  • ThreadingMixIn és necessari per la mateixa raó que els fils de la lliçó anterior: sense ell, una petició lenta bloqueja totes les altres. I amb ell, _cadenat és imprescindible.
  • Els tipus que XML-RPC sap transportar són pocs: enters de 32 bits, doubles, cadenes, booleans, dates, binaris, llistes i diccionaris amb claus de text. Un Decimal per al preu no viatja; un enter de 64 bits, tampoc (a la implementació estàndard). Aquestes limitacions del marshalling són una de les raons per preferir formats amb esquema (02-03).

El client a comandes

# km0/serveis/comandes/rpc_client.py
import socket
import xmlrpc.client


class TransportAmbTimeout(xmlrpc.client.Transport):
    """El Transport per defecte no té timeout: la fal·làcia 1 en estat pur."""

    def __init__(self, timeout):
        super().__init__()
        self._timeout = timeout

    def make_connection(self, host):
        connexio = super().make_connection(host)   # http.client.HTTPConnection
        connexio.timeout = self._timeout
        return connexio


# L'stub: un objecte local amb la mateixa "forma" que el servei remot.
inventari = xmlrpc.client.ServerProxy("http://localhost:8001/rpc",
                                      transport=TransportAmbTimeout(2.0),
                                      allow_none=True)

if __name__ == "__main__":
    print("Mètodes remots:", inventari.system.listMethods())
    print("Estoc de formatge curat:", inventari.consultar_estoc("formatge-curat"))
    print("Reserva de l'Anna:", inventari.reservar_estoc("formatge-curat", 2))
    print("Reserva d'en Marc:", inventari.reservar_estoc("formatge-curat", 4))

Sortida:

Mètodes remots: ['consultar_estoc', 'reservar_estoc', 'system.listMethods', ...]
Estoc de formatge curat: 5
Reserva de l'Anna: {'producte': 'formatge-curat', 'reservat': 2, 'restant': 3}
Traceback (most recent call last):
  ...
xmlrpc.client.Fault: <Fault 101: 'només queden 3 unitats de formatge-curat'>

ServerProxy és l'stub: inventari.reservar_estoc(...) no existeix com a mètode Python; ServerProxy intercepta l'accés a l'atribut, construeix un XML amb el nom del mètode i els arguments, fa el POST, i desempaqueta la resposta. Per al desenvolupador de comandes, és una crida. Per veure el que viatja, aquest és el cos de la petició de l'Anna:

<?xml version='1.0'?>
<methodCall>
  <methodName>reservar_estoc</methodName>
  <params>
    <param><value><string>formatge-curat</string></value></param>
    <param><value><int>2</int></value></param>
  </params>
</methodCall>

Uns 230 bytes per transmetre dos arguments: el marshalling en XML és còmode de llegir i caríssim de transmetre i de parsejar. I la resposta d'error d'en Marc:

<methodResponse>
  <fault>
    <value><struct>
      <member><name>faultCode</name><value><int>101</int></value></member>
      <member><name>faultString</name><value><string>només queden 3 unitats de formatge-curat</string></value></member>
    </struct></value>
  </fault>
</methodResponse>

  1. Errors que travessen la xarxa

L'exemple anterior acaba amb un traceback perquè no capturem el Fault. Un client RPC seriós ha de distingir quatre famílies d'error, perquè cadascuna exigeix una reacció diferent:

Família Exemple Excepció a xmlrpc.client S'ha executat l'operació? Reacció raonable
Error d'aplicació Estoc insuficient, producte desconegut Fault (amb el nostre faultCode) Sí, i ha decidit fallar Tractar-ho com a regla de negoci: informar el client, no reintentar
No s'ha pogut connectar inventari caigut, DNS incorrecte ConnectionRefusedError, socket.gaierror No Reintentar després d'una espera, o degradar (02-05 i 07-04)
Timeout Xarxa lenta, servidor saturat socket.timeout / TimeoutError No se sap Reintentar només si l'operació és idempotent (02-05)
Error de protocol Ruta incorrecta, proxy que retorna 502, resposta no XML ProtocolError, xmlrpc.client.ResponseError Normalment no, però un 502 d'un proxy no ho garanteix Registrar i alertar: sol ser un error de desplegament o configuració

El client de comandes amb la gestió completa:

# km0/serveis/comandes/confirmar_comanda.py
import socket
import xmlrpc.client
from rpc_client import inventari, TransportAmbTimeout  # noqa: F401

ERR_ESTOC_INSUFICIENT = 101


def reservar_per_comanda(producte, quantitat):
    """Retorna (ok, missatge). No llança mai: converteix cada família d'error
    en una decisió de negoci explícita."""
    try:
        r = inventari.reservar_estoc(producte, quantitat)
        return True, f"reservades {r['reservat']} de {producte}, en queden {r['restant']}"
    except xmlrpc.client.Fault as f:
        if f.faultCode == ERR_ESTOC_INSUFICIENT:
            return False, f"sense estoc suficient: {f.faultString}"
        return False, f"inventari ha rebutjat la petició ({f.faultCode}): {f.faultString}"
    except (ConnectionRefusedError, socket.gaierror) as e:
        # Segur que no s'ha executat: es pot reintentar sense risc.
        return False, f"inventari no disponible ({e}); comanda en espera"
    except (socket.timeout, TimeoutError):
        # AMBIGU: pot haver-se executat. Reintentar aquí duplicaria reserves (01-04).
        return False, "inventari no ha respost a temps; estat de la reserva desconegut"
    except xmlrpc.client.ProtocolError as e:
        return False, f"error de protocol {e.errcode} a {e.url}: {e.errmsg}"


if __name__ == "__main__":
    for client, producte, quantitat in [("Anna", "formatge-curat", 2),
                                        ("Marc", "formatge-curat", 4),
                                        ("Llúcia", "vi-crianca", 6)]:
        ok, msg = reservar_per_comanda(producte, quantitat)
        print(f"{client}: {'OK' if ok else 'NO'} - {msg}")

Dues observacions. La primera: la branca del timeout és l'única que no es pot resoldre en aquesta lliçó. Amb el que sabem ara, el més honest és deixar la comanda en un estat "reserva pendent de confirmar" i no reintentar; la solució completa (que la petició porti un identificador i que inventari recordi quines ha processat) és la idempotència de 02-05. La segona: l'excepció Fault viatja per la xarxa; les altres neixen al client. Aquesta distinció es manté en tots els sistemes RPC: gRPC la formalitza amb codis d'estat (02-03), i REST amb codis HTTP 4xx (aplicació) davant d'errors de connexió i 5xx (infraestructura).

  1. RMI: objectes remots en Java

Java RMI porta la idea un pas més enllà: el que és remot no és un procediment sinó un objecte, amb estat i amb identitat. El client obté una referència remota a un objecte que viu en una altra JVM i n'invoca mètodes. Encara que Quilòmetre Zero està en Python, val la pena veure RMI perquè mostra amb tota claredat la maquinària (interfície remota, stub, registre, serialització) i perquè continua viu en moltíssims sistemes Java corporatius amb què un arquitecte es trobarà.

Les peces

  1. Interfície remota: estén java.rmi.Remote; cada mètode declara throws RemoteException. És el contracte (fa el paper de l'IDL).
  2. Implementació: estén UnicastRemoteObject, que en construir-se exporta l'objecte: el fa accessible per la xarxa en un port i en crea l'skeleton.
  3. Registre (rmiregistry): un servei de noms on el servidor publica l'objecte amb un nom i el client el busca. És una forma primitiva de descobriment de serveis.
  4. Stub: des de Java 5 es genera dinàmicament (amb java.lang.reflect.Proxy); el client en rep un en fer lookup i el fa servir com si fos l'objecte.
  5. Serialització: els arguments i resultats han d'implementar java.io.Serializable (viatgen per valor) o ser objectes remots (viatgen per referència: s'envia l'stub).

El contracte

// km0/serveis/inventari-java/Inventari.java
import java.rmi.Remote;
import java.rmi.RemoteException;

public interface Inventari extends Remote {
    int consultarEstoc(String producte) throws RemoteException, ProducteDesconegutException;
    Reserva reservarEstoc(String producte, int quantitat)
            throws RemoteException, ProducteDesconegutException, EstocInsuficientException;
}

RemoteException a cada signatura és RMI negant-se a amagar la fal·làcia 1: tota crida remota pot fallar per la xarxa, i el compilador obliga a tractar-ho. Les altres dues excepcions són d'aplicació (l'equivalent als nostres Fault amb codi).

// km0/serveis/inventari-java/Reserva.java
import java.io.Serializable;

public class Reserva implements Serializable {
    private static final long serialVersionUID = 1L;   // versió del format serialitzat
    public final String producte;
    public final int reservat;
    public final int restant;

    public Reserva(String producte, int reservat, int restant) {
        this.producte = producte; this.reservat = reservat; this.restant = restant;
    }
}

// km0/serveis/inventari-java/EstocInsuficientException.java
public class EstocInsuficientException extends Exception {
    public EstocInsuficientException(String missatge) { super(missatge); }
}

// km0/serveis/inventari-java/ProducteDesconegutException.java
public class ProducteDesconegutException extends Exception {
    public ProducteDesconegutException(String missatge) { super(missatge); }
}

Reserva viatja per valor: el servidor en construeix una, RMI la serialitza a bytes, el client rep una còpia. serialVersionUID és la versió del format: si el servidor afegeix un camp i no l'actualitza (o l'actualitza sense que el client es recompili), la deserialització falla. És el problema de l'evolució d'esquemes en la seva forma més crua, que 02-03 resol amb regles explícites. Les excepcions són Serializable a través de Throwable, així que travessen la xarxa sense més.

La implementació i el servidor

// km0/serveis/inventari-java/InventariImpl.java
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.HashMap;
import java.util.Map;

public class InventariImpl extends UnicastRemoteObject implements Inventari {
    private final Map<String, Integer> estoc = new HashMap<>();

    public InventariImpl() throws RemoteException {
        super();   // exporta l'objecte: a partir d'aquí és accessible per xarxa
        estoc.put("tomaquet-rosa", 120); estoc.put("carbasso", 80);
        estoc.put("formatge-curat", 5);  estoc.put("formatge-fresc", 30);
        estoc.put("vi-crianca", 200);
    }

    // synchronized: RMI atén cada crida en un fil; l'estat és compartit
    public synchronized int consultarEstoc(String producte) throws ProducteDesconegutException {
        Integer u = estoc.get(producte);
        if (u == null) throw new ProducteDesconegutException("producte desconegut: " + producte);
        return u;
    }

    public synchronized Reserva reservarEstoc(String producte, int quantitat)
            throws ProducteDesconegutException, EstocInsuficientException {
        int disponible = consultarEstoc(producte);
        if (disponible < quantitat)
            throw new EstocInsuficientException("només queden " + disponible + " de " + producte);
        estoc.put(producte, disponible - quantitat);
        System.out.println("[inventari] reservades " + quantitat + " de " + producte);
        return new Reserva(producte, quantitat, disponible - quantitat);
    }
}
// km0/serveis/inventari-java/ServidorInventari.java
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;

public class ServidorInventari {
    public static void main(String[] args) throws Exception {
        LocateRegistry.createRegistry(1099);             // engega el registre en aquest procés
        Inventari inventari = new InventariImpl();       // exportat en construir-se
        Naming.rebind("rmi://localhost:1099/inventari", inventari);
        System.out.println("[inventari] RMI publicat com a 'inventari'");
        // El procés no acaba: l'objecte exportat manté viva la JVM
    }
}

El client a comandes

// km0/serveis/comandes-java/ClientComandes.java
import java.rmi.Naming;
import java.rmi.RemoteException;

public class ClientComandes {
    public static void main(String[] args) {
        try {
            // lookup retorna l'STUB: un proxy que implementa Inventari
            Inventari inventari = (Inventari) Naming.lookup("rmi://localhost:1099/inventari");
            System.out.println("Estoc: " + inventari.consultarEstoc("formatge-curat"));
            Reserva anna = inventari.reservarEstoc("formatge-curat", 2);
            System.out.println("Anna: en queden " + anna.restant);
            Reserva marc = inventari.reservarEstoc("formatge-curat", 4);
            System.out.println("Marc: en queden " + marc.restant);
        } catch (EstocInsuficientException e) {
            System.out.println("Sense estoc: " + e.getMessage());          // error d'aplicació
        } catch (ProducteDesconegutException e) {
            System.out.println("Producte desconegut: " + e.getMessage());
        } catch (RemoteException e) {
            System.out.println("Fallada de xarxa o del servidor: " + e);   // s'ha executat? no se sap
        } catch (java.net.MalformedURLException | java.rmi.NotBoundException e) {
            System.out.println("Error de configuració: " + e);            // nom o URL incorrectes
        }
    }
}

Per executar-ho (amb la interfície i les classes serialitzables compartides entre tots dos programes):

javac *.java
java ServidorInventari &
# Timeout de resposta de 2 s: RMI tampoc no el té per defecte
java -Dsun.rmi.transport.tcp.responseTimeout=2000 ClientComandes

Sortida esperada:

Estoc: 5
Anna: en queden 3
Sense estoc: només queden 3 de formatge-curat

El que RMI afegeix sobre RPC, i el seu preu:

  • Referències remotes. Si un mètode retornés un altre objecte Remote (per exemple, si Reserva fos remota per poder fer cancellar() després), el client rebria un stub a aquest objecte, que continuaria vivint al servidor. Això permet dissenys orientats a objectes naturals... i crea estat al servidor lligat a clients, que cal netejar quan desapareixen (RMI té un recol·lector de brossa distribuït basat en arrendaments, leases). És exactament el tipus d'acoblament que els serveis moderns eviten: gRPC i REST són deliberadament sense estat per crida.
  • Acoblament a Java. Tots dos extrems han de ser JVM, compartir les classes i els seus serialVersionUID. Sense IDL neutre, no hi ha client Python possible.
  • Serialització nativa de Java, històricament font de vulnerabilitats greus (deserialització d'objectes no confiables). Una raó més per als formats amb esquema de 02-03.

  1. RPC contra RMI contra REST

REST no és RPC: és un estil d'arquitectura sobre HTTP en què es manipulen recursos identificats per URL mitjançant els verbs estàndard (GET, POST, PUT, DELETE), amb la semàntica de cada verb ben definida (per exemple, GET i PUT són idempotents per contracte). Però competeix amb RPC pel mateix lloc (comunicació síncrona entre serveis), així que convé comparar-los:

Criteri RPC (XML-RPC, gRPC) RMI REST
Unitat de disseny Procediment / funció Objecte amb mètodes i estat Recurs amb representació
Contracte IDL (gRPC) o signatura de funció (XML-RPC) Interfície Java URL + verbs HTTP + esquema del cos (OpenAPI, opcional)
Exemple reservar_estoc reservar_estoc("formatge-curat", 2) inventari.reservarEstoc("formatge-curat", 2) POST /productes/formatge-curat/reserves {"quantitat": 2}
Llenguatges Multillenguatge (gRPC), Python (XML-RPC) Només JVM Qualsevol amb HTTP
Errors Codis propis / excepcions remotes Excepcions Java serialitzades Codis d'estat HTTP + cos
Estat al servidor entre crides No (per disseny) Possible (referències remotes) No (un dels seus principis)
Idempotència Definida per convenció per operació Definida per convenció Definida pel verb (GET, PUT, DELETE sí; POST no)
Rendiment Alt (gRPC binari, HTTP/2) / baix (XML) Mitjà (serialització Java) Mitjà (JSON sobre HTTP/1.1, habitualment)
Memòria cau intermèdia (proxies, CDN) No No Sí, nativa d'HTTP per a GET
Ús típic Servei a servei, intern Sistemes Java corporatius heretats API públiques, navegadors, tercers
A Quilòmetre Zero comandes → inventari (gRPC, 02-03) No aplica (Python) API pública per a apps i productors (06-05)

La regla pràctica que adoptarà Quilòmetre Zero: REST cap enfora, RPC cap endins. Les apps de l'Anna, en Marc i la Llúcia i els productors parlen REST amb una passarel·la (lliçó 06-05), perquè és universal, es pot desar en memòria cau i no exigeix generar clients. Entre serveis, on controlem tots dos extrems i el volum és alt, RPC amb esquema és més eficient i més segur davant d'errors de contracte.

Errors Comuns i Consells

  • Dissenyar la interfície remota com si fos local. Quaranta crides d'un element cadascuna en lloc d'una crida amb quaranta elements. Cada crida remota costa un viatge d'anada i tornada; agrupa.
  • Tractar el timeout com a "no s'ha executat". És la manera més freqüent de duplicar reserves i cobraments. Un timeout vol dir "no ho sé"; reintentar només és segur si l'operació és idempotent (02-05).
  • Fer servir l'excepció genèrica per a tot. Si el client no pot distingir "sense estoc" de "servidor caigut", no pot reaccionar bé a cap dels dos. Codis d'error explícits al contracte, sempre.
  • Oblidar el timeout al client. Ni ServerProxy ni RMI el posen per defecte. Ja hem vist a 01-04 i 02-01 què passa.
  • Exposar objectes amb estat per referència (RMI). Cada referència remota és estat al servidor lligat a un client que pot desaparèixer. Prefereix crides sense estat amb identificadors explícits.
  • Canviar la signatura d'un mètode remot sense versionar. Amb XML-RPC, el client vell fallarà en temps d'execució amb un error críptic; amb RMI, amb una excepció de deserialització. És el problema que resolen l'IDL i les regles d'evolució (02-03).
  • Consell: quan escriguis un client RPC, escriu abans la taula de les quatre famílies d'error (aplicació, connexió, timeout, protocol) i què farà el teu codi en cadascuna. Si una fila queda buida, el disseny no està acabat.
  • Consell: genera identificadors de petició al client des del primer dia, encara que encara no els facis servir. Quan arribi 02-05 i vulguis idempotència, ja seran als logs i als contractes.

Exercicis

Exercici 1: Semàntiques d'invocació

Un client de comandes crida reservar_estoc("formatge-curat", 2) amb un stub que reintenta fins a 3 vegades davant d'un timeout. En cadascun d'aquests escenaris, indica quantes vegades s'executa la reserva a inventari, què veu el client, i quina semàntica s'està aplicant: (a) la primera petició es perd a la xarxa i la segona arriba i respon; (b) la primera arriba i s'executa, però la resposta es perd; la segona arriba i respon; (c) les tres peticions arriben i s'executen, i les tres respostes es perden. Després, descriu què hauria d'afegir el servidor perquè l'escenari (b) no dupliqués la reserva.

Exercici 2: Un mètode de gra gruixut

Afegeix a Inventari (versió XML-RPC) un mètode reservar_linies(linies) que rebi una llista de diccionaris {"producte": ..., "quantitat": ...} i reservi totes o cap: si alguna línia no té estoc, no es descompta res i es retorna un Fault amb codi 101 que indiqui quin producte ha fallat. Explica per què aquest mètode és preferible a cridar reservar_estoc en bucle des de comandes, en termes de latència i de fallades parcials.

Exercici 3: Classificar errors

Un desenvolupador observa aquests cinc missatges als logs de comandes durant la "Setmana del Formatge Artesà". Per a cadascun, indica la família d'error (aplicació, connexió, timeout, protocol), si l'operació s'ha executat a inventari i si és correcte reintentar automàticament:

  1. xmlrpc.client.Fault: <Fault 101: 'només queden 1 unitats de formatge-curat'>
  2. ConnectionRefusedError: [Errno 111] Connection refused
  3. socket.timeout: timed out
  4. xmlrpc.client.ProtocolError: <ProtocolError for localhost:8001/rpc: 502 Bad Gateway>
  5. xmlrpc.client.Fault: <Fault 1: "<class 'TypeError'>:unsupported operand type(s)">

Solucions

Solució 1:

(a) La reserva s'executa una vegada (només la segona petició ha arribat). El client veu una resposta correcta després d'un timeout. Semàntica efectiva: almenys una vegada, que en aquest cas coincideix amb exactament una.

(b) La reserva s'executa dues vegades: l'estoc baixa 4 unitats encara que l'Anna en volia 2. El client veu una resposta correcta (de la segona execució) i no té manera de saber que n'hi ha hagut una primera. Semàntica: at-least-once, amb la seva conseqüència típica de duplicació.

(c) S'executa tres vegades (l'estoc baixa 6) i el client veu una fallada definitiva després de tres timeouts: creu que no s'ha reservat res. És el pitjor cas: execució múltiple més informació falsa al client. És el que la simulació de 01-04 mostrava amb la llavor 16.

Perquè (b) no dupliqués, el servidor necessitaria semàntica at-most-once: el client inclou un identificador únic de petició (per exemple, un UUID generat abans del primer intent i reutilitzat als reintents), i el servidor desa, per identificador, la resposta de cada petició ja executada. Quan arriba el reintent amb el mateix identificador, no reexecuta: retorna la resposta desada. La reserva passa una vegada i el client rep el seu resultat. La implementació amb persistència a PostgreSQL és el tema de 02-05.

Solució 2:

def reservar_linies(self, linies):
    with self._cadenat:
        # Fase 1: comprovar-ho tot sense tocar res
        for linia in linies:
            producte, quantitat = linia["producte"], linia["quantitat"]
            if not isinstance(quantitat, int) or quantitat <= 0:
                raise Fault(ERR_QUANTITAT_INVALIDA, f"quantitat invàlida per a {producte}")
            disponible = self._estoc.get(producte)
            if disponible is None:
                raise Fault(ERR_PRODUCTE_DESCONEGUT, f"producte desconegut: {producte}")
            if disponible < quantitat:
                raise Fault(ERR_ESTOC_INSUFICIENT,
                            f"{producte}: només en queden {disponible}, se'n demanaven {quantitat}")
        # Fase 2: aplicar-ho tot (ja no pot fallar res, i el cadenat continua agafat)
        resultat = []
        for linia in linies:
            self._estoc[linia["producte"]] -= linia["quantitat"]
            resultat.append({"producte": linia["producte"],
                             "restant": self._estoc[linia["producte"]]})
    return resultat

Avantatges respecte del bucle al client: (1) Latència: un cistell amb 6 línies costa un viatge d'anada i tornada en lloc de sis (amb un RTT de 2 ms entre contenidors sembla poc; en campanya, amb 1.200 comandes per segon, són 6.000 crides contra 1.200). (2) Fallades parcials: amb el bucle, si la quarta línia falla per estoc o per timeout, les tres primeres ja estan reservades i comandes les ha de desfer amb més crides remotes, que també poden fallar. Amb l'operació atòmica al servidor, o es reserva tot o res, i el cadenat garanteix que cap altra comanda no es cola entre la comprovació i l'aplicació. L'atomicitat és fàcil aquí perquè tot l'estoc viu en un procés; quan estigui repartit entre inv-bcn i inv-vlc, aquesta mateixa operació es converteix en una transacció distribuïda (lliçó 03-05).

Solució 3:

  1. Aplicació (codi 101). S'ha executat la comprovació i inventari ha decidit rebutjar. No reintentar: el resultat serà el mateix; informar el client que no queda estoc.
  2. Connexió. No s'ha executat (la petició no ha sortit mai). Reintentar és segur, però convé esperar (el servei està caigut o reiniciant-se) i limitar els reintents; vegeu 07-04 per a l'estratègia completa.
  3. Timeout. No se sap. No reintentar automàticament una reserva sense idempotència (02-05); deixar la comanda pendent i comprovar-ho després.
  4. Protocol (un proxy o balancejador ha respost 502). Gairebé segur que la petició no ha arribat a inventari, però un 502 es pot produir si el backend ha tancat la connexió a mitja resposta, així que és tan ambigu com un timeout. No reintentar sense idempotència; alertar: sol indicar un desplegament o una configuració incorrecta.
  5. Aplicació, però per un bug: el codi 1 genèric i el TypeError indiquen que el servidor ha llançat una excepció no controlada (probablement algú ha passat "2" com a cadena en lloc de 2). S'ha executat parcialment fins a l'error; amb el cadenat i sense efectes previs, l'estoc no ha canviat. No reintentar (fallaria igual); corregir el client i, al servidor, validar tipus abans de tocar l'estat.

Conclusió

Hem obert la caixa d'una crida remota. Un stub al client empaqueta (marshalling) els arguments i els envia; un skeleton al servidor els desempaqueta, invoca la implementació i retorna el resultat; un IDL, quan existeix, fixa el contracte entre tots dos i permet generar els stubs. La promesa d'RPC és que tot això sigui invisible, i hem vist per què no ho pot ser del tot: la latència obliga a dissenyar interfícies de gra gruixut, els arguments viatgen per valor, i les fallades parcials introdueixen el resultat "no se sap", que les semàntiques d'invocació (maybe, at-least-once, at-most-once) gestionen de maneres diferents; exactly-once no existeix d'extrem a extrem, i el que es persegueix en lloc seu és la idempotència. Amb XML-RPC hem implementat inventari.consultar_estoc i reservar_estoc, els hem cridat des de comandes amb timeout i hem classificat els errors en quatre famílies amb reaccions diferents; amb Java RMI hem vist la mateixa maquinària aplicada a objectes remots, amb les seves referències, el seu registre i el seu acoblament a la JVM. REST, RPC i RMI ocupen llocs diferents: REST cap enfora, RPC cap endins.

El que XML-RPC i RMI no resolen bé és evident al codi: XML de 230 bytes per a dos arguments, tipus limitats, contractes implícits que es trenquen en silenci en evolucionar, i absència d'streaming. La lliçó següent, gRPC i Serialització de Dades, aborda exactament això: un IDL explícit (.proto), un format binari compacte amb regles d'evolució, i un RPC sobre HTTP/2 amb deadlines, codis d'estat i quatre tipus de crida. Serà la versió definitiva de la interfície comandes → inventari de Quilòmetre Zero.

Curs d'Arquitectures Distribuïdes

Mòdul 1: Introducció als Sistemes Distribuïts

Mòdul 2: Comunicació en Sistemes Distribuïts

Mòdul 3: Consistència i Replicació

Mòdul 4: Emmagatzematge Distribuït

Mòdul 5: Computació Distribuïda

Mòdul 6: Seguretat en Sistemes Distribuïts

Mòdul 7: Monitoratge i Manteniment

Mòdul 8: Casos d'Estudi i Aplicacions

© Copyright 2026. Tots els drets reservats