Los que hace más tiempo que leéis nuestros artículos, seguro que estáis familiarizados con Gopher API,
una API HTTP JSON que sigue una arquitectura REST,
utilizando los verbos HTTP más habituales (GET
, POST
, DELETE
, etc) para realizar operaciones CRUD
sobre una colección (de gophers). Ésta, la hemos utilizado como referencia
a lo largo del último año para explicar no solo cómo crear una API REST en Go,
sino también para explicar cómo testearla y cómo mockerla.
Sin embargo, la arquitectura REST, utilizada en dicho proyecto, no siempre es la ideal para implementar nuestros servicios,
ya que ese enfoque CRUD sobre colecciones no siempre es aplicable a todos los casos de uso.
Además, trabajar sobre colecciones en modelos de datos complejos puede llegar a ser engorroso, pues terminaremos teniendo
que hacer muchísimas llamadas (cliente-servidor) para cada caso de uso de nuestra aplicación y/o nos tocará recurrir a
apaños poco escalables como los includes de la especificación JSON API.
Y por si todos estos inconvenientes fueran pocos, ¿quién no ha tenido que implementar un api-client
para una API que
tenía que consumir como tercero? Sin duda, algo nada agradable de desarrollar y mantener, pues no deja de ser un elemento
más que aumenta la complejidad accidental de nuestros proyectos.
Como solución a algunas de estas problemáticas, en Facebook nació GraphQL, que básicamente consiste en la definición de un lenguaje de consultas para APIs. Sin embargo, algunos de los inconvenientes de la arquitectura REST que mencionamos anteriormente no se solucionan con una implementación de este estilo. Para ello, Google publicó el método de serialización de datos llamado protocol buffers y el framework gRPC, que funciona sobre RPC, HTTP/2 y los propios protocol buffers. Además, ambas tecnologías no solo nos aportan una solución a algunas de las problemáticas planteadas anteriormente, sinó que además nos permiten mejorar otros aspectos.
¿Qué son los protocol buffers?
Los protocol buffers (también conocidos como “protobuf"), son un mecanismo de serialización de datos agnóstico al lenguaje y la plataforma. A modo de ejemplo, y para facilitar la comprensión, suelen ser comparados con una versión mucho más simple y liviana de XML, pues la serialización resultante no deja de ser contenido comprimido. Al final, lo que nos proporcionan es:
- un lenguaje (sintaxis) para definir la estructura de nuestros datos.
- un compilador para multitud de lenguajes (Go, Java, Python, etc).
De modo que, una vez hayamos definido la estructura de nuestros datos, simplemente vamos a poder usar uno de esos compiladores para generar el código responsable de hacer la serialización / deserialización de los datos.
Pero, ¿qué pinta tienen?
A modo de ejemplo, y para poder complementar este artículo de introducción con un caso real, vamos a suponer que los datos
que queremos de/serializar son los correspondientes a una lista de deseos (wishlist en inglés). En ese caso, podríamos tener
algo así (proto/wishlist.proto
):
syntax = "proto3";
package grpc;
message Item {
enum ItemPriority {
LOW = 0;
MID = 50;
HIGH = 100;
}
enum ItemStatus {
INACTIVE = 0;
ACTIVE = 1;
}
string id = 1;
string wishListId = 2;
string name = 3;
string link = 4;
double price = 5;
ItemPriority priority = 6;
ItemStatus status = 7;
}
message WishList {
enum WishListStatus {
INACTIVE = 0;
ACTIVE = 1;
}
string id = 1;
string name = 2;
WishListStatus status = 3;
}
Como podéis ver, la definición de nuestros datos (message) básicamente consiste en la definición de sus atributos así como del tipo de los mismos, además de un identificador (que en sesiones más avanzadas ya veremos para qué sirve).
Una vez lista la definición en un fichero .proto
, ya podríamos compilarla. Para ello, necesitaríamos el compilador
de los protocol buffers (protoc
) y el módulo
correspondiente a Go, que nos dará soporte para dicho lenguaje.
Una vez instaladas ambas herramientas solo nos faltaría realizar dicha compilación:
protoc -I path/to/proto --go_out=plugins=grpc:internal/net/grpc path/to/proto/*.proto
dónde path/to/proto
se correspondería con la ruta del directorio donde tenemos almacenadas nuestras definiciones (los
ficheros .proto
como el que vimos anteriormente), y dónde internal/net/grpc
se correspondería con la ruta de salida
que deseemos.
Esto nos generaria un fichero .pb.go
para cada uno de los ficheros .proto
que hayamos compilado y ya será el código
fuente que usaremos en nuestro proyecto.
¿Puedo usar protobuf en una API REST?
Si os habéis adelantado, ¡estáis en lo cierto! Pues, si bien es cierto que hemos aprovechado este artículo para presentar los protocol buffers dado que son el mecanismo de serialización de gRPC, efectivamente podríamos obtener sus beneficios haciendo uso de dicho mecanismo de de/serialización en nuestras APIs HTTP, sustituyendo JSON por protobuf de forma que la transmisión de la información sea mucho más óptima (comprimida).
Entonces, ¿qué beneficios nos aporta el uso de gRPC?
Vale, nos ha quedado claro que gRPC usa protocol buffers y, por lo tanto, que la transmisión de información mediante dicho mecanismo será más óptima. Pero si esos beneficios los puedo obtener también con una API HTTP estándar, ¿qué beneficios me aporta?
gRPC funciona sobre HTTP/2
Como dijimos anteriormente, gRPC es un framework, así que sus beneficios no se ciñen estrictamente a las funcionalidades de una librería específica, sinó al conjunto de tecnologías que lo forman. Y, si anteriormente vimos los beneficios de usar protocol buffers, ahora podríamos ver los beneficios de que gRPC funcione sobre HTTP/2:
-
Conexión única, es decir, con una única conexión TCP será suficiente para cargar un sitio web, y esta perdurará mientras tengamos el sitio web abierto.
-
Multiplexación, es decir, se podrán realizar múltiples peticiones HTTP a la vez y en la misma conexión, sin tener que esperar a que el resto de peticiones haya terminado para hacer nuevas peticiones.
-
Server Push, es decir, el servidor podrá enviar información adicional sin que el cliente tenga que pedirlo de forma explícita.
-
Prioritización, es decir, dentro de las peticiones multiplexadas, podremos definir una prioridad a cada una de ellas de forma que las más urgentes puedan ser atendidas de forma más rápida.
-
Transmisión binaria, es decir, no será necesaria la traducción de texto a binario (y viceversa) del contenido enviado a traves de las peticiones HTTP, de forma que esta será mucho más eficiente y menos propensa a errores.
-
Compresión de cabeceras, es decir, se podrá usar la compresión HPACK para optimizar el envío de los headers HTTP.
-
Duplex streaming, es decir, será posible leer y escribir simultáneamente, ya que los eventos de lectura y escritura seran independientes entre sí.
En definitiva, que al igual que antes, podríamos desarrollar nuestra API HTTP sobre la versión 2 de dicho protocolo y obtener los beneficios del mismo, sin embargo, con la adopción del framework gRPC estos beneficios nos vendrán “gratis”.
¡Y más!
Sin embargo, la cosa no queda aquí. Ya que como decíamos al inicio, la implementación de los populares api-client
s es
también un engorro de las APIs HTTP tradicionales que nos vamos a ahorrar a la hora de hacer uso de gRPC, pues en esta
ocasión, lo que vamos a hacer es definir de manera formal (de igual modo que hacíamos anteriormente con los datos) las
acciones de nuestro servicio y vamos a usar un compilador para generar el código correspondiente para el lenguaje que
necesitemos. Así que, se acabó lo de ir desarrollando y manteniendo múltiples api-client
s. Además, como veremos a
continuación, y siguiendo la filosofia RPC, se acabó lo de restringir nuestras acciones a los verbos HTTP y lo de tener
que trabajar siempre con colecciones. A partir de ahora vamos a tener total libertad de definición.
Un ejemplo práctico
Siguiendo con el ejemplo anterior (la wishlist) y con los nuevos conceptos aprendidos hasta ahora, podríamos definir nuestro servicio del siguiente modo (a continuación de lo visto anteriormente):
// file: proto/wishlist.proto
message CreateWishListReq {
WishList wishList = 1;
}
message CreateWishListResp {
string wishListId = 1;
}
message AddItemReq {
Item item = 1;
}
message AddItemResp {
string itemId = 1;
}
message ListWishListReq {
string wishListId = 1;
}
message ListWishListResp {
repeated Item items = 1;
}
service WishListService {
rpc Create(CreateWishListReq) returns (CreateWishListResp);
rpc Add(AddItemReq) returns (AddItemResp);
rpc List(ListWishListReq) returns (ListWishListResp);
}
De forma que, en nuestro servicio se podrán realizar tres acciones:
- Crear una nueva lista de deseos.
- Añadir un nuevo elemento a una de las listas.
- Listar los elementos de una lista.
Implementando un caso de uso
Una vez realizada la definición, el siguiente paso, al igual que hicimos anteriormente, sería compilar nuestro servicio. Y una vez compilado ya tendríamos:
- el esqueleto para implementar nuestro servidor.
- el código de cliente para el lenguaje en cuestión.
Para ello, de nuevo:
protoc -I path/to/proto --go_out=plugins=grpc:internal/net/grpc path/to/proto/*.proto
A lo que obtendremos un paquete grpc
(le llamamos así por simplicidad), con el código de ambos: cliente y servidor (esqueleto).
Por lo tanto, para implementar nuestro servidor solo tendríamos que implementar la interfaz definida en dicho paquete:
// WishListServiceServer is the server API for WishListService service.
type WishListServiceServer interface {
Create(context.Context, *CreateWishListReq) (*CreateWishListResp, error)
Add(context.Context, *AddItemReq) (*AddItemResp, error)
List(context.Context, *ListWishListReq) (*ListWishListResp, error)
}
con nuestra lógica de negocio, es decir, algo así como:
type grpcServer struct {}
// NewWishListServer provides WishList gRPC operations
func NewWishListServer() grpc.WishListServiceServer {
return &wishListHandler{}
}
func (s wishListHandler) Create(ctx context.Context, req *grpc.CreateWishListReq) (*grpc.CreateWishListResp, error) {
// TODO: Implement the create action
}
func (s wishListHandler) Add(ctx context.Context, req *grpc.AddItemReq) (*grpc.AddItemResp, error) {
// TODO: Implement the add action
}
func (s wishListHandler) List(ctx context.Context, req *grpc.ListWishListReq) (*grpc.ListWishListResp, error) {
// TODO: Implement the list action
}
Además, en esta ocasión podríamos utilizar la función NewWishListServer()
para inyectar cualquier tipo de dependencia
a nuestra implementación de gRPC, véase los servicios de aplicación.
Poniendo en marcha nuestro servidor
Una vez ya con el servidor implementado, el siguiente paso sería poner en marcha nuestro servidor. Para ello, vamos a usar una encapsulación del concepto servidor que nos permita abstraernos de si la implementación es gRPC o de si es una API tradicional:
// Server define a server behaviour
type Server interface {
// Serve serves a service's server implementation
Serve() error
}
// Config contains the configuration to set up the server
type Config struct {
Protocol string
Host string
Port string
}
De forma que ahora podremos definir nuestra implementación gRPC (en el paquete gRPC
) que cumpla dicha interfaz:
type grpcServer struct {
config server.Config
}
func NewServer(
config server.Config,
) server.Server {
return &grpcServer{config: config}
}
func (s *grpcServer) Serve() error {
addr := fmt.Sprintf("%s:%s", s.config.Host, s.config.Port)
listener, err := net.Listen(s.config.Protocol, addr)
if err != nil {
return err
}
srv := googlegrpc.NewServer()
serviceServer := NewWishListServer()
grpc.RegisterWishListServiceServer(srv, serviceServer)
if err := srv.Serve(listener); err != nil {
return err
}
return nil
}
Como podéis ver, toda la lógica está contenida en el método Serve()
de dicho servidor, donde:
- Se empiezan a escuchar (
net.Listen
) las conexiones en una dirección. - Se usa el paquete de gRPC de Google (
"google.golang.org/grpc"
) para instanciar y registrar la nueva implementación. - Se sirve (
srv.Serve
) el servidor gRPC con la implementación registrada previamente.
Finalmente, nuestro servidor podría ser iniciado desde el punto de entrada de la aplicación (cmd/server/main.go
):
func main() {
srvCfg := server.Config{Protocol: "tcp", Host: "localhost", Port: "3333"}
srv := grpc.NewServer(srvCfg, creatingService, addingService, listingService)
log.Printf("gRPC server running at %s://%s:%s ...\n", srvCfg.Protocol, srvCfg.Host, srvCfg.Port)
log.Fatal(srv.Serve())
}
¡Y ya lo tendríamos funcionado!
Consumiendo nuestra aplicación
Sin embargo, como dijimos anteriormente, uno de los beneficios de seguir una arquitectura RPC era el hecho de no tener que desarrollar nuestros propios clientes, ya que estos podían ser autogenerados a partir de la definición de nuestro servicio.
Y efectivamente, ¡así es! Si revisamos el fichero wishlist.pb.go
que se generó al compilar nuestro .proto
, veréis
que tenemos lista una implementación de nuestro cliente:
type wishListServiceClient struct {
cc *grpc.ClientConn
}
func NewWishListServiceClient(cc *grpc.ClientConn) WishListServiceClient {
return &wishListServiceClient{cc}
}
func (c *wishListServiceClient) Create(ctx context.Context, in *CreateWishListReq, opts ...grpc.CallOption) (*CreateWishListResp, error) {
out := new(CreateWishListResp)
err := c.cc.Invoke(ctx, "/grpc.WishListService/Create", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *wishListServiceClient) Add(ctx context.Context, in *AddItemReq, opts ...grpc.CallOption) (*AddItemResp, error) {
out := new(AddItemResp)
err := c.cc.Invoke(ctx, "/grpc.WishListService/Add", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *wishListServiceClient) List(ctx context.Context, in *ListWishListReq, opts ...grpc.CallOption) (*ListWishListResp, error) {
out := new(ListWishListResp)
err := c.cc.Invoke(ctx, "/grpc.WishListService/List", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
De forma que, desde nuestro punto de entrada, o desde dónde queramos consumir el servicio, podríamos hacer algo así como:
func main() {
addr := fmt.Sprintf("%s:%s", host, port)
conn, err := googlegrpc.Dial(addr, googlegrpc.WithInsecure())
if err != nil {
log.Fatalf("impossible connect: %v", err)
}
client := wishgrpc.NewWishListServiceClient(conn)
// here we can start using the client:
w := &grpc.WishList{
Name: name,
Status: grpc.WishList_ACTIVE,
}
res, err := client.Create(ctx, &grpc.CreateWishListReq{WishList: w})
fmt.Println(res)
fmt.Println(err)
}
Y ya estaríamos consumiendo nuestro servicio sin haber tenido que dedicar un solo minuto a la implementación del cliente. Además, este cliente podría estar publicado como librería externa para ser consumido por cualquier otra aplicación que vaya a usar nuestros servicios y/o incluso podríamos compilar clientes en cualquiera de los otros lenguajes con soporte para gRPC.
BloomRPC
Pero la cosa no termina aquí, porqué sabemos que la reacción de alguien acostumbrado a trabajar con APIs HTTP JSON tradicionales, sería algo similar a:
Y es normal, porqué nosotros también pasamos por esa fase. Sin embargo, queremos que vuestra experiencia sea lo más agradable posible, por eso os queremos recomendar BloomRPC, una interfaz gráfica para probar servicios gRPC. Vaya, algo muy similar a Postman pero con soporte para gRPC, tal y como se puede ver en el siguiente GIF:
Además, para los más tradicionales, también podéis encontrar adaptaciones de cURL con soporte para gRPC. Ya sabéis, solo es cuestión de investigar y probar un poco, que herramientas las hay a montones.
Siguientes pasos
Y ahora sí, hasta aquí nuestra introducción a gRPC, pues entendemos que con estos conocimientos ya deberíamos ser capaces de implementar la versión V1 de nuestro nuevo servicio en gRPC. Sin embargo, aún nos hemos dejado algunas cosas en el tintero, que, sin duda alguna, repasaremos en futuros artículos dónde entraremos más en detalle en algunos aspectos de gRPC:
- Uso de middlewares en este tipo de arquitectura.
- Cómo convivir con otras arquitecturas (tipo REST).
- Qué soluciones tenemos para mockear un servicio gRPC.
- Cómo gestionar los errores de forma adecuada en este tipo de arquitectura.
- Cómo hacer el versionado de nuestras definiciones (
.proto
). - Y mucho más…
Por el momento podéis echarle un vistazo a este repositorio de referencia del artículo en el que os hemos dejado una implementación de un servicio gRPC de listas de deseos (wishlists) con algunos casos de uso ya funcionando.
Y ya para terminar, una sencilla pregunta:
¿Habiaís usado gRPC antes de leer el artículo? ¡Contadnos vuestras experiencias!
Ya sabéis, todos los comentarios, feedback o si os ha quedado alguna duda por resolver, será bienvenido en la sección de comentarios del artículo o en nuestro Twitter @FriendsofGoTech.