gRPC explicado con ejemplos (servidor y cliente)

0
3698
Logo gRPC
https://grpc.io/img/logos/grpc-icon-color.png

1. Introducción

En los últimos tiempos los microservicios han tenido mucho auge, y esto ha provocado una revisión en los métodos y protocolos de comunicación entre aplicaciones o servicios. Hemos pasado de comunicar todas nuestras aplicaciones en base a un API REST(ful en el mejor de los casos), a tener APIs asíncronas en base a eventos, definir payloads con BSON (JSON binario), usar websockets y streams, etc. Uno de los más recientes es gRPC, una evolución del protocolo Stubby creada por Google en 2015 y liberada como código abierto.
En este tutorial vamos a tratar de mostrar qué es gRPC y un ejemplo de uso tanto desde el punto de vista de Servidor como de Cliente que lo consume.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 16″.
  • Sistema Operativo: Mac OS Ventura 13.0.1
  • Entorno de desarrollo: JetBrains IntelliJ, JDK 19, Apache Maven 3.8.6.

3. ¿Qué es gRPC?

Como ya hemos comentado Google creo gRPC como evolución de otro protocolo. En 2015 utilizaban una infraestructura basada en llamadas RPC entre sus microservicios, y para cubrir sus necesidades extendieron y desarrollaron una nueva versión del protocolo Stubby. La principal idea detrás de dicha evolución era aprovechar los conceptos que habían surgido en otras tecnologías como HTTP/2 (que utiliza por debajo para comunicarse) o WebSockets e implementar un estándar no acoplado a su infraestructura que permitiese liberarlo para consumo general.

3.1. Principales diferencias con REST

Cuando hablamos de un endpoint estamos acostumbrados por los años de uso a referirnos a un endpoint REST. Sin embargo, los endpoint que exponemos con gRPC cambian sustancialmente el concepto de qué exponemos y cómo. Al estar basado en RPC (Remote Procedure Call) lo que realiza es una llamada directa a un método con los parámetros de entrada. Los viejos del lugar lo conocen bien, puesto que los primeros sistemas cliente-servidor hacían uso de este sistema.

Por lo tanto, mientras que en REST ejecutamos operaciones sobre un punto de API, cuya semántica viene determinada por el verbo HTTP utilizado, con gRPC ejecutamos un método cuya semántica es completa.

De igual manera, el concepto de información intercambiada se ve alterado. En REST exponemos recursos, que no dejan de ser una referencia a los objetos de dominio del sistema. Con gRPC lo que vamos a intercambiar son mensajes, objetos completos definidos en el propio API.

3.2. Cómo funciona gRPC

El protocolo gRPC tiene un funcionamiento muy parecido a REST en cuanto a concepto. Tenemos un servidor que expone aquellas operaciones que es capaz de ejecutar y nuestros clientes, que en lugar de usar un rest template o un rest client se apoyan en un stub para realizar la llamada. Dichos stubs son únicamente unos pequeños wrapper sobre los objetos Channel, que serán los que realmente utilicemos por debajo para realizar la comunicación. Normalmente, los stub se presentan con dos formas: una bloqueante, en la que el cliente esperará a la respuesta para continuar con la ejecución de código, y un stub asíncrono que será el que empleemos cuando trabajemos con un Futuro, pasando el observer como parámetro cuando sea necesario y continuando con la ejecución de código por parte del cliente.

Los stub se definirán por cada lenguaje, y tendrán su implementación propia según el cliente. Mediante la especificación de las operaciones y mensajes a intercambiar con Protobuff (u otro medio) conseguimos que sea posible la interacción de distintos clientes con nuestro servidor.

Descripción del funcionamiento de gRPC en base a servidor y stubs.
https://grpc.io/img/landing-2.svg

Esta forma de desacoplar servidor y cliente nos permite escalar nuestro ecosistema con servicios en distintos lenguajes de programación aprovechando toda la potencia de gRPC de forma común a todos los servicios sin tener que hacer adaptaciones adicionales.

4. Definiendo nuestro servicio

En este ejemplo vamos a tomar como referencia la guía de implementación de la documentación oficial, con alguna variación en la implementación y sobre todo comentando qué hacemos en cada caso y para qué nos sirve.

Funcionalmente, no es más que una colección de métodos que nos muestran las posibilidades de intercambio de información mediante una aplicación de geolocalización. Podríamos entenderla como la base de un sistema de seguimiento, localización de geocachés o aplicaciones de checkin tipo Foursquare. Para el propósito de este tutorial no es tan relevante el qué sino el cómo.

Todo el código necesario está disponible en el repositorio público del tutorial (https://github.com/ysegura/tutorial-grpc), por lo que aquí únicamente nos vamos a centrar en los conceptos más importantes.

4.1. Configurando el entorno

Para poder desarrollar un servicio gRPC con Protobuff tenemos que tener el compilador de Protobuff instalado en nuestro entorno. Como el objeto de este tutorial no es cubrir todos los casos de instalación, en este enlace podéis seguir un paso a paso para instalarlo en vuestro sistema concreto: instalación de protoc. Yo lo he instalado con Homebrew.

Una vez que tenemos protoc instalado, podemos configurar nuestro pom.xml para automatizar la generación de código en base a una extensión y un plugin:

  • os-maven-plugin: nos independiza del SO que utilicemos y así no tendremos que cambiar la configuración de protoc según sea el entorno de desarrollo UNIX o Windows.
  • protobuf-maven-plugin: automatiza la compilación de los ficheros .proto, generando automáticamente las clases que necesitemos.
pom.xml
<build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>1.7.1</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.6.1</version>
                <configuration>
                    <protocArtifact>com.google.protobuf:protoc:3.21.7:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.51.0:exe:${os.detected.classifier}</pluginArtifact>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

4.2. Implementando el API

Cuando queremos implementar un nuevo servicio, lo primero que tenemos que hacer es definir nuestro API. gRPC permite definir las API con varios estándares, como JSON, aunque por defecto utiliza Protocol Buffers (Protobuff), también de Google. En nuestro caso nos vamos a ceñir al estándar de facto y usaremos Protobuff para controlar la serialización de los mensajes intercambiados.

Como gRPC permite mensajes en stream tanto de entrada como de salida, vamos a cubrir dichas posibilidades. Por lo tanto, en nuestro fichero .proto definiremos los servicios que exponemos y los mensajes a intercambiar:

Service.proto
// Opciones de configuración: versión Protobuff, paquetería, etc.
syntax = "proto3";

option java_package = "es.ysegura.grpctutorial.protobuff";
option java_multiple_files = true;
option java_outer_classname = "RouteGuideProto";
option objc_class_prefix = "RTG";

// Definición del servicio con sus métodos
service RouteGuide {

  // Ejemplo de una llamada con respuesta única
  rpc GetFeature(Point) returns (Feature){}

  // Ejemplo de llamada con respuesta en stream
  rpc ListFeatures(Rectangle) returns (stream Feature){}

  // Ejemplo de llamada con parámetro en stream y respuesta única
  rpc RecordRoute(stream Point) returns (RouteSummary){}

  // Ejemplo de llamada con entrada y respuesta en stream
  rpc RouteChat(stream RouteNote) returns (stream RouteNote){}

}

// Mensajes intercambiados

message Point{
  int32 latitude = 1;
  int32 longitude = 2;
}

message Rectangle {
  Point lo = 1;
  Point hi = 2;
}

message Feature {
  string name = 1;
  Point location = 2;
}

message FeatureDatabase {
  repeated Feature feature = 1;
}

message RouteNote {
  Point location = 1;
  string message = 2;
}

message RouteSummary {
  int32 point_count = 1;
  int32 feature_count = 2;
  int32 distance = 3;
  int32 elapsed_time = 4;
}

En base a esta definición, y haciendo uso del plugin de maven que hemos configurado, se nos generarán automáticamente las clases con los objetos definidos en los mensajes y la implementación por defecto de nuestro servicio cuando ejecutemos el goal compile.

4.3. Creando nuestro servidor gRPC

gRPC nos provee de un servidor (Netty) para que podamos exponer nuestros servicios. Lo más importante a la hora de levantarlo será añadir nuestros servicios mediante el builder:

public RouteGuideServerRunner(ServerBuilder<?> serverBuilder, int port, Collection<Feature> features){
        this.port = port;
        server = serverBuilder
                .addService(new RouteGuideService(features))
                .build();
    }

Para implementar ese RouteGuideService, que contiene los métodos a invocar mediante gRPC tendremos que partir del código autogenerado según la definición que hicimos en nuestro fichero Service.proto. En concreto, nos habrá generado una clase llamada RouteGuideGrpc, que contiene la descripción de los métodos a llamar, los stubs, handlers y todas las piezas necesarias de uso interno. Pero también tiene una clase abstracta: RouteGuideImplBase. Nuestro RouteGuideService extenderá dicha clase, dando una implementación a los métodos que definimos en Service.proto, y que serán los que finalmente se ejecuten. Vamos a tratar cada uno de los métodos por separado (se omiten algunos métodos auxiliares por legibilidad):

  • Método unario o tradicional petición-respuesta
    Este caso de uso es el más parecido a una llamada REST tradicional. El cliente ejecuta un método enviando un mensaje y como respuesta obtiene otro. En nuestro ejemplo, se envían unas coordenadas y se obtiene el nombre de la localización si se dispone de él:

    @Override
        public void getFeature(Point request, StreamObserver<Feature> responseObserver) {
            responseObserver.onNext(checkFeature(request));
            responseObserver.onCompleted();
        }

    Indicamos al observer que tiene que añadir el resultado de checkFeature como respuesta y completar la ejecución.

  • Respuesta en streaming
    En este caso, el servidor ejecuta un método cuya respuesta puede ser voluminosa, por lo que empieza a emitir el mensaje de respuesta en streaming, sin esperar a tenerlo completo. Para nuestro ejemplo, se envían los límites de un rectángulo y obtendremos un conjunto de localizaciones que estén dentro de su área:

    @Override
        public void listFeatures(Rectangle area, StreamObserver<Feature> responseObserver) {
            int left = getLeft(area);
            int right = getRight(area);
            int top = getTop(area);
            int bottom = getBottom(area);
    
            features.stream()
                    .filter(RouteGuideUtil::exists)
                    .filter(feature -> featureWithinLimits(left, right, top, bottom, feature))
                    .forEach(responseObserver::onNext);
    
            responseObserver.onCompleted();
        }

    Podemos observar que para cada uno de los elementos que componen la respuesta se ejecuta el método onNext del observer, por lo que emitiremos el mensaje correspondiente. Ya al completar el procesamiento de todos los elementos se informa de ello mediante la invocación al onCompleted del observer.

  • Petición en streaming
    En ocasiones nos encontraremos con que los datos de entrada son muy voluminosos, y podremos incluso empezar a procesarlos sin esperar a tenerlos todos completos, lo que aconsejaría el uso de este tipo de métodos. Para el ejemplo se van a recibir un conjunto indeterminado de puntos de entrada como «visitas», y cuando hayamos recibido todos devolveremos un mensaje con el número de puntos visitados y el tiempo empleado en ello. Como necesitamos hacer un tratamiento de los datos de entrada para contarlos e ir anotando la distancia recorrida, necesitamos definir un StreamObserver propio, que hemos separado en una clase aparte (PointStreamObserver):

    @Override
        public StreamObserver<Point> recordRoute(StreamObserver<RouteSummary> responseObserver) {
            return new PointStreamObserver(responseObserver, features);
        }
        @Override
        public void onNext(Point point) {
            pointCount++;
            if (RouteGuideUtil.exists(checkFeature(point))) {
                featureCount++;
            }
            if (previous != null) {
                distance += RouteGuideUtil.calcDistance(previous, point);
            }
            previous = point;
        }
    
        @Override
        public void onError(Throwable throwable) {
            log.warn("recordRoute cancelled");
        }
    
        @Override
        public void onCompleted() {
            long seconds = NANOSECONDS.toSeconds(System.nanoTime() - startTime);
            responseObserver.onNext(
                    RouteSummary.newBuilder()
                            .setPointCount(pointCount)
                            .setFeatureCount(featureCount)
                            .setDistance(distance)
                            .setElapsedTime((int) seconds)
                            .build()
            );
            responseObserver.onCompleted();
        }
    

    Podemos ver que en este caso, se han sobreescrito los métodos onNextonErroronCompleted para darles un tratamiento propio. Según se van recibiendo los mensajes entrantes gRPC se encarga de invocar al método onNext del observer que hemos definido, por lo que según el tratamiento que hacemos se incrementa la cuenta y se calcula la distancia recorrida entre los puntos sin esperar a tenerlos todos. Además, el mensaje de respuesta se prepara en el método onCompleted.

  • Streaming bidireccional
    Este es posiblemente el caso más complejo, ya que tanto la entrada como la respuesta están compuestos por un stream de datos. Además, en el ejemplo no tenemos que confundir el hecho de que sea bidireccional con que sea una conversación: el cliente envía todos los datos y cuando termina es el servidor el que contesta con su conjunto de mensajes. Si bien, el servidor podría ir publicando respuestas en el stream, lo que posibilitaría ese modelo de «conversación» entre ambos.

    @Override
        public StreamObserver<RouteNote> routeChat(StreamObserver<RouteNote> responseObserver) {
            return new RouteNoteStreamObserver(responseObserver);
    
        }
    @Override
    public void onNext(RouteNote note) {
        List notes = getOrCreateNotes(note.getLocation());
        // Respond with all previous notes at this location.
        routeNotes.forEach((k, v) -> v.forEach(responseObserver::onNext));
        notes.add(note);
    }
    
    @Override
    public void onError(Throwable throwable) {
        log.warn("routeChat cancelled due to " + throwable.getMessage());
    }
    
    @Override
    public void onCompleted() {
        responseObserver.onCompleted();
    }

    En este caso el ejemplo lo que realiza es que va componiendo la respuesta con las notas que ha ido recibiendo, acumulando las que ya existían (y por tanto repitiéndolas), de forma que el cliente reciba el mensaje con todas las ubicaciones por las que ha ido pasando en cada momento. Eso sí, hasta que no se realiza la llamada al método onCompleted no recibirá nada.

4.4. Probando el servidor

Para probar el servidor damos dos alternativas, montamos una batería de pruebas con JUnit y las ejecutamos, o utilizamos Postman importando el fichero Service.proto, lo que nos creará las llamadas grpc necesarias y simplemente tendremos que probar manualmente.

Como sabéis, siempre es mejor contar con una batería de pruebas automatizadas para poder ejecutarlas más fácilmente cuando cambiemos cualquier cosa 😉

4.5. Creando nuestro cliente gRPC

En el mundo de los sistemas informáticos no sólo exponemos información, sino que gran parte de las veces consumimos datos procedentes de otros sistemas. Por lo tanto, vamos a implementar un cliente sobre el servidor del ejemplo, para comprobar cómo podemos interactuar con cada una de las opciones que tenemos.

En el código autogenerado tenemos a nuestra disposición dos clases en las que nos apoyaremos para realizar las comunicaciones, los stub. Como comentamos anteriormente, tenemos disponibles dos: RouteGuideBlockingStub (bloqueante) y RouteGuideStub (asíncrono). Nuestro cliente lo único que necesita es declarar dichos stub para utilizarlos:

    // Define las llamadas bloqueantes, que nos aparecerán como métodos del blockingStub
    private final RouteGuideGrpc.RouteGuideBlockingStub blockingStub;

    // Define las llamadas que utilizan stream (asíncronas). Se invocan como métodos del asyncStub
    private final RouteGuideGrpc.RouteGuideStub asyncStub;
    private final Random random = new Random();

    public RouteGuideClient(Channel channel) {
        blockingStub = RouteGuideGrpc.newBlockingStub(channel);
        asyncStub = RouteGuideGrpc.newStub(channel);
    }

Vamos a desarrollar las cuatro operaciones del servidor desde el punto de vista de cliente:

  • Método unario o tradicional petición-respuesta
    public Feature getFeature(int lat, int lon) {
        log.info(String.format("*** GetFeature: lat=%d lon=%d", lat, lon));
    
        Point request = Point.newBuilder().setLatitude(lat).setLongitude(lon).build();
    
        try {
            Feature feature = blockingStub.getFeature(request);
            printFeatureInformation(feature);
            return feature;
        } catch (StatusRuntimeException e) {
            log.warn(String.format("RPC failed: %s", e.getStatus()));
            return null;
        }
    }

    Lo único que tenemos que hacer es invocar al método getFeature de nuestro stub con los parámetros necesarios y tendremos la respuesta. Así de sencillo.

  • Respuesta en streaming
    public Iterator<Feature> listFeatures(int lowLat, int lowLon, int hiLat, int hiLon) {
        log.info(String.format("*** ListFeatures: lowLat=%d lowLon=%d hiLat=%d hiLon=%d", lowLat, lowLon, hiLat, hiLon));
    
        Rectangle rectangle =
                Rectangle.newBuilder()
                        .setLo(Point.newBuilder().setLatitude(lowLat).setLongitude(lowLon).build())
                        .setHi(Point.newBuilder().setLatitude(hiLat).setLongitude(hiLon).build())
                        .build();
        try {
            // Mostramos los resultados obtenidos
            Iterator<Feature> features = blockingStub.listFeatures(rectangle);
            features.forEachRemaining(f -> log.info(String.format("Feature found:%n%s", f)));
            return features;
        } catch (StatusRuntimeException e) {
            log.warn(String.format("RPC failed: %s", e.getStatus()));
            return null;
        }
    }

    Al igual que en el ejemplo anterior, si obviamos la construcción del rectángulo, la llamada únicamente difiere en que la respuesta es un stream de datos, y por lo tanto tendremos un Iterator para procesarla.

  • Petición en streaming
    public void recordRoute(List<Point> points) throws InterruptedException {
        log.info("*** RecordRoute");
        final CountDownLatch finishLatch = new CountDownLatch(1);
    
        StreamObserver<RouteSummary> responseObserver = new RouteSummaryStreamObserver(finishLatch);
        StreamObserver<Point> requestObserver = asyncStub.recordRoute(responseObserver);
    
        try {
            points.forEach(point -> {
                if (finishLatch.getCount() != 0) {
                    log.info(String.format("Visiting point %f, %f",
                            RouteGuideUtil.getLatitude(point),
                            RouteGuideUtil.getLongitude(point)));
                    requestObserver.onNext(point);
    
                    // Simulamos que el cliente envía la información poco a poco (comportamiento humano)
                    // Esto, evidentemente, no puede pasar a código productivo.
                    try {
                        Thread.sleep(random.nextInt(1000) + 500L);
                    } catch (InterruptedException e) {
                        log.error("Error sending request: " + e.getMessage());
                        requestObserver.onError(e);
                    }
                }
            });
            requestObserver.onCompleted();
    
            // Como la recepción es asíncrona, controlamos el timeout con finishLatch
            if (!finishLatch.await(1, TimeUnit.MINUTES)) {
                log.warn("recordRoute can not finish within 1 minutes");
            }
        } catch (InterruptedException e) {
            requestObserver.onError(e);
            throw e;
        }
    }

    Como aquí ya estamos trabajando con un stub asíncrono, empezamos a utilizar los StreamObserver. La única diferencia con lo anterior, es que al ser Futuros, vamos añadiendo datos a la respuesta y cuando terminamos tenemos que notificarlo mediante la invocación al método onCompleted.

  • Streaming bidireccional
    public CountDownLatch routeChat(List<RouteNote> routeNotes) {
        log.info("*** RouteChat");
        final CountDownLatch finishLatch = new CountDownLatch(1);
        StreamObserver<RouteNote> requestObserver =
                asyncStub.routeChat(new StreamObserver<>() {
                    @Override
                    public void onNext(RouteNote note) {
                        log.info(String.format("Got message \"%s\" at %d, %d",
                                note.getMessage(),
                                note.getLocation().getLatitude(),
                                note.getLocation().getLongitude()));
                    }
    
                    @Override
                    public void onError(Throwable t) {
                        log.warn(String.format("RouteChat Failed: %s", Status.fromThrowable(t)));
                        finishLatch.countDown();
                    }
    
                    @Override
                    public void onCompleted() {
                        log.info("Finished RouteChat");
                        finishLatch.countDown();
                    }
                });
    
        try {
            routeNotes.forEach(request -> {
                log.info(String.format("Sending message \"%s\" at %d, %d",
                        request.getMessage(),
                        request.getLocation().getLatitude(),
                        request.getLocation().getLongitude()));
                requestObserver.onNext(request);
            });
        } catch (RuntimeException e) {
            // Cancel RPC
            requestObserver.onError(e);
            throw e;
        }
        // Mark the end of requests
        requestObserver.onCompleted();
    
        // return the latch while receiving happens asynchronously
        return finishLatch;
    }

    En este caso hemos definido un StreamObserver en línea sobre el cliente, para tratar la respuesta que vamos recibiendo del servidor. Funciona exactamente igual que los que ya conocemos, con los métodos onNext, onCompleted y onError a implementar.
    Cabe destacar el uso de CountdownLatch para controlar cuándo hemos terminado de procesar la respuesta y controlar la sincronía.

4.6. Probando el cliente

A la hora de comprobar el cliente, lo más sencillo es realizar unos test de integración con JUnit. Para ello, levantaremos el servidor, ejercitaremos los métodos que hemos creado y podremos comprobar las respuestas obtenidas de una forma sencilla.

Todos los casos se prueban mediante asserts salvo el correspondiente a la comunicación en streaming bidireccional, en el que únicamente se controla que se responda dentro del tiempo establecido.

El código se encuentra subido al repositorio bajo la clase RouteGuideClientTest, que no vamos a reproducir aquí por evitar extendernos más de la cuenta.

5. Conclusiones

gRPC es un protocolo de comunicación que cuenta con implementaciones en la mayoría de lenguajes utilizados hoy en día: C/C++, C#, Dart, GO, Java, Kotlin, Node.js, Objective-C, PHP, Python y Ruby (fuente: https://grpc.io/docs/#official-support). Esto permite tener infraestructuras políglotas y comunicar aplicaciones desarrolladas con distintas tecnologías. Es cierto que no vamos a tener todos estos lenguajes funcionando en un mismo ecosistema simultáneamente, pero contar con esa flexibilidad siempre es bueno para no limitar nuestra capacidad de decisión.

Contar con una definición de API fuerte, que tanto cliente como servidor conocen y acuerdan (nuestros ficheros .proto) nos permite gestionar un correcto gobierno, con políticas de versionado, obsolescencia, etc.

Incorpora securización SSL/TLS, tiene una gran eficiencia en la transmisión de información al realizar el intercambio en formato binario, es capaz de atender varias llamadas bajo una misma conexión y además permite comunicación bidireccional. Además, el hecho de que se apoye en conceptos ya conocidos y contrastados, y haya incorporado novedades de otros protocolos como las capacidades de comunicación en streaming lo hace versátil a la vez que potente. Prueba de ello son algunas de las compañías que hacen uso de gRPC en sus stacks: Netflix, Juniper Networks o Cisco, entre otros.

Pero como no todo puede ser bueno, sí que es cierto que tiene algunas desventajas. Al cambiar los conceptos que tenemos tan asimilados tiene una pequeña curva de aprendizaje, más orientada a evitar hacer las cosas mal que por la dificultad intrínseca. También es cierto que a día de hoy no es un protocolo cuyo uso esté ampliamente extendido, por lo que la experiencia general en su uso no es tan común (todavía…). Eso, unido a que la comunicación en tiempo real, tan demandada hoy en día, no es su punto fuerte, hace que en ocasiones sea más recomendable optar por alguna alternativa como WebSockets o el ya conocido REST.

6. Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad