Optimizando la serialización de datos con Protocol Buffers

0
102
Logo de Protocol Buffers (protobuf) con flechas de colores rojo, azul, verde y amarillo.
Logo de Protocol Buffers (protobuf), un formato de serialización de datos de Google.

Índice

  1. Introducción
  2. ¿Qué son los Protocols Buffers?
  3. Comparativa con JSON
  4. Uso en sistemas con pocos recursos
  5. Ejemplo
  6. Referencias
  7. Créditos

 

Introducción

En el desarrollo de software es habitual encontrar escenarios en los que se necesita comunicar datos estructurados entre diferentes elementos de un sistema. En ocasiones los ecosistemas pueden ser muy heterogéneos, con múltiples componentes ejecutándose sobre equipos, arquitecturas y lenguajes de programación diferentes. Es el caso de los ecosistemas IoT, en los que típicamente los datos se recogen y empaquetan en pequeños dispositivos con grandes limitaciones en capacidad de cómputo, memoria o energía disponibles, programados en C para ser enviados a servicios gestionados y escalables en plataformas en la nube como las Cloud Functions de Google Cloud.

Para simplificar las comunicaciones y resolver el problema de la heterogeneidad, los datos suelen ser serializados en el emisor, invirtiendo la operación en el receptor.

Uno de los sistemas de serialización de datos más extendidos en desarrollos de software es JSON, que permite intercambiar datos de forma agnóstica al lenguaje de programación y legible para los desarrolladores.

Sin embargo, en el caso de sistemas en los que la optimización de las comunicaciones es un factor clave, es posible utilizar otros sistemas de serialización que proporcionan un mejor rendimiento que JSON en cuanto a recursos necesarios para su codificación y decodificación y al tamaño del mensaje final. De esta forma se introduce el tema de este artículo: Protocol Buffers, el sistema de serialización desarrollado por Google.

 

¿Qué son los Protocol Buffers?

Protocol Buffers are a language-neutral, platform-neutral extensible mechanism for serializing structured data.

Según la definición en la web oficial, Protocol Buffers (también llamados protobuf) son un mecanismo extensible para serializar datos estructurados de forma agnóstica al lenguaje y a la plataforma. Es similar a JSON, pero más rápido y ligero, y es capaz de generar código optimizado para diferentes lenguajes.

Protocol Buffers surge alrededor de 2001 como una herramienta para ser utilizada en proyectos internos de Google, desarrollándose de forma interna hasta su liberación como código abierto en 2008.

Al ser de código abierto, se utiliza en todo tipo de proyectos y aplicaciones, entre los que destaca gRPC, un sistema de llamadas a procedimientos remotos desarrollado por Google orientado a comunicar microservicios.

Entre sus principales características destacan:

  • Formato de definición de estructura de datos propio (.proto).
  • Almacenamiento compacto, siendo especialmente relevante cuando se maneja gran cantidad de datos o se utilizan dispositivos con restricciones energéticas o de tráfico de red.
  • Codificación e interpretación rápida y multiplataforma.
    • Soporte oficial en múltiples lenguajes incluyendo C++, Java, Kotlin y Python.
    • Soporte de librerías de terceros para otros lenguajes no soportados oficialmente.
  • Código abierto, permitiendo analizar su funcionamiento interno o desarrollar complementos.

El formato de definición de la estructura de datos es muy flexible y soporta los tipos primitivos más comunes (equivalentes a int, float, double, bool, string…) y otras herramientas útiles como enumerados o la capacidad de incluir estructuras dentro de otras. Los atributos pueden además definirse como opcionales (si no están presentes se considera el valor por defecto) y repetidos (puede incluir un número de elementos de cero o más).

message Person {
  string name = 1;
  int32 id = 2;  // Unique ID number for this person.
  string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;

  google.protobuf.Timestamp last_updated = 5;
}

// Our address book file is just one of these.
message AddressBook {
  repeated Person people = 1;
}

Fragmento de ejemplo de un fichero .proto. Para más información, consulta el archivo de ejemplo AddressBook.proto.

En el caso del ejemplo anterior, se define un mensaje Person que incluye su nombre, identificador, correo electrónico, una lista de teléfonos que a su vez incluyen el número y el tipo (móvil, casa o trabajo según el enumerado PhoneType) y la marca de tiempo de su última modificación. También se define el mensaje AddressBook, que es un conjunto de repeticiones del mensaje Person.

 

Comparativa con JSON

Utilizando el fichero de definición .proto indicado anteriormente, se realiza una comparativa entre el tamaño de la salida serializada como Protobuf y como JSON. Merece la pena destacar que este tipo de datos no es precisamente el que más se beneficia de las capacidades de optimización de Protocol Buffers, dado que los datos tipo string también se codifican eficientemente en JSON.

Concretamente, se genera una agenda con la información de cuatro personas ficticias y se codifica en JSON y Protobuf con un script básico en Python detallado más adelante en el Ejemplo. El resultado muestra la salida y el tamaño en ambos casos.

Protoc size: 316
Protoc content (hex):
0a4e0a0e4a6172657420436f7272696e6e651a18636f7272696e655f6a6172657440676d61696c2e636f6d22100a0c30313632352d393332323039100222100a0c30313634322d33323239353410010a4e0a0f4665726e616e64612057726974657210011a156665726e616e6461407772697465722e636f2e756b22100a0c30313633302d323032303533100222100a0c30313638372d38373933393110010a4a0a0b4564676172204b616e6e6510021a1565646761722e6b616e6e65407961686f6f2e636f6d22100a0c30313332362d353332333337100222100a0c30313636362d36333831373610010a4e0a0d4c656e6e792047617a7a6f6c6110031a176c656e6e792e67617a7a6f6c61407961686f6f2e636f6d22100a0c30313331322d323333323533100222100a0c30313334322d3730363839331001
JSON size: 646
JSON content:
[{"id": 0, "name": "Jaret Corrinne", "email": "corrinne_jaret@gmail.com", "phones": [{"type": 2, "number": "01625-932209"}, {"type": 1, "number": "01642-322954"}]}, {"id": 1, "name": "Fernanda Writer", "email": "fernanda@writer.co.uk", "phones": [{"type": 2, "number": "01630-202053"}, {"type": 1, "number": "01687-879391"}]}, {"id": 2, "name": "Edgar Kanne", "email": "edgar.kanne@yahoo.com", "phones": [{"type": 2, "number": "01326-532337"}, {"type": 1, "number": "01666-638176"}]}, {"id": 3, "name": "Lenny Gazzola", "email": "lenny.gazzola@yahoo.com", "phones": [{"type": 2, "number": "01312-233253"}, {"type": 1, "number": "01342-706893"}]}]

Mensaje codificado en JSON y Protobuf

En el caso de Protocol Buffers, la salida no es directamente legible al estar en crudo. Se representa en hexadecimal en la salida del script, y se puede interpretar fácilmente desde la shell con ayuda de la herramienta protoc:

echo -n '0a4e….1001' | xxd -r -p | protoc --decode=AddressBook --proto_path=./ test.proto
people {
  name: "Jaret Corrinne"
  email: "corrinne_jaret@gmail.com"
  phones {
    type: WORK
    number: "01625-932209"
  }
  phones {
    type: HOME
    number: "01642-322954"
  }
}
people {
  name: "Fernanda Writer"
  email: "fernanda@writer.co.uk"
  phones {
    type: WORK
    number: "01630-202053"
  }
  phones {
    type: HOME
    number: "01687-879391"
  }
}
people {
  name: "Edgar Kanne"
  email: "edgar.kanne@yahoo.com"
  phones {
    type: WORK
    number: "01326-532337"
  }
  phones {
    type: HOME
    number: "01666-638176"
  }
}
people {
  name: "Lenny Gazzola"
  email: "lenny.gazzola@yahoo.com"
  phones {
    type: WORK
    number: "01312-233253"
  }
  phones {
    type: HOME
    number: "01342-706893"
  }
}

