La lección anterior terminó con un servidor TCP de inventario que entendía STOCK queso-curado\n y con una incomodidad: para cada nueva operación había que inventar un formato de texto, escribir un parser a cada lado y traducir errores a mano. Lo que un desarrollador de pedidos querría escribir es inventario.reservar_stock("queso-curado", 2) y obtener un resultado, sin pensar en bytes ni en sockets. Esa aspiración tiene nombre desde 1984: llamada a procedimiento remoto (RPC, Remote Procedure Call), y su versión orientada a objetos en Java es RMI (Remote Method Invocation).
En esta lección entenderemos qué hay dentro de una llamada remota (stubs, marshalling, IDL), por qué "como si fuera local" es una promesa que la red no puede cumplir del todo (retomando las falacias de 01-04), qué semánticas de invocación existen y por qué "exactamente una vez" es un mito. Implementaremos inventario como servidor XML-RPC en Python, lo llamaremos desde pedidos y trataremos los errores que cruzan la red; después veremos el mismo servicio en Java RMI, para entender qué cambia cuando lo remoto son objetos. Terminaremos comparando RPC, RMI y REST. gRPC, el RPC moderno que usará Kilómetro Cero, queda para la lección siguiente: aquí sentamos los conceptos que gRPC da por sabidos.
Contenido
- La idea: llamar a un procedimiento como si estuviera aquí
- Anatomía de una llamada remota: stubs, marshalling e IDL
- Semánticas de invocación: qué pasa cuando algo falla
- Transparencia y sus límites
- RPC clásico en Python:
inventariocon XML-RPC - Errores que cruzan la red
- RMI: objetos remotos en Java
- RPC frente a RMI frente a REST
- Errores comunes y consejos
- Ejercicios
- Conclusión
- La idea: llamar a un procedimiento como si estuviera aquí
En el monolito de Kilómetro Cero, confirmar un pedido es una llamada de función Python: inventario.reservar_stock(lineas) (lección 01-06). El módulo pedidos no sabe ni le importa cómo está implementada. RPC propone conservar exactamente esa experiencia cuando inventario pasa a ser otro proceso en otra máquina: el programador escribe la misma llamada y una capa intermedia (el middleware de la lección 01-01) se encarga de convertir los argumentos en bytes, enviarlos, esperar, y convertir la respuesta en un valor.
La propuesta original es de Birrell y Nelson (1984) y ha tenido descendientes en cada década: Sun RPC (la base de NFS), DCE RPC (la base de las llamadas remotas de Windows), CORBA (objetos remotos multilenguaje en los 90), Java RMI (1997), XML-RPC y SOAP (RPC sobre HTTP con XML, 1998-2000), y finalmente Thrift y gRPC (RPC binario con esquemas, 2007-2015). Cambian los formatos y los transportes; la estructura interna es siempre la misma, y es lo que vamos a diseccionar.
- Anatomía de una llamada remota: stubs, marshalling e IDL
Una llamada RPC pasa por diez pasos, cinco en cada extremo:
sequenceDiagram
participant P as pedidos (código de negocio)
participant SC as Stub cliente
participant RED as Red (TCP/HTTP)
participant SK as Skeleton servidor
participant I as inventario (implementación)
P->>SC: reservar_stock("queso-curado", 2)
SC->>SC: marshalling: args → bytes
SC->>RED: enviar petición
RED->>SK: recibir petición
SK->>SK: unmarshalling: bytes → args
SK->>I: reservar_stock("queso-curado", 2)
I-->>SK: {"restante": 3}
SK->>SK: marshalling del resultado
SK-->>RED: enviar respuesta
RED-->>SC: recibir respuesta
SC->>SC: unmarshalling
SC-->>P: {"restante": 3}
Cada pieza tiene un nombre que conviene fijar, porque los reencontraremos en gRPC:
- Stub cliente (o proxy): un objeto local que tiene la misma firma que el procedimiento remoto.
pedidoslo llama como si fuera la implementación real. Su trabajo es empaquetar la llamada y enviarla. - Marshalling (empaquetado) y unmarshalling: conversión de argumentos y resultados entre la representación en memoria del lenguaje y una secuencia de bytes que pueda viajar por la red. Incluye resolver diferencias de representación entre máquinas (orden de bytes de los enteros, codificación de cadenas) y decidir qué hacer con punteros o referencias, que no tienen sentido en otra máquina. El término serialización es prácticamente sinónimo, y los formatos concretos (JSON, XML, Protocol Buffers) son el tema de la lección 02-03.
- Skeleton servidor (o dispatcher): recibe los bytes, los desempaqueta, identifica qué procedimiento se pide, lo invoca y empaqueta la respuesta.
- Transporte: el protocolo de la lección anterior (TCP crudo, HTTP...). Al RPC no le importa cuál sea, pero sus propiedades (timeouts, orden, fiabilidad) sí condicionan las garantías.
- IDL (Interface Definition Language): un lenguaje neutro para describir el contrato, es decir, qué procedimientos existen, con qué parámetros y tipos, y qué devuelven. A partir del IDL, un generador produce automáticamente los stubs y skeletons en cada lenguaje. Sun RPC tenía
.x, CORBA tenía su propio IDL, gRPC usa.proto. XML-RPC y Java RMI no tienen IDL separado: el contrato es la propia firma de las funciones Python o la interfaz Java.
El IDL es la pieza conceptualmente más importante. Sin él, el contrato entre pedidos e inventario existe solo en la cabeza de los desarrolladores y en el código de cada lado, y se rompe en silencio cuando uno cambia. Con él, el contrato es un fichero versionado que ambos equipos comparten y del que se genera código: el enfoque contract-first que la lección 02-03 desarrolla.
- Semánticas de invocación: qué pasa cuando algo falla
Una llamada local o se ejecuta o lanza una excepción; no hay más opciones. Una llamada remota tiene un tercer resultado, que ya nos encontramos en la lección 01-04: no se sabe. Hay tres puntos donde un mensaje puede perderse, y son indistinguibles para el cliente:
flowchart LR
A[1. Se pierde la petición] --> X[El cliente solo ve: no llega respuesta]
B[2. inventario cae después de ejecutar] --> X
C[3. Se pierde la respuesta] --> X
En el caso 1 la reserva no se hizo; en los casos 2 y 3 sí se hizo. El cliente ve lo mismo: silencio hasta el timeout. Lo que haga el stub ante ese silencio define la semántica de invocación:
| Semántica | Qué hace el stub | Resultado posible en reservar_stock |
Cuándo es aceptable |
|---|---|---|---|
| Maybe (quizá) | Envía una vez, no reintenta, no espera confirmación | Ejecutado 0 o 1 veces; el cliente no sabe cuál | Telemetría, notificaciones sin importancia |
| At-least-once (al menos una vez) | Reintenta hasta obtener respuesta | Ejecutado 1 o más veces: el stock puede descontarse dos veces | Operaciones idempotentes (consultar stock, fijar un valor absoluto) |
| At-most-once (como mucho una vez) | Reintenta, pero el servidor filtra duplicados por identificador de petición | Ejecutado 0 o 1 veces; si hay respuesta, es de la única ejecución | Operaciones con efectos (reservar, cobrar) cuando se tolera que no ocurran |
| Exactly-once (exactamente una vez) | — | Ejecutado 1 vez, garantizado | No existe de extremo a extremo; ver más abajo |
Por qué exactly-once no existe en la práctica. Para garantizar que una operación con efectos se ejecuta exactamente una vez pase lo que pase, haría falta que el servidor ejecutara la operación y registrara "ya la he ejecutado" de forma atómica, y que ese registro sobreviviera a su propia caída, y que el cliente pudiera saber siempre en qué estado quedó. Se puede llegar muy cerca (registro durable de peticiones procesadas, identificadores únicos, reintentos), pero siempre hay una ventana (el servidor cae entre ejecutar y registrar; el disco falla) en la que la garantía se convierte en "al menos una vez" o "como mucho una vez". Lo que sí se puede conseguir es que la operación sea efectivamente una vez: que ejecutarla dos veces tenga el mismo resultado que ejecutarla una (idempotencia). Ese es el enfoque práctico, y la lección 02-05 lo implementa con una tabla de peticiones procesadas en inventario. Aquí basta con entender que la pregunta "¿se ejecutó mi llamada?" no siempre tiene respuesta, y que el diseño del contrato debe asumirlo.
Un detalle útil: at-most-once suele implementarse con un identificador de petición generado por el cliente y una caché de respuestas en el servidor. Si llega una petición repetida, el servidor no la reejecuta: devuelve la respuesta guardada. Guarda este mecanismo en la memoria; es el mismo que reaparecerá en 02-05 con otro nombre.
- Transparencia y sus límites
RPC vende transparencia de acceso (lección 01-01): la llamada remota se escribe igual que la local. Es una promesa útil y a la vez peligrosa. En 1994, Waldo, Wyant, Wollrath y Kendall (los diseñadores de lo que sería Java RMI) publicaron "A Note on Distributed Computing", cuya tesis es que hay cuatro diferencias entre lo local y lo remoto que ningún middleware puede ocultar, y que fingir que no existen produce sistemas frágiles:
- Latencia. Una llamada local cuesta nanosegundos; una remota, milisegundos: entre cuatro y seis órdenes de magnitud. Un bucle que llama a
consultar_stockpara cada uno de los 40 productos de un carrito es inocuo en el monolito y desastroso con RPC (40 viajes de ida y vuelta). Falacia 2 de 01-04. Consecuencia de diseño: interfaces remotas de grano grueso (consultar_stock(lista_de_productos)), no de grano fino. - Acceso a memoria. Un puntero o una referencia a un objeto no significa nada en otra máquina. Los argumentos viajan por valor (copiados); modificar la copia en el servidor no modifica el original. Esto cambia la semántica del código de formas sutiles.
- Fallos parciales. Ya lo hemos visto: "no se sabe". Localmente, si la función no devuelve es porque el proceso entero murió. Remotamente, el cliente sigue vivo y tiene que decidir qué hacer sin información. Falacia 1.
- Concurrencia. El servidor atiende a muchos clientes a la vez sin que el cliente lo vea; el estado compartido en el servidor necesita protección (el candado del ejercicio 1 de la lección anterior), y el orden de llegada de llamadas de distintos clientes no está definido.
La conclusión de Waldo y compañía, que gRPC y toda la industria han acabado adoptando, es que las interfaces remotas deben diseñarse como remotas: firmas explícitas, tipos que viajen bien, timeouts obligatorios, errores de red distinguibles de los errores de aplicación, y sin fingir que un objeto remoto es un objeto local. Tenlo presente al leer el código de los apartados siguientes: XML-RPC y RMI hacen la llamada parecer local, pero en cada punto veremos dónde asoma la red.
- RPC clásico en Python:
inventario con XML-RPC
inventario con XML-RPCXML-RPC es el RPC más simple que existe: las llamadas se codifican en XML y viajan como cuerpo de un POST HTTP. Python lo incluye en la biblioteca estándar (xmlrpc.server y xmlrpc.client), lo que lo hace perfecto para ver los conceptos sin instalar nada. No lo usaríamos en producción (es lento, verboso y sin esquema), pero cada pieza tiene su equivalente en gRPC.
El servidor
# km0/servicios/inventario/rpc_servidor.py
import threading
from socketserver import ThreadingMixIn
from xmlrpc.client import Fault
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
# Códigos de error del contrato de inventario. Forman parte de la interfaz:
# el cliente los necesita para distinguir causas sin parsear texto.
ERR_PRODUCTO_DESCONOCIDO = 100
ERR_STOCK_INSUFICIENTE = 101
ERR_CANTIDAD_INVALIDA = 102
class Inventario:
"""Implementación del servicio. Todos sus métodos públicos son remotos."""
def __init__(self):
self._stock = {
"tomate-rosa": 120, "calabacin": 80, "queso-curado": 5,
"queso-fresco": 30, "vino-crianza": 200,
}
self._candado = threading.Lock()
def consultar_stock(self, producto):
with self._candado:
unidades = self._stock.get(producto)
if unidades is None:
raise Fault(ERR_PRODUCTO_DESCONOCIDO, f"producto desconocido: {producto}")
return unidades
def reservar_stock(self, producto, cantidad):
if not isinstance(cantidad, int) or cantidad <= 0:
raise Fault(ERR_CANTIDAD_INVALIDA, "la cantidad debe ser un entero positivo")
with self._candado:
disponible = self._stock.get(producto)
if disponible is None:
raise Fault(ERR_PRODUCTO_DESCONOCIDO, f"producto desconocido: {producto}")
if disponible < cantidad:
raise Fault(ERR_STOCK_INSUFICIENTE,
f"solo quedan {disponible} unidades de {producto}")
self._stock[producto] = disponible - cantidad
restante = self._stock[producto]
print(f"[inventario] reservadas {cantidad} de {producto}, quedan {restante}")
return {"producto": producto, "reservado": cantidad, "restante": restante}
class ServidorConHilos(ThreadingMixIn, SimpleXMLRPCServer):
"""SimpleXMLRPCServer atiende en serie; con ThreadingMixIn, un hilo por petición."""
daemon_threads = True
class Manejador(SimpleXMLRPCRequestHandler):
rpc_paths = ("/rpc",) # solo acepta POST a esta ruta
if __name__ == "__main__":
servidor = ServidorConHilos(("0.0.0.0", 8001), requestHandler=Manejador,
allow_none=True, logRequests=False)
servidor.register_introspection_functions() # system.listMethods, etc.
servidor.register_instance(Inventario()) # expone sus métodos públicos
print("[inventario] XML-RPC escuchando en :8001/rpc")
servidor.serve_forever()Puntos importantes:
register_instancehace de skeleton: recibe el XML, busca un método con ese nombre en la instancia, lo invoca con los argumentos desempaquetados y empaqueta el resultado. No hay IDL: el contrato es la lista de métodos públicos deInventario. Los métodos que empiezan por_no se exponen (por eso_stocky_candadollevan guion bajo, y no solo por convención).Faultes la excepción que forma parte del protocolo: XML-RPC define cómo se codifica en el XML de respuesta (unfaultCodeentero y unfaultString). Definimos códigos propios porque el cliente necesita saber por qué ha fallado sin analizar el texto. Si el método lanzara una excepción Python cualquiera (ValueError), el servidor la convertiría en unFaultgenérico con código 1 y un texto como<class 'ValueError'>:...: útil para depurar, inútil para programar contra él.ThreadingMixInes necesario por la misma razón que los hilos de la lección anterior: sin él, una petición lenta bloquea a todas las demás. Y con él,_candadoes imprescindible.- Los tipos que XML-RPC sabe transportar son pocos: enteros de 32 bits, doubles, cadenas, booleanos, fechas, binarios, listas y diccionarios con claves de texto. Un
Decimalpara el precio no viaja; un entero de 64 bits, tampoco (en la implementación estándar). Estas limitaciones del marshalling son una de las razones para preferir formatos con esquema (02-03).
El cliente en pedidos
# km0/servicios/pedidos/rpc_cliente.py
import socket
import xmlrpc.client
class TransporteConTimeout(xmlrpc.client.Transport):
"""El Transport por defecto no tiene timeout: la falacia 1 en estado puro."""
def __init__(self, timeout):
super().__init__()
self._timeout = timeout
def make_connection(self, host):
conexion = super().make_connection(host) # http.client.HTTPConnection
conexion.timeout = self._timeout
return conexion
# El stub: un objeto local con la misma "forma" que el servicio remoto.
inventario = xmlrpc.client.ServerProxy("http://localhost:8001/rpc",
transport=TransporteConTimeout(2.0),
allow_none=True)
if __name__ == "__main__":
print("Métodos remotos:", inventario.system.listMethods())
print("Stock de queso curado:", inventario.consultar_stock("queso-curado"))
print("Reserva de Ana:", inventario.reservar_stock("queso-curado", 2))
print("Reserva de Marc:", inventario.reservar_stock("queso-curado", 4))Salida:
Métodos remotos: ['consultar_stock', 'reservar_stock', 'system.listMethods', ...]
Stock de queso curado: 5
Reserva de Ana: {'producto': 'queso-curado', 'reservado': 2, 'restante': 3}
Traceback (most recent call last):
...
xmlrpc.client.Fault: <Fault 101: 'solo quedan 3 unidades de queso-curado'>ServerProxy es el stub: inventario.reservar_stock(...) no existe como método Python; ServerProxy intercepta el acceso al atributo, construye un XML con el nombre del método y los argumentos, hace el POST, y desempaqueta la respuesta. Para el desarrollador de pedidos, es una llamada. Para ver lo que viaja, este es el cuerpo de la petición de Ana:
<?xml version='1.0'?>
<methodCall>
<methodName>reservar_stock</methodName>
<params>
<param><value><string>queso-curado</string></value></param>
<param><value><int>2</int></value></param>
</params>
</methodCall>Unos 230 bytes para transmitir dos argumentos: el marshalling en XML es cómodo de leer y carísimo de transmitir y de parsear. Y la respuesta de error de Marc:
<methodResponse>
<fault>
<value><struct>
<member><name>faultCode</name><value><int>101</int></value></member>
<member><name>faultString</name><value><string>solo quedan 3 unidades de queso-curado</string></value></member>
</struct></value>
</fault>
</methodResponse>
- Errores que cruzan la red
El ejemplo anterior termina con un traceback porque no capturamos el Fault. Un cliente RPC serio debe distinguir cuatro familias de error, porque cada una exige una reacción distinta:
| Familia | Ejemplo | Excepción en xmlrpc.client |
¿Se ejecutó la operación? | Reacción razonable |
|---|---|---|---|---|
| Error de aplicación | Stock insuficiente, producto desconocido | Fault (con nuestro faultCode) |
Sí, y decidió fallar | Tratar como regla de negocio: informar al cliente, no reintentar |
| No se pudo conectar | inventario caído, DNS incorrecto |
ConnectionRefusedError, socket.gaierror |
No | Reintentar tras una espera, o degradar (02-05 y 07-04) |
| Timeout | Red lenta, servidor saturado | socket.timeout / TimeoutError |
No se sabe | Solo reintentar si la operación es idempotente (02-05) |
| Error de protocolo | Ruta incorrecta, proxy que devuelve 502, respuesta no XML | ProtocolError, xmlrpc.client.ResponseError |
Normalmente no, pero un 502 de un proxy no lo garantiza | Registrar y alertar: suele ser un error de despliegue o configuración |
El cliente de pedidos con manejo completo:
# km0/servicios/pedidos/confirmar_pedido.py
import socket
import xmlrpc.client
from rpc_cliente import inventario, TransporteConTimeout # noqa: F401
ERR_STOCK_INSUFICIENTE = 101
def reservar_para_pedido(producto, cantidad):
"""Devuelve (ok, mensaje). Nunca lanza: convierte cada familia de error
en una decisión de negocio explícita."""
try:
r = inventario.reservar_stock(producto, cantidad)
return True, f"reservadas {r['reservado']} de {producto}, quedan {r['restante']}"
except xmlrpc.client.Fault as f:
if f.faultCode == ERR_STOCK_INSUFICIENTE:
return False, f"sin stock suficiente: {f.faultString}"
return False, f"inventario rechazó la petición ({f.faultCode}): {f.faultString}"
except (ConnectionRefusedError, socket.gaierror) as e:
# Seguro que no se ejecutó: se puede reintentar sin riesgo.
return False, f"inventario no disponible ({e}); pedido en espera"
except (socket.timeout, TimeoutError):
# AMBIGUO: pudo ejecutarse. Reintentar aquí duplicaría reservas (01-04).
return False, "inventario no respondió a tiempo; estado de la reserva desconocido"
except xmlrpc.client.ProtocolError as e:
return False, f"error de protocolo {e.errcode} en {e.url}: {e.errmsg}"
if __name__ == "__main__":
for cliente, producto, cantidad in [("Ana", "queso-curado", 2),
("Marc", "queso-curado", 4),
("Lucía", "vino-crianza", 6)]:
ok, msg = reservar_para_pedido(producto, cantidad)
print(f"{cliente}: {'OK' if ok else 'NO'} - {msg}")Dos observaciones. La primera: la rama del timeout es la única que no puede resolverse en esta lección. Con lo que sabemos ahora, lo más honesto es dejar el pedido en un estado "reserva pendiente de confirmar" y no reintentar; la solución completa (que la petición lleve un identificador y que inventario recuerde cuáles ha procesado) es la idempotencia de 02-05. La segunda: la excepción Fault viaja por la red; las demás nacen en el cliente. Esta distinción se mantiene en todos los sistemas RPC: gRPC la formaliza con códigos de estado (02-03), y REST con códigos HTTP 4xx (aplicación) frente a errores de conexión y 5xx (infraestructura).
- RMI: objetos remotos en Java
Java RMI lleva la idea un paso más allá: lo remoto no es un procedimiento sino un objeto, con estado y con identidad. El cliente obtiene una referencia remota a un objeto que vive en otra JVM y le invoca métodos. Aunque Kilómetro Cero está en Python, merece la pena ver RMI porque muestra con toda claridad la maquinaria (interfaz remota, stub, registro, serialización) y porque sigue vivo en muchísimos sistemas Java corporativos con los que un arquitecto se encontrará.
Las piezas
- Interfaz remota: extiende
java.rmi.Remote; cada método declarathrows RemoteException. Es el contrato (hace el papel del IDL). - Implementación: extiende
UnicastRemoteObject, que al construirse exporta el objeto: lo hace accesible por la red en un puerto y crea su skeleton. - Registro (
rmiregistry): un servicio de nombres donde el servidor publica el objeto con un nombre y el cliente lo busca. Es una forma primitiva de descubrimiento de servicios. - Stub: desde Java 5 se genera dinámicamente (con
java.lang.reflect.Proxy); el cliente recibe uno al hacerlookupy lo usa como si fuera el objeto. - Serialización: los argumentos y resultados deben implementar
java.io.Serializable(viajan por valor) o ser objetos remotos (viajan por referencia: se envía el stub).
El contrato
// km0/servicios/inventario-java/Inventario.java
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface Inventario extends Remote {
int consultarStock(String producto) throws RemoteException, ProductoDesconocidoException;
Reserva reservarStock(String producto, int cantidad)
throws RemoteException, ProductoDesconocidoException, StockInsuficienteException;
}RemoteException en cada firma es RMI negándose a ocultar la falacia 1: toda llamada remota puede fallar por la red, y el compilador obliga a tratarlo. Las otras dos excepciones son de aplicación (el equivalente a nuestros Fault con código).
// km0/servicios/inventario-java/Reserva.java
import java.io.Serializable;
public class Reserva implements Serializable {
private static final long serialVersionUID = 1L; // versión del formato serializado
public final String producto;
public final int reservado;
public final int restante;
public Reserva(String producto, int reservado, int restante) {
this.producto = producto; this.reservado = reservado; this.restante = restante;
}
}
// km0/servicios/inventario-java/StockInsuficienteException.java
public class StockInsuficienteException extends Exception {
public StockInsuficienteException(String mensaje) { super(mensaje); }
}
// km0/servicios/inventario-java/ProductoDesconocidoException.java
public class ProductoDesconocidoException extends Exception {
public ProductoDesconocidoException(String mensaje) { super(mensaje); }
}Reserva viaja por valor: el servidor construye una, RMI la serializa a bytes, el cliente recibe una copia. serialVersionUID es la versión del formato: si el servidor añade un campo y no lo actualiza (o lo actualiza sin que el cliente se recompile), la deserialización falla. Es el problema de la evolución de esquemas en su forma más cruda, que 02-03 resuelve con reglas explícitas. Las excepciones son Serializable a través de Throwable, así que cruzan la red sin más.
La implementación y el servidor
// km0/servicios/inventario-java/InventarioImpl.java
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.HashMap;
import java.util.Map;
public class InventarioImpl extends UnicastRemoteObject implements Inventario {
private final Map<String, Integer> stock = new HashMap<>();
public InventarioImpl() throws RemoteException {
super(); // exporta el objeto: a partir de aquí es accesible por red
stock.put("tomate-rosa", 120); stock.put("calabacin", 80);
stock.put("queso-curado", 5); stock.put("queso-fresco", 30);
stock.put("vino-crianza", 200);
}
// synchronized: RMI atiende cada llamada en un hilo; el estado es compartido
public synchronized int consultarStock(String producto) throws ProductoDesconocidoException {
Integer u = stock.get(producto);
if (u == null) throw new ProductoDesconocidoException("producto desconocido: " + producto);
return u;
}
public synchronized Reserva reservarStock(String producto, int cantidad)
throws ProductoDesconocidoException, StockInsuficienteException {
int disponible = consultarStock(producto);
if (disponible < cantidad)
throw new StockInsuficienteException("solo quedan " + disponible + " de " + producto);
stock.put(producto, disponible - cantidad);
System.out.println("[inventario] reservadas " + cantidad + " de " + producto);
return new Reserva(producto, cantidad, disponible - cantidad);
}
}// km0/servicios/inventario-java/ServidorInventario.java
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
public class ServidorInventario {
public static void main(String[] args) throws Exception {
LocateRegistry.createRegistry(1099); // arranca el registro en este proceso
Inventario inventario = new InventarioImpl(); // exportado al construirse
Naming.rebind("rmi://localhost:1099/inventario", inventario);
System.out.println("[inventario] RMI publicado como 'inventario'");
// El proceso no termina: el objeto exportado mantiene la JVM viva
}
}El cliente en pedidos
// km0/servicios/pedidos-java/ClientePedidos.java
import java.rmi.Naming;
import java.rmi.RemoteException;
public class ClientePedidos {
public static void main(String[] args) {
try {
// lookup devuelve el STUB: un proxy que implementa Inventario
Inventario inventario = (Inventario) Naming.lookup("rmi://localhost:1099/inventario");
System.out.println("Stock: " + inventario.consultarStock("queso-curado"));
Reserva ana = inventario.reservarStock("queso-curado", 2);
System.out.println("Ana: quedan " + ana.restante);
Reserva marc = inventario.reservarStock("queso-curado", 4);
System.out.println("Marc: quedan " + marc.restante);
} catch (StockInsuficienteException e) {
System.out.println("Sin stock: " + e.getMessage()); // error de aplicación
} catch (ProductoDesconocidoException e) {
System.out.println("Producto desconocido: " + e.getMessage());
} catch (RemoteException e) {
System.out.println("Fallo de red o del servidor: " + e); // ¿se ejecutó? no se sabe
} catch (java.net.MalformedURLException | java.rmi.NotBoundException e) {
System.out.println("Error de configuración: " + e); // nombre o URL incorrectos
}
}
}Para ejecutarlo (con la interfaz y las clases serializables compartidas entre ambos programas):
javac *.java
java ServidorInventario &
# Timeout de respuesta de 2 s: RMI tampoco lo tiene por defecto
java -Dsun.rmi.transport.tcp.responseTimeout=2000 ClientePedidosSalida esperada:
Lo que RMI añade sobre RPC, y su precio:
- Referencias remotas. Si un método devolviera otro objeto
Remote(por ejemplo,Reservafuera remota para podercancelar()después), el cliente recibiría un stub a ese objeto, que seguiría viviendo en el servidor. Esto permite diseños orientados a objetos naturales... y crea estado en el servidor ligado a clientes, que hay que limpiar cuando desaparecen (RMI tiene un recolector de basura distribuido basado en arrendamientos, leases). Es exactamente el tipo de acoplamiento que los servicios modernos evitan: gRPC y REST son deliberadamente sin estado por llamada. - Acoplamiento a Java. Ambos extremos deben ser JVM, compartir las clases y sus
serialVersionUID. Sin IDL neutro, no hay cliente Python posible. - Serialización nativa de Java, históricamente fuente de vulnerabilidades graves (deserialización de objetos no confiables). Una razón más para los formatos con esquema de 02-03.
- RPC frente a RMI frente a REST
REST no es RPC: es un estilo de arquitectura sobre HTTP en el que se manipulan recursos identificados por URL mediante los verbos estándar (GET, POST, PUT, DELETE), con la semántica de cada verbo bien definida (por ejemplo, GET y PUT son idempotentes por contrato). Pero compite con RPC por el mismo puesto (comunicación síncrona entre servicios), así que conviene compararlos:
| Criterio | RPC (XML-RPC, gRPC) | RMI | REST |
|---|---|---|---|
| Unidad de diseño | Procedimiento / función | Objeto con métodos y estado | Recurso con representación |
| Contrato | IDL (gRPC) o firma de función (XML-RPC) | Interfaz Java | URL + verbos HTTP + esquema del cuerpo (OpenAPI, opcional) |
Ejemplo reservar_stock |
reservar_stock("queso-curado", 2) |
inventario.reservarStock("queso-curado", 2) |
POST /productos/queso-curado/reservas {"cantidad": 2} |
| Lenguajes | Multilenguaje (gRPC), Python (XML-RPC) | Solo JVM | Cualquiera con HTTP |
| Errores | Códigos propios / excepciones remotas | Excepciones Java serializadas | Códigos de estado HTTP + cuerpo |
| Estado en el servidor entre llamadas | No (por diseño) | Posible (referencias remotas) | No (uno de sus principios) |
| Idempotencia | Definida por convención por operación | Definida por convención | Definida por el verbo (GET, PUT, DELETE sí; POST no) |
| Rendimiento | Alto (gRPC binario, HTTP/2) / bajo (XML) | Medio (serialización Java) | Medio (JSON sobre HTTP/1.1, habitualmente) |
| Caché intermedia (proxies, CDN) | No | No | Sí, nativa de HTTP para GET |
| Uso típico | Servicio a servicio, interno | Sistemas Java corporativos heredados | APIs públicas, navegadores, terceros |
| En Kilómetro Cero | pedidos → inventario (gRPC, 02-03) |
No aplica (Python) | API pública para apps y productores (06-05) |
La regla práctica que adoptará Kilómetro Cero: REST hacia fuera, RPC hacia dentro. Las apps de Ana, Marc y Lucía y los productores hablan REST con una puerta de enlace (lección 06-05), porque es universal, cacheable y no exige generar clientes. Entre servicios, donde controlamos ambos extremos y el volumen es alto, RPC con esquema es más eficiente y más seguro frente a errores de contrato.
Errores Comunes y Consejos
- Diseñar la interfaz remota como si fuera local. Cuarenta llamadas de un elemento cada una en lugar de una llamada con cuarenta elementos. Cada llamada remota cuesta un viaje de ida y vuelta; agrupa.
- Tratar el timeout como "no se ejecutó". Es la forma más frecuente de duplicar reservas y cobros. Un timeout significa "no sé"; reintentar solo es seguro si la operación es idempotente (02-05).
- Usar la excepción genérica para todo. Si el cliente no puede distinguir "sin stock" de "servidor caído", no puede reaccionar bien a ninguno. Códigos de error explícitos en el contrato, siempre.
- Olvidar el timeout en el cliente. Ni
ServerProxyni RMI lo ponen por defecto. Ya hemos visto en 01-04 y 02-01 lo que ocurre. - Exponer objetos con estado por referencia (RMI). Cada referencia remota es estado en el servidor atado a un cliente que puede desaparecer. Prefiere llamadas sin estado con identificadores explícitos.
- Cambiar la firma de un método remoto sin versionar. Con XML-RPC, el cliente viejo fallará en tiempo de ejecución con un error críptico; con RMI, con una excepción de deserialización. Es el problema que resuelven el IDL y las reglas de evolución (02-03).
- Consejo: cuando escribas un cliente RPC, escribe antes la tabla de las cuatro familias de error (aplicación, conexión, timeout, protocolo) y qué hará tu código en cada una. Si una fila queda vacía, el diseño no está terminado.
- Consejo: genera identificadores de petición en el cliente desde el primer día, aunque todavía no los uses. Cuando llegue 02-05 y quieras idempotencia, ya estarán en los logs y en los contratos.
Ejercicios
Ejercicio 1: Semánticas de invocación
Un cliente de pedidos llama a reservar_stock("queso-curado", 2) con un stub que reintenta hasta 3 veces ante timeout. En cada uno de estos escenarios, indica cuántas veces se ejecuta la reserva en inventario, qué ve el cliente, y qué semántica se está aplicando: (a) la primera petición se pierde en la red y la segunda llega y responde; (b) la primera llega y se ejecuta, pero la respuesta se pierde; la segunda llega y responde; (c) las tres peticiones llegan y se ejecutan, y las tres respuestas se pierden. Después, describe qué debería añadir el servidor para que el escenario (b) no duplicara la reserva.
Ejercicio 2: Un método de grano grueso
Añade a Inventario (versión XML-RPC) un método reservar_lineas(lineas) que reciba una lista de diccionarios {"producto": ..., "cantidad": ...} y reserve todas o ninguna: si alguna línea no tiene stock, no se descuenta nada y se devuelve un Fault con código 101 indicando qué producto falló. Explica por qué este método es preferible a llamar a reservar_stock en bucle desde pedidos, en términos de latencia y de fallos parciales.
Ejercicio 3: Clasificar errores
Un desarrollador observa estos cinco mensajes en los logs de pedidos durante la "Semana del Queso Artesano". Para cada uno, indica la familia de error (aplicación, conexión, timeout, protocolo), si la operación se ejecutó en inventario y si es correcto reintentar automáticamente:
xmlrpc.client.Fault: <Fault 101: 'solo quedan 1 unidades de queso-curado'>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)">
Soluciones
Solución 1:
(a) La reserva se ejecuta una vez (solo la segunda petición llegó). El cliente ve una respuesta correcta tras un timeout. Semántica efectiva: al menos una vez, que en este caso coincide con exactamente una.
(b) La reserva se ejecuta dos veces: el stock baja 4 unidades aunque Ana quería 2. El cliente ve una respuesta correcta (de la segunda ejecución) y no tiene forma de saber que hubo una primera. Semántica: at-least-once, con su consecuencia típica de duplicación.
(c) Se ejecuta tres veces (el stock baja 6) y el cliente ve un fallo definitivo tras tres timeouts: cree que no se ha reservado nada. Es el peor caso: ejecución múltiple más información falsa en el cliente. Es lo que la simulación de 01-04 mostraba con la semilla 16.
Para que (b) no duplicara, el servidor necesitaría semántica at-most-once: el cliente incluye un identificador único de petición (por ejemplo, un UUID generado antes del primer intento y reutilizado en los reintentos), y el servidor guarda, por identificador, la respuesta de cada petición ya ejecutada. Al llegar el reintento con el mismo identificador, no reejecuta: devuelve la respuesta guardada. La reserva ocurre una vez y el cliente recibe su resultado. La implementación con persistencia en PostgreSQL es el tema de 02-05.
Solución 2:
def reservar_lineas(self, lineas):
with self._candado:
# Fase 1: comprobar todo sin tocar nada
for linea in lineas:
producto, cantidad = linea["producto"], linea["cantidad"]
if not isinstance(cantidad, int) or cantidad <= 0:
raise Fault(ERR_CANTIDAD_INVALIDA, f"cantidad inválida para {producto}")
disponible = self._stock.get(producto)
if disponible is None:
raise Fault(ERR_PRODUCTO_DESCONOCIDO, f"producto desconocido: {producto}")
if disponible < cantidad:
raise Fault(ERR_STOCK_INSUFICIENTE,
f"{producto}: solo quedan {disponible}, se pedían {cantidad}")
# Fase 2: aplicar todo (nada puede fallar ya, y el candado sigue tomado)
resultado = []
for linea in lineas:
self._stock[linea["producto"]] -= linea["cantidad"]
resultado.append({"producto": linea["producto"],
"restante": self._stock[linea["producto"]]})
return resultadoVentajas frente al bucle en el cliente: (1) Latencia: un carrito con 6 líneas cuesta un viaje de ida y vuelta en lugar de seis (con RTT de 2 ms entre contenedores parece poco; en campaña, con 1.200 pedidos por segundo, son 6.000 llamadas frente a 1.200). (2) Fallos parciales: con el bucle, si la cuarta línea falla por stock o por timeout, las tres primeras ya están reservadas y pedidos tiene que deshacerlas con más llamadas remotas, que también pueden fallar. Con la operación atómica en el servidor, o se reserva todo o nada, y el candado garantiza que ningún otro pedido se cuela entre la comprobación y la aplicación. La atomicidad es fácil aquí porque todo el stock vive en un proceso; cuando esté repartido entre inv-bcn e inv-vlc, esta misma operación se convierte en una transacción distribuida (lección 03-05).
Solución 3:
- Aplicación (código 101). Se ejecutó la comprobación y
inventariodecidió rechazar. No reintentar: el resultado será el mismo; informar al cliente de que no queda stock. - Conexión. No se ejecutó (la petición nunca salió). Reintentar es seguro, pero conviene esperar (el servicio está caído o reiniciándose) y limitar los reintentos; ver 07-04 para la estrategia completa.
- Timeout. No se sabe. No reintentar automáticamente una reserva sin idempotencia (02-05); dejar el pedido pendiente y comprobar después.
- Protocolo (un proxy o balanceador respondió 502). Casi seguro que la petición no llegó a
inventario, pero un 502 puede producirse si el backend cerró la conexión a mitad de respuesta, así que es tan ambiguo como un timeout. No reintentar sin idempotencia; alertar: suele indicar un despliegue o una configuración incorrecta. - Aplicación, pero por un bug: el código 1 genérico y el
TypeErrorindican que el servidor lanzó una excepción no controlada (probablemente alguien pasó"2"como cadena en lugar de2). Se ejecutó parcialmente hasta el error; con el candado y sin efectos previos, el stock no cambió. No reintentar (fallaría igual); corregir el cliente y, en el servidor, validar tipos antes de tocar el estado.
Conclusión
Hemos abierto la caja de una llamada remota. Un stub en el cliente empaqueta (marshalling) los argumentos y los envía; un skeleton en el servidor los desempaqueta, invoca la implementación y devuelve el resultado; un IDL, cuando existe, fija el contrato entre ambos y permite generar los stubs. La promesa de RPC es que todo esto sea invisible, y hemos visto por qué no puede serlo del todo: la latencia obliga a diseñar interfaces de grano grueso, los argumentos viajan por valor, y los fallos parciales introducen el resultado "no se sabe", que las semánticas de invocación (maybe, at-least-once, at-most-once) gestionan de formas distintas; exactly-once no existe de extremo a extremo, y lo que se persigue en su lugar es la idempotencia. Con XML-RPC hemos implementado inventario.consultar_stock y reservar_stock, los hemos llamado desde pedidos con timeout y hemos clasificado los errores en cuatro familias con reacciones distintas; con Java RMI hemos visto la misma maquinaria aplicada a objetos remotos, con sus referencias, su registro y su acoplamiento a la JVM. REST, RPC y RMI ocupan lugares distintos: REST hacia fuera, RPC hacia dentro.
Lo que XML-RPC y RMI no resuelven bien es evidente en el código: XML de 230 bytes para dos argumentos, tipos limitados, contratos implícitos que se rompen en silencio al evolucionar, y ausencia de streaming. La siguiente lección, gRPC y Serialización de Datos, aborda exactamente eso: un IDL explícito (.proto), un formato binario compacto con reglas de evolución, y un RPC sobre HTTP/2 con deadlines, códigos de estado y cuatro tipos de llamada. Será la versión definitiva de la interfaz pedidos → inventario de Kilómetro Cero.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
