La lección anterior dejó a inventario funcionando como servicio XML-RPC, y dejó a la vista sus tres debilidades: 230 bytes de XML para transmitir dos argumentos, un contrato implícito que se rompe en silencio cuando cambia una firma, y ningún mecanismo para que el servidor envíe un flujo de datos al cliente. Las tres tienen que ver con la misma pregunta de fondo: cómo se convierten las estructuras de datos en bytes y cómo evoluciona ese formato sin romper a nadie. Es la pregunta de la serialización, y de su respuesta depende buena parte del rendimiento, la interoperabilidad y la mantenibilidad de una plataforma distribuida.
En esta lección compararemos formatos de texto (JSON, XML) y binarios (Protocol Buffers, Avro, MessagePack), aprenderemos a escribir esquemas Protocol Buffers y las reglas que permiten cambiarlos sin romper clientes antiguos, y construiremos la versión definitiva de la interfaz pedidos → inventario de Kilómetro Cero con gRPC: contrato en inventario.proto, servidor en servicios/inventario/servidor.py, cliente con deadline en pedidos, y un flujo del servidor para observar cambios de stock. Terminaremos midiendo con código cuántos bytes ahorra protobuf frente a JSON para el mismo pedido. La mensajería asíncrona (colas, Kafka) es el tema de la lección siguiente: aquí todo es síncrono.
Contenido
- Serialización: de estructuras en memoria a bytes
- Formatos de texto frente a formatos binarios
- Contract-first: el esquema como fuente de verdad
- Protocol Buffers: sintaxis, tipos y campos numerados
- Evolución de esquemas: compatibilidad hacia delante y hacia atrás
- gRPC: RPC sobre HTTP/2
inventarioen gRPC: contrato, servidor y cliente con deadline- Streaming del servidor: observar cambios de stock
- Medir: JSON frente a protobuf para el mismo pedido
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Serialización: de estructuras en memoria a bytes
En la lección 02-02 lo llamamos marshalling; el término general es serialización: transformar un objeto en memoria (un diccionario de Python, una instancia de una clase Java) en una secuencia de bytes que pueda escribirse en la red o en disco, y deserialización, el proceso inverso. Todo lo que cruza una frontera de proceso pasa por aquí: argumentos de RPC, eventos en una cola, filas en una base de datos, ficheros en un almacén de objetos.
Un formato de serialización tiene que decidir cuatro cosas:
- Cómo representa los tipos: ¿un entero es texto (
"120") o binario (4 bytes)? ¿Distingue entre entero y decimal? ¿Tiene fechas, o son cadenas con un convenio? - Cómo nombra los campos: ¿lleva el nombre en cada mensaje (
"producto": "queso-curado"), o un número (1: "queso-curado"), o nada (posición fija)? - Dónde vive el esquema: ¿en el propio mensaje (autodescriptivo), en un fichero externo que ambos extremos comparten, o en la cabeza de los programadores?
- Cómo evoluciona: ¿qué pasa cuando el emisor añade un campo que el receptor no conoce, o al revés?
Las respuestas a estas preguntas separan a los formatos en dos grandes familias.
- Formatos de texto frente a formatos binarios
| Criterio | JSON | XML | Protocol Buffers | Avro | MessagePack |
|---|---|---|---|---|---|
| Representación | Texto | Texto | Binario | Binario | Binario |
| Legible por personas | Sí | Sí (verboso) | No | No | No |
| Nombres de campo en el mensaje | Sí, en cada uno | Sí, en cada uno | No (números) | No (esquema aparte) | Sí, en cada uno |
| Esquema | Opcional (JSON Schema, aparte) | Opcional (XSD, DTD) | Obligatorio (.proto) |
Obligatorio (.avsc), viaja con los datos o en un registro |
No |
| Tamaño relativo (mismo pedido) | 100 % | ~180 % | ~40 % | ~35 % | ~75 % |
| Velocidad de (de)serialización | Media | Baja | Alta | Alta | Alta |
| Tipos | Pocos (número, cadena, booleano, lista, objeto, null) | Todo es texto | Ricos (int32/64, float, bytes, enum, mensajes anidados, map) | Ricos, con tipos lógicos (fecha, decimal) | Como JSON más binario |
| Evolución de esquema | Manual, sin reglas | Manual | Reglas claras (números de campo) | Reglas claras (resolución de esquemas escritor/lector) | Manual |
| Ecosistema | Universal | Corporativo, SOAP | gRPC, Google, Kubernetes | Kafka, Hadoop, Spark | Redis, algunos RPC |
| Uso en Kilómetro Cero | API REST pública; eventos en la primera fase (02-04) | No | gRPC entre servicios | Candidato para eventos en Kafka con registro de esquemas (02-05) | No |
Algunas observaciones que la tabla no captura:
- JSON no distingue enteros de decimales ni tiene tipo para dinero:
14.50es unfloatde doble precisión, y ya vimos en 01-06 que el precio enfloates un error (falacia 8, y errores de redondeo). Los convenios habituales (enviar céntimos como entero, o el importe como cadena"14.50") son eso, convenios, que hay que documentar y que cada cliente puede incumplir. - El coste de JSON no es solo el tamaño, es el parseo: convertir
"120"en el entero 120 requiere interpretar caracteres; leer 4 bytes como un entero es una instrucción. En un servicio que serializa miles de mensajes por segundo, la CPU dedicada a JSON es medible. - Los formatos binarios sin esquema (MessagePack) ahorran poco: siguen enviando los nombres de campo en cada mensaje. El ahorro grande viene de sustituir nombres por números, y eso exige un esquema compartido.
- Avro y Protocol Buffers resuelven el mismo problema con filosofías distintas: protobuf compila el esquema a código y numera los campos; Avro no genera código necesariamente y resuelve las diferencias entre el esquema con el que se escribió un dato y el esquema con el que se lee. Avro encaja especialmente bien con Kafka y con ficheros de datos masivos (Módulo 5); protobuf, con RPC.
- Contract-first: el esquema como fuente de verdad
Hay dos maneras de llegar a un contrato entre pedidos e inventario:
- Code-first: se escribe la implementación (la clase
Inventariode XML-RPC, la interfaz Java de RMI) y el contrato es lo que se deduce de ella. Rápido al principio; el contrato queda acoplado a un lenguaje y cambia sin que nadie lo revise. - Contract-first: se escribe primero el contrato en un lenguaje neutro (el IDL de 02-02), se revisa como se revisaría una API pública, se versiona en el repositorio, y a partir de él se genera el código de cliente y servidor en cada lenguaje.
Con contract-first, el fichero .proto es la única fuente de verdad: si el equipo de pedidos quiere saber qué devuelve ReservarStock, lo lee ahí, no en el código Python de inventario. Los cambios al contrato pasan por revisión de código, se pueden validar automáticamente contra las reglas de compatibilidad (apartado 5), y la generación de código garantiza que ningún cliente puede llamar a un método con tipos equivocados: el TypeError del ejercicio 3 de la lección anterior se convierte en un error de compilación. En Kilómetro Cero, los contratos vivirán en km0/contratos/ y cada servicio generará su código desde allí.
- Protocol Buffers: sintaxis, tipos y campos numerados
Protocol Buffers (protobuf) es el IDL y formato de serialización de Google, y el que usa gRPC por defecto. Un fichero .proto define mensajes (estructuras de datos) y, opcionalmente, servicios (conjuntos de RPC). Empecemos por un mensaje sencillo, el pedido de Kilómetro Cero:
// km0/contratos/pedido.proto
syntax = "proto3";
package km0.pedidos.v1;
message LineaPedido {
string producto = 1; // slug del producto: "queso-curado"
int32 cantidad = 2;
int64 precio_centimos = 3; // dinero en céntimos, nunca en float
}
message Pedido {
string id = 1; // "P-2026-000123"
string cliente = 2; // "ana"
int64 fecha_ms = 3; // milisegundos desde epoch, UTC
repeated LineaPedido lineas = 4; // lista de líneas
string mercado = 5; // "girona", "lleida", "tarragona", "valencia"
}Cada elemento tiene un papel:
syntax = "proto3"fija la versión del lenguaje. proto3 es la actual y la que usaremos.packageevita colisiones de nombres entre contratos y aparece en los nombres completos de los tipos (km0.pedidos.v1.Pedido). El sufijov1es una convención para versionar contratos completos, distinta de la evolución campo a campo del apartado 5.- Cada campo tiene un tipo, un nombre y un número. El número es lo importante: es lo que viaja por la red, no el nombre. Cuando se serializa
producto = "queso-curado", se escribe "campo 1, tipo cadena, longitud 12, bytes". El nombreproductono aparece en ningún byte; existe solo en el código generado. Por eso protobuf es compacto y por eso renombrar un campo es gratis mientras cambiar su número rompe todo. repeatedmarca una lista. En proto3 todos los campos son opcionales en el sentido de que pueden faltar; si faltan, se lee su valor por defecto (0, cadena vacía, lista vacía,false), y un campo con valor por defecto no se serializa (más bytes ahorrados).
Tipos escalares
| Tipo protobuf | En Python | Uso recomendado | Nota |
|---|---|---|---|
int32, int64 |
int |
Contadores, identificadores numéricos | Codificación varint: los valores pequeños ocupan 1 byte; los negativos, 10. Para negativos frecuentes, sint32/sint64 |
uint32, uint64 |
int |
Sin negativos | Varint |
fixed32, fixed64 |
int |
Valores grandes siempre (hashes) | Tamaño fijo, más rápido que varint para valores grandes |
float, double |
float |
Coordenadas GPS, medidas | Nunca dinero |
bool |
bool |
Banderas | 1 byte |
string |
str |
Texto UTF-8 | Prefijo de longitud + bytes |
bytes |
bytes |
Binario opaco (imágenes, tokens) | Prefijo de longitud + bytes |
Más allá de los escalares: enum (con el valor 0 obligatorio como primer elemento y reservado a "desconocido"), mensajes anidados, map<string, int32> (diccionarios), oneof (exactamente uno de varios campos, útil para "resultado o error") y el modificador optional de proto3 (desde la versión 3.15), que permite distinguir "campo ausente" de "campo con valor por defecto", algo que de otro modo no es posible: sin optional, no hay forma de saber si cantidad = 0 significa cero o "no me lo dijeron".
Cómo se ve en el cable
Para entender el ahorro, aquí está la serialización de LineaPedido{producto: "queso-curado", cantidad: 2, precio_centimos: 1450}:
0a 0c 71 75 65 73 6f 2d 63 75 72 61 64 6f campo 1 (0x0a = nº1, tipo longitud), 12 bytes, "queso-curado" 10 02 campo 2 (0x10 = nº2, tipo varint), valor 2 18 aa 0b campo 3 (0x18 = nº3, tipo varint), valor 1450 en 2 bytes
19 bytes. El mismo objeto en JSON compacto, {"producto":"queso-curado","cantidad":2,"precio_centimos":1450}, ocupa 63. La diferencia son los nombres de campo, las comillas, los dos puntos y los números representados como texto.
- Evolución de esquemas: compatibilidad hacia delante y hacia atrás
Un contrato que no puede cambiar no sirve. Kilómetro Cero desplegará inventario y pedidos de forma independiente (era uno de los objetivos de 01-06), lo que significa que durante un tiempo convivirán versiones distintas del contrato en producción. Dos definiciones:
- Compatibilidad hacia atrás (backward): el código nuevo puede leer mensajes escritos con el esquema antiguo. Necesaria cuando se actualiza primero el lector (por ejemplo,
inventarionuevo recibe peticiones de unpedidosviejo). - Compatibilidad hacia delante (forward): el código antiguo puede leer mensajes escritos con el esquema nuevo. Necesaria cuando se actualiza primero el escritor.
En la práctica hacen falta las dos, porque no se controla el orden de despliegue de todos los clientes (y con eventos persistidos en Kafka, 02-04, se leerán mensajes de hace días con el esquema de hoy). Protobuf las proporciona si se siguen estas reglas:
| Cambio | ¿Compatible? | Por qué |
|---|---|---|
| Añadir un campo con número nuevo | Sí | El lector viejo ignora números que no conoce (y los conserva como campos desconocidos si reenvía el mensaje); el lector nuevo lee el valor por defecto si el escritor viejo no lo envió |
| Eliminar un campo y reservar su número y nombre | Sí | Nadie volverá a usar ese número con otro significado |
| Renombrar un campo | Sí (en el cable) | El nombre no viaja. Rompe el código generado que lo usa, pero no la compatibilidad de mensajes |
| Reutilizar un número eliminado para un campo nuevo | No | Un mensaje viejo con el número 4 como string se leerá como el nuevo campo 4 de otro tipo: datos corruptos sin error |
| Cambiar el tipo de un campo | No (salvo entre tipos de la misma codificación, como int32↔int64, con matices) |
La codificación en el cable difiere |
Cambiar repeated a escalar o viceversa |
No | Cambia la codificación |
| Cambiar el valor por defecto (implícito en proto3) | No aplica | proto3 no permite defaults personalizados, precisamente por esto |
Convertir un campo en optional |
Sí | Misma codificación |
Añadir un valor a un enum |
Sí, con cuidado | El lector viejo verá un valor desconocido; debe tratarlo (por eso el 0 es "desconocido") |
Ejemplo: en la segunda fase, pedidos quiere añadir la dirección de entrega al pedido y eliminar mercado (que pasa a deducirse de la dirección):
message Pedido {
reserved 5; // número de "mercado": nunca se reutilizará
reserved "mercado"; // ni el nombre, para que nadie lo redefina por error
string id = 1;
string cliente = 2;
int64 fecha_ms = 3;
repeated LineaPedido lineas = 4;
Direccion direccion_entrega = 6; // número NUEVO, nunca el 5
}
message Direccion {
string calle = 1;
string ciudad = 2;
string codigo_postal = 3;
}Un pedidos nuevo envía el campo 6; un reparto viejo lo ignora y sigue funcionando (con mercado vacío, que su código debe tolerar). Un pedidos viejo envía el campo 5; un reparto nuevo lo descarta. Ningún despliegue tiene que coordinarse. Esta disciplina (números únicos y nunca reutilizados, reserved al eliminar, campos nuevos siempre opcionales con valor por defecto tolerable) es probablemente la habilidad más valiosa de toda la lección, y se aplicará igual a los esquemas de eventos en 02-05.
- gRPC: RPC sobre HTTP/2
gRPC es el sistema RPC de Google, publicado en 2015, y hoy el estándar de hecho para comunicación síncrona entre servicios. Toma las ideas de 02-02 (stubs, skeleton, IDL) y las apoya sobre las dos piezas anteriores: Protocol Buffers como IDL y serialización, y HTTP/2 como transporte. Esa segunda decisión no es un detalle:
- La multiplexación de HTTP/2 (lección 02-01) permite que
pedidosmantenga un solo canal haciainventarioy lance por él cientos de llamadas simultáneas sin abrir conexiones nuevas ni esperar respuestas en serie. - Los flujos de HTTP/2 son bidireccionales y de larga duración, lo que hace posible el streaming.
- Las cabeceras comprimidas transportan los metadatos (autenticación, trazas) con poco coste.
- Al ser HTTP, atraviesa balanceadores, proxies y mallas de servicios (con soporte de HTTP/2).
Los cuatro tipos de llamada
flowchart LR
subgraph U["Unaria"]
U1[cliente] -- 1 petición --> U2[servidor]
U2 -- 1 respuesta --> U1
end
subgraph SS["Streaming de servidor"]
S1[cliente] -- 1 petición --> S2[servidor]
S2 -- N respuestas --> S1
end
subgraph SC["Streaming de cliente"]
C1[cliente] -- N peticiones --> C2[servidor]
C2 -- 1 respuesta --> C1
end
subgraph SB["Bidireccional"]
B1[cliente] <-- N ↔ M --> B2[servidor]
end
| Tipo | Firma en .proto |
Ejemplo en Kilómetro Cero |
|---|---|---|
| Unaria | rpc ReservarStock (Req) returns (Resp) |
pedidos reserva stock y espera la confirmación |
| Streaming de servidor | rpc ObservarCambios (Req) returns (stream Cambio) |
catalogo se suscribe a los cambios de stock de unos productos para mostrar "quedan pocas unidades" |
| Streaming de cliente | rpc EnviarPosiciones (stream Posicion) returns (Resumen) |
furgoneta-3 envía posiciones durante toda la ruta y recibe un resumen al final |
| Bidireccional | rpc Chat (stream Msg) returns (stream Msg) |
Negociación en tiempo real entre reparto y la app del repartidor (asignaciones y confirmaciones) |
Deadlines, códigos de estado y metadatos
Tres conceptos de gRPC que resuelven problemas que en 02-02 quedaron a medias:
- Deadline. En lugar de un timeout relativo ("espera 2 s"), gRPC propaga un instante absoluto ("esta llamada caduca a las 10:00:02,000"). La diferencia importa cuando una llamada provoca otras: si
pedidostiene 2 s para responder a Ana y tarda 1,5 s en llegar ainventario,inventariosabe que solo le quedan 0,5 s, y puede abortar trabajo inútil. El servidor consultacontext.time_remaining(); el cliente recibeDEADLINE_EXCEEDED. Es la solución sistemática a "ninguna llamada remota sin límite de tiempo". - Códigos de estado. gRPC define 17 códigos estándar, con semántica compartida por todos los lenguajes. Sustituyen a nuestros
faultCodeinventados:
| Código | Significado | Familia (02-02) | ¿Reintentar? |
|---|---|---|---|
OK |
Éxito | — | — |
INVALID_ARGUMENT |
La petición está mal formada (cantidad negativa) | Aplicación | No |
NOT_FOUND |
El recurso no existe (producto desconocido) | Aplicación | No |
FAILED_PRECONDITION |
El estado del sistema no permite la operación (sin stock) | Aplicación | No (hasta que cambie el estado) |
ALREADY_EXISTS |
Ya existe (una reserva con ese id) | Aplicación | No |
PERMISSION_DENIED, UNAUTHENTICATED |
Autorización (Módulo 6) | Aplicación | No |
RESOURCE_EXHAUSTED |
Cuota o límite de tasa superado | Infraestructura | Sí, con espera |
UNAVAILABLE |
El servidor no está disponible (conexión rechazada, reinicio) | Conexión | Sí, con espera |
DEADLINE_EXCEEDED |
Se agotó el deadline | Timeout | Solo si es idempotente |
UNKNOWN, INTERNAL |
Error no controlado en el servidor | Bug | No |
UNIMPLEMENTED |
El método no existe en esta versión del servidor | Protocolo / despliegue | No |
- Metadatos. Pares clave-valor que acompañan a la llamada sin formar parte del contrato: se transportan como cabeceras HTTP/2. Es donde viajan los tokens de autenticación (06-01), los identificadores de traza (07-02) y, como veremos, el identificador de petición para la idempotencia (02-05).
inventario en gRPC: contrato, servidor y cliente con deadline
inventario en gRPC: contrato, servidor y cliente con deadlineEs el momento de reemplazar el servidor XML-RPC. Instalación (en el requirements.txt del servicio):
El contrato
// km0/contratos/inventario.proto
syntax = "proto3";
package km0.inventario.v1;
service Inventario {
rpc ConsultarStock (ConsultarStockRequest) returns (ConsultarStockResponse);
rpc ReservarStock (ReservarStockRequest) returns (ReservarStockResponse);
// Streaming de servidor: el cliente pide observar unos productos y recibe
// un CambioStock cada vez que uno de ellos cambia, hasta que corta la llamada.
rpc ObservarCambios (ObservarCambiosRequest) returns (stream CambioStock);
}
message ConsultarStockRequest {
string producto = 1;
}
message ConsultarStockResponse {
string producto = 1;
int32 unidades = 2;
string replica = 3; // qué réplica respondió: "inv-bcn", "inv-vlc"
}
message ReservarStockRequest {
string producto = 1;
int32 cantidad = 2;
string pedido_id = 3; // "P-2026-000123", para trazabilidad
string id_reserva = 4; // UUID generado por el cliente; base de la
// idempotencia que se implementa en 02-05
}
message ReservarStockResponse {
string id_reserva = 1;
int32 restante = 2;
}
message ObservarCambiosRequest {
repeated string productos = 1; // vacío = todos
}
message CambioStock {
string producto = 1;
int32 antes = 2;
int32 despues = 3;
string motivo = 4; // "reserva", "reposicion", "cancelacion"
int64 timestamp_ms = 5;
}Generar el código
cd km0/servicios/inventario
python -m grpc_tools.protoc -I ../../contratos \
--python_out=. --grpc_python_out=. ../../contratos/inventario.protoEsto produce dos ficheros que no se editan a mano (se regeneran cada vez que cambia el .proto, idealmente en la construcción de la imagen Docker):
inventario_pb2.py: las clases de mensaje (ReservarStockRequest, etc.), con serialización incluida.inventario_pb2_grpc.py:InventarioServicer(la clase base que el servidor implementa: el skeleton) eInventarioStub(el stub del cliente).
El mismo comando, ejecutado en servicios/pedidos, genera los mismos ficheros para el cliente. Cada servicio compila el contrato compartido; nadie copia código de otro servicio.
El servidor
# km0/servicios/inventario/servidor.py
import queue
import threading
import time
import uuid
from concurrent import futures
import grpc
import inventario_pb2
import inventario_pb2_grpc
REPLICA = "inv-bcn"
class InventarioServicer(inventario_pb2_grpc.InventarioServicer):
"""Implementación del servicio. Cada método recibe la petición
deserializada y un 'context' con deadline, metadatos y control de errores."""
def __init__(self):
self._stock = {"tomate-rosa": 120, "calabacin": 80, "queso-curado": 5,
"queso-fresco": 30, "vino-crianza": 200}
self._candado = threading.Lock()
self._observadores = [] # una cola por cliente de ObservarCambios
def ConsultarStock(self, request, context):
with self._candado:
unidades = self._stock.get(request.producto)
if unidades is None:
# abort() serializa el error con su código y termina la llamada
context.abort(grpc.StatusCode.NOT_FOUND,
f"producto desconocido: {request.producto}")
return inventario_pb2.ConsultarStockResponse(
producto=request.producto, unidades=unidades, replica=REPLICA)
def ReservarStock(self, request, context):
if request.cantidad <= 0:
context.abort(grpc.StatusCode.INVALID_ARGUMENT,
"la cantidad debe ser positiva")
if not request.id_reserva:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "falta id_reserva")
with self._candado:
disponible = self._stock.get(request.producto)
if disponible is None:
context.abort(grpc.StatusCode.NOT_FOUND,
f"producto desconocido: {request.producto}")
if disponible < request.cantidad:
context.abort(grpc.StatusCode.FAILED_PRECONDITION,
f"solo quedan {disponible} unidades de {request.producto}")
self._stock[request.producto] = disponible - request.cantidad
restante = self._stock[request.producto]
print(f"[{REPLICA}] pedido {request.pedido_id}: reservadas "
f"{request.cantidad} de {request.producto}, quedan {restante}")
self._notificar(request.producto, disponible, restante, "reserva")
return inventario_pb2.ReservarStockResponse(
id_reserva=request.id_reserva, restante=restante)
def ObservarCambios(self, request, context):
"""Generador: cada 'yield' envía un mensaje al cliente por el flujo."""
cola = queue.Queue()
filtro = set(request.productos)
self._observadores.append(cola)
try:
while context.is_active(): # False cuando el cliente cancela o vence el deadline
try:
cambio = cola.get(timeout=1.0)
except queue.Empty:
continue
if not filtro or cambio.producto in filtro:
yield cambio
finally:
self._observadores.remove(cola) # limpieza al terminar el flujo
def _notificar(self, producto, antes, despues, motivo):
cambio = inventario_pb2.CambioStock(
producto=producto, antes=antes, despues=despues, motivo=motivo,
timestamp_ms=int(time.time() * 1000))
for cola in list(self._observadores):
cola.put(cambio)
def main():
servidor = grpc.server(futures.ThreadPoolExecutor(max_workers=16))
inventario_pb2_grpc.add_InventarioServicer_to_server(InventarioServicer(), servidor)
servidor.add_insecure_port("0.0.0.0:50051") # sin TLS por ahora: mTLS en 06-04
servidor.start()
print(f"[{REPLICA}] gRPC escuchando en :50051")
servidor.wait_for_termination()
if __name__ == "__main__":
main()Comparado con XML-RPC (02-02), fíjate en lo que ha cambiado: los errores usan códigos estándar con semántica conocida por cualquier cliente; el context da acceso al deadline y a la cancelación; un método de streaming es simplemente un generador de Python; y el servidor es un ThreadPoolExecutor con un tamaño explícito (16 hilos: cuando se agoten, las llamadas esperan, y con deadline caducan en lugar de acumularse indefinidamente).
El cliente en pedidos, con deadline
# km0/servicios/pedidos/cliente_inventario.py
import uuid
import grpc
import inventario_pb2
import inventario_pb2_grpc
class ClienteInventario:
"""Envoltorio del stub gRPC. Un canal por proceso, reutilizado."""
def __init__(self, destino="localhost:50051", timeout_s=2.0):
# El canal es perezoso y persistente: una conexión HTTP/2 multiplexada
self._canal = grpc.insecure_channel(destino)
self._stub = inventario_pb2_grpc.InventarioStub(self._canal)
self._timeout = timeout_s
def consultar_stock(self, producto):
respuesta = self._stub.ConsultarStock(
inventario_pb2.ConsultarStockRequest(producto=producto),
timeout=self._timeout) # se convierte en deadline absoluto
return respuesta.unidades
def reservar_stock(self, producto, cantidad, pedido_id):
id_reserva = str(uuid.uuid4()) # generado ANTES de la llamada (02-05)
peticion = inventario_pb2.ReservarStockRequest(
producto=producto, cantidad=cantidad,
pedido_id=pedido_id, id_reserva=id_reserva)
respuesta = self._stub.ReservarStock(
peticion, timeout=self._timeout,
metadata=(("x-pedido-id", pedido_id),)) # metadatos: fuera del contrato
return respuesta.restante
def reservar_para_pedido(cliente, producto, cantidad, pedido_id):
"""Traduce cada código de estado a una decisión de negocio (tabla del apartado 6)."""
try:
restante = cliente.reservar_stock(producto, cantidad, pedido_id)
return True, f"reservadas {cantidad} de {producto}, quedan {restante}"
except grpc.RpcError as e:
codigo, detalle = e.code(), e.details()
if codigo == grpc.StatusCode.FAILED_PRECONDITION:
return False, f"sin stock: {detalle}"
if codigo in (grpc.StatusCode.NOT_FOUND, grpc.StatusCode.INVALID_ARGUMENT):
return False, f"petición rechazada: {detalle}"
if codigo == grpc.StatusCode.UNAVAILABLE:
return False, "inventario no disponible; pedido en espera (no se ejecutó)"
if codigo == grpc.StatusCode.DEADLINE_EXCEEDED:
return False, "inventario no respondió a tiempo; estado desconocido (02-05)"
return False, f"error inesperado {codigo.name}: {detalle}"
if __name__ == "__main__":
cliente = ClienteInventario()
print("stock inicial:", cliente.consultar_stock("queso-curado"))
for nombre, producto, cantidad, pedido in [("Ana", "queso-curado", 2, "P-2026-000123"),
("Marc", "queso-curado", 4, "P-2026-000124"),
("Lucía", "vino-crianza", 6, "P-2026-000125")]:
ok, msg = reservar_para_pedido(cliente, producto, cantidad, pedido)
print(f"{nombre}: {'OK' if ok else 'NO'} - {msg}")Salida:
stock inicial: 5 Ana: OK - reservadas 2 de queso-curado, quedan 3 Marc: NO - sin stock: solo quedan 3 unidades de queso-curado Lucía: OK - reservadas 6 de vino-crianza, quedan 194
Y con inventario parado: Ana: NO - inventario no disponible; pedido en espera (no se ejecutó), en milisegundos, gracias a que gRPC distingue UNAVAILABLE (conexión rechazada) de DEADLINE_EXCEEDED (silencio). Es la tabla de cuatro familias de errores de 02-02, ahora estandarizada. El id_reserva viaja en cada petición pero el servidor aún no lo usa para filtrar duplicados: eso es exactamente lo que añadirá 02-05.
- Streaming del servidor: observar cambios de stock
Un cliente que quiera mostrar "quedan pocas unidades" (será catalogo) no debería preguntar el stock en cada visualización. Con ObservarCambios abre un flujo y recibe cada cambio:
# km0/servicios/catalogo/observador_stock.py
import grpc
import inventario_pb2
import inventario_pb2_grpc
canal = grpc.insecure_channel("localhost:50051")
stub = inventario_pb2_grpc.InventarioStub(canal)
peticion = inventario_pb2.ObservarCambiosRequest(productos=["queso-curado", "queso-fresco"])
# timeout largo: el flujo vive hasta que caduque, el cliente lo cancele o el servidor cierre
flujo = stub.ObservarCambios(peticion, timeout=3600)
try:
for cambio in flujo: # bloquea hasta que llega cada mensaje
aviso = " <- QUEDAN POCAS" if cambio.despues < 5 else ""
print(f"[catalogo] {cambio.producto}: {cambio.antes} -> {cambio.despues} "
f"({cambio.motivo}){aviso}")
except grpc.RpcError as e:
print("flujo terminado:", e.code().name)Arranca el observador y luego ejecuta el cliente de pedidos: verás queso-curado: 5 -> 3 (reserva) <- QUEDAN POCAS. Todo viaja por un flujo HTTP/2 que se mantiene abierto; cada yield del servidor es una trama en ese flujo. Dos advertencias: este flujo es una conexión punto a punto, así que si el observador se reinicia se pierde lo ocurrido entretanto, y si hay diez servicios interesados en los cambios, inventario mantiene diez flujos. Para difundir eventos a muchos consumidores con persistencia, la herramienta adecuada es la mensajería de la lección siguiente; el streaming gRPC brilla en flujos de uno a uno, como la telemetría de un repartidor concreto.
- Medir: JSON frente a protobuf para el mismo pedido
Nada convence más que los números. Generamos el código de pedido.proto (python -m grpc_tools.protoc -I ../../contratos --python_out=. ../../contratos/pedido.proto) y comparamos:
# km0/servicios/pedidos/medir_tamano.py
import json
import time
import pedido_pb2
pedido_dict = {
"id": "P-2026-000123", "cliente": "ana", "fecha_ms": 1789000000000, "mercado": "girona",
"lineas": [
{"producto": "queso-curado", "cantidad": 2, "precio_centimos": 1450},
{"producto": "tomate-rosa", "cantidad": 3, "precio_centimos": 320},
{"producto": "vino-crianza", "cantidad": 6, "precio_centimos": 990},
],
}
pedido_pb = pedido_pb2.Pedido(
id=pedido_dict["id"], cliente=pedido_dict["cliente"],
fecha_ms=pedido_dict["fecha_ms"], mercado=pedido_dict["mercado"],
lineas=[pedido_pb2.LineaPedido(**l) for l in pedido_dict["lineas"]])
json_bytes = json.dumps(pedido_dict, separators=(",", ":")).encode("utf-8")
pb_bytes = pedido_pb.SerializeToString()
print(f"JSON compacto : {len(json_bytes):4d} bytes")
print(f"Protobuf : {len(pb_bytes):4d} bytes ({100 * len(pb_bytes) / len(json_bytes):.0f} %)")
N = 100_000
t0 = time.perf_counter()
for _ in range(N):
json.loads(json.dumps(pedido_dict, separators=(",", ":")))
t_json = time.perf_counter() - t0
t0 = time.perf_counter()
for _ in range(N):
pedido_pb2.Pedido.FromString(pedido_pb.SerializeToString())
t_pb = time.perf_counter() - t0
print(f"JSON: {N / t_json:,.0f} ciclos/s protobuf: {N / t_pb:,.0f} ciclos/s")Salida orientativa (los tiempos dependen de la máquina y de si protobuf usa su implementación en C):
JSON compacto : 282 bytes Protobuf : 97 bytes (34 %) JSON: 210,000 ciclos/s protobuf: 650,000 ciclos/s
Un pedido de tres líneas pesa menos de la mitad y se procesa unas tres veces más rápido. Con 1.200 pedidos por segundo (lección 01-03) y decenas de llamadas internas por pedido, la diferencia se traduce en ancho de banda, CPU y latencia. Y lo que no mide este script es igual de importante: el .proto ha validado los tipos en tiempo de construcción del mensaje (precio_centimos="14.50" habría fallado inmediatamente), mientras que el diccionario acepta cualquier cosa.
Añadir inventario al docker-compose.yml
Con esto, inventario es el primer servicio real de km0/servicios/. Su Dockerfile compila el contrato al construir la imagen, y docker-compose.yml gana un servicio:
inventario:
build:
context: . # necesita acceso a contratos/ y a servicios/inventario/
dockerfile: servicios/inventario/Dockerfile
ports:
- "50051:50051"# km0/servicios/inventario/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY servicios/inventario/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY contratos/ /contratos/
COPY servicios/inventario/ .
RUN python -m grpc_tools.protoc -I /contratos --python_out=. --grpc_python_out=. /contratos/inventario.proto
CMD ["python", "servidor.py"]El monolito, por ahora, sigue usando su propio módulo inventario interno; el estrangulamiento (01-06) consiste en ir redirigiendo sus llamadas a este servicio. Ese cambio no tiene nada de especial técnicamente (es sustituir una llamada local por ClienteInventario) y no lo detallaremos; lo que sí tiene mucho de especial es que ahora pedidos e inventario tienen datos separados, y ese es el problema del Módulo 3.
Errores Comunes y Consejos
- Reutilizar un número de campo. El error más grave con protobuf, porque no produce ningún error: produce datos corruptos leídos con total confianza.
reservedsiempre al eliminar. - Cambiar el tipo de un campo "porque es equivalente".
int32→stringrompe;int32→int64funciona en el cable, pero un valor grande escrito por el nuevo se truncará en el viejo. Ante la duda, campo nuevo con número nuevo. - Dinero en
float/double. Céntimos enint64, o una cadena decimal si hace falta precisión arbitraria. Lo repetiremos hasta el proyecto final. - Confiar en que "campo ausente" y "valor por defecto" se distinguen. En proto3 no se distinguen salvo con
optional. Sicantidad = 0puede ser legítimo, usaoptionalo valida en el servidor. - Un canal por llamada.
grpc.insecure_channelabre una conexión HTTP/2 con su handshake; crea uno por proceso (o unos pocos) y reutilízalo. Un canal por llamada es HTTP/1.1 sin keep-alive con más pasos. - Sin deadline. El parámetro
timeoutes opcional en la API y obligatorio en la práctica. Sin él, la llamada espera para siempre, con todo lo que aprendimos en 01-04. - Ignorar el código de estado y mirar solo el texto.
e.details()es para personas;e.code()es para programas. El texto puede cambiar entre versiones del servidor; el código, no. - Editar los ficheros
_pb2.pygenerados. Se sobrescriben en la siguiente generación. Todo cambio va al.proto. - Consejo: valida la compatibilidad de los
.protoen la integración continua con una herramienta comobuf breaking: convierte las reglas del apartado 5 en una comprobación automática, y ningún cambio incompatible llega a producción por descuido. - Consejo: cuando diseñes un mensaje, piensa en la versión 3 antes de publicar la 1: deja los números bajos (1-15, que ocupan un solo byte de etiqueta) para los campos más frecuentes, usa nombres neutros, y no metas en un mensaje lo que pertenece a otro servicio.
Ejercicios
Ejercicio 1: Evolucionar ReservarStockRequest
El equipo de pedidos necesita que una reserva pueda ser temporal (caduca a los 15 minutos si no se paga, para liberar el último queso si Ana abandona el carrito) y quiere además eliminar pedido_id de la petición, porque a partir de ahora viajará como metadato. Escribe la nueva versión del mensaje respetando las reglas de compatibilidad y explica qué verá un servidor inventario antiguo cuando reciba la petición nueva, y qué verá un inventario nuevo cuando reciba una petición antigua.
Ejercicio 2: Propagar el deadline
pedidos recibe de la app de Ana una petición con un límite total de 3 segundos, y para atenderla llama primero a inventario (reservar) y luego a pagos (cobrar). Explica por qué usar timeout=3.0 en ambas llamadas gRPC es incorrecto, y escribe una función tiempo_restante(deadline_absoluto) que calcule el timeout a pasar a cada llamada. ¿Qué debería hacer pedidos si al ir a llamar a pagos quedan 50 ms?
Ejercicio 3: Elegir tipo de llamada y formato
Para cada interacción indica el tipo de llamada gRPC (unaria, streaming de servidor, de cliente o bidireccional) o si conviene otra cosa (REST, mensajería), y el formato de serialización:
- La app de Lucía descarga la ficha de un producto con su foto.
pedidospide ainventarioel stock de los 8 productos de un carrito.furgoneta-3envía 1 posición por segundo arepartodurante una ruta de 2 horas, y al final recibe el resumen de kilómetros.analiticanecesita todos los pedidos de la "Semana de la Vendimia" para un informe nocturno.- Un operador de atención al cliente y la app de un repartidor intercambian mensajes cortos en tiempo real sobre una incidencia de entrega.
Soluciones
Solución 1:
message ReservarStockRequest {
reserved 3;
reserved "pedido_id";
string producto = 1;
int32 cantidad = 2;
string id_reserva = 4;
int32 caduca_en_segundos = 5; // 0 (valor por defecto) = reserva permanente
}Se elimina pedido_id reservando su número (3) y su nombre; el campo nuevo toma el número 5, nunca el 3. Se elige que el valor por defecto (0) signifique "comportamiento anterior", de modo que una petición que no lo envíe se comporte como antes. Un inventario antiguo que reciba la petición nueva verá pedido_id vacío (la cadena por defecto: su log de trazabilidad imprimirá pedido : en blanco, tolerable) e ignorará el campo 5, que no conoce: hará una reserva permanente, lo cual es un comportamiento degradado pero no un error; el equipo debe saber que la caducidad no funciona hasta que inventario se despliegue. Un inventario nuevo que reciba una petición antigua verá caduca_en_segundos = 0 y hará una reserva permanente, exactamente lo que ese cliente esperaba. Ningún despliegue necesita coordinarse, aunque la funcionalidad completa solo existe cuando ambos están actualizados.
Solución 2:
Con timeout=3.0 en cada llamada, el peor caso es que inventario tarde 2,9 s (dentro de su límite) y pagos otros 2,9 s: pedidos respondería a Ana en 5,8 s, casi el doble de lo prometido, y la app ya habría abandonado. El deadline debe ser uno solo y absoluto, calculado al recibir la petición, y cada llamada recibe lo que queda:
import time
def tiempo_restante(deadline_absoluto):
"""Segundos que quedan hasta el deadline; nunca negativo."""
return max(0.0, deadline_absoluto - time.monotonic())
deadline = time.monotonic() + 3.0 # al recibir la petición de Ana
stub_inventario.ReservarStock(req, timeout=tiempo_restante(deadline))
restante = tiempo_restante(deadline)
if restante < 0.2: # umbral: menos de lo que tarda pagos normalmente
# No merece la pena llamar: fallará por deadline y dejará el cobro en estado desconocido.
# Mejor abortar limpiamente: liberar la reserva (o dejar que caduque) y responder
# "no hemos podido procesar el pedido, inténtalo de nuevo" o dejarlo en "pago pendiente".
...
else:
stub_pagos.Cobrar(req_pago, timeout=restante)Con 50 ms restantes lo correcto es no llamar a pagos: una llamada que va a caducar deja la operación más peligrosa (un cobro) en estado "no se sabe". Abortar antes es la versión de "fallar rápido" que gRPC facilita al hacer explícito el tiempo que queda; los servidores gRPC pueden además consultar context.time_remaining() para no empezar trabajo que no podrán terminar. Nota que se usa time.monotonic() (no time.time()): el deadline es una duración, no un instante de reloj de pared, y los relojes de pared saltan (lección 01-05).
Solución 3:
- REST con JSON para los metadatos y la foto como recurso HTTP aparte (cacheable por CDN). Es un cliente externo, navegador o app, y el contenido se beneficia de la caché HTTP. gRPC en navegador requiere gRPC-Web y no aporta caché.
- Unaria con un mensaje que lleve
repeated string productos: una llamada, una respuesta con las 8 unidades (grano grueso, 02-02). Protobuf. - Streaming de cliente: N posiciones → 1 resumen. Protobuf; cada posición son ~25 bytes. Ojo: si la red móvil es mala, un flujo HTTP/2 sobre TCP sufre bloqueo de cabeza de línea (02-01), y MQTT (08-02) puede ser mejor opción para el tramo móvil; el streaming gRPC encaja entre la pasarela y
reparto. - Ninguna llamada síncrona: es un volumen grande y un proceso por lotes.
analiticadebería consumir los eventos de pedido (02-04) o leer del almacén analítico (Módulo 5), no pedir apedidosdecenas de miles de registros en una llamada (aunque técnicamente un streaming de servidor lo permitiría, acoplaría la carga analítica al servicio transaccional: síntoma 5 de 01-06). Formato: Avro o Parquet para datos masivos. - Bidireccional: mensajes en ambos sentidos, en cualquier orden, en tiempo real. Protobuf. Si uno de los extremos es un navegador, la alternativa práctica es WebSockets (08-02), que es lo mismo conceptualmente con un transporte que los navegadores soportan de forma nativa.
Conclusión
Esta lección ha cerrado el círculo abierto en 02-02. La serialización decide cómo se convierten las estructuras en bytes, y esa decisión determina tamaño, velocidad, seguridad de tipos y capacidad de evolución: los formatos de texto (JSON, XML) son legibles y universales pero pesados y sin esquema; los binarios con esquema (Protocol Buffers, Avro) pesan menos de la mitad, se procesan varias veces más rápido y, sobre todo, tienen reglas de evolución (números de campo únicos y nunca reutilizados, reserved al eliminar, campos nuevos con valores por defecto tolerables) que permiten desplegar pedidos e inventario de forma independiente. Con contract-first, el .proto es la fuente de verdad de la que se genera el código. gRPC apoya esos esquemas sobre HTTP/2 para ofrecer un canal multiplexado, cuatro tipos de llamada (unaria y tres formas de streaming), deadlines absolutos que se propagan, códigos de estado estándar y metadatos. Kilómetro Cero tiene ahora su primer servicio extraído de verdad: inventario en servicios/inventario/servidor.py, con su contrato en contratos/inventario.proto, un cliente en pedidos que traduce cada código de estado a una decisión de negocio, y un flujo de cambios de stock. La medición ha confirmado el ahorro: un pedido de tres líneas pasa de 282 bytes en JSON a 97 en protobuf.
Pero todo lo hecho en este módulo hasta aquí es síncrono: pedidos llama, espera y recibe. Al final de 01-06 quedó claro que eso es lo correcto para reservar stock y lo incorrecto para casi todo lo demás: avisar a analitica de una venta, planificar el reparto o actualizar el indicador del catálogo no deberían bloquear al cliente ni depender de que el destinatario esté vivo en ese instante. Para eso hace falta que alguien guarde el mensaje y lo entregue cuando el destinatario pueda: un broker. La siguiente lección, Mensajería y Colas de Mensajes, introduce RabbitMQ y Apache Kafka, y con ellos el primer flujo asíncrono de Kilómetro Cero: el evento pedido.creado.
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