Ejemplo de decodificación de Protocol Buffers con protoc

Si en lugar de almacenar los números de teléfono como string se almacenan en un formato más eficiente (directamente como números), la diferencia aumenta.


protoc size: 252
protoc content (hex):
0a3e0a0e4a6172657420436f7272696e6e651a18636f7272696e6e655f6a6172657440676d61696c2e636f6d220808b183a7870610022208088ab88f8f0610010a3e0a0f4665726e616e64612057726974657210011a156665726e616e6461407772697465722e636f2e756b220808c5d1ab89061002220808dffdeba40610010a3a0a0b4564676172204b616e6e6510021a1565646761722e6b616e6e65407961686f6f2e636f6d220808f18dc5f8041002220808e0c2db9a0610010a3e0a0d4c656e6e792047617a7a6f6c6110031a176c656e6e792e67617a7a6f6c61407961686f6f2e636f6d220808a5aedcf1041002220808cda9a080051001
JSON size: 614
JSON content:
 [{"id": 0, "name": "Jaret Corrinne", "email": "corrinne_jaret@gmail.com", "phones": [{"type": 2, "number": 1625932209}, {"type": 1, "number": 1642322954}]}, {"id": 1, "name": "Fernanda Writer", "email": "fernanda@writer.co.uk", "phones": [{"type": 2, "number": 1630202053}, {"type": 1, "number": 1687879391}]}, {"id": 2, "name": "Edgar Kanne", "email": "edgar.kanne@yahoo.com", "phones": [{"type": 2, "number": 1326532337}, {"type": 1, "number": 1666638176}]}, {"id": 3, "name": "Lenny Gazzola", "email": "lenny.gazzola@yahoo.com", "phones": [{"type": 2, "number": 1312233253}, {"type": 1, "number": 1342706893}]}]

Ejemplo de decodificación de Protocol Buffers con protoc

 

Uso en sistemas con pocos recursos

Para utilizar Protocol Buffers en sistemas con pocos recursos, como suelen ser los dispositivos IoT, se encuentra disponible la librería de código abierto Nanopb para ser utilizada en el lenguaje C, típicamente empleado para programar microcontroladores.

Logo de nanoPb con dos esferas conectadas en color negro, una de ellas con el símbolo Pb.
Logo de nanoPb con el símbolo del plomo (Pb).

Nanopb destaca por sus bajos requerimientos de espacio en flash y RAM, y sus facilidades para ser integrada en cualquier sistema. Concretamente, se destacan las siguientes características:

  • Tamaño típico de la librería entre 5 y 20 kB, dependiendo del procesador y compilador.
  • Baja utilización de RAM, típicamente ~1 kB de memoria en la pila.
  • Permite definir tamaños máximos para strings y arrays, facilitando la reserva estática de memoria.
  • No requiere utilizar memoria dinámica.
  • Se puede separar la parte de codificación de la de decodificación, ahorrando el tamaño en flash de la parte que no es necesaria.
  • Permite utilizar callbacks para gestionar poco a poco mensajes que no quepan al completo en la RAM disponible.

Al compilar el fichero de descripción .proto anterior, Nanopb genera los ficheros test.pb.c y test.pb.h, de los que se muestran algunos extractos destacables:


/* Enum definitions */
typedef enum _Person_PhoneType {
    Person_PhoneType_MOBILE = 0,
    Person_PhoneType_HOME = 1,
    Person_PhoneType_WORK = 2
} Person_PhoneType;

Implementación del enumerado PhoneType en C


#define Timestamp_init_zero                  	{0, 0}
#define Person_init_zero                     	{{{NULL}, NULL}, 0, {{NULL}, NULL}, {{NULL}, NULL}, false, Timestamp_init_zero}
#define Person_PhoneNumber_init_zero         	{{{NULL}, NULL}, _Person_PhoneType_MIN}
#define AddressBook_init_zero                	{{{NULL}, NULL}}

Macros para inicializar los diferentes mensajes a cero


typedef struct _Person {
    pb_callback_t name;
    int32_t id; /* Unique ID number for this person. */
    pb_callback_t email;
    pb_callback_t phones;
    bool has_last_updated;
    Timestamp last_updated;
} Person;

Estructura en C generada a partir del tipo Persona

 

Ejemplo

Como demostración del funcionamiento de los Protocol Buffers y su compatibilidad entre plataformas y lenguajes, se realizan dos ejemplos distintos pero relacionados entre sí. El primer ejemplo se realiza en un PC con Linux, usando el lenguaje Python, y el segundo en un DK (Development Kit) con un chip de Nordic (nRF52840) y en lenguaje C. Para ambos ejemplos se utiliza como punto de partida el fichero .proto explicado anteriormente en este documento y que se corresponde con la codificación de una agenda de teléfonos.

Ejemplo 1

Plataforma PC (Linux – 64 bits)
Lenguaje Python

Esta prueba tiene 2 fases distintas:

  1. Compilar el archivo .proto para que genere una librería en Python (test_pb2.py) que permita serializar y deserializar nuestros datos. En este caso, se ha instalado el programa “protoc” en un PC con Linux y se ha ejecutado el siguiente comando desde una terminal.
    protoc --proto_path=./ --python_out=. test.proto

    protoc --proto_path=./ --python_out=. test.proto

    Comando para compilar la librería test_pb2 con el archivo .proto.

  2. Crear un programa en Python que ejecute esa librería y permita crear una agenda con personas para serializar su información a través de Protocol Buffers y comparar el tamaño con la misma información en formato JSON.
    El programa realiza los siguientes pasos:
    • Carga la librería generada a través del fichero .proto (test_pb2.py) para codificar y decodificar un mensaje correspondiente a una agenda de teléfonos y direcciones (AddressBook).
      
      import test_pb2
                      

      Añade librería generada.

    • Añade 4 usuarios con sus respectivos datos y genera su codificación con Protocol Buffers y con JSON con el objetivo de realizar una comparativa directa en tamaño para transmitir la misma información usando Protocol Buffers frente a JSON.
      
      addressbook = test_pb2.AddressBook()
      addressbook_list = []
      
      person = test_pb2.Person()
      phonenumber = test_pb2.Person.PhoneNumber()
      
      person.id = 0
      person.name = "Jaret Corrinne"
      person.email = "corrinne_jaret@gmail.com"
      phonenumber.type = test_pb2.Person.PhoneType.WORK
      phonenumber.number = 1625932209
      person.phones.append(phonenumber)
      phonenumber.type = test_pb2.Person.PhoneType.HOME
      phonenumber.number = 1642322954
      person.phones.append(phonenumber)
      addressbook.people.append(person)
      addressbook_list.append(person_to_map(person))
      
      ...

      Código que añade usuarios a la agenda de tipo AddressBook.

    • Una vez ejecutado el programa, la salida confirma lo explicado en el punto anterior del documento, que hay un incremento considerable de la eficiencia al necesitar menos datos para transmitir la misma información usando Protocol Buffers frente a JSON.
      binary_out = addressbook.SerializeToString()
      print("Protoc size: " + str(len(binary_out)))
      print("Protoc content (hex): " + binary_out.hex())
      
      json_out = json.dumps(addressbook_list)
      print("Json size: " + str(len(json_out)))
      print("Json content: " + json_out)

      Código para mostrar los formatos y tamaños de cada protocolo.

NOTA: El mensaje codificado a través de “protobuf” nos sirve como entrada de datos para el siguiente ejemplo.

 

