Introducción a gRPC y ejemplo con Java

Escrito por picodotdev el .
java planeta-codigo
Enlace permanente Comentarios

Para crear una API expuesta de forma externa o para ofrecer un servicio a otros servicios en una arquitectura de microservicios ha varias opciones. Tres de las opciones son REST, GraphQL y gRPC cada una con sus características que la hacen mas adecuadas según los requerimientos de la aplicación. gRPC es especialmente adecuada para servicios que requieran un alto rendimiento y solo necesite consumirse de forma interna. gRPC es una implementación de llamada a procedimiento remoto o RPC agnóstica del lenguaje de programación de alto rendmiento al emplear un formato de intercambio de datos binario más eficiente que JSON.

gRPC

Java

Desarrollar una API con interfaz REST o GraphQL es la opción empleada mayoritariamente en los casos que hay que proporcionar datos y acceso a operaciones de forma programática a otra aplicación. Las API REST se basan en los elementos sobre los cuales está construida la web como el protocolo HTTP y su semántica junto con las operaciones de creación, modificación, lectura y eliminación organizando las operaciones alrededor de los recursos que se exponen mediante URLs utilizando JSON como formato de intercambio de datos basado en texto. GraphQL tiene importantes diferencias con REST al utilizar un esquema para definir el formato de los datos y la posibilidad de realizar varias consultas en la misma petición pero igualmente se basa en el protocolo HTTP y JSON.

Hace un tiempo ya comentaba una opción alternativa a las API REST y son las API RPC usando llamadas a métodos remotos. Las API REST son una buena opción aunque en casos que se necesite alto rendimiento imponen cierta sobrecarga en la comunicación al tener que hacer múltiples peticiones para obtener todos los datos necesarios de los diferentes endpoints y por utilizar el formato de intercambio de datos JSON que impone cierto procesamiento tanto para procesarlo como para generarlo.

Uno de los problemas que presentaban las llamadas a métodos remotos es que solían estar encadenados a un lenguaje de programación en concreto como RMI con Java, pero hay algunas opciones de RPC más modernas que eliminan esta restricción y ofrecen mayor rendimiento y tipado seguro. Hace un tiempo ya comentaba sobre Apache Thrift otra de ellas similar es gRPC auspiciada originalmente por Google.

Qué es gRPC

gRPC es una implementación de llamada a procedimiento remoto o Remote Procedure Call (RPC) de alto rendmiento. Las invocaciones RPC permiten hacer llamadas a funciones remotas como si se tratase de llamadas a funciones locales ocultando en gran parte la dificultad en la comunicación por la red subyacente. Los servicios remotos se defiene en archivos descriptores o Interface Description Language (IDL) en los que se incluyen las operaciones que ofrece el servicio así como las propiedades de los argumentos que recibe y devuelve como respuesta esta descripción basándose en Protocol Buffers. Los tipos soportados para las propiedades incluyen varios numéricos, de coma flotante, boleano, string y byte.

gRPC al contrario que RMI que era específico de Java es agnóstico del lenguaje de programación en el que se implemente el servicio, a partir del archivo descriptor del servicio se genera unos archivos que sirven como base tanto para realizar la implementación en el lenguaje para la parte servidor como de la parte cliente del servicio pudiendo el servidor y cliente estar implementado en diferentes lenguajes. Los lenguajes soportados por gRPC están los más populares como Java, C#, C++, Dart, Go, Kotlin, Node/JavaScript, PHP, Python o Ruby.

Características de gRPC

  • Alto rendimiento con seguridad: gRPC tiene alto rendimiento al usar protobuf y HTTP/2 que son multiplexados, requieren una única conexión TCP, utilizan un formato de datos binario y posibilitan la comunicación bidireccional. Los mensajes al tener un esquema son más seguros de procesar.
  • Comunicación bidireccional: esto permite a la parte cliente y servidor enviar datos simultáneamente en ambas direcciones.
  • Balanceo de carga: incorpora balanceo de carga para seleccionar la instancia del servicio servidor a la que enviar el tráfico.
  • Compresión selectiva: si se envían datos en formato texto e imagen se puede deshabilitar la compresión para las imágenes.
  • Generación de código servidor y cliente: a partir del esquema se generan artefactos en cualquiera de los lenguajes soportado con los cuales crear la implementación del servidor y cliente rápidamente.

