Capítulo 4. gRPC: bajo el capó

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Como has aprendido en capítulos anteriores, las aplicaciones gRPC se comunican utilizando RPC a través de la red. Como desarrollador de aplicaciones gRPC, no necesitas preocuparte por los detalles subyacentes de cómo se implementa RPC, qué técnicas de codificación de mensajes se utilizan y cómo funciona RPC a través de la red. Utilizas la definición del servicio para generar código del lado del servidor o del lado del cliente para el lenguaje que elijas. Todos los detalles de comunicación de bajo nivel se implementan en el código generado y obtienes algunas abstracciones de alto nivel con las que trabajar. Sin embargo, cuando se construyen sistemas complejos basados en gRPC y se ejecutan en producción, es vital saber cómo funciona gRPC bajo el capó.

En este capítulo exploraremos cómo se implementa el flujo de comunicación gRPC, qué técnicas de codificación se utilizan, cómo utiliza gRPC las técnicas de comunicación de red subyacentes, etc. Te guiaremos a través del flujo de mensajes en el que el cliente invoca una determinada RPC, luego veremos cómo se transforma en una llamada gRPC que va por la red, cómo se utiliza el protocolo de comunicación de red, cómo se descomprime en el servidor, cómo se invoca el servicio correspondiente y la función remota, etc.

También veremos cómo utilizamos los búferes de protocolo como técnica de codificación y HTTP/2 como protocolo de comunicación para gRPC. Por último, nos sumergiremos en la arquitectura de implementación de gRPC y la pila de soporte de lenguaje construida a su alrededor. Aunque los detalles de bajo nivel que vamos a discutir aquí pueden no ser de mucha utilidad en la mayoría de las aplicaciones gRPC, tener una buena comprensión de los detalles de comunicación de bajo nivel es bastante útil si estás diseñando una aplicación gRPC compleja o tratando de depurar aplicaciones existentes.

Flujo RPC

En un sistema RPC, el servidor implementa un conjunto de funciones que pueden invocarse remotamente. La aplicación cliente puede generar un stub que proporcione abstracciones para las mismas funciones ofrecidas desde el servidor, de modo que la aplicación cliente pueda llamar directamente a funciones stub que invoquen a las funciones remotas de la aplicación servidor.

Veamos el servicio ProductInfo del que hablamos en el Capítulo 2 para entender cómo funciona una llamada a procedimiento remoto a través de la red. Una de las funciones que implementamos como parte de nuestro servicio ProductInfo es getProduct, donde el cliente puede recuperar los detalles del producto proporcionando el ID del producto. La Figura 4-1 ilustra las acciones que tienen lugar cuando el cliente llama a una función remota.

How remote procedure call works over the network
Figura 4-1. Cómo funciona una llamada a procedimiento remoto a través de la red

Como se muestra en la Figura 4-1, podemos identificar los siguientes pasos clave cuando el cliente llama a la función getProduct en el stub generado:

1

El proceso cliente llama a la función getProduct en el stub generado.

2

El stub del cliente crea una solicitud HTTP POST con el mensaje codificado. En gRPC, todas las solicitudes son solicitudes HTTP POST con el prefijo application/grpc como tipo de contenido. La función remota (/ProductInfo/getProduct) que invoca se envía como una cabecera HTTP independiente.

3

El mensaje de solicitud HTTP se envía a través de la red a la máquina del servidor.

4

Cuando el mensaje se recibe en el servidor, éste examina las cabeceras del mensaje para ver a qué función de servicio hay que llamar y entrega el mensaje al stub de servicio.

5

El stub del servicio analiza los bytes del mensaje en estructuras de datos específicas del idioma.

6

A continuación, utilizando el mensaje analizado, el servicio realiza una llamada local a la función getProduct.

7

La respuesta de la función de servicio se codifica y se envía de vuelta al cliente. El mensaje de respuesta sigue el mismo procedimiento que observamos en el lado del cliente (response→encode→HTTP response on the wire); el mensaje se descomprime y su valor se devuelve al proceso cliente en espera.

Estos pasos son bastante similares a la mayoría de los sistemas RPC como CORBA, Java RMI, etc. La principal diferencia de gRPC en este caso es la forma en que codifica el mensaje, que vimos en la Figura 4-1. Para codificar los mensajes, gRPC utiliza búferes de protocolo. Los búferes de protocolo son un mecanismo extensible, independiente del lenguaje y de la plataforma, para serializar datos estructurados. Tú defines cómo quieres que se estructuren tus datos una vez, y luego puedes utilizar el código fuente generado especialmente para escribir y leer fácilmente tus datos estructurados en y desde una variedad de flujos de datos.

Veamos cómo utiliza gRPC los búferes de protocolo para codificar los mensajes.

Codificación de mensajes mediante búferes de protocolo

Como ya hemos comentado en capítulos anteriores, gRPC utiliza búferes de protocolo para escribir la definición del servicio para los servicios gRPC. Definir el servicio utilizando buffers de protocolo incluye definir métodos remotos en el servicio y definir los mensajes que queremos enviar a través de la red. Por ejemplo, tomemos el método getProduct en el servicio ProductInfo. El método getProduct acepta un mensaje ProductID como parámetro de entrada y devuelve un mensaje Product. Podemos definir esas estructuras de mensajes de entrada y salida utilizando buffers de protocolo, como se muestra en el Ejemplo 4-1.

Ejemplo 4-1. Definición del servicio ProductInfo con la función getProduct
syntax = "proto3";

package ecommerce;

service ProductInfo {
   rpc getProduct(ProductID) returns (Product);
}

message Product {
   string id = 1;
   string name = 2;
   string description = 3;
   float price = 4;
}

message ProductID {
    string value = 1;
}

Según el Ejemplo 4-1, el mensaje ProductID lleva un ID de producto único. Por tanto, sólo tiene un campo de tipo cadena. El mensaje Product tiene la estructura necesaria para representar el producto. Es importante que el mensaje se defina correctamente, porque la forma en que se define el mensaje determina cómo se codifican los mensajes. Más adelante hablaremos de cómo se utilizan las definiciones de los mensajes al codificarlos.

Ahora que tenemos la definición del mensaje, veamos cómo codificar el mensaje y generar el contenido en bytes equivalente. Normalmente, de esto se encarga el código fuente generado para la definición del mensaje. Todos los lenguajes soportados tienen sus propios compiladores para generar el código fuente. Como desarrollador de aplicaciones, tienes que pasar la definición del mensaje y generar el código fuente para leer y escribir el mensaje.

Supongamos que necesitamos obtener los detalles del ID del producto 15; creamos un objeto mensaje con valor igual a 15 y lo pasamos a la función getProduct. El siguiente fragmento de código muestra cómo crear un mensaje ProductID con valor igual a 15 y pasarlo a la función getProduct para obtener los detalles del producto:

product, err := c.GetProduct(ctx, &pb.ProductID{Value: “15”})

Este fragmento de código está escrito en Go. Aquí, la definición del mensaje ProductID está en el código fuente generado. Creamos una instancia de ProductID y establecemos el valor como 15. Del mismo modo, en el lenguaje Java, utilizamos métodos generados para crear una instancia de ProductID como se muestra en el siguiente fragmento de código:

ProductInfoOuterClass.Product product = stub.getProduct(
       ProductInfoOuterClass.ProductID.newBuilder()
               .setValue("15").build());

En la estructura del mensaje ProductID que sigue, hay un campo llamado value con el índice de campo 1. Cuando creamos una instancia de mensaje con value igual a 15, el contenido en bytes equivalente consiste en un identificador de campo para el campo value seguido de su valor codificado. Este identificador de campo también se conoce como etiqueta:

message ProductID {
    string value = 1;
}

Esta estructura de contenido en bytes se parece a la Figura 4-2, donde cada campo del mensaje consta de un identificador de campo seguido de su valor codificado.

Protocol buffer encoded byte stream
Figura 4-2. Flujo de bytes codificado en la memoria intermedia del protocolo

Esta etiqueta acumula dos valores: el índice de campo y el tipo de cable. El índice de campo es el número único que asignamos a cada campo de mensaje al definir el mensaje en el archivo proto. El tipo de cable se basa en el tipo de campo, que es el tipo de datos que pueden entrar en el campo. Este tipo de cable proporciona información para encontrar la longitud del valor. La Tabla 4-1 muestra cómo se asignan los tipos de cable a los tipos de campo. Este es el mapeo predefinido de los tipos de hilo y los tipos de campo. Puedes consultar el documento oficial de codificación de búferes de protocolo para obtener más información sobre el mapeo.

Tabla 4-1. Tipos de cable disponibles y tipos de campo correspondientes
Tipo de cable Categoría Tipos de campo

0

Varint

int32, int64, uint32, uint64, sint32, sint64, bool, enum

1

64 bits

fixed64, sfixed64, doble

2

Longitud delimitada

cadena, bytes, mensajes incrustados, campos repetidos empaquetados

3

Grupo inicial

grupos (obsoleto)

4

Grupo final

grupos (obsoleto)

5

32 bits

fixed32, sfixed32, float

Una vez que conocemos el índice de campo y el tipo de cable de un determinado campo, podemos determinar el valor de etiqueta del campo mediante la siguiente ecuación. Aquí desplazamos a la izquierda la representación binaria del índice de campo en tres dígitos y realizamos una unión bit a bit con la representación binaria del valor del tipo de cable:

Tag value = (field_index << 3) | wire_type

La Figura 4-3 muestra cómo se disponen el índice de campo y el tipo de cable en un valor de etiqueta.

Structure of the tag value
Figura 4-3. Estructura del valor de la etiqueta

Intentemos comprender esta terminología utilizando el ejemplo que hemos usado antes. El mensaje ProductID tiene un campo de cadena con índice de campo igual a 1 y el tipo de cable de cadena es 2. Cuando los convertimos a representación binaria, el índice de campo parece 00000001 y el tipo de cable 00000010. Si introducimos esos valores en la ecuación anterior, el valor de etiqueta 10 se obtiene de la siguiente manera:

Tag value = (00000001 << 3) | 00000010
          = 000 1010

El siguiente paso es codificar el valor del campo del mensaje. Los búferes de protocolo utilizan diferentes técnicas de codificación para codificar los distintos tipos de datos. Por ejemplo, si se trata de un valor de cadena, el búfer de protocolo utiliza UTF-8 para codificar el valor y si se trata de un valor entero con el tipo de campo int32, utiliza una técnica de codificación llamada varints. Discutiremos en detalle las distintas técnicas de codificación y cuándo se aplican en la siguiente sección. Por ahora, hablaremos de cómo codificar un valor de cadena para completar el ejemplo.

En la codificación de los búferes de protocolo, los valores de cadena se codifican utilizando la técnica de codificación UTF-8. UTF (Formato de Transformación Unicode) utiliza bloques de 8 bits para representar un carácter. Es una técnica de codificación de caracteres de longitud variable que también es la técnica de codificación preferida en páginas web y correos electrónicos.

En nuestro ejemplo, el valor del campo value del mensaje ProductID es 15 y el valor codificado en UTF-8 de 15 es \x31 \x35. En la codificación UTF-8, la longitud del valor codificado no es fija. En otras palabras, el número de bloques de 8 bits necesarios para representar el valor codificado no es fijo. Varía en función del valor del campo del mensaje. En nuestro ejemplo, son dos bloques. Así que tenemos que pasar la longitud del valor codificado (número de bloques que abarca el valor codificado) antes del valor codificado. La representación hexadecimal del valor codificado de 15 tendrá el siguiente aspecto:

A 02 31 35

Los dos bytes de la derecha son el valor codificado en UTF-8 de 15. El valor 0x02 representa la longitud del valor de la cadena codificada en bloques de 8 bits.

Cuando se codifica un mensaje, sus etiquetas y valores se concatenan en un flujo de bytes. La Figura 4-2 ilustra cómo se ordenan los valores de los campos en un flujo de bytes cuando un mensaje tiene varios campos. El final del flujo se marca enviando una etiqueta con valor 0.

Ya hemos terminado de codificar un mensaje sencillo con un campo de cadena utilizando los búferes de protocolo. Los búferes de protocolo admiten varios tipos de campo y algunos tipos de campo tienen distintos mecanismos de codificación. Repasemos rápidamente las técnicas de codificación que utilizan los búferes de protocolo.

Técnicas de codificación

Los búferes de protocolo admiten muchas técnicas de codificación. Se aplican distintas técnicas de codificación en función del tipo de datos. Por ejemplo, los valores de cadena se codifican utilizando la codificación de caracteres UTF-8, mientras que los valores int32 se codifican utilizando una técnica llamada varints. Conocer cómo se codifican los datos en cada tipo de datos es importante a la hora de diseñar la definición del mensaje, porque nos permite establecer el tipo de datos más adecuado para cada campo de mensaje, de modo que los mensajes se codifiquen eficazmente en tiempo de ejecución.

En los búferes de protocolo, los tipos de campo admitidos se clasifican en distintos grupos y cada grupo utiliza una técnica diferente para codificar el valor. En la siguiente sección se enumeran algunas técnicas de codificación utilizadas habitualmente en los búferes de protocolo.

Varints

Los Varints (enteros de longitud variable) son un método de serializar enteros utilizando uno o más bytes. Se basan en la idea de que la mayoría de los números no están distribuidos uniformemente. Por tanto, el número de bytes asignados a cada valor no es fijo. Depende del valor. Según la Tabla 4-1, los tipos de campo como int32, int64, uint32, uint64, sint32, sint64, bool y enum se agrupan en varints y se codifican como varints. La Tabla 4-2 muestra qué tipos de campo se clasifican en varints, y para qué se utiliza cada tipo.

Tabla 4-2. Definiciones de tipos de campo
Tipo de campo Definición

int32

Un tipo de valor que representa enteros con signo con valores que van de 2.147.483.648 negativo a 2.147.483.647 positivo. Ten en cuenta que este tipo es ineficaz para codificar números negativos.

int64

Un tipo de valor que representa enteros con signo con valores que van de 9.223.372.036.854.775.808 negativo a 9.223.372.036.854.775.807 positivo. Ten en cuenta que este tipo es ineficaz para codificar números negativos.

uint32

Un tipo de valor que representa enteros sin signo con valores que van de 0 a 4.294.967.295.

uint64

Un tipo de valor que representa enteros sin signo con valores que van de 0 a 18.446.744.073.709.551.615.

sint32

Un tipo de valor que representa enteros con signo con valores que van de 2.147.483.648 negativo a 2.147.483.647 positivo. Codifica los números negativos de forma más eficiente que los int32 normales.

sint64

Un tipo de valor que representa enteros con signo con valores que van de 9.223.372.036.854.775.808 negativo a 9.223.372.036.854.775.807 positivo. Codifica los números negativos de forma más eficiente que los int64 normales.

bool

Tipo de valor que representa dos valores posibles, normalmente denotados como verdadero o falso.

enum

Un tipo de valor que representa un conjunto de valores con nombre.

En los varints, cada byte, excepto el último, tiene el bit más significativo (MSB) activado para indicar que hay más bytes por venir. Los 7 bits inferiores de cada byte se utilizan para almacenar la representación en complemento a dos del número. Además, el grupo menos significativo va primero, lo que significa que debemos añadir un bit de continuación al grupo de orden inferior.

Enteros con signo

Los enteros con signo son tipos que representan valores enteros tanto positivos como negativos. Los tipos de campo como sint32 y sint64 se consideran enteros con signo. Para los tipos con signo, se utiliza la codificación en zigzag para convertir los enteros con signo en enteros sin signo. Después, los enteros sin signo se codifican utilizando la codificación varints, como se ha mencionado anteriormente.

En la codificación en zigzag, los enteros con signo se mapean a enteros sin signo en zigzag a través de enteros negativos y positivos. La Tabla 4-3 muestra cómo funciona el mapeo en la codificación en zigzag.

Tabla 4-3. La codificación en zigzag utilizada en los enteros con signo
Valor original Valor asignado

0

0

-1

1

1

2

-2

3

2

4

Como se muestra en la Tabla 4-3, el valor cero se asigna al valor original cero y los demás valores se asignan a números positivos en zigzag. Los valores originales negativos se asignan a números positivos impares y los valores originales positivos se asignan a números positivos pares. Tras la codificación en zigzag, obtenemos un número positivo independientemente del signo del valor original. Una vez que tenemos un número positivo, realizamos varints para codificar el valor.

Para los valores enteros negativos, se recomienda utilizar tipos enteros con signo como sint32 y sint64, porque si utilizamos un tipo normal como int32 o int64, los valores negativos se convierten a binario utilizando la codificación varints. La codificación varints para un valor entero negativo necesita más bytes para representar un valor binario equivalente que un valor entero positivo. Así que la forma eficiente de codificar un valor negativo es convertir el valor negativo en un número positivo y luego codificar el valor positivo. En los tipos enteros con signo, como sint32, los valores negativos se convierten primero en valores positivos utilizando la codificación en zigzag y luego se codifican utilizando varints.

Números no varint

Los tipos no varint son justo lo contrario de los tipos varint. Asignan un número fijo de bytes independientemente del valor real. Los búferes de protocolo utilizan dos tipos de cable que se clasifican como números no varint. Uno es para los tipos de datos de 64 bits como fixed64, sfixed64 y double. El otro es para tipos de datos de 32 bits como fixed32, sfixed32 y float.

Tipo de cadena

En los búferes de protocolo, el tipo cadena pertenece al tipo de cable delimitado por la longitud, lo que significa que el valor es una longitud codificada por una variable seguida del número especificado de bytes de datos. Los valores de cadena se codifican utilizando la codificación de caracteres UTF-8.

Acabamos de resumir las técnicas utilizadas para codificar tipos de datos de uso común. Puedes encontrar una explicación detallada sobre la codificación del búfer de protocolo en la página oficial.

Ahora que hemos codificado el mensaje utilizando los búferes de protocolo, el siguiente paso es enmarcar el mensaje antes de enviarlo al servidor a través de la red.

Enmarcado de mensajes con longitud prefijada

En términos comunes, el enfoque del encuadre del mensaje construye la información y la comunicación de modo que la audiencia a la que va dirigida pueda extraer fácilmente la información. Lo mismo se aplica a la comunicación gRPC. Una vez que tenemos los datos codificados para enviarlos a la otra parte, tenemos que empaquetar los datos de forma que las otras partes puedan extraer fácilmente la información. Para empaquetar el mensaje y enviarlo a través de la red, gRPC utiliza una técnica de enmarcado de mensajes llamada enmarcado de prefijos de longitud.

El prefijo de longitud es un enfoque de enmarcado de mensajes que escribe el tamaño de cada mensaje antes de escribir el mensaje en sí. Como puedes ver en la Figura 4-4, antes del mensaje binario codificado hay 4 bytes asignados para especificar el tamaño del mensaje. En la comunicación gRPC, se asignan 4 bytes adicionales a cada mensaje para establecer su tamaño. El tamaño del mensaje es un número finito, y asignar 4 bytes para representar el tamaño del mensaje significa que la comunicación gRPC puede manejar todos los mensajes de hasta 4 GB de tamaño.

How a gRPC message is encoded and framed
Figura 4-4. Cómo utiliza una trama de mensaje gRPC el encuadre longitud-prefijo

Como se ilustra en la Figura 4-4, cuando el mensaje se codifica utilizando búferes de protocolo, obtenemos el mensaje en formato binario. Después calculamos el tamaño del contenido binario y lo añadimos antes del contenido binario en formato big-endian.

Nota

Big-endian es una forma de ordenar los datos binarios en el sistema o mensaje. En el formato big-endian, el valor más significativo (la mayor potencia de dos) de la secuencia se almacena en la dirección de almacenamiento más baja.

Además del tamaño del mensaje, la trama también tiene un entero sin signo de 1 byte para indicar si los datos están comprimidos o no. Un valor Compressed-Flag de 1 indica que los datos binarios están comprimidos utilizando el mecanismo declarado en la cabecera Message-Encoding, que es una de las cabeceras declaradas en el transporte HTTP. El valor 0 indica que no se ha producido ninguna codificación de los bytes del mensaje. Hablaremos en detalle de las cabeceras HTTP admitidas en la comunicación gRPC en la siguiente sección.

Así que ahora el mensaje está enmarcado y listo para ser enviado por la red al destinatario. Para un mensaje de solicitud de cliente, el destinatario es el servidor. Para un mensaje de respuesta, el destinatario es el cliente. En el lado del destinatario, una vez recibido el mensaje, primero tiene que leer el primer byte para comprobar si el mensaje está comprimido o no. A continuación, el destinatario lee los cuatro bytes siguientes para obtener el tamaño del mensaje binario codificado. Una vez conocido el tamaño, se puede leer la longitud exacta de bytes del flujo. Para los mensajes unarios/simples, sólo tendremos un mensaje de longitud prefijada, y para los mensajes de flujo, tendremos varios mensajes de longitud prefijada que procesar.

Ahora ya entiendes cómo se preparan los mensajes para entregarlos al destinatario a través de la red. En la siguiente sección, vamos a hablar de cómo gRPC envía esos mensajes de longitud prefijada a través de la red. Actualmente, el núcleo de gRPC admite tres implementaciones de transporte: HTTP/2, Cronet e in-process. Entre ellas, el transporte más habitual para enviar mensajes es HTTP/2. Veamos cómo utiliza gRPC la red HTTP/2 para enviar mensajes de forma eficiente.

gRPC sobre HTTP/2

HTTP/2 es la segunda gran versión del protocolo de Internet HTTP. Se introdujo para superar algunos de los problemas de seguridad, velocidad, etc. de la versión anterior (HTTP/1.1). HTTP/2 soporta todas las características principales de HTTP/1.1, pero de forma más eficiente. Por tanto, las aplicaciones escritas en HTTP/2 son más rápidas, sencillas y robustas.

gRPC utiliza HTTP/2 como protocolo de transporte para enviar mensajes a través de la red. Ésta es una de las razones por las que gRPC es un marco RPC de alto rendimiento. Exploremos la relación entre gRPC y HTTP/2.

Nota

En HTTP/2, toda la comunicación entre un cliente y un servidor se realiza a través de una única conexión TCP que puede transportar cualquier número de flujos bidireccionales de bytes. Para comprender el proceso HTTP/2, debes estar familiarizado con la siguiente terminología importante:

  • Flujo: Un flujo bidireccional de bytes dentro de una conexión establecida. Un flujo puede transportar uno o varios mensajes.

  • Trama: La unidad más pequeña de comunicación en HTTP/2. Cada trama contiene una cabecera de trama, que como mínimo identifica el flujo al que pertenece la trama.

  • Mensaje: Una secuencia completa de tramas que se mapean en un mensaje HTTP lógico que consta de una o más tramas. Esto permite multiplexar los mensajes, permitiendo que el cliente y el servidor descompongan el mensaje en tramas independientes, las intercalen y luego las vuelvan a ensamblar en el otro lado.

Como puedes ver en la Figura 4-5, el canal gRPC representa una conexión a un endpoint, que es una conexión HTTP/2. Cuando la aplicación cliente crea un canal gRPC, entre bastidores crea una conexión HTTP/2 con el servidor. Una vez creado el canal, podemos reutilizarlo para enviar múltiples llamadas remotas al servidor. Estas llamadas remotas se asignan a flujos en HTTP/2. Los mensajes que se envían en la llamada remota se envían como tramas HTTP/2. Una trama puede transportar un mensaje gRPC de longitud prefijada, o si un mensaje gRPC es bastante grande puede abarcar varias tramas de datos.

How gRPC semantics relate to HTTP/2
Figura 4-5. Cómo se relaciona la semántica gRPC con HTTP/2

En la sección anterior, hablamos de cómo enmarcar nuestro mensaje en un mensaje de longitud prefijada. Cuando los enviamos por la red como un mensaje de solicitud o respuesta, necesitamos enviar cabeceras adicionales junto con el mensaje. Vamos a discutir cómo estructurar los mensajes de solicitud/respuesta y qué cabeceras hay que pasar para cada mensaje en las siguientes secciones.

Solicitar mensaje

El mensaje de solicitud es el que inicia la llamada remota. En gRPC, el mensaje de solicitud siempre lo lanza la aplicación cliente y consta de tres componentes principales: las cabeceras de solicitud, el mensaje de longitud prefijada y la bandera de fin de flujo, como se muestra en la Figura 4-6. La llamada remota se inicia una vez que el cliente envía las cabeceras de solicitud. A continuación, se envían los mensajes de longitud prefijada en la llamada. Por último, se envía la bandera EOS (end of stream) para notificar al destinatario que hemos terminado de enviar el mensaje de solicitud .

Sequence of message elements in request message
Figura 4-6. Secuencia de elementos del mensaje de solicitud

Utilicemos la misma función getProduct en el servicio ProductInfo para explicar cómo se envía el mensaje de solicitud en tramas HTTP/2. Cuando llamamos a la función getProduct, el cliente inicia una llamada enviando las cabeceras de solicitud , como se muestra aquí:

HEADERS (flags = END_HEADERS)
:method = POST 1
:scheme = http 2
:path = /ProductInfo/getProduct 3
:authority = abc.com 4
te = trailers 5
grpc-timeout = 1S 6
content-type = application/grpc 7
grpc-encoding = gzip 8
authorization = Bearer xxxxxx 9
1

Define el método HTTP. Para gRPC, la cabecera :method es siempre POST.

2

Define el esquema HTTP. Si TLS (Transport Level Security) está activado, el esquema se establece en "https", de lo contrario es "http".

3

Define la ruta del punto final. Para gRPC, este valor se construye como "/" {nombre del servicio} "/" {nombre del método}.

4

Define el nombre de host virtual de la URI de destino.

5

Define la detección de proxies incompatibles. Para gRPC, el valor debe ser "remolques".

6

Define el tiempo de espera de la llamada. Si no se especifica, el servidor debe asumir un tiempo de espera infinito .

7

Define el tipo de contenido. Para gRPC, el tipo de contenido debe empezar por application/grpc. Si no es así, los servidores gRPC responderán con un estado HTTP de 415 (Tipo de medio no compatible).

8

Define el tipo de compresión del mensaje. Los valores posibles son identity, gzip, deflate, snappy y {custom}.

9

Se trata de metadatos opcionales. authorization metadatos se utilizan para acceder al punto final seguro.

Nota

Algunas otras notas sobre este ejemplo:

  • Los nombres de cabecera que empiezan por ":" se denominan cabeceras reservadas y HTTP/2 exige que las cabeceras reservadas aparezcan antes que las demás.

  • Las cabeceras que se pasan en la comunicación gRPC se clasifican en dos tipos: cabeceras de definición de llamada y metadatos personalizados.

  • Las cabeceras de definición de llamada son cabeceras predefinidas soportadas por HTTP/2. Estas cabeceras deben enviarse antes que los metadatos personalizados.

  • Los metadatos personalizados son un conjunto arbitrario de pares clave-valor definidos por la capa de aplicación. Cuando definas metadatos personalizados, debes asegurarte de no utilizar un nombre de encabezado que empiece por grpc-. Esto aparece como un nombre reservado en el núcleo de gRPC.

Una vez que el cliente inicia la llamada con el servidor, el cliente envía mensajes de longitud prefijada como tramas de datos HTTP/2. Si el mensaje de longitud prefijada no cabe en una trama de datos, puede abarcar varias tramas de datos. El final del mensaje de solicitud se indica añadiendo una bandera END_STREAM en la última trama DATA. Cuando no queden datos por enviar pero necesitemos cerrar el flujo de solicitud, la implementación debe enviar una trama de datos vacía con la bandera END_STREAM:

DATA (flags = END_STREAM)
<Length-Prefixed Message>

Esto es sólo una visión general de la estructura del mensaje de solicitud gRPC. Puedes encontrar más detalles en el repositorio oficial de gRPC en GitHub.

Al igual que el mensaje de solicitud, el mensaje de respuesta también tiene su propia estructura. Veamos la estructura de los mensajes de respuesta y las cabeceras relacionadas.

Mensaje de respuesta

El mensaje de respuesta lo genera el servidor en respuesta a la solicitud del cliente. Al igual que el mensaje de solicitud, en la mayoría de los casos el mensaje de respuesta también consta de tres componentes principales: cabeceras de respuesta, mensajes de longitud prefijada y trailers. Cuando no hay ningún mensaje de longitud prefijada que enviar como respuesta al cliente, el mensaje de respuesta consta sólo de cabeceras y trailers, como se muestra en la Figura 4-7.

Sequence of message elements in a response message
Figura 4-7. Secuencia de elementos de un mensaje de respuesta

Veamos el mismo ejemplo para explicar la secuencia de encuadre HTTP/2 del mensaje de respuesta. Cuando el servidor envía una respuesta al cliente, primero envía las cabeceras de respuesta, como se muestra aquí:

HEADERS (flags = END_HEADERS)
:status = 200 1
grpc-encoding = gzip 2
content-type = application/grpc 3
1

Indica el estado de la solicitud HTTP.

2

Define el tipo de compresión del mensaje. Los valores posibles son identity, gzip, deflate, snappy y {custom}.

3

Define el content-type. Para gRPC, el content-type debe empezar por application/grpc.

Nota

De forma similar a las cabeceras de solicitud, en las cabeceras de respuesta se pueden establecer metadatos personalizados que contengan un conjunto arbitrario de pares clave-valor definidos por la capa de aplicación.

Una vez que el servidor envía las cabeceras de respuesta, los mensajes de longitud prefijada se envían como tramas de datos HTTP/2 en la llamada. De forma similar al mensaje de solicitud, si el mensaje de longitud prefijada no cabe en un marco de datos, puede abarcar varios marcos de datos. Como se muestra a continuación, la bandera END_STREAM no se envía con las tramas de datos. Se envía como una cabecera separada llamada trailer:

DATA
<Length-Prefixed Message>

Al final, se envían trailers para notificar al cliente que hemos terminado de enviar el mensaje de respuesta. Los trailers también llevan el código de estado y el mensaje de estado de la solicitud:

HEADERS (flags = END_STREAM, END_HEADERS)
grpc-status = 0 # OK 1
grpc-message = xxxxxx 2
1

Define el código de estado de gRPC. gRPC utiliza un conjunto de códigos de estado bien definidos. Puedes encontrar la definición de los códigos de estado en la documentación oficial de gRPC.

2

Define la descripción del error. Es opcional. Sólo se establece cuando se produce un error al procesar la solicitud.

Nota

Los trailers también se entregan como tramas de cabecera HTTP/2, pero al final del mensaje de respuesta. El final del flujo de respuesta se indica fijando la bandera END_STREAM en las cabeceras del trailer. Además, contiene las cabeceras grpc-status y grpc-message.

En determinadas situaciones, puede producirse un fallo inmediato en la llamada de solicitud. En esos casos, el servidor necesita enviar una respuesta de vuelta sin las tramas de datos. Así que el servidor sólo envía trailers como respuesta. Esos trailers también se envían como una trama de encabezado HTTP/2 y también contienen la bandera END_STREAM. Además, en los trailers se incluyen las siguientes cabeceras:

  • Estado HTTP → :status

  • Tipo de contenido → content-type

  • Estado → grpc-status

  • Mensaje de estado → grpc-message

Ahora que sabemos cómo fluye un mensaje gRPC a través de una conexión HTTP/2, vamos a intentar comprender el flujo de mensajes de diferentes patrones de comunicación en gRPC.

Comprender el flujo de mensajes en los patrones de comunicación gRPC

En el capítulo anterior, hablamos de cuatro patrones de comunicación soportados por gRPC. Son el RPC simple, el RPC servidor-streaming, el RPC cliente-streaming y el RPC bidireccional-streaming. También hemos hablado de cómo funcionan esos patrones de comunicación utilizando casos de uso del mundo real. En esta sección, vamos a volver a examinar esos patrones desde un ángulo diferente. Vamos a discutir cómo funciona cada patrón a nivel de transporte con los conocimientos que hemos recopilado en este capítulo.

RPC simple

En RPC simple siempre tienes una única petición y una única respuesta en la comunicación entre el servidor gRPC y el cliente gRPC. Como se muestra en la Figura 4-8, el mensaje de solicitud contiene cabeceras seguidas de un mensaje de longitud prefijada, que puede abarcar una o más tramas de datos. Al final del mensaje se añade una bandera de fin de flujo (EOS) para medio cerrar la conexión en el lado del cliente y marcar el final del mensaje de solicitud. Aquí "semicerrar la conexión" significa que el cliente cierra la conexión por su parte, de modo que el cliente ya no puede enviar mensajes al servidor, pero sigue pudiendo escuchar los mensajes entrantes del servidor. El servidor crea el mensaje de respuesta sólo después de recibir el mensaje completo en el lado del servidor. El mensaje de respuesta contiene una trama de encabezamiento seguida de un mensaje de longitud prefijada. La comunicación finaliza cuando el servidor envía la cabecera final con los detalles del estado.

Simple RPC: message flow
Figura 4-8. RPC simple: flujo de mensajes

Éste es el patrón de comunicación más sencillo. Pasemos a un escenario RPC de flujo de servidor un poco más complejo.

RPC de flujo de servidor

Desde la perspectiva del cliente, tanto el RPC simple como el RPC de flujo de servidor tienen el mismo flujo de mensajes de solicitud. En ambos casos, enviamos un mensaje de solicitud. La principal diferencia está en el lado del servidor. En lugar de enviar un mensaje de respuesta al cliente, el servidor envía varios mensajes. El servidor espera a recibir el mensaje de solicitud completo y envía las cabeceras de respuesta y los múltiples mensajes de longitud prefijada, como se muestra en la Figura 4-9. La comunicación finaliza cuando el servidor envía la cabecera final con los detalles del estado.

Server-streaming RPC: message flow
Figura 4-9. RPC de flujo de servidor: flujo de mensajes

Veamos ahora el RPC de flujo de cliente, que es prácticamente lo contrario del RPC de flujo de servidor.

RPC de flujo de clientes

En el RPC de flujo de cliente, el cliente envía varios mensajes al servidor y éste envía un mensaje de respuesta como contestación. El cliente establece primero la conexión con el servidor enviando las tramas de encabezamiento. Una vez establecida la conexión, el cliente envía múltiples mensajes de longitud prefijada como tramas de datos al servidor, como se muestra en la Figura 4-10. Al final, el cliente cierra a medias la conexión enviando una bandera EOS en la última trama de datos. Mientras tanto, el servidor lee los mensajes recibidos del cliente. Una vez que recibe todos los mensajes, el servidor envía un mensaje de respuesta junto con la cabecera final y cierra la conexión.

Client-streaming RPC: message flow
Figura 4-10. RPC de flujo cliente: flujo de mensajes

Pasemos ahora al último patrón de comunicación, el RPC bidireccional, en el que el cliente y el servidor se envían mutuamente varios mensajes hasta que cierran la conexión.

RPC bidireccional

En este patrón, el cliente establece la conexión enviando tramas de cabecera. Una vez establecida la conexión, tanto el cliente como el servidor envían mensajes de longitud prefijada sin esperar a que el otro termine. Como se muestra en la Figura 4-11, tanto el cliente como el servidor envían mensajes simultáneamente. Ambos pueden finalizar la conexión por su parte, lo que significa que no pueden enviar más mensajes.

Bidirectional-streaming RPC: message flow
Figura 4-11. RPC bidireccional: flujo de mensajes

Con esto, hemos llegado al final de nuestro recorrido en profundidad por la comunicación gRPC. Las operaciones relacionadas con la red y el transporte en la comunicación se gestionan normalmente en la capa central de gRPC y no necesitas conocer los detalles como desarrollador de aplicaciones gRPC.

Antes de terminar este capítulo, veamos la arquitectura de implementación de gRPC y la pila de lenguajes.

Arquitectura de implementación de gRPC

Como se muestra en la Figura 4-12, la implementación de gRPC puede dividirse en varias capas. La capa base es la capa central de gRPC. Es una capa fina y abstrae todas las operaciones de red de las capas superiores para que los desarrolladores de aplicaciones puedan hacer fácilmente llamadas RPC a través de la red. La capa básica también proporciona extensiones a la funcionalidad básica. Algunos de los puntos de extensión son los filtros de autenticación para gestionar la seguridad de las llamadas y un filtro de plazos para implementar los plazos de las llamadas, etc.

gRPC es compatible de forma nativa con los lenguajes C/C++, Go y Java. gRPC también proporciona enlaces de lenguaje en muchos lenguajes populares como Python, Ruby, PHP, etc. Estos enlaces de lenguaje son envoltorios sobre la API C de bajo nivel.

Por último, el código de la aplicación va encima de los enlaces lingüísticos. Esta capa de aplicación se encarga de la lógica de la aplicación y de la lógica de codificación de datos. Normalmente, los desarrolladores generan código fuente para la lógica de codificación de datos utilizando compiladores proporcionados por distintos lenguajes. Por ejemplo, si utilizamos búferes de protocolo para codificar los datos, se puede utilizar el compilador de búferes de protocolo para generar el código fuente. Así, los desarrolladores pueden escribir la lógica de su aplicación invocando los métodos del código fuente generado.

gRPC native implementation architecture
Figura 4-12. Arquitectura de implementación nativa de gRPC

Con esto, hemos cubierto la mayor parte de los detalles de implementación y ejecución de bajo nivel de las aplicaciones basadas en gRPC. Como desarrollador de aplicaciones, siempre es mejor conocer los detalles de bajo nivel de las técnicas que vas a utilizar en la aplicación. No sólo ayuda a diseñar aplicaciones robustas, sino también a solucionar fácilmente los problemas de la aplicación.

Resumen

gRPC se basa en dos protocolos rápidos y eficientes llamados búferes de protocolo y HTTP/2. Los búferes de protocolo son un protocolo de serialización de datos que es un mecanismo independiente del lenguaje, independiente de la plataforma y extensible para serializar datos estructurados. Una vez serializados, este protocolo produce una carga binaria que es más pequeña que una carga JSON normal y está fuertemente tipada. Esta carga binaria serializada viaja después a través del protocolo de transporte binario llamado HTTP/2.

HTTP/2 es la próxima gran versión del protocolo de Internet HTTP. HTTP/2 está totalmente multiplexado, lo que significa que HTTP/2 puede enviar varias solicitudes de datos en paralelo a través de una única conexión TCP. Esto hace que las aplicaciones escritas en HTTP/2 sean más rápidas, sencillas y robustas que otras.

Todos estos factores hacen de gRPC un marco RPC de alto rendimiento.

En este capítulo hemos tratado detalles de bajo nivel sobre la comunicación gRPC. Puede que estos detalles no sean esenciales para desarrollar una aplicación gRPC, porque ya los gestiona la biblioteca, pero entender el flujo de mensajes gRPC de bajo nivel es absolutamente esencial cuando se trata de solucionar problemas relacionados con la comunicación gRPC cuando utilizas gRPC en producción. En el próximo capítulo, hablaremos de algunas capacidades avanzadas que ofrece gRPC para satisfacer las necesidades del mundo real.

Get gRPC: funcionando now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.