Ejemplo 2

Plataforma Development Kit PCA100056 Nordic (Stand alone – 32 bits)
Lenguaje C

Esta prueba se basa en un ejemplo de utilización de protobuf presente en el código del sistema operativo Zephyr para microcontroladores (link). Sobre este ejemplo se han realizado unas pequeñas adaptaciones para demostrar que el mensaje codificado generado en el ejemplo 1 es capaz de decodificarse en una plataforma y lenguajes distintos.

Este ejemplo utiliza la librería nanopb y proporciona un fichero .proto llamado “simple.proto” a través del cual genera dos archivos en tiempo de compilación:

  • simple.pb.h: contiene las estructuras, macros y definiciones necesarias para codificar/decodificar los protobuf.
  • simple.pb.c: contiene las macros que unen las estructuras creadas con los tipos de datos que se esperan.

Para dicha prueba, se han realizado los siguientes pasos:

  1. Añadir el fichero .proto que va a ser la base para poder codificar y decodificar de manera correcta los datos.
  2. Definir los datos codificados generados en la prueba 1 en Python, los cuales se quieren decodificar a través de la información suministrada por el .proto.
    static const uint8_t message[] = {
    0x0a, 0x3e, 0x0a, 0x0e, 0x4a, 0x61, 0x72, 0x65, 0x74, 0x20, 0x43, 0x6f, 0x72, 0x72, 0x69, 0x6e, 0x6e, 0x65, 0x1a, 0x18, 0x63, 0x6f, 0x72, 0x72, 0x69, 0x6e, 0x6e, 0x65, 0x5f, 0x6a, 0x61, 0x72, 0x65, 0x74, 0x40, 0x67, 0x6d, 0x61, 0x69, 0x6c, 0x2e, 0x63, 0x6f, 0x6d, 0x22, 0x08, 0x08, 0xb1, 0x83, 0xa7, 0x87, 0x06, 0x10, 0x02, 0x22, 0x08, 0x08, 0x8a, 0xb8, 0x8f, 0x8f, 0x06, 0x10, 0x01, 0x0a, 0x3e, 0x0a, 0x0f, 0x46, 0x65, 0x72, 0x6e, 0x61, 0x6e, 0x64, 0x61, 0x20, 0x57, 0x72, 0x69, 0x74, 0x65, 0x72, 0x10, 0x01, 0x1a, 0x15, 0x66, 0x65, 0x72, 0x6e, 0x61, 0x6e, 0x64, 0x61, 0x40, 0x77, 0x72, 0x69, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x2e, 0x75, 0x6b, 0x22, 0x08, 0x08, 0xc5, 0xd1, 0xab, 0x89, 0x06, 0x10, 0x02, 0x22, 0x08, 0x08, 0xdf, 0xfd, 0xeb, 0xa4, 0x06, 0x10, 0x01, 0x0a, 0x3a, 0x0a, 0x0b, 0x45, 0x64, 0x67, 0x61, 0x72, 0x20, 0x4b, 0x61, 0x6e, 0x6e, 0x65, 0x10, 0x02, 0x1a, 0x15, 0x65, 0x64, 0x67, 0x61, 0x72, 0x2e, 0x6b, 0x61, 0x6e, 0x6e, 0x65, 0x40, 0x79, 0x61, 0x68, 0x6f, 0x6f, 0x2e, 0x63, 0x6f, 0x6d, 0x22, 0x08, 0x08, 0xf1, 0x8d, 0xc5, 0xf8, 0x04, 0x10, 0x02, 0x22, 0x08, 0x08, 0xe0, 0xc2, 0xdb, 0x9a, 0x06, 0x10, 0x01, 0x0a, 0x3e, 0x0a, 0x0d, 0x4c, 0x65, 0x6e, 0x6e, 0x79, 0x20, 0x47, 0x61, 0x7a, 0x7a, 0x6f, 0x6c, 0x61, 0x10, 0x03, 0x1a, 0x17, 0x6c, 0x65, 0x6e, 0x6e, 0x79, 0x2e, 0x67, 0x61, 0x7a, 0x7a, 0x6f, 0x6c, 0x61, 0x40, 0x79, 0x61, 0x68, 0x6f, 0x6f, 0x2e, 0x63, 0x6f, 0x6d, 0x22, 0x08, 0x08, 0xa5, 0xae, 0xdc, 0xf1, 0x04, 0x10, 0x02, 0x22, 0x08, 0x08, 0xcd, 0xa9, 0xa0, 0x80, 0x05, 0x10, 0x01 };
    Datos codificados con protobuf en Python (Parámetros de entrada de la prueba)
            
  3. Modificar la función de decodificación que viene de base en el ejemplo para que sea capaz de entender nuestro tipo de mensaje AddressBook, tal y como se puede ver a continuación.
    
    /* Allocate space for the decoded message. */
    AddressBook message = AddressBook_init_zero;
    
    /* Create a stream that reads from the buffer. */
    pb_istream_t stream = pb_istream_from_buffer(buffer, message_length);
    
    /* Now we are ready to decode the message. */
    status = pb_decode(&stream, AddressBook_fields, &message);
            

    Función editada para decodificar nuestro protobuf de tipo AddressBook

  4. Implementar un bucle que muestre la información decodificada
    
        for (int i = 0 ; i < message.people_count ; i++) {
    
            printk("Name: %s\n", message.people[i].name);
            printk(" Email: %s\n", message.people[i].email);
    
            for(int i = 0 ; i < message.people[i].phones_count ; i++) {
    
                uint8_t phone_type[12] = {0};
    
                if(message.people[i].phones[i].type ==
                    Person_PhoneType_MOBILE ) {
                    strncpy(phone_type, "Mobile", strlen("Mobile"));
                } else if (message.people[i].phones[i].type ==
                    Person_PhoneType_HOME) {
                    strncpy(phone_type, "Home", strlen("Home"));
                } else if(message.people[i].phones[i].type ==
                    Person_PhoneType_WORK) {
                    strncpy(phone_type, "Work", strlen("Work"));
                }
    
                printk(" Phone%d(%s): %lld\n",
                        i,
                        phone_type,
                        message.people[i].phones[i].number);
            }
            printk("\n");
        }
            

    Función de visualización de datos decodificados

  5. Implementar la función main con una llamada a la función de decodificación utilizando como argumento de entrada el mensaje definido anteriormente.
  6. Añadir el fichero de cabeceras de la librería generada a través del fichero .proto en tiempo de compilación.
  7. Una vez ejecutado el ejemplo, la salida generada coincide con los miembros añadidos a la agenda en el ejemplo anterior de Python y sus datos asociados, como se puede ver a continuación.
    
    *** Booting nRF Connect SDK v2.5.1 ***
    
    Name: Jaret Corrinne
     Email: corrinne_jaret@gmail.com
     Phone0(Work): 1625932209
     Phone1(Home): 1687879391
    
    Name: Fernanda Writer
     Email: fernanda@writer.co.uk
     Phone0(Work): 1625932209
     Phone1(Home): 1687879391
    
    Name: Edgar Kanne
     Email: edgar.kanne@yahoo.com
     Phone0(Work): 1625932209
     Phone1(Home): 1687879391
    
    Name: Lenny Gazzola
     Email: lenny.gazzola@yahoo.com
     Phone0(Work): 1625932209
     Phone1(Home): 1687879391
    Salida del programa en C
            

 

Referencias

En esta sección encontrarás recursos útiles y ejemplos para trabajar con Protocol Buffers y NanoPB, incluyendo documentación oficial y ejemplos de código en GitHub.

 

Créditos

Coautores: Manuel Flores Llorente y Víctor Serrano Ruíz-Valdepeñas

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