Las diferencias entre gRPC y REST

  • Formato de intercambio de dato (Protobuf contra JSON): esa es una de las diferencias principales entre REST y gRPC, los mensajes REST contiene datos en formato JSON habitualmente mientras gRPC hace uso de Protobuf. Protobuf es una mejor forma de codificar datos estructurados, tiene mejor compresión y es más eficiente que JSON.
  • Tipado fuerte contra serialización: en REST que normalmente se usa JSON no hay ningún mecanismo pra coordinar el formato de los datos intercambiados en las peticiones y respuestas que hay que tener en cuenta especialmente cuando se hacen cambios en la API para mantener la compatibilidad con los clientes o requiere actualizar los clientes de forma corrdinada a la nueva versión lo que suele ser muy difícil. Por otro lado el formato JSON ha de ser convertir tanto en el servidor como en el cliente a estructuras de datos del lenguaje en el que estén implementados, la serialización es otro paso que añade la posibilidad de errores así como sobrecarga en el rendimiento.
  • Mensajes contra recursos y verbos: REST está estrechamente basado en la semántica del protocolo HTTP, en lógica de negocio compleja es difícil trasladar la lógica de negocio y sus operaciones a los recursos y verbos que emplea REST. El modelo de gRPC traslada directamente los conceptos a los lenguajes de programación como interfaces, métodos y estructuras de datos.
  • Streaming contra Petición-Respuesta: REST solo soporta el único modelo petición-respuesta disponible en HTTP/1. gRPC hace uso de las capacidades de HTTP/2 y permite enviar y recibir información de forma constante tanto en el servidor como en el cliente.
  • gRPC se basa en HTTP/2: HTTP/2 es un protocolo binario más eficiente y seguro de procesar, REST puede usarse con HTTP/1 pero si se usa junto a HTTP/2 obtiene algunos de sus beneficios.

Desventajas de gRPC

  • No hay soporte para los navegadores web de modo que no puede ser usado en ellos como servicios expuestos al exterior. Para no imponer su uso su aplicación son para servicios que sean consumidos de forma interna y ofrecer una API basada en REST o GraphQL de forma externa.
  • No hay endpoints basados en URLs de modo que las peticiones y respuestas no pueden ser probadas con las herramientas Postman o el comando curl.
  • No hay códigos de estados predefinidos, cada situación de error es específica de cada método ofrecido por el servicio.

GraphQL tiene algunas similitudes con gRPC, igual que él utiliza un esquema para definir la interfaz del servicio y define las estructuras de datos que utilizan el cliente y el servidor ni emplea recursos ni verbos del protocolo HTTP. Pero al igual que REST utiliza JSON como formato para intercambiar los datos.

Estas presentaciones en formato vídeo sobre gRPC contienen una introducción y explican algunos detalles con más profundidad.

Ejemplo de servicio gRPC con Java

La construcción de un servicio o API basada en gRPC comienza con la definición del servicio en un archivo descriptor que incluye tanto las operaciones disponibles así como las estructuras de datos que incluye los nombres de los campos y tipos que reciben y devuelven como respuestas las llamadas a funciones remotas. El archivo descriptor del servicio sigue el formato de Protocol Buffers.

En este ejemplo el servicio HelloWorld tiene dos operaciones HelloMessage y HelloStream que reciben y devuelven dos estructuras de datos HelloRequest y HelloResponse. La operación HelloStream muestra como enviar el cliente un número indeterminado de mensajes al cliente que los va procesando según los envía el servidor. El esquema también contiene algunas opciones utilizadas por gRPC para personalizar la generación de las clases que sirven como base para realizar la implementación.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
syntax = "proto3";

option java_outer_classname = "HelloWorldClass";
option java_package = "io.github.picodotdev.blogbitix.grpc.service";

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

