La lliçó anterior va deixar inventari funcionant com a servei XML-RPC, i va deixar a la vista les seves tres febleses: 230 bytes d'XML per transmetre dos arguments, un contracte implícit que es trenca en silenci quan canvia una signatura, i cap mecanisme perquè el servidor enviï un flux de dades al client. Les tres tenen a veure amb la mateixa pregunta de fons: com es converteixen les estructures de dades en bytes i com evoluciona aquest format sense trencar ningú. És la pregunta de la serialització, i de la seva resposta depèn bona part del rendiment, la interoperabilitat i la mantenibilitat d'una plataforma distribuïda.
En aquesta lliçó compararem formats de text (JSON, XML) i binaris (Protocol Buffers, Avro, MessagePack), aprendrem a escriure esquemes Protocol Buffers i les regles que permeten canviar-los sense trencar clients antics, i construirem la versió definitiva de la interfície comandes → inventari de Quilòmetre Zero amb gRPC: contracte a inventari.proto, servidor a serveis/inventari/servidor.py, client amb deadline a comandes, i un flux del servidor per observar canvis d'estoc. Acabarem mesurant amb codi quants bytes estalvia protobuf respecte de JSON per a la mateixa comanda. La missatgeria asíncrona (cues, Kafka) és el tema de la lliçó següent: aquí tot és síncron.
Contingut
- Serialització: d'estructures en memòria a bytes
- Formats de text contra formats binaris
- Contract-first: l'esquema com a font de veritat
- Protocol Buffers: sintaxi, tipus i camps numerats
- Evolució d'esquemes: compatibilitat cap endavant i cap enrere
- gRPC: RPC sobre HTTP/2
inventarien gRPC: contracte, servidor i client amb deadline- Streaming del servidor: observar canvis d'estoc
- Mesurar: JSON contra protobuf per a la mateixa comanda
- Errors comuns i consells
- Exercicis
- Conclusió
- Serialització: d'estructures en memòria a bytes
A la lliçó 02-02 en vam dir marshalling; el terme general és serialització: transformar un objecte en memòria (un diccionari de Python, una instància d'una classe Java) en una seqüència de bytes que es pugui escriure a la xarxa o al disc, i deserialització, el procés invers. Tot el que travessa una frontera de procés passa per aquí: arguments d'RPC, esdeveniments en una cua, files en una base de dades, fitxers en un magatzem d'objectes.
Un format de serialització ha de decidir quatre coses:
- Com representa els tipus: un enter és text (
"120") o binari (4 bytes)? Distingeix entre enter i decimal? Té dates, o són cadenes amb un conveni? - Com anomena els camps: porta el nom a cada missatge (
"producte": "formatge-curat"), o un número (1: "formatge-curat"), o res (posició fixa)? - On viu l'esquema: al mateix missatge (autodescriptiu), en un fitxer extern que tots dos extrems comparteixen, o al cap dels programadors?
- Com evoluciona: què passa quan l'emissor afegeix un camp que el receptor no coneix, o a l'inrevés?
Les respostes a aquestes preguntes separen els formats en dues grans famílies.
- Formats de text contra formats binaris
| Criteri | JSON | XML | Protocol Buffers | Avro | MessagePack |
|---|---|---|---|---|---|
| Representació | Text | Text | Binari | Binari | Binari |
| Llegible per persones | Sí | Sí (verbós) | No | No | No |
| Noms de camp al missatge | Sí, a cadascun | Sí, a cadascun | No (números) | No (esquema a part) | Sí, a cadascun |
| Esquema | Opcional (JSON Schema, a part) | Opcional (XSD, DTD) | Obligatori (.proto) |
Obligatori (.avsc), viatja amb les dades o en un registre |
No |
| Mida relativa (mateixa comanda) | 100 % | ~180 % | ~40 % | ~35 % | ~75 % |
| Velocitat de (de)serialització | Mitjana | Baixa | Alta | Alta | Alta |
| Tipus | Pocs (número, cadena, booleà, llista, objecte, null) | Tot és text | Rics (int32/64, float, bytes, enum, missatges imbricats, map) | Rics, amb tipus lògics (data, decimal) | Com JSON més binari |
| Evolució d'esquema | Manual, sense regles | Manual | Regles clares (números de camp) | Regles clares (resolució d'esquemes escriptor/lector) | Manual |
| Ecosistema | Universal | Corporatiu, SOAP | gRPC, Google, Kubernetes | Kafka, Hadoop, Spark | Redis, alguns RPC |
| Ús a Quilòmetre Zero | API REST pública; esdeveniments a la primera fase (02-04) | No | gRPC entre serveis | Candidat per a esdeveniments a Kafka amb registre d'esquemes (02-05) | No |
Algunes observacions que la taula no captura:
- JSON no distingeix enters de decimals ni té tipus per als diners:
14.50és unfloatde doble precisió, i ja vam veure a 01-06 que el preu enfloatés un error (fal·làcia 8, i errors d'arrodoniment). Els convenis habituals (enviar cèntims com a enter, o l'import com a cadena"14.50") són això, convenis, que cal documentar i que cada client pot incomplir. - El cost de JSON no és només la mida, és el parseig: convertir
"120"en l'enter 120 requereix interpretar caràcters; llegir 4 bytes com un enter és una instrucció. En un servei que serialitza milers de missatges per segon, la CPU dedicada a JSON és mesurable. - Els formats binaris sense esquema (MessagePack) estalvien poc: continuen enviant els noms de camp a cada missatge. L'estalvi gran ve de substituir noms per números, i això exigeix un esquema compartit.
- Avro i Protocol Buffers resolen el mateix problema amb filosofies diferents: protobuf compila l'esquema a codi i numera els camps; Avro no genera codi necessàriament i resol les diferències entre l'esquema amb què es va escriure una dada i l'esquema amb què es llegeix. Avro encaixa especialment bé amb Kafka i amb fitxers de dades massives (Mòdul 5); protobuf, amb RPC.
- Contract-first: l'esquema com a font de veritat
Hi ha dues maneres d'arribar a un contracte entre comandes i inventari:
- Code-first: s'escriu la implementació (la classe
Inventarid'XML-RPC, la interfície Java d'RMI) i el contracte és el que se'n dedueix. Ràpid al principi; el contracte queda acoblat a un llenguatge i canvia sense que ningú ho revisi. - Contract-first: s'escriu primer el contracte en un llenguatge neutre (l'IDL de 02-02), es revisa com es revisaria una API pública, es versiona al repositori, i a partir d'ell es genera el codi de client i servidor en cada llenguatge.
Amb contract-first, el fitxer .proto és l'única font de veritat: si l'equip de comandes vol saber què retorna ReservarEstoc, ho llegeix allà, no al codi Python d'inventari. Els canvis al contracte passen per revisió de codi, es poden validar automàticament contra les regles de compatibilitat (apartat 5), i la generació de codi garanteix que cap client no pot cridar un mètode amb tipus equivocats: el TypeError de l'exercici 3 de la lliçó anterior es converteix en un error de compilació. A Quilòmetre Zero, els contractes viuran a km0/contractes/ i cada servei generarà el seu codi des d'allà.
- Protocol Buffers: sintaxi, tipus i camps numerats
Protocol Buffers (protobuf) és l'IDL i format de serialització de Google, i el que fa servir gRPC per defecte. Un fitxer .proto defineix missatges (estructures de dades) i, opcionalment, serveis (conjunts d'RPC). Comencem per un missatge senzill, la comanda de Quilòmetre Zero:
// km0/contractes/comanda.proto
syntax = "proto3";
package km0.comandes.v1;
message LiniaComanda {
string producte = 1; // slug del producte: "formatge-curat"
int32 quantitat = 2;
int64 preu_centims = 3; // diners en cèntims, mai en float
}
message Comanda {
string id = 1; // "P-2026-000123"
string client = 2; // "anna"
int64 data_ms = 3; // mil·lisegons des de l'epoch, UTC
repeated LiniaComanda linies = 4; // llista de línies
string mercat = 5; // "girona", "lleida", "tarragona", "valencia"
}Cada element té un paper:
syntax = "proto3"fixa la versió del llenguatge. proto3 és l'actual i la que farem servir.packageevita col·lisions de noms entre contractes i apareix als noms complets dels tipus (km0.comandes.v1.Comanda). El sufixv1és una convenció per versionar contractes complets, diferent de l'evolució camp a camp de l'apartat 5.- Cada camp té un tipus, un nom i un número. El número és l'important: és el que viatja per la xarxa, no el nom. Quan se serialitza
producte = "formatge-curat", s'escriu "camp 1, tipus cadena, longitud 14, bytes". El nomproducteno apareix a cap byte; existeix només al codi generat. Per això protobuf és compacte i per això reanomenar un camp és gratuït mentre que canviar-ne el número ho trenca tot. repeatedmarca una llista. A proto3 tots els camps són opcionals en el sentit que poden faltar; si falten, se'n llegeix el valor per defecte (0, cadena buida, llista buida,false), i un camp amb valor per defecte no se serialitza (més bytes estalviats).
Tipus escalars
| Tipus protobuf | En Python | Ús recomanat | Nota |
|---|---|---|---|
int32, int64 |
int |
Comptadors, identificadors numèrics | Codificació varint: els valors petits ocupen 1 byte; els negatius, 10. Per a negatius freqüents, sint32/sint64 |
uint32, uint64 |
int |
Sense negatius | Varint |
fixed32, fixed64 |
int |
Valors grans sempre (hashes) | Mida fixa, més ràpid que varint per a valors grans |
float, double |
float |
Coordenades GPS, mesures | Mai diners |
bool |
bool |
Indicadors | 1 byte |
string |
str |
Text UTF-8 | Prefix de longitud + bytes |
bytes |
bytes |
Binari opac (imatges, tokens) | Prefix de longitud + bytes |
Més enllà dels escalars: enum (amb el valor 0 obligatori com a primer element i reservat a "desconegut"), missatges imbricats, map<string, int32> (diccionaris), oneof (exactament un de diversos camps, útil per a "resultat o error") i el modificador optional de proto3 (des de la versió 3.15), que permet distingir "camp absent" de "camp amb valor per defecte", cosa que d'altra manera no és possible: sense optional, no hi ha manera de saber si quantitat = 0 vol dir zero o "no m'ho han dit".
Com es veu al cable
Per entendre l'estalvi, aquí hi ha la serialització de LiniaComanda{producte: "formatge-curat", quantitat: 2, preu_centims: 1450}:
0a 0e 66 6f 72 6d 61 74 67 65 2d 63 75 72 61 74 camp 1 (0x0a = núm. 1, tipus longitud), 14 bytes, "formatge-curat" 10 02 camp 2 (0x10 = núm. 2, tipus varint), valor 2 18 aa 0b camp 3 (0x18 = núm. 3, tipus varint), valor 1450 en 2 bytes
21 bytes. El mateix objecte en JSON compacte, {"producte":"formatge-curat","quantitat":2,"preu_centims":1450}, n'ocupa 63. La diferència són els noms de camp, les cometes, els dos punts i els números representats com a text.
- Evolució d'esquemes: compatibilitat cap endavant i cap enrere
Un contracte que no pot canviar no serveix. Quilòmetre Zero desplegarà inventari i comandes de manera independent (era un dels objectius de 01-06), cosa que vol dir que durant un temps conviuran versions diferents del contracte en producció. Dues definicions:
- Compatibilitat cap enrere (backward): el codi nou pot llegir missatges escrits amb l'esquema antic. Necessària quan s'actualitza primer el lector (per exemple, un
inventarinou rep peticions d'uncomandesvell). - Compatibilitat cap endavant (forward): el codi antic pot llegir missatges escrits amb l'esquema nou. Necessària quan s'actualitza primer l'escriptor.
A la pràctica calen totes dues, perquè no es controla l'ordre de desplegament de tots els clients (i amb esdeveniments persistits a Kafka, 02-04, es llegiran missatges de fa dies amb l'esquema d'avui). Protobuf les proporciona si se segueixen aquestes regles:
| Canvi | Compatible? | Per què |
|---|---|---|
| Afegir un camp amb número nou | Sí | El lector vell ignora números que no coneix (i els conserva com a camps desconeguts si reenvia el missatge); el lector nou llegeix el valor per defecte si l'escriptor vell no l'ha enviat |
| Eliminar un camp i reservar-ne el número i el nom | Sí | Ningú no tornarà a fer servir aquest número amb un altre significat |
| Reanomenar un camp | Sí (al cable) | El nom no viatja. Trenca el codi generat que el fa servir, però no la compatibilitat de missatges |
| Reutilitzar un número eliminat per a un camp nou | No | Un missatge vell amb el número 4 com a string es llegirà com el nou camp 4 d'un altre tipus: dades corruptes sense error |
| Canviar el tipus d'un camp | No (llevat d'entre tipus de la mateixa codificació, com int32↔int64, amb matisos) |
La codificació al cable difereix |
Canviar repeated a escalar o viceversa |
No | Canvia la codificació |
| Canviar el valor per defecte (implícit a proto3) | No aplica | proto3 no permet defaults personalitzats, precisament per això |
Convertir un camp en optional |
Sí | Mateixa codificació |
Afegir un valor a un enum |
Sí, amb cura | El lector vell veurà un valor desconegut; l'ha de tractar (per això el 0 és "desconegut") |
Exemple: a la segona fase, comandes vol afegir l'adreça de lliurament a la comanda i eliminar mercat (que passa a deduir-se de l'adreça):
message Comanda {
reserved 5; // número de "mercat": no es reutilitzarà mai
reserved "mercat"; // ni el nom, perquè ningú no el redefineixi per error
string id = 1;
string client = 2;
int64 data_ms = 3;
repeated LiniaComanda linies = 4;
Adreca adreca_lliurament = 6; // número NOU, mai el 5
}
message Adreca {
string carrer = 1;
string ciutat = 2;
string codi_postal = 3;
}Un comandes nou envia el camp 6; un repartiment vell l'ignora i continua funcionant (amb mercat buit, que el seu codi ha de tolerar). Un comandes vell envia el camp 5; un repartiment nou el descarta. Cap desplegament no s'ha de coordinar. Aquesta disciplina (números únics i mai reutilitzats, reserved en eliminar, camps nous sempre opcionals amb valor per defecte tolerable) és probablement l'habilitat més valuosa de tota la lliçó, i s'aplicarà igual als esquemes d'esdeveniments a 02-05.
- gRPC: RPC sobre HTTP/2
gRPC és el sistema RPC de Google, publicat el 2015, i avui l'estàndard de fet per a la comunicació síncrona entre serveis. Pren les idees de 02-02 (stubs, skeleton, IDL) i les apuntala sobre les dues peces anteriors: Protocol Buffers com a IDL i serialització, i HTTP/2 com a transport. Aquesta segona decisió no és un detall:
- La multiplexació d'HTTP/2 (lliçó 02-01) permet que
comandesmantingui un sol canal cap ainventarii hi llanci centenars de crides simultànies sense obrir connexions noves ni esperar respostes en sèrie. - Els fluxos d'HTTP/2 són bidireccionals i de llarga durada, cosa que fa possible l'streaming.
- Les capçaleres comprimides transporten les metadades (autenticació, traces) amb poc cost.
- En ser HTTP, travessa balancejadors, proxies i malles de serveis (amb suport d'HTTP/2).
Els quatre tipus de crida
flowchart LR
subgraph U["Unària"]
U1[client] -- 1 petició --> U2[servidor]
U2 -- 1 resposta --> U1
end
subgraph SS["Streaming de servidor"]
S1[client] -- 1 petició --> S2[servidor]
S2 -- N respostes --> S1
end
subgraph SC["Streaming de client"]
C1[client] -- N peticions --> C2[servidor]
C2 -- 1 resposta --> C1
end
subgraph SB["Bidireccional"]
B1[client] <-- N ↔ M --> B2[servidor]
end
| Tipus | Signatura al .proto |
Exemple a Quilòmetre Zero |
|---|---|---|
| Unària | rpc ReservarEstoc (Req) returns (Resp) |
comandes reserva estoc i espera la confirmació |
| Streaming de servidor | rpc ObservarCanvis (Req) returns (stream Canvi) |
cataleg se subscriu als canvis d'estoc d'uns productes per mostrar "en queden poques unitats" |
| Streaming de client | rpc EnviarPosicions (stream Posicio) returns (Resum) |
furgoneta-3 envia posicions durant tota la ruta i rep un resum al final |
| Bidireccional | rpc Chat (stream Msg) returns (stream Msg) |
Negociació en temps real entre repartiment i l'app del repartidor (assignacions i confirmacions) |
Deadlines, codis d'estat i metadades
Tres conceptes de gRPC que resolen problemes que a 02-02 van quedar a mitges:
- Deadline. En lloc d'un timeout relatiu ("espera 2 s"), gRPC propaga un instant absolut ("aquesta crida caduca a les 10:00:02,000"). La diferència importa quan una crida en provoca d'altres: si
comandesté 2 s per respondre a l'Anna i triga 1,5 s a arribar ainventari,inventarisap que només li queden 0,5 s, i pot avortar feina inútil. El servidor consultacontext.time_remaining(); el client repDEADLINE_EXCEEDED. És la solució sistemàtica a "cap crida remota sense límit de temps". - Codis d'estat. gRPC defineix 17 codis estàndard, amb semàntica compartida per tots els llenguatges. Substitueixen els nostres
faultCodeinventats:
| Codi | Significat | Família (02-02) | Reintentar? |
|---|---|---|---|
OK |
Èxit | — | — |
INVALID_ARGUMENT |
La petició està mal formada (quantitat negativa) | Aplicació | No |
NOT_FOUND |
El recurs no existeix (producte desconegut) | Aplicació | No |
FAILED_PRECONDITION |
L'estat del sistema no permet l'operació (sense estoc) | Aplicació | No (fins que canviï l'estat) |
ALREADY_EXISTS |
Ja existeix (una reserva amb aquest id) | Aplicació | No |
PERMISSION_DENIED, UNAUTHENTICATED |
Autorització (Mòdul 6) | Aplicació | No |
RESOURCE_EXHAUSTED |
Quota o límit de taxa superat | Infraestructura | Sí, amb espera |
UNAVAILABLE |
El servidor no està disponible (connexió rebutjada, reinici) | Connexió | Sí, amb espera |
DEADLINE_EXCEEDED |
S'ha esgotat el deadline | Timeout | Només si és idempotent |
UNKNOWN, INTERNAL |
Error no controlat al servidor | Bug | No |
UNIMPLEMENTED |
El mètode no existeix en aquesta versió del servidor | Protocol / desplegament | No |
- Metadades. Parells clau-valor que acompanyen la crida sense formar part del contracte: es transporten com a capçaleres HTTP/2. És on viatgen els tokens d'autenticació (06-01), els identificadors de traça (07-02) i, com veurem, l'identificador de petició per a la idempotència (02-05).
inventari en gRPC: contracte, servidor i client amb deadline
inventari en gRPC: contracte, servidor i client amb deadlineÉs el moment de substituir el servidor XML-RPC. Instal·lació (al requirements.txt del servei):
El contracte
// km0/contractes/inventari.proto
syntax = "proto3";
package km0.inventari.v1;
service Inventari {
rpc ConsultarEstoc (ConsultarEstocRequest) returns (ConsultarEstocResponse);
rpc ReservarEstoc (ReservarEstocRequest) returns (ReservarEstocResponse);
// Streaming de servidor: el client demana observar uns productes i rep
// un CanviEstoc cada vegada que un d'ells canvia, fins que talla la crida.
rpc ObservarCanvis (ObservarCanvisRequest) returns (stream CanviEstoc);
}
message ConsultarEstocRequest {
string producte = 1;
}
message ConsultarEstocResponse {
string producte = 1;
int32 unitats = 2;
string replica = 3; // quina rèplica ha respost: "inv-bcn", "inv-vlc"
}
message ReservarEstocRequest {
string producte = 1;
int32 quantitat = 2;
string comanda_id = 3; // "P-2026-000123", per a traçabilitat
string id_reserva = 4; // UUID generat pel client; base de la
// idempotència que s'implementa a 02-05
}
message ReservarEstocResponse {
string id_reserva = 1;
int32 restant = 2;
}
message ObservarCanvisRequest {
repeated string productes = 1; // buit = tots
}
message CanviEstoc {
string producte = 1;
int32 abans = 2;
int32 despres = 3;
string motiu = 4; // "reserva", "reposicio", "cancellacio"
int64 timestamp_ms = 5;
}Generar el codi
cd km0/serveis/inventari
python -m grpc_tools.protoc -I ../../contractes \
--python_out=. --grpc_python_out=. ../../contractes/inventari.protoAixò produeix dos fitxers que no s'editen a mà (es regeneren cada vegada que canvia el .proto, idealment en construir la imatge Docker):
inventari_pb2.py: les classes de missatge (ReservarEstocRequest, etc.), amb serialització inclosa.inventari_pb2_grpc.py:InventariServicer(la classe base que el servidor implementa: l'skeleton) iInventariStub(l'stub del client).
La mateixa ordre, executada a serveis/comandes, genera els mateixos fitxers per al client. Cada servei compila el contracte compartit; ningú no copia codi d'un altre servei.
El servidor
# km0/serveis/inventari/servidor.py
import queue
import threading
import time
import uuid
from concurrent import futures
import grpc
import inventari_pb2
import inventari_pb2_grpc
REPLICA = "inv-bcn"
class InventariServicer(inventari_pb2_grpc.InventariServicer):
"""Implementació del servei. Cada mètode rep la petició
deserialitzada i un 'context' amb deadline, metadades i control d'errors."""
def __init__(self):
self._estoc = {"tomaquet-rosa": 120, "carbasso": 80, "formatge-curat": 5,
"formatge-fresc": 30, "vi-crianca": 200}
self._cadenat = threading.Lock()
self._observadors = [] # una cua per client d'ObservarCanvis
def ConsultarEstoc(self, request, context):
with self._cadenat:
unitats = self._estoc.get(request.producte)
if unitats is None:
# abort() serialitza l'error amb el seu codi i acaba la crida
context.abort(grpc.StatusCode.NOT_FOUND,
f"producte desconegut: {request.producte}")
return inventari_pb2.ConsultarEstocResponse(
producte=request.producte, unitats=unitats, replica=REPLICA)
def ReservarEstoc(self, request, context):
if request.quantitat <= 0:
context.abort(grpc.StatusCode.INVALID_ARGUMENT,
"la quantitat ha de ser positiva")
if not request.id_reserva:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "falta id_reserva")
with self._cadenat:
disponible = self._estoc.get(request.producte)
if disponible is None:
context.abort(grpc.StatusCode.NOT_FOUND,
f"producte desconegut: {request.producte}")
if disponible < request.quantitat:
context.abort(grpc.StatusCode.FAILED_PRECONDITION,
f"només queden {disponible} unitats de {request.producte}")
self._estoc[request.producte] = disponible - request.quantitat
restant = self._estoc[request.producte]
print(f"[{REPLICA}] comanda {request.comanda_id}: reservades "
f"{request.quantitat} de {request.producte}, en queden {restant}")
self._notificar(request.producte, disponible, restant, "reserva")
return inventari_pb2.ReservarEstocResponse(
id_reserva=request.id_reserva, restant=restant)
def ObservarCanvis(self, request, context):
"""Generador: cada 'yield' envia un missatge al client pel flux."""
cua = queue.Queue()
filtre = set(request.productes)
self._observadors.append(cua)
try:
while context.is_active(): # False quan el client cancel·la o venç el deadline
try:
canvi = cua.get(timeout=1.0)
except queue.Empty:
continue
if not filtre or canvi.producte in filtre:
yield canvi
finally:
self._observadors.remove(cua) # neteja en acabar el flux
def _notificar(self, producte, abans, despres, motiu):
canvi = inventari_pb2.CanviEstoc(
producte=producte, abans=abans, despres=despres, motiu=motiu,
timestamp_ms=int(time.time() * 1000))
for cua in list(self._observadors):
cua.put(canvi)
def main():
servidor = grpc.server(futures.ThreadPoolExecutor(max_workers=16))
inventari_pb2_grpc.add_InventariServicer_to_server(InventariServicer(), servidor)
servidor.add_insecure_port("0.0.0.0:50051") # sense TLS de moment: mTLS a 06-04
servidor.start()
print(f"[{REPLICA}] gRPC escoltant a :50051")
servidor.wait_for_termination()
if __name__ == "__main__":
main()Comparat amb XML-RPC (02-02), fixa't en el que ha canviat: els errors fan servir codis estàndard amb semàntica coneguda per qualsevol client; el context dona accés al deadline i a la cancel·lació; un mètode d'streaming és simplement un generador de Python; i el servidor és un ThreadPoolExecutor amb una mida explícita (16 fils: quan s'esgotin, les crides esperen, i amb deadline caduquen en lloc d'acumular-se indefinidament).
El client a comandes, amb deadline
# km0/serveis/comandes/client_inventari.py
import uuid
import grpc
import inventari_pb2
import inventari_pb2_grpc
class ClientInventari:
"""Embolcall de l'stub gRPC. Un canal per procés, reutilitzat."""
def __init__(self, desti="localhost:50051", timeout_s=2.0):
# El canal és mandrós i persistent: una connexió HTTP/2 multiplexada
self._canal = grpc.insecure_channel(desti)
self._stub = inventari_pb2_grpc.InventariStub(self._canal)
self._timeout = timeout_s
def consultar_estoc(self, producte):
resposta = self._stub.ConsultarEstoc(
inventari_pb2.ConsultarEstocRequest(producte=producte),
timeout=self._timeout) # es converteix en deadline absolut
return resposta.unitats
def reservar_estoc(self, producte, quantitat, comanda_id):
id_reserva = str(uuid.uuid4()) # generat ABANS de la crida (02-05)
peticio = inventari_pb2.ReservarEstocRequest(
producte=producte, quantitat=quantitat,
comanda_id=comanda_id, id_reserva=id_reserva)
resposta = self._stub.ReservarEstoc(
peticio, timeout=self._timeout,
metadata=(("x-comanda-id", comanda_id),)) # metadades: fora del contracte
return resposta.restant
def reservar_per_comanda(client, producte, quantitat, comanda_id):
"""Tradueix cada codi d'estat a una decisió de negoci (taula de l'apartat 6)."""
try:
restant = client.reservar_estoc(producte, quantitat, comanda_id)
return True, f"reservades {quantitat} de {producte}, en queden {restant}"
except grpc.RpcError as e:
codi, detall = e.code(), e.details()
if codi == grpc.StatusCode.FAILED_PRECONDITION:
return False, f"sense estoc: {detall}"
if codi in (grpc.StatusCode.NOT_FOUND, grpc.StatusCode.INVALID_ARGUMENT):
return False, f"petició rebutjada: {detall}"
if codi == grpc.StatusCode.UNAVAILABLE:
return False, "inventari no disponible; comanda en espera (no s'ha executat)"
if codi == grpc.StatusCode.DEADLINE_EXCEEDED:
return False, "inventari no ha respost a temps; estat desconegut (02-05)"
return False, f"error inesperat {codi.name}: {detall}"
if __name__ == "__main__":
client = ClientInventari()
print("estoc inicial:", client.consultar_estoc("formatge-curat"))
for nom, producte, quantitat, comanda in [("Anna", "formatge-curat", 2, "P-2026-000123"),
("Marc", "formatge-curat", 4, "P-2026-000124"),
("Llúcia", "vi-crianca", 6, "P-2026-000125")]:
ok, msg = reservar_per_comanda(client, producte, quantitat, comanda)
print(f"{nom}: {'OK' if ok else 'NO'} - {msg}")Sortida:
estoc inicial: 5 Anna: OK - reservades 2 de formatge-curat, en queden 3 Marc: NO - sense estoc: només queden 3 unitats de formatge-curat Llúcia: OK - reservades 6 de vi-crianca, en queden 194
I amb inventari aturat: Anna: NO - inventari no disponible; comanda en espera (no s'ha executat), en mil·lisegons, gràcies al fet que gRPC distingeix UNAVAILABLE (connexió rebutjada) de DEADLINE_EXCEEDED (silenci). És la taula de quatre famílies d'errors de 02-02, ara estandarditzada. L'id_reserva viatja a cada petició però el servidor encara no el fa servir per filtrar duplicats: això és exactament el que afegirà 02-05.
- Streaming del servidor: observar canvis d'estoc
Un client que vulgui mostrar "en queden poques unitats" (serà cataleg) no hauria de preguntar l'estoc a cada visualització. Amb ObservarCanvis obre un flux i rep cada canvi:
# km0/serveis/cataleg/observador_estoc.py
import grpc
import inventari_pb2
import inventari_pb2_grpc
canal = grpc.insecure_channel("localhost:50051")
stub = inventari_pb2_grpc.InventariStub(canal)
peticio = inventari_pb2.ObservarCanvisRequest(productes=["formatge-curat", "formatge-fresc"])
# timeout llarg: el flux viu fins que caduqui, el client el cancel·li o el servidor tanqui
flux = stub.ObservarCanvis(peticio, timeout=3600)
try:
for canvi in flux: # bloqueja fins que arriba cada missatge
avis = " <- EN QUEDEN POCS" if canvi.despres < 5 else ""
print(f"[cataleg] {canvi.producte}: {canvi.abans} -> {canvi.despres} "
f"({canvi.motiu}){avis}")
except grpc.RpcError as e:
print("flux acabat:", e.code().name)Engega l'observador i després executa el client de comandes: veuràs formatge-curat: 5 -> 3 (reserva) <- EN QUEDEN POCS. Tot viatja per un flux HTTP/2 que es manté obert; cada yield del servidor és una trama en aquest flux. Dos advertiments: aquest flux és una connexió punt a punt, així que si l'observador es reinicia es perd el que ha passat entremig, i si hi ha deu serveis interessats en els canvis, inventari manté deu fluxos. Per difondre esdeveniments a molts consumidors amb persistència, l'eina adequada és la missatgeria de la lliçó següent; l'streaming gRPC brilla en fluxos d'un a un, com la telemetria d'un repartidor concret.
- Mesurar: JSON contra protobuf per a la mateixa comanda
Res no convenç més que els números. Generem el codi de comanda.proto (python -m grpc_tools.protoc -I ../../contractes --python_out=. ../../contractes/comanda.proto) i comparem:
# km0/serveis/comandes/mesurar_mida.py
import json
import time
import comanda_pb2
comanda_dict = {
"id": "P-2026-000123", "client": "anna", "data_ms": 1789000000000, "mercat": "girona",
"linies": [
{"producte": "formatge-curat", "quantitat": 2, "preu_centims": 1450},
{"producte": "tomaquet-rosa", "quantitat": 3, "preu_centims": 320},
{"producte": "vi-crianca", "quantitat": 6, "preu_centims": 990},
],
}
comanda_pb = comanda_pb2.Comanda(
id=comanda_dict["id"], client=comanda_dict["client"],
data_ms=comanda_dict["data_ms"], mercat=comanda_dict["mercat"],
linies=[comanda_pb2.LiniaComanda(**l) for l in comanda_dict["linies"]])
json_bytes = json.dumps(comanda_dict, separators=(",", ":")).encode("utf-8")
pb_bytes = comanda_pb.SerializeToString()
print(f"JSON compacte : {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(comanda_dict, separators=(",", ":")))
t_json = time.perf_counter() - t0
t0 = time.perf_counter()
for _ in range(N):
comanda_pb2.Comanda.FromString(comanda_pb.SerializeToString())
t_pb = time.perf_counter() - t0
print(f"JSON: {N / t_json:,.0f} cicles/s protobuf: {N / t_pb:,.0f} cicles/s")Sortida orientativa (els temps depenen de la màquina i de si protobuf fa servir la seva implementació en C):
JSON compacte : 276 bytes Protobuf : 100 bytes (36 %) JSON: 210,000 cicles/s protobuf: 650,000 cicles/s
Una comanda de tres línies pesa menys de la meitat i es processa unes tres vegades més ràpid. Amb 1.200 comandes per segon (lliçó 01-03) i desenes de crides internes per comanda, la diferència es tradueix en amplada de banda, CPU i latència. I el que no mesura aquest script és igual d'important: el .proto ha validat els tipus en el moment de construir el missatge (preu_centims="14.50" hauria fallat immediatament), mentre que el diccionari accepta qualsevol cosa.
Afegir inventari al docker-compose.yml
Amb això, inventari és el primer servei real de km0/serveis/. El seu Dockerfile compila el contracte en construir la imatge, i docker-compose.yml guanya un servei:
inventari:
build:
context: . # necessita accés a contractes/ i a serveis/inventari/
dockerfile: serveis/inventari/Dockerfile
ports:
- "50051:50051"# km0/serveis/inventari/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY serveis/inventari/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY contractes/ /contractes/
COPY serveis/inventari/ .
RUN python -m grpc_tools.protoc -I /contractes --python_out=. --grpc_python_out=. /contractes/inventari.proto
CMD ["python", "servidor.py"]El monòlit, de moment, continua fent servir el seu propi mòdul inventari intern; l'estrangulament (01-06) consisteix a anar redirigint les seves crides a aquest servei. Aquest canvi no té res d'especial tècnicament (és substituir una crida local per ClientInventari) i no el detallarem; el que sí que té molt d'especial és que ara comandes i inventari tenen dades separades, i aquest és el problema del Mòdul 3.
Errors Comuns i Consells
- Reutilitzar un número de camp. L'error més greu amb protobuf, perquè no produeix cap error: produeix dades corruptes llegides amb total confiança.
reservedsempre en eliminar. - Canviar el tipus d'un camp "perquè és equivalent".
int32→stringtrenca;int32→int64funciona al cable, però un valor gran escrit pel nou es truncarà al vell. En cas de dubte, camp nou amb número nou. - Diners en
float/double. Cèntims enint64, o una cadena decimal si cal precisió arbitrària. Ho repetirem fins al projecte final. - Confiar que "camp absent" i "valor per defecte" es distingeixen. A proto3 no es distingeixen llevat que es faci servir
optional. Siquantitat = 0pot ser legítim, fes serviroptionalo valida al servidor. - Un canal per crida.
grpc.insecure_channelobre una connexió HTTP/2 amb el seu handshake; crea'n un per procés (o uns quants) i reutilitza'l. Un canal per crida és HTTP/1.1 sense keep-alive amb més passos. - Sense deadline. El paràmetre
timeoutés opcional a l'API i obligatori a la pràctica. Sense ell, la crida espera per sempre, amb tot el que vam aprendre a 01-04. - Ignorar el codi d'estat i mirar només el text.
e.details()és per a persones;e.code()és per a programes. El text pot canviar entre versions del servidor; el codi, no. - Editar els fitxers
_pb2.pygenerats. Se sobreescriuen a la generació següent. Tot canvi va al.proto. - Consell: valida la compatibilitat dels
.protoa la integració contínua amb una eina combuf breaking: converteix les regles de l'apartat 5 en una comprovació automàtica, i cap canvi incompatible no arriba a producció per descuit. - Consell: quan dissenyis un missatge, pensa en la versió 3 abans de publicar la 1: deixa els números baixos (1-15, que ocupen un sol byte d'etiqueta) per als camps més freqüents, fes servir noms neutres, i no fiquis en un missatge el que pertany a un altre servei.
Exercicis
Exercici 1: Evolucionar ReservarEstocRequest
L'equip de comandes necessita que una reserva pugui ser temporal (caduca al cap de 15 minuts si no es paga, per alliberar l'últim formatge si l'Anna abandona el cistell) i vol a més eliminar comanda_id de la petició, perquè a partir d'ara viatjarà com a metadada. Escriu la nova versió del missatge respectant les regles de compatibilitat i explica què veurà un servidor inventari antic quan rebi la petició nova, i què veurà un inventari nou quan rebi una petició antiga.
Exercici 2: Propagar el deadline
comandes rep de l'app de l'Anna una petició amb un límit total de 3 segons, i per atendre-la crida primer inventari (reservar) i després pagaments (cobrar). Explica per què fer servir timeout=3.0 en totes dues crides gRPC és incorrecte, i escriu una funció temps_restant(deadline_absolut) que calculi el timeout a passar a cada crida. Què hauria de fer comandes si en anar a cridar pagaments queden 50 ms?
Exercici 3: Triar tipus de crida i format
Per a cada interacció indica el tipus de crida gRPC (unària, streaming de servidor, de client o bidireccional) o si convé una altra cosa (REST, missatgeria), i el format de serialització:
- L'app de la Llúcia descarrega la fitxa d'un producte amb la seva foto.
comandesdemana ainventaril'estoc dels 8 productes d'un cistell.furgoneta-3envia 1 posició per segon arepartimentdurant una ruta de 2 hores, i al final rep el resum de quilòmetres.analiticanecessita totes les comandes de la "Setmana de la Verema" per a un informe nocturn.- Un operador d'atenció al client i l'app d'un repartidor intercanvien missatges curts en temps real sobre una incidència de lliurament.
Solucions
Solució 1:
message ReservarEstocRequest {
reserved 3;
reserved "comanda_id";
string producte = 1;
int32 quantitat = 2;
string id_reserva = 4;
int32 caduca_en_segons = 5; // 0 (valor per defecte) = reserva permanent
}S'elimina comanda_id reservant-ne el número (3) i el nom; el camp nou pren el número 5, mai el 3. Es tria que el valor per defecte (0) signifiqui "comportament anterior", de manera que una petició que no l'enviï es comporti com abans. Un inventari antic que rebi la petició nova veurà comanda_id buit (la cadena per defecte: el seu log de traçabilitat imprimirà comanda : en blanc, tolerable) i ignorarà el camp 5, que no coneix: farà una reserva permanent, cosa que és un comportament degradat però no un error; l'equip ha de saber que la caducitat no funciona fins que inventari es desplegui. Un inventari nou que rebi una petició antiga veurà caduca_en_segons = 0 i farà una reserva permanent, exactament el que aquell client esperava. Cap desplegament no necessita coordinar-se, encara que la funcionalitat completa només existeix quan tots dos estan actualitzats.
Solució 2:
Amb timeout=3.0 a cada crida, el pitjor cas és que inventari trigui 2,9 s (dins del seu límit) i pagaments uns altres 2,9 s: comandes respondria a l'Anna en 5,8 s, gairebé el doble del que s'havia promès, i l'app ja hauria abandonat. El deadline ha de ser un de sol i absolut, calculat en rebre la petició, i cada crida rep el que queda:
import time
def temps_restant(deadline_absolut):
"""Segons que queden fins al deadline; mai negatiu."""
return max(0.0, deadline_absolut - time.monotonic())
deadline = time.monotonic() + 3.0 # en rebre la petició de l'Anna
stub_inventari.ReservarEstoc(req, timeout=temps_restant(deadline))
restant = temps_restant(deadline)
if restant < 0.2: # llindar: menys del que triga pagaments normalment
# No val la pena cridar: fallarà per deadline i deixarà el cobrament en estat desconegut.
# Millor avortar netament: alliberar la reserva (o deixar que caduqui) i respondre
# "no hem pogut processar la comanda, torna-ho a provar" o deixar-la en "pagament pendent".
...
else:
stub_pagaments.Cobrar(req_pagament, timeout=restant)Amb 50 ms restants el correcte és no cridar pagaments: una crida que caducarà deixa l'operació més perillosa (un cobrament) en estat "no se sap". Avortar abans és la versió de "fallar ràpid" que gRPC facilita en fer explícit el temps que queda; els servidors gRPC poden a més consultar context.time_remaining() per no començar feina que no podran acabar. Fixa't que es fa servir time.monotonic() (no time.time()): el deadline és una durada, no un instant de rellotge de paret, i els rellotges de paret salten (lliçó 01-05).
Solució 3:
- REST amb JSON per a les metadades i la foto com a recurs HTTP a part (que la CDN pot desar en memòria cau). És un client extern, navegador o app, i el contingut es beneficia de la memòria cau d'HTTP. gRPC al navegador requereix gRPC-Web i no aporta memòria cau.
- Unària amb un missatge que porti
repeated string productes: una crida, una resposta amb les 8 unitats (gra gruixut, 02-02). Protobuf. - Streaming de client: N posicions → 1 resum. Protobuf; cada posició són ~25 bytes. Compte: si la xarxa mòbil és dolenta, un flux HTTP/2 sobre TCP pateix bloqueig de cap de línia (02-01), i MQTT (08-02) pot ser millor opció per al tram mòbil; l'streaming gRPC encaixa entre la passarel·la i
repartiment. - Cap crida síncrona: és un volum gran i un procés per lots.
analiticahauria de consumir els esdeveniments de comanda (02-04) o llegir del magatzem analític (Mòdul 5), no demanar acomandesdesenes de milers de registres en una crida (encara que tècnicament un streaming de servidor ho permetria, acoblaria la càrrega analítica al servei transaccional: símptoma 5 de 01-06). Format: Avro o Parquet per a dades massives. - Bidireccional: missatges en tots dos sentits, en qualsevol ordre, en temps real. Protobuf. Si un dels extrems és un navegador, l'alternativa pràctica és WebSockets (08-02), que és el mateix conceptualment amb un transport que els navegadors suporten de manera nativa.
Conclusió
Aquesta lliçó ha tancat el cercle obert a 02-02. La serialització decideix com es converteixen les estructures en bytes, i aquesta decisió determina mida, velocitat, seguretat de tipus i capacitat d'evolució: els formats de text (JSON, XML) són llegibles i universals però pesants i sense esquema; els binaris amb esquema (Protocol Buffers, Avro) pesen menys de la meitat, es processen diverses vegades més ràpid i, sobretot, tenen regles d'evolució (números de camp únics i mai reutilitzats, reserved en eliminar, camps nous amb valors per defecte tolerables) que permeten desplegar comandes i inventari de manera independent. Amb contract-first, el .proto és la font de veritat de la qual es genera el codi. gRPC apuntala aquests esquemes sobre HTTP/2 per oferir un canal multiplexat, quatre tipus de crida (unària i tres formes d'streaming), deadlines absoluts que es propaguen, codis d'estat estàndard i metadades. Quilòmetre Zero té ara el seu primer servei extret de debò: inventari a serveis/inventari/servidor.py, amb el seu contracte a contractes/inventari.proto, un client a comandes que tradueix cada codi d'estat a una decisió de negoci, i un flux de canvis d'estoc. La mesura ha confirmat l'estalvi: una comanda de tres línies passa de 276 bytes en JSON a 100 en protobuf.
Però tot el que s'ha fet en aquest mòdul fins aquí és síncron: comandes crida, espera i rep. Al final de 01-06 va quedar clar que això és el correcte per reservar estoc i l'incorrecte per a gairebé tota la resta: avisar analitica d'una venda, planificar el repartiment o actualitzar l'indicador del catàleg no haurien de bloquejar el client ni dependre que el destinatari sigui viu en aquell instant. Per a això cal que algú desi el missatge i el lliuri quan el destinatari pugui: un broker. La lliçó següent, Missatgeria i Cues de Missatges, introdueix RabbitMQ i Apache Kafka, i amb ells el primer flux asíncron de Quilòmetre Zero: l'esdeveniment comanda.creada.
Curs d'Arquitectures Distribuïdes
Mòdul 1: Introducció als Sistemes Distribuïts
- Conceptes Bàsics de Sistemes Distribuïts
- Models de Sistemes Distribuïts
- Avantatges i Desafiaments dels Sistemes Distribuïts
- Les Fal·làcies de la Computació Distribuïda
- Temps, Rellotges i Ordenació d'Esdeveniments
- Del Monòlit a la Plataforma Distribuïda: el Cas Quilòmetre Zero
Mòdul 2: Comunicació en Sistemes Distribuïts
- Protocols de Comunicació
- RPC i RMI
- gRPC i Serialització de Dades
- Missatgeria i Cues de Missatges
- Patrons de Comunicació Asíncrona
Mòdul 3: Consistència i Replicació
- Models de Consistència
- El Teorema CAP i PACELC
- Algorismes de Consens
- Replicació de Dades
- Transaccions Distribuïdes i Sagues
Mòdul 4: Emmagatzematge Distribuït
- Particionament de Dades i Hashing Consistent
- Sistemes de Fitxers Distribuïts
- Emmagatzematge d'Objectes
- Bases de Dades Distribuïdes
- Memòries Cau Distribuïdes
Mòdul 5: Computació Distribuïda
- Models de Computació Distribuïda
- MapReduce i Hadoop
- Spark i Computació en Memòria
- Processament de Fluxos de Dades
- Planificació de Treballs i Pipelines de Dades
Mòdul 6: Seguretat en Sistemes Distribuïts
- Autenticació i Autorització
- Xifratge i Protecció de Dades
- Gestió d'Identitats
- Seguretat entre Serveis: mTLS i Gestió de Secrets
- Passarel·les d'API, Limitació de Taxa i Auditoria
Mòdul 7: Monitoratge i Manteniment
- Monitoratge de Sistemes Distribuïts
- Logs Centralitzats i Traçabilitat Distribuïda
- Gestió de Fallades i Recuperació
- Patrons de Resiliència: Timeouts, Reintents i Circuit Breaker
- Automatització i Orquestració
- Proves en Sistemes Distribuïts i Enginyeria del Caos
