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 pedidosinventario 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

  1. Serialización: de estructuras en memoria a bytes
  2. Formatos de texto frente a formatos binarios
  3. Contract-first: el esquema como fuente de verdad
  4. Protocol Buffers: sintaxis, tipos y campos numerados
  5. Evolución de esquemas: compatibilidad hacia delante y hacia atrás
  6. gRPC: RPC sobre HTTP/2
  7. inventario en gRPC: contrato, servidor y cliente con deadline
  8. Streaming del servidor: observar cambios de stock
  9. Medir: JSON frente a protobuf para el mismo pedido
  10. Errores comunes y consejos
  11. Ejercicios
  12. Conclusión

  1. 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:

  1. 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?
  2. 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)?
  3. 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?
  4. 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.

  1. 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í (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.50 es un float de doble precisión, y ya vimos en 01-06 que el precio en float es 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.

  1. 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 Inventario de 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í.

  1. 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.
  • package evita colisiones de nombres entre contratos y aparece en los nombres completos de los tipos (km0.pedidos.v1.Pedido). El sufijo v1 es 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 nombre producto no 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.
  • repeated marca 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.

  1. 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, inventario nuevo recibe peticiones de un pedidos viejo).
  • 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 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 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 int32int64, 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 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.

  1. 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 pedidos mantenga un solo canal hacia inventario y 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 pedidos tiene 2 s para responder a Ana y tarda 1,5 s en llegar a inventario, inventario sabe que solo le quedan 0,5 s, y puede abortar trabajo inútil. El servidor consulta context.time_remaining(); el cliente recibe DEADLINE_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 faultCode inventados:
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).

  1. inventario en gRPC: contrato, servidor y cliente con deadline

Es el momento de reemplazar el servidor XML-RPC. Instalación (en el requirements.txt del servicio):

pip install grpcio grpcio-tools protobuf

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.proto

Esto 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) e InventarioStub (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.

  1. 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.

  1. 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. reserved siempre al eliminar.
  • Cambiar el tipo de un campo "porque es equivalente". int32string rompe; int32int64 funciona 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 en int64, 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. Si cantidad = 0 puede ser legítimo, usa optional o valida en el servidor.
  • Un canal por llamada. grpc.insecure_channel abre 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 timeout es 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.py generados. Se sobrescriben en la siguiente generación. Todo cambio va al .proto.
  • Consejo: valida la compatibilidad de los .proto en la integración continua con una herramienta como buf 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:

  1. La app de Lucía descarga la ficha de un producto con su foto.
  2. pedidos pide a inventario el stock de los 8 productos de un carrito.
  3. furgoneta-3 envía 1 posición por segundo a reparto durante una ruta de 2 horas, y al final recibe el resumen de kilómetros.
  4. analitica necesita todos los pedidos de la "Semana de la Vendimia" para un informe nocturno.
  5. 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:

  1. 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é.
  2. Unaria con un mensaje que lleve repeated string productos: una llamada, una respuesta con las 8 unidades (grano grueso, 02-02). Protobuf.
  3. 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.
  4. Ninguna llamada síncrona: es un volumen grande y un proceso por lotes. analitica debería consumir los eventos de pedido (02-04) o leer del almacén analítico (Módulo 5), no pedir a pedidos decenas 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.
  5. 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

Módulo 2: Comunicación en Sistemas Distribuidos

Módulo 3: Consistencia y Replicación

Módulo 4: Almacenamiento Distribuido

Módulo 5: Computación Distribuida

Módulo 6: Seguridad en Sistemas Distribuidos

Módulo 7: Monitoreo y Mantenimiento

Módulo 8: Casos de Estudio y Aplicaciones

© Copyright 2026. Todos los derechos reservados