service HelloWorld {
   rpc HelloMessage(HelloRequest) returns (HelloResponse) {}
   rpc HelloStream(HelloRequest) returns (stream HelloResponse);
}
HelloWorld.proto

Una vez escrita la definición del servicio hay que generar las clases que sirven como base para realizar la implementación, gRPC ofrece un plugin para Gradle sin necesidad de instalar ninguna herramienta adicional en el sistema. En el archivo de construcción además de las dependencias se indican algunas propiedades para personalizar la compilación del archivo proto como el directorio en el que ubicar los archivos de código fuente generados y algunas opciones para que el IDE de IntelliJ IDEA reconozca ese directorio como ubicación de código fuente. En la documentación de Protocol Buffers hay una guía del lenguaje y un tutorial para Java.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
plugins {
    id 'java'
    id 'application'
    id 'idea'
    id 'com.google.protobuf' version '0.8.8'
}

repositories {
    jcenter()
}

dependencies {
    implementation 'io.grpc:grpc-protobuf:1.31.1'
    implementation 'io.grpc:grpc-stub:1.31.1'
    implementation 'io.grpc:grpc-netty-shaded:1.31.1'
    compileOnly 'org.apache.tomcat:annotations-api:6.0.53'
}

application {
    mainClassName = 'io.github.picodotdev.blogbitix.grpc.Main'
}

idea {
    module {
        sourceDirs += file("src/generated");
    }
}

protobuf {
    generatedFilesBaseDir = "$projectDir/src/generated"

    protoc {
        artifact = "com.google.protobuf:protoc:3.13.0"
    }
    plugins {
        grpc {
            artifact = 'io.grpc:protoc-gen-grpc-java:1.31.1'
        }
    }
    generateProtoTasks {
        all()*.plugins {
            grpc {}
        }
    }
}

clean {
    delete protobuf.generatedFilesBaseDir
}
build.gradle

El siguiente paso es generar los artefactos para Java con la siguiente tarea, los artefactos se ubican en el directorio src/generated.

1
$ ./gradlew build
gradle-build.sh

Una vez generador los artefactos hay que realizar su implementación. En esta caso son muy sencillos, en casos más complejos seguramente se utilicen junto a Spring para inyectarles dependencias que necesiten como otros servicios de la capa de aplicación que le permita obtener o persistir datos en la base de datos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package io.github.picodotdev.blogbitix.grpc;

import io.github.picodotdev.blogbitix.grpc.service.HelloWorldClass;
import io.github.picodotdev.blogbitix.grpc.service.HelloWorldGrpc;
import io.grpc.stub.StreamObserver;

import java.text.MessageFormat;
import java.util.stream.IntStream;

public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase {
    @Override
    public void helloMessage(HelloWorldClass.HelloRequest request, StreamObserver<HelloWorldClass.HelloResponse> responseObserver) {
        HelloWorldClass.HelloResponse response = HelloWorldClass.HelloResponse.newBuilder().setMessage(MessageFormat.format("Hello {0}!", request.getName())).build();

        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }

    @Override
    public void helloStream(HelloWorldClass.HelloRequest request, StreamObserver<HelloWorldClass.HelloResponse> responseObserver) {
        IntStream.range(1, 6).forEach(i -> {
            HelloWorldClass.HelloResponse response = HelloWorldClass.HelloResponse.newBuilder().setMessage(MessageFormat.format("Hello {0} {1}!", request.getName(), i)).build();
            responseObserver.onNext(response);

            sleep(3000);
        });
        responseObserver.onCompleted();
    }

    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
HelloWorldService.java

gRPC necesita de una parte que actúa como servidor y otra parte que actúa como cliente, el servidor se inicia en un puerto para la comunicación por red y el cliente se conecta al puerto y dirección IP donde se inicia una de las instancias del servidor. Estas clases para el servidor y cliente son clases Java normales que haceun uso de algunas de las clases de gRPC.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package io.github.picodotdev.blogbitix.grpc;

import io.grpc.Server;
import io.grpc.ServerBuilder;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

public class HelloWorldServer {

    private int port;
    private Server server;

    public HelloWorldServer(int port) {
        this.port = port;
        this.server = ServerBuilder.forPort(port).addService(new HelloWorldService()).build();
    }

    public void start() throws IOException {
        server.start();
        addShutdownHook();
        System.out.printf("Server started, listening on %d%n", port);
    }

    public void stop() throws InterruptedException {
        if (server != null) {
            server.shutdown().awaitTermination(30, TimeUnit.SECONDS);
        }
    }

    public void blockUntilShutdown() throws InterruptedException {
        if (server != null) {
            server.awaitTermination();
        }
    }

    private void addShutdownHook() {
        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                try {
                    HelloWorldServer.this.stop();
                } catch (InterruptedException e) {
                    e.printStackTrace(System.err);
                }
            }
        });
    }
}
HelloWorldServer.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package io.github.picodotdev.blogbitix.grpc;

