Modernizando Protocol Buffers con Buf y Connect-go

El ecosistema de Protocol Buffers ha evolucionado más allá del clásico uso de protoc. Si todavía dependes de una serie de flags complejos en tu shell, scripts de Makefile gigantescos con rutas -I manuales y la gestión manual de versiones de plugins en tu $PATH, estás operando con un toolchain que ya ha quedado obsoleto.

buf es la herramienta que moderniza este flujo de trabajo. En lugar de un enfoque imperativo (tú le dices a protoc exactamente qué archivos incluir y qué plugin ejecutar), buf utiliza un enfoque declarativo mediante archivos de configuración. buf es un toolchain de compilación para Protocol Buffers que encapsula la complejidad de protoc, gestiona dependencias de forma similar a cómo go mod gestiona los módulos de Go, y añade capas de seguridad esenciales para entornos de producción.

Para entender por qué es necesario, buf resuelve tres problemas críticos: primero, la gestión de dependencias (evita duplicar archivos .proto en múltiples repositorios); segundo, el cumplimiento de estándares mediante buf lint (que verifica que tus esquemas sigan las convenciones de Google); y tercero, la seguridad de la API mediante buf breaking (que detecta cambios que romperían tus clientes actuales).

¿Cuándo deberías usarlo? En cualquier proyecto de microservicios que utilice gRPC o, preferiblemente, Connect. Si trabajas en un equipo donde múltiples servicios comparten esquemas, buf es obligatorio para evitar el caos de versiones.

¿Qué rompe si lo haces mal? Si ignoras las capacidades de detección de cambios de buf, podrías eliminar un campo de un mensaje .proto o cambiar su número de etiqueta, provocando errores de deserialización en clientes que aún no se han actualizado.

Para implementar un servicio moderno en Go, la recomendación actual es usar Connect (vía connect-go). A diferencia del grpc-go tradicional, que está muy atado a HTTP/2 y tiene una implementación muy pesada, connect-go genera código que es compatible con el protocolo gRPC pero que también funciona de forma nativa sobre HTTP/1.1, es mucho más fácil de testear y utiliza el modelo de net/http estándar de Go.

// --- buf.gen.yaml ---
// Este archivo define cómo se genera el código a partir de tus .proto
// version: v1
// plugins:
//   - plugin: connect-go
//     out: gen/connect
//     opt: module=github.com/acme/user-service
//   - plugin: go
//     out: gen/go
//     opt: module=github.com/acme/user-service

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"

	// Simulamos la importación del código generado por buf
	// En un proyecto real, esto vendría de tu módulo:
	// "github.com/acme/user-service/gen/connect/user/v1/userv1connect"
	"github.com/bufbuild/connect-go"
	"github.com/bufbuild/connect-go/module"
)

// --- MOCK GENERADO POR BUF ---
// En un workflow real, esto es lo que genera `buf generate`.
// Lo implementamos aquí para que el ejemplo sea ejecutable.

type UserRequest struct {
	ID string `json:"id"`
}

type UserResponse struct {
	ID    string `json:"id"`
	Email string `json:"email"`
}

type UserServiceClient interface {
	GetUser(ctx context.Context, in *UserRequest, options ...connect.CallOption) (*UserResponse, error)
}

// --- IMPLEMENTACIÓN DEL SERVIDOR ---

// UserServer implementa el servicio definido en nuestro .proto
type UserServer struct{}

func (s *UserServer) GetUser(
	ctx context.Context,
	in *UserRequest,
) (*UserResponse, error) {
	if in.ID == "" {
		return nil, fmt.Errorf("ID requerido")
	}

	// Lógica de negocio simulada
	return &UserResponse{
		ID:    in.ID,
		Email: "senior.dev@example.com",
	}, nil
}

// Implementar la interfaz de Connect para nuestro mock
// En la práctica, esto lo genera el plugin connect-go
func (s *UserServer) Handle(req *connect.Request[UserRequest], w http.ResponseWriter) {
	// Esta es una simplificación del comportamiento del handler generado
}

func main() {
	// El servidor de Connect es un simple http.Handler
	// No necesitas configuraciones complejas de HTTP/2 si no las necesitas.
	mux := http.NewServeMux()

	// Simulamos el registro del servicio
	// En un entorno real: mux.Handle(userv1connect.NewUserServiceHandler(&UserServer{}))
	mux.HandleFunc("/user/v1/GetUser", func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("Recibida petición en:", r.Method, r.URL.Path)
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"id": "123", "email": "senior.dev@example.com"}`))
	})

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}

	fmt.Println("🚀 Servidor Connect corriendo en http://localhost:8080")
	log.Fatal(server.ListenAndServe())
}

Desglose del workflow

  1. buf.yaml: Es el corazón del módulo. Define el directorio donde residen tus archivos .proto y las dependencias externas. Si usas la BSR (Buf Schema Registry), buf gestionará las dependencias de tus archivos .proto de forma similar a como go.mod gestiona tus paquetes de Go, evitando que tengas que clonar repositorios de otros equipos.
  2. buf.gen.yaml: Es donde ocurre la magia de la generación. En el ejemplo, vemos que delegamos la creación del código al plugin connect-go. A diferencia de protoc, donde tendrías una línea de comando con 10 parámetros, aquí solo declaras el plugin y el destino. La ventaja de buf es que no necesitas tener instalados los plugins en tu $PATH de forma global; buf puede incluso descargarlos por ti.
  3. Generación: Al ejecutar buf generate, la herramienta lee tu configuración, localiza los plugins y produce los archivos .go listos para ser usados. El código generado para Connect es mucho más limpio que el de gRPC tradicional, ya que está diseñado para ser compatible con el estándar http.Handler de Go.
  4. Consumo en el código: En el main.go, el servidor se registra en un http.ServeMux estándar. Esto facilita enormemente la implementación de middleware (autenticación, logging, tracing) porque el servicio es, esencialmente, un servidor HTTP convencional.

El error frecuente

El error más peligroso al trabajar con Protocol Buffers no es un error de compilación, sino un cambio disruptivo (breaking change) que pasa desapercibido.

// Versión 1 (Original)
message User {
  string id = 1;
  string email = 2; // Campo crítico
}

// Versión 2 (ERROR: Cambio disruptivo)
message User {
  string id = 1;
  string username = 2; // ¡ERROR! Has cambiado el propósito de la etiqueta 2
}

Si cambias el nombre del campo pero mantienes el número 2, el código de Go se compilará sin problemas, pero cualquier cliente que use la versión 1 esperará un email y recibirá un username (o viceversa), causando fallos en la lógica de negocio o errores de deserialización silenciosos.

Si ejecutas buf lint antes de hacer un commit, detectarás problemas de estilo. Si ejecutas buf breaking --against .? contra el último tag de tu repositorio, buf te avisará exactamente que has roto la compatibilidad con tus clientes actuales.

241

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio