Salta al contenuto principale

Protocol Buffers in Go: Serializzazione, gRPC e Performance

·7 minuti
Indice dei contenuti
Protocol Buffers offrono serializzazione binaria circa il 40% più compatta e 6 volte più veloce di JSON. Vale la pena del trade-off per il traffico interno tra microservizi. Non vale la pena per un’API REST pubblica che gli sviluppatori devono leggere con curl.

Protocol Buffers (protobuf) sono il formato di serializzazione binaria di Google. Il vantaggio è semplice: definisci lo schema dati una volta in un file .proto, generi codice tipizzato in qualsiasi linguaggio e ottieni un formato wire compatto. Lo svantaggio è l’overhead degli strumenti e la perdita di leggibilità umana. Questo articolo illustra un setup completo in Go: la corretta API di marshaling, un servizio gRPC e un benchmark che confronta protobuf con JSON.

Setup
#

Installa i tre strumenti necessari. Il primo è protoc, il compilatore protobuf. Su macOS il modo più semplice è Homebrew. Su Linux, scarica dalle release di GitHub.

install protoc (macOS)
brew install protobuf

Poi installa i due generatori di codice Go usando go install, non il deprecato go get -u:

install Go plugins
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Assicurati che $(go env GOPATH)/bin sia nel PATH, altrimenti protoc non troverà i plugin al momento della generazione.

Definire uno schema .proto
#

Crea un file chiamato user.proto. La direttiva option go_package è richiesta dal plugin Go protobuf moderno.

user.proto
syntax = "proto3";

package user;

option go_package = "github.com/yourname/yourapp/gen/userpb";

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 GetUserRequest {
  string id = 1;
}

message ListUsersResponse {
  repeated User users = 1;
}

message CreateUserRequest {
  User user = 1;
}

service UserService {
  rpc GetUser(GetUserRequest)       returns (User);
  rpc ListUsers(google.protobuf.Empty) returns (ListUsersResponse);
  rpc CreateUser(CreateUserRequest) returns (User);
}

Genera il codice Go. Il flag --go_out produce i tipi dei messaggi; --go-grpc_out produce le interfacce del servizio:

generate Go code
protoc \
  --go_out=gen/userpb --go_opt=paths=source_relative \
  --go-grpc_out=gen/userpb --go-grpc_opt=paths=source_relative \
  user.proto
Importante

Rigenera il codice ogni volta che modifichi user.proto. I file .pb.go generati vengono committati nel controllo versione in modo che i consumer non abbiano bisogno del toolchain protoc per compilare il progetto. Trattali come artefatti generati, non come codice modificato a mano.

Marshaling corretto: proto.Marshal e proto.Unmarshal
#

I tipi struct generati non hanno metodi Marshal() o Unmarshal(). La corretta API si trova nel package google.golang.org/protobuf/proto:

main.go
package main

import (
	"fmt"
	"log"

	"google.golang.org/protobuf/proto"

	userpb "github.com/yourname/yourapp/gen/userpb"
)

func main() {
	original := &userpb.User{
		Id:    "usr-001",
		Name:  "Alice",
		Email: "alice@example.com",
		Age:   31,
		Address: &userpb.Address{
			Street:  "10 Downing Street",
			City:    "London",
			Country: "UK",
		},
	}

	// Serializza in bytes wire.
	data, err := proto.Marshal(original)
	if err != nil {
		log.Fatalf("marshal: %v", err)
	}
	fmt.Printf("wire size: %d bytes\n", len(data))

	// Deserializza in un nuovo messaggio.
	decoded := &userpb.User{}
	if err := proto.Unmarshal(data, decoded); err != nil {
		log.Fatalf("unmarshal: %v", err)
	}

	fmt.Printf("name: %s, city: %s\n", decoded.Name, decoded.Address.City)
}
Avviso

Non importare github.com/golang/protobuf/proto. Quella e la vecchia API v1. Usa sempre google.golang.org/protobuf/proto (nessun suffisso di versione nel path dell’import nonostante sia il runtime v2).

Implementazione del server gRPC
#

Il plugin protoc-gen-go-grpc genera un’interfaccia che devi implementare:

server/server.go
package server

import (
	"context"
	"sync"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/emptypb"

	userpb "github.com/yourname/yourapp/gen/userpb"
)

type UserServer struct {
	userpb.UnimplementedUserServiceServer
	mu    sync.RWMutex
	store map[string]*userpb.User
}

func NewUserServer() *UserServer {
	return &UserServer{store: make(map[string]*userpb.User)}
}

func (s *UserServer) GetUser(ctx context.Context, req *userpb.GetUserRequest) (*userpb.User, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	u, ok := s.store[req.Id]
	if !ok {
		return nil, status.Errorf(codes.NotFound, "user %q not found", req.Id)
	}
	return u, nil
}

func (s *UserServer) ListUsers(ctx context.Context, _ *emptypb.Empty) (*userpb.ListUsersResponse, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	users := make([]*userpb.User, 0, len(s.store))
	for _, u := range s.store {
		users = append(users, u)
	}
	return &userpb.ListUsersResponse{Users: users}, nil
}

func (s *UserServer) CreateUser(ctx context.Context, req *userpb.CreateUserRequest) (*userpb.User, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.store[req.User.Id] = req.User
	return req.User, nil
}

Registra e avvia il server:

cmd/server/main.go
package main

import (
	"log"
	"net"

	"google.golang.org/grpc"

	userpb "github.com/yourname/yourapp/gen/userpb"
	"github.com/yourname/yourapp/server"
)

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("listen: %v", err)
	}
	s := grpc.NewServer()
	userpb.RegisterUserServiceServer(s, server.NewUserServer())
	log.Println("gRPC server in ascolto su :50051")
	log.Fatal(s.Serve(lis))
}

Client gRPC con deadline context
#

Imposta sempre una deadline sulle chiamate gRPC in uscita. Senza deadline, un server lento puo tenere la tua goroutine bloccata indefinitamente.

cmd/client/main.go
package main

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"

	userpb "github.com/yourname/yourapp/gen/userpb"
)

func main() {
	conn, err := grpc.NewClient(
		"localhost:50051",
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	if err != nil {
		log.Fatalf("dial: %v", err)
	}
	defer conn.Close()

	client := userpb.NewUserServiceClient(conn)

	// Ogni chiamata ha il proprio context con deadline.
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	user, err := client.CreateUser(ctx, &userpb.CreateUserRequest{
		User: &userpb.User{
			Id:    "usr-001",
			Name:  "Alice",
			Email: "alice@example.com",
			Age:   31,
		},
	})
	if err != nil {
		log.Fatalf("CreateUser: %v", err)
	}
	log.Printf("creato: %s", user.Id)
}

Benchmark: protobuf vs JSON
#

Il seguente benchmark serializza e deserializza lo stesso messaggio User con un sotto-messaggio Address usando entrambe le codifiche.

bench_test.go
package bench_test

import (
	"encoding/json"
	"testing"

	"google.golang.org/protobuf/proto"

	userpb "github.com/yourname/yourapp/gen/userpb"
)

var pbUser = &userpb.User{
	Id:    "usr-001",
	Name:  "Alice Wonderland",
	Email: "alice@example.com",
	Age:   31,
	Address: &userpb.Address{
		Street:  "10 Downing Street",
		City:    "London",
		Country: "UK",
	},
}

type JSONUser struct {
	ID      string      `json:"id"`
	Name    string      `json:"name"`
	Email   string      `json:"email"`
	Age     int32       `json:"age"`
	Address JSONAddress `json:"address"`
}
type JSONAddress struct {
	Street  string `json:"street"`
	City    string `json:"city"`
	Country string `json:"country"`
}

var jsonUser = JSONUser{
	ID: "usr-001", Name: "Alice Wonderland", Email: "alice@example.com", Age: 31,
	Address: JSONAddress{Street: "10 Downing Street", City: "London", Country: "UK"},
}

func BenchmarkProtoMarshal(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_, _ = proto.Marshal(pbUser)
	}
}

func BenchmarkJSONMarshal(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_, _ = json.Marshal(jsonUser)
	}
}

func BenchmarkProtoUnmarshal(b *testing.B) {
	data, _ := proto.Marshal(pbUser)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		out := &userpb.User{}
		_ = proto.Unmarshal(data, out)
	}
}

func BenchmarkJSONUnmarshal(b *testing.B) {
	data, _ := json.Marshal(jsonUser)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		var out JSONUser
		_ = json.Unmarshal(data, &out)
	}
}

Eseguendo go test -bench=. -benchmem su Apple M2:

OperazioneFormatons/opbytes/opallocs/opWire size
Marshalprotobuf19596174 bytes
MarshalJSON11802242127 bytes
Unmarshalprotobuf3104327-
UnmarshalJSON195049612-

Protobuf e circa il 40% piu compatto sul wire e da 5 a 6 volte piu veloce nella serializzazione. Il vantaggio sulle allocazioni e significativo ad alto throughput.

Quando usare protobuf vs JSON
#

ScenarioRaccomandazione
Chiamate interne tra microserviziProtobuf + gRPC. Controlli entrambi i lati; sfrutta le performance.
API REST pubblicaJSON. La leggibilita con curl conta piu dell’efficienza sul wire.
Client browserJSON o gRPC-Web (aggiungi complessita solo in presenza di un vero collo di bottiglia).
Streaming di grandi dataset (server-streaming RPC)Protobuf. Il framing e il multiplexing di gRPC su HTTP/2 sono un vantaggio concreto.
Debug del traffico in produzioneJSON. Puoi leggerlo senza schema.
flowchart TD
    Q1{Chi sono i client?}
    Q1 -->|Servizi interni| Q2{Alto throughput?}
    Q1 -->|Esterni / browser| JSON[Usa JSON REST]
    Q2 -->|Si| PROTO[Usa Protobuf + gRPC]
    Q2 -->|No| EITHER[Entrambi vanno bene -- scegli JSON per la debuggabilita]
**Usare il vecchio import path.** `github.com/golang/protobuf/proto` e lo shim di compatibilita v1. Funziona, ma il codice generato da `protoc-gen-go` v2 usa `google.golang.org/protobuf/proto`. Mischiare i due causa incompatibilita sottili. Usa il nuovo path ovunque. **Dimenticare di rigenerare dopo le modifiche al .proto.** Il codice generato e una snapshot. Cambia il nome di un campo nel `.proto` e il vecchio file generato sara silenziosamente in disaccordo. Configura `go generate` nel Makefile in modo che `make generate` produca sempre codice aggiornato prima della build. **Nessuna deadline sulle chiamate gRPC del client.** Una chiamata gRPC senza deadline sul context puo bloccarsi indefinitamente se il server e lento o non risponde. Usa sempre `context.WithTimeout` o `context.WithDeadline`. **Trattare il codice generato come read-only.** I file `.pb.go` generati vengono committati e non dovrebbero essere modificati a mano, ma il codice circostante (il tuo struct server, i tuoi wrapper client) richiede assolutamente logica personalizzata. Non mettere logica di business nel file generato. **Importare `google.golang.org/protobuf/v2`.** Questo path non esiste. Il runtime v2 si trova in `google.golang.org/protobuf` (senza il suffisso `/v2`). La confusione nasce dal fatto che il modulo ha sostituito semanticamente la v1, ma il path del modulo non segue le convenzioni Go standard per le major version.

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