import io.github.picodotdev.blogbitix.grpc.service.HelloWorldClass;
import io.github.picodotdev.blogbitix.grpc.service.HelloWorldGrpc;
import io.grpc.Channel;
import io.grpc.ManagedChannelBuilder;

import java.util.Iterator;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

public class HelloWorldClient {

    private final HelloWorldGrpc.HelloWorldBlockingStub blockingStub;

    public HelloWorldClient(String host, int port) {
        this(ManagedChannelBuilder.forAddress(host, port).usePlaintext().build());
    }

    public HelloWorldClient(Channel channel) {
        blockingStub = HelloWorldGrpc.newBlockingStub(channel);
    }

    public String getHelloMessage(String name) {
        HelloWorldClass.HelloResponse response = blockingStub.helloMessage(HelloWorldClass.HelloRequest.newBuilder().setName(name).build());
        return response.getMessage();
    }

    public Stream<String> getHelloStream(String name) {
        Iterator<HelloWorldClass.HelloResponse> response = blockingStub.helloStream(HelloWorldClass.HelloRequest.newBuilder().setName(name).build());
        Spliterator<HelloWorldClass.HelloResponse> splitIterator = Spliterators.spliteratorUnknownSize(response, Spliterator.ORDERED);
        Stream<String> stream = StreamSupport.stream(splitIterator, false).map(HelloWorldClass.HelloResponse::getMessage);
        return stream;
    }
}
HelloWorldClient.java

Con Gradle se inicia programa que inicia servidor en la máquina local y el cliente que realiza varias peticiones e imprime en la salida sus respuestas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package io.github.picodotdev.blogbitix.grpc;

public class Main {

    public static void main(String[] args) throws Exception {
        startServer();

        Thread.sleep(2000);

        startClient();

        System.exit(0);
    }

    private static void startServer() throws Exception {
        HelloWorldServer server = new HelloWorldServer(8980);
        server.start();
    }

    private static void startClient() {
        HelloWorldClient client = new HelloWorldClient("localhost", 8980);
        System.out.println(client.getHelloMessage("gRPC"));
        client.getHelloRandom("gRPC").forEach(message -> {
            System.out.println(message);
        });
    }
}
Main.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ ./gradlew run

> Task :run
Server started, listening on 8980
Hello gRPC!
Hello gRPC 1!
Hello gRPC 2!
Hello gRPC 3!
Hello gRPC 4!
Hello gRPC 5!
gradle-run.sh

Este es un ejemplo muy básico y no incluye varias necesidades habituales que se necesitan para implementar servicios y una API con grado de producción como balanceo de carga con múltiples instancias del servicio, tolerancia a fallos en caso de que una instancia deje de funcionar, descubrimiento y registro de servicios para que los clientes conozcan las ubicaciones de las instancias de los servidores, métricas, autenticación o como evolucionar los servicios cuando estos requieran cambios. Para implementar en un servicio gRPC varias de estas funcionalidades hay que usar herramientas de las que he escrito y mostrado en otros artículos de forma específica como Spring Boot, Consul, Traefik, Resilience4j, Nomad que no tienen nada de diferentes al aplicarlas por el hecho de que el servicio esté basado en gRPC.

Terminal

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradlew run

Comparte el artículo: