Salta al contenuto principale

Servire Protocol Buffers con FastAPI: Endpoint Binari e gRPC Gateway

·6 minuti
Indice dei contenuti
FastAPI e eccellente per lo sviluppo rapido di API. Protobuf e eccellente per payload binari compatti e tipizzati. Puoi combinarli senza alcuna magia di integrazione Pydantic: leggi i byte grezzi, parsali con la classe generata e restituisci bytes con il Content-Type corretto.

Se cerchi “FastAPI protobuf” troverai molti articoli che cercano di usare i modelli Pydantic come ponte alla serializzazione protobuf. Non esiste tale ponte in Pydantic. L’approccio corretto e piu semplice e diretto: gli endpoint FastAPI possono accettare e restituire bytes grezzi. La libreria Python di protobuf gestisce la codifica effettiva. Questo articolo illustra un’implementazione completa funzionante, la content negotiation e il testing.

Setup
#

Installa i pacchetti necessari:

install dependencies
pip install fastapi uvicorn protobuf betterproto grpcio-tools
  • protobuf e il runtime Python ufficiale Google per le classi generate da .proto
  • betterproto e un’alternativa che genera dataclass Python invece del vecchio stile imperativo
  • grpcio-tools include protoc e il plugin gRPC per Python, cosi puoi compilare file .proto senza installare protoc separatamente

Definire e compilare uno schema proto
#

protos/user.proto
syntax = "proto3";

package user;

message Address {
  string street  = 1;
  string city    = 2;
  string country = 3;
}

message User {
  string  id      = 1;
  string  name    = 2;
  string  email   = 3;
  int32   age     = 4;
  Address address = 5;
}

message CreateUserRequest {
  User user = 1;
}

Compila in Python usando grpcio-tools (nessun protoc separato necessario):

compile proto
python -m grpc_tools.protoc \
  -I protos \
  --python_out=gen \
  --grpc_python_out=gen \
  protos/user.proto

Questo produce gen/user_pb2.py (tipi dei messaggi) e gen/user_pb2_grpc.py (stub del servizio). Per betterproto, usa --python_betterproto_out=gen al posto di --python_out.

Nota

Aggiungi gen/ al .gitignore o committa i file generati: sii coerente nel team. I file generati possono divergere se i membri del team usano versioni diverse di protoc.

Endpoint FastAPI corretto: bytes in ingresso, bytes in uscita
#

PROTOBUF non esiste in Pydantic. Il pattern corretto e usare Request direttamente e restituire Response con il content type di protobuf:

app/main.py
from fastapi import FastAPI, Request, Response, HTTPException
from gen.user_pb2 import User, CreateUserRequest

app = FastAPI()

PROTOBUF_CONTENT_TYPE = "application/x-protobuf"


@app.post("/users", response_class=Response)
async def create_user(request: Request) -> Response:
    content_type = request.headers.get("content-type", "")
    if content_type != PROTOBUF_CONTENT_TYPE:
        raise HTTPException(status_code=415, detail="expected application/x-protobuf")

    body = await request.body()

    try:
        req = CreateUserRequest()
        req.ParseFromString(body)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"invalid protobuf payload: {exc}")

    # Logica di business -- per questo esempio, restituisce l'utente con un ID assegnato.
    req.user.id = "usr-generated-001"

    return Response(
        content=req.user.SerializeToString(),
        media_type=PROTOBUF_CONTENT_TYPE,
    )
Importante

ParseFromString modifica l’oggetto in-place e restituisce il numero di byte consumati, non l’oggetto parsato. L’errore comune e scrivere user = User().ParseFromString(body) che assegna un intero, non un User.

Client che invia protobuf
#

client.py
import requests
from gen.user_pb2 import User, CreateUserRequest

user = User(
    name="Alice",
    email="alice@example.com",
    age=31,
    address={"street": "10 Downing Street", "city": "London", "country": "UK"},
)
req = CreateUserRequest(user=user)

response = requests.post(
    "http://localhost:8000/users",
    data=req.SerializeToString(),
    headers={"Content-Type": "application/x-protobuf"},
)
response.raise_for_status()

created = User()
created.ParseFromString(response.content)
print(f"created user id: {created.id}")

Content negotiation: JSON e protobuf dallo stesso endpoint
#

Puoi servire entrambi i formati da un singolo handler ispezionando l’header Accept. E utile durante una migrazione quando alcuni client hanno gia adottato protobuf e altri no:

app/negotiation.py
import json
from fastapi import FastAPI, Request, Response
from google.protobuf.json_format import MessageToDict
from gen.user_pb2 import User

app = FastAPI()


@app.get("/users/{user_id}", response_class=Response)
async def get_user(user_id: str, request: Request) -> Response:
    # Recupera l'utente dallo storage -- semplificato qui.
    user = User(
        id=user_id,
        name="Alice",
        email="alice@example.com",
        age=31,
    )

    accept = request.headers.get("accept", "application/json")

    if "application/x-protobuf" in accept:
        return Response(
            content=user.SerializeToString(),
            media_type="application/x-protobuf",
        )

    # Default a JSON usando il serializzatore JSON di protobuf per coerenza dei nomi dei campi.
    return Response(
        content=json.dumps(MessageToDict(user, preserving_proto_field_name=True)),
        media_type="application/json",
    )
Suggerimento

Usa google.protobuf.json_format.MessageToDict con preserving_proto_field_name=True quando converti in JSON. Mantiene i nomi dei campi in snake_case coerenti con la definizione proto, invece di convertirli in camelCase.

Usare betterproto per dataclass piu pulite
#

Il protoc --python_out predefinito genera un’API imperativa verbosa. betterproto genera dataclass Python piu idiomatiche:

app/betterproto_example.py
# Con il codice generato da betterproto, l'API diventa stile dataclass.
# Compila con: python -m grpc_tools.protoc ... --python_betterproto_out=gen_bt

import betterproto
from gen_bt.user import User, Address, CreateUserRequest


async def handle_create(body: bytes) -> bytes:
    req = CreateUserRequest().parse(body)
    req.user.id = "usr-generated-001"
    return bytes(req.user)  # gli oggetti betterproto si serializzano con bytes()


# In un handler FastAPI:
# body = await request.body()
# result_bytes = await handle_create(body)
# return Response(content=result_bytes, media_type="application/x-protobuf")
from gen.user_pb2 import User

# Parse
user = User()
user.ParseFromString(raw_bytes)

# Serialize
raw_bytes = user.SerializeToString()

Pro: supporto ufficiale Google, stabile, ampiamente compatibile. Contro: codice generato verboso, non stile dataclass.

from gen_bt.user import User

# Parse
user = User().parse(raw_bytes)

# Serialize
raw_bytes = bytes(user)

Pro: dataclass pulite, type hint, async-friendly, nessuna magia descriptor pool. Contro: terze parti, alcuni casi limite di proto3 differiscono dall’implementazione di riferimento.

Testing degli endpoint protobuf con pytest
#

tests/test_users.py
import pytest
from fastapi.testclient import TestClient
from gen.user_pb2 import User, CreateUserRequest
from app.main import app

client = TestClient(app)


def test_create_user_protobuf():
    user = User(name="Alice", email="alice@example.com", age=31)
    req = CreateUserRequest(user=user)

    response = client.post(
        "/users",
        content=req.SerializeToString(),
        headers={"content-type": "application/x-protobuf"},
    )

    assert response.status_code == 200
    assert response.headers["content-type"] == "application/x-protobuf"

    created = User()
    created.ParseFromString(response.content)
    assert created.name == "Alice"
    assert created.id != ""


def test_create_user_wrong_content_type():
    response = client.post(
        "/users",
        content=b"{}",
        headers={"content-type": "application/json"},
    )
    assert response.status_code == 415

Alternativa: pattern gRPC-Gateway
#

Se hai bisogno sia di un’interfaccia gRPC che di un’interfaccia REST/JSON dallo stesso servizio, il pattern gRPC-Gateway vale la pena di essere conosciuto. Invece di due applicazioni separate, definisci le annotazioni HTTP nel file .proto e un proxy gateway traduce le chiamate REST in gRPC:

flowchart LR
    Browser["Browser / curl\n(JSON + REST)"] --> GW["gRPC-Gateway\n(proxy grpc-gateway)"]
    GRPCClient["gRPC Client\n(Go / Python / Java)"] --> SVC
    GW -->|transcodifica JSON in protobuf| SVC["gRPC Service\n(il tuo server Go)"]

Per i servizi Python questo e tipicamente gestito eseguendo grpc-gateway come sidecar o usando grpcio direttamente accanto a FastAPI. L’approccio FastAPI in questo articolo e piu semplice e appropriato quando possiedi tutti i client e vuoi un singolo endpoint binario.

**Usare Pydantic per la serializzazione protobuf.** Pydantic non ha supporto protobuf. Non esiste un import `PROTOBUF`. Cercare di usare i modelli Pydantic come ponte richiede di copiare manualmente ogni campo, il che vanifica lo scopo della generazione del codice. **Assegnare il valore di ritorno di ParseFromString.** `ParseFromString` restituisce un intero (byte consumati), non l'oggetto messaggio. Chiamalo sempre su un oggetto esistente e usa quell'oggetto in seguito. **Non impostare Content-Type sulla richiesta.** Inviare bytes protobuf senza `Content-Type: application/x-protobuf` confondera qualsiasi middleware che si aspetta JSON. Il server dovrebbe rifiutare esplicitamente i content type non corrispondenti invece di cercare silenziosamente di parsare dati non validi. **Usare la semantica REST con protobuf.** Gli endpoint protobuf non hanno bisogno di `GET` vs `POST` per distinguere operazioni di lettura da quelle di scrittura: il tipo di messaggio porta quell'intento. Se stai inserendo protobuf nelle convenzioni REST, considera se gRPC sarebbe una soluzione piu pulita. **Non gestire il caso di body vuoto.** `ParseFromString(b"")` su un messaggio proto3 ha successo e restituisce un messaggio con tutti i valori predefiniti. Decidi esplicitamente se un body vuoto e una richiesta valida o un errore.

Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati