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
- La idea: cridar un procediment com si fos aquí
- Anatomia d'una crida remota: stubs, marshalling i IDL
- Semàntiques d'invocació: què passa quan alguna cosa falla
- Transparència i els seus límits
- RPC clàssic en Python:
inventariamb XML-RPC - Errors que travessen la xarxa
- RMI: objectes remots en Java
- RPC contra RMI contra REST
- Errors comuns i consells
- Exercicis
- Conclusió
- 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.
- 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.
comandesel 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.
- 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.
- 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:
- Latència. Una crida local costa nanosegons; una de remota, mil·lisegons: entre quatre i sis ordres de magnitud. Un bucle que crida
consultar_estocper 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. - 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.
- 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.
- 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.
- RPC clàssic en Python:
inventari amb XML-RPC
inventari amb XML-RPCXML-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_instancefa 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ò_estoci_cadenatporten 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 (unfaultCodeenter i unfaultString). 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 unFaultgenè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
Decimalper 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>
- 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).
- 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
- Interfície remota: estén
java.rmi.Remote; cada mètode declarathrows RemoteException. És el contracte (fa el paper de l'IDL). - 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. - 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. - Stub: des de Java 5 es genera dinàmicament (amb
java.lang.reflect.Proxy); el client en rep un en ferlookupi el fa servir com si fos l'objecte. - 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 ClientComandesSortida esperada:
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, siReservafos remota per poder fercancellar()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.
- 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
ServerProxyni 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:
xmlrpc.client.Fault: <Fault 101: 'només queden 1 unitats de formatge-curat'>ConnectionRefusedError: [Errno 111] Connection refusedsocket.timeout: timed outxmlrpc.client.ProtocolError: <ProtocolError for localhost:8001/rpc: 502 Bad Gateway>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 resultatAvantatges 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:
- Aplicació (codi 101). S'ha executat la comprovació i
inventariha decidit rebutjar. No reintentar: el resultat serà el mateix; informar el client que no queda estoc. - 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.
- Timeout. No se sap. No reintentar automàticament una reserva sense idempotència (02-05); deixar la comanda pendent i comprovar-ho després.
- 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. - Aplicació, però per un bug: el codi 1 genèric i el
TypeErrorindiquen que el servidor ha llançat una excepció no controlada (probablement algú ha passat"2"com a cadena en lloc de2). 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
- Conceptes Bàsics de Sistemes Distribuïts
- Models de Sistemes Distribuïts
- Avantatges i Desafiaments dels Sistemes Distribuïts
- Les Fal·làcies de la Computació Distribuïda
- Temps, Rellotges i Ordenació d'Esdeveniments
- Del Monòlit a la Plataforma Distribuïda: el Cas Quilòmetre Zero
Mòdul 2: Comunicació en Sistemes Distribuïts
- Protocols de Comunicació
- RPC i RMI
- gRPC i Serialització de Dades
- Missatgeria i Cues de Missatges
- Patrons de Comunicació Asíncrona
Mòdul 3: Consistència i Replicació
- Models de Consistència
- El Teorema CAP i PACELC
- Algorismes de Consens
- Replicació de Dades
- Transaccions Distribuïdes i Sagues
Mòdul 4: Emmagatzematge Distribuït
- Particionament de Dades i Hashing Consistent
- Sistemes de Fitxers Distribuïts
- Emmagatzematge d'Objectes
- Bases de Dades Distribuïdes
- Memòries Cau Distribuïdes
Mòdul 5: Computació Distribuïda
- Models de Computació Distribuïda
- MapReduce i Hadoop
- Spark i Computació en Memòria
- Processament de Fluxos de Dades
- Planificació de Treballs i Pipelines de Dades
Mòdul 6: Seguretat en Sistemes Distribuïts
- Autenticació i Autorització
- Xifratge i Protecció de Dades
- Gestió d'Identitats
- Seguretat entre Serveis: mTLS i Gestió de Secrets
- Passarel·les d'API, Limitació de Taxa i Auditoria
Mòdul 7: Monitoratge i Manteniment
- Monitoratge de Sistemes Distribuïts
- Logs Centralitzats i Traçabilitat Distribuïda
- Gestió de Fallades i Recuperació
- Patrons de Resiliència: Timeouts, Reintents i Circuit Breaker
- Automatització i Orquestració
- Proves en Sistemes Distribuïts i Enginyeria del Caos
