Implementando un protocolo con Protocol Buffers

Implementando un protocolo con Protocol Buffers

Protocol Buffers es un mecanismo sencillo para serializar estructuras de datos, de tal forma que los datos así codificados pueden ser almacenados o enviados a través de una red de comunicaciones. Esto nos ofrece una forma sencilla de crear nuestro propio protocolo de comunicaciones, adaptado a las necesidades de un problema concreto.

Los pasos concretos para usar Protocol Buffers son lo siguientes:

  1. Especificar la estructura de datos del mensaje del nuevo protocolo en un archivo .proto. Estos archivos se escriben utilizando un lenguaje de descripción de interfaz que es propio de Protocol Buffers.

  2. Ejecutar el compilador de Protocol Buffers, para el lenguaje de la aplicación, sobre el archivo .proto con el objeto de generar las clases de acceso a los datos. Estas proporcionan accesores para cada campo, así como métodos para serializar y deserializar los mensajes a y desde una secuencia de bytes.

  3. Incluir las clases generadas en nuestra aplicación y usarlas para generar instancias del mensaje, serializarlas y enviar los mensajes codificados o leer dichos mensajes, deserializarlos y reconstruir las instancias de los mensajes para acceder a sus campos.

Definir la estructura del mensaje

Supongamos que conectados a una red tenemos un conjunto de Arduinos equipados con varios sensores de diferente tipo: temperatura, humedad, luminosidad, movimiento, etc. Cada Arduino tiene un nombre que lo identifica y su función es leer el estado de dichos sensores, a intervalos regulares, y enviar mensajes con los datos de los mismos a un servidor.

Teniendo esto presente, el archivo .proto podría ser el siguiente:

// sensorsreport.proto - Protocolo de comunicaciones con Arduino
//
message SensorsReport {
    required string deviceName = 1;     // Nombre del Arduino
    required uint64 timestamp = 2;      // Segundos desde 1/1/1970

    enum SensorType {
        HUMIDITY = 0;
        LUMINOSITY = 1;
        MOTION = 2;
        TEMPERATURE = 3;
    }

    message SensorStatus {
        required SensorType type = 1;
        required int32 value = 2;
    }

    repeated SensorStatus sensors = 3;   // Vector de estados de los sensores
}

Como se puede observar, el lenguaje usado en los archivos .proto es muy sencillo. Solamente hay que indicar el nombre y el tipo de cada campo, así como si es opcional (optional), requerido (required) o se repite (repeated).

En Protocol Buffers los campos se etiquetan de manera única con un entero que después es utilizado en la codificación binaria para identificarlos.

Clases de acceso a los datos

Una vez tenemos la definición de la estructura del mensaje, podemos invocar al compilador de Protocol Buffers para generar las clases de acceso a los datos.

Desde línea de comandos

Desde línea de comandos generar las clases es tan sencillo como invocar el compilador de la siguiente manera:

protoc --cpp_out=. sensorsreport.proto

que genera los archivos sensorsreport.pb.cc y sensorsreport.pb.h en el directorio actual. Después se debe incluir el archivo de cabecera en nuestro código fuente allí donde vaya a ser utilizado:

#include "sensorsreport.pb.h"

Y finalmente compilar el ejecutable junto con el archivo sensorsreport.pb.cc y enlazar con la librería protobuf.

Con qmake

Si estamos usando qmake para construir nuestro proyecto (como es el caso cuando desarrollamos con el IDE Qt Creator) lo más cómodo es que este se encargue de invocar al compilador Protocol Buffers para generar las clases de acceso de forma automática.

En este sentido el archivo protobuf.pri del proyecto ostinato puede ser de gran ayuda con algunos cambios:

#
# Qt qmake integration with Google Protocol Buffers compiler protoc
#
# To compile protocol buffers with qt qmake, specify PROTOS variable and
# include this file
#
# Example:
# PROTOS = a.proto b.proto
# include(protobuf.pri)
# LIBS += -lprotobuf
#

message("Generating protocol buffer classes from .proto files.")

protobuf_decl.name = protobuf header
protobuf_decl.input = PROTOS
protobuf_decl.output = ${QMAKE_FILE_BASE}.pb.h
protobuf_decl.commands = protoc --cpp_out="." --proto_path=${QMAKE_FILE_IN_PATH} ${QMAKE_FILE_NAME}
protobuf_decl.variable_out = HEADERS
QMAKE_EXTRA_COMPILERS += protobuf_decl

protobuf_impl.name = protobuf implementation
protobuf_impl.input = PROTOS
protobuf_impl.output = ${QMAKE_FILE_BASE}.pb.cc
protobuf_impl.depends = ${QMAKE_FILE_BASE}.pb.h
protobuf_impl.commands = $$escape_expand(\n)
protobuf_impl.variable_out = SOURCES
QMAKE_EXTRA_COMPILERS += protobuf_impl

Para usarlo sólo tenemos que:

  • Crear el archivo protobuf.pri con el contenido anterior en el directorio del proyecto.

  • Abrir el archivo .pro del proyecto y añadir las líneas:

PROTOS = sensorsreport.proto
include(protobuf.pri)
LIBS += -lprotobuf

Finalmente sólo tenemos que compilar el proyecto y obtendremos los archivos sensorsreport.pb.cc y sensorsreport.pb.h que hemos mencionado.

Interfaz de Protocol Buffers

Si abrimos el archivo sensorsreport.pb.h veremos que la clase SensorsReport nos ofrece los siguientes accesores:

class SensorReport
{
    // Declaraciones previas...

    // required string deviceName = 1;
    inline bool has_devicename() const;
    inline void clear_devicename();
    inline const ::std::string& devicename() const;
    inline void set_devicename(const ::std::string& value);
    inline void set_devicename(const char* value);
    inline void set_devicename(const char* value, size_t size);
    inline ::std::string* mutable_devicename();
    inline ::std::string* release_devicename();

    // required uint64 timestamp = 2;
    inline bool has_timestamp() const;
    inline void clear_timestamp();
    inline ::google::protobuf::uint64 timestamp() const;
    inline void set_timestamp(::google::protobuf::uint64 value);

    // repeated SensorsReport.SensorStatus sensors = 3;
    inline int sensors_size() const;
    inline void clear_sensors();
    inline const ::SensorsReport_SensorStatus& sensors(int index) const;
    inline ::SensorsReport_SensorStatus* mutable_sensors(int index);
    inline ::SensorsReport_SensorStatus* add_sensors();
    inline const ::google::protobuf::RepeatedPtrField<
        ::SensorsReport_SensorStatus >& sensors() const;
    inline ::google::protobuf::RepeatedPtrField<
        ::SensorsReport_SensorStatus >* mutable_sensors();

    // Declaraciones posteriores...
};

a los campos del mensaje. Además se define el enum SensorsReport::SensorStatus y la clase SensorsReport::SensorStatus.

Todos los detalles sobre el código generado por el compilador están documentados en la referencia del código generado en C++. Eso incluye los accesores creados según el tipo de definición de los campos.

Veamos algunos ejemplos.

Campos individuales de tipos básicos

Para definiciones de este tipo:

optional int32 foo = 1;
required int32 foo = 1;

el compilador genera los siguientes accesores:

  • bool has_foo() const Devuelve true si el campo foo tiene un valor.

  • int32 foo() const Devuelve el valor del campo foo. Si el campo no tiene valor, devuelve el valor por defecto.

  • void set_foo(int32 value) Fija el valor del campo. Después de llamar a este método, llamar a has_foo() devolvería true.

  • void clear_foo() Limpia el valor del campo. Después de llamar a este método, llamar a has_foo() devolvería false.

que nos permiten hacer cosas tales como:

SensorsReport report;

report.set_devicename("ARDUINO01");
report.set_timestamp(1362507283);

cout << "Device name: " << report.devicename() << '\n';
cout << "Timestamp: " << report.timestamp() << '\n';

Campos de tipos básicos con repeticiones

Mientras que para definiciones de este tipo:

repeated int32 foo = 1;

El compilador genera los siguientes accesores:

  • int foo_size() const Devuelve el número de elementos en el campo.

  • int32 foo(int index) const Devuelve el elemento en el índice indicado.

  • void set_foo(int index, int32 value) Fija el valor del elemento en el índice indicado.

  • void add_foo(int32 value) Añade un nuevo elemento con el valor indicado.

  • void clear_foo() Elimina todos los elementos del campo.

  • const RepeatedField& foo() const Devuelve el objeto RepeatedFieldque almacena todos los elementos. Este contenedor proporciona iteradores al estilo de otros contenedores de la STL.

Campos de tipo mensaje embebido con repeticiones

Un mensaje puede contener campos cuyo tipo es otro tipo de mensaje. Son los denominados campos de tipo mensaje embebido. Por ejemplo, si queremos un campo que admita varios mensajes de tipo MyMessage —que a su vez es un mensaje— sólo tenemos que añadir lo siguiente:

repeated MyMessage foo = 1;

Entonces el compilador generá los siguientes accesores:

  • int foo_size() const Devuelve el número de elementos en el campo.

  • const MyMessage& foo(int index) const Devuelve el elemento en el índice indicado.

  • MyMessage* mutable_foo(int index) Devuelve un puntero al elemento mutable en el índice indicado.

  • MyMessage* add_foo() Añade un nuevo elemento y devuelve un puntero a él con el valor indicado.

  • void clear_foo() Elimina todos los elementos del campo.

  • const RepeatedPtrField& foo() const Devuelve el objeto RepeatedPtrField que almacena todos los elementos. Este contenedor proporciona iteradores al estilo de otros contenedores de la STL.

  • RepeatedField* mutable_foo() const Devuelve un puntero al objeto RepeatedPtrField mutable que almacena todos los elementos. Este contenedor también proporciona iteradores al estilo de otros contenedores de la STL, sólo que en este caso se puede usar para modificar los elementos almacenados.

que podemos usar de la siguiente manera:

SensorsReport report;

SensorsReport::SensorsStatus* sensors = report.add_sensors();
sensors->set_type(SensorsReport::TEMPERATURE);
sensors>set_value(25);

cout << "Temperature: " << sensors->value() << '\n';

Serialización y deserialización

Cada clase de un mensaje ofrece un conjunto de métodos para codificar —serializar— y decodificar —deserializar— los mensajes:

  • bool SerializeToString(string* output) const Serializa el mensaje y almacena los bytes en la cadena especificada en el argumento output. Nótese que estos bytes son binarios, no texto, y que la clase std::string se usa como un mero contenedor.

  • bool ParseFromString(const string&amp; data) Deserializa un mensaje codificado en la cadena especificada en el argumento data.

  • bool SerializeToOstream(ostream* output) const Escribe el mensaje serializado en el flujo ostream indicado.

  • bool ParseFromIstream(istream* input) Deserializa un mensaje leido del flujo istream indicado.

Almacenamiento y transmisión por red de múltiples mensajes

El formato de codificación de Protocol Buffers no está auto-limitado. Es decir, no incluye marcas que permitan identificar el principio y fin de los mensajes. Esto es un problema si se quieren almacenar o enviar varios mensajes en un mismo flujo de datos.

La forma más sencilla de resolverlo es comenzar escribiendo el tamaño del mensaje codificado y después escribir el mensaje en si mismo.

// Serializar el mensaje
std::string buffer;
report.SerializeToString(&buffer);
uint32 bufferSize = buffer.size();

// Abrir el archivo de destino y escribir el mensaje
//
// std::ofstream ofs(...);
//
ofs.write(reinterpret_cast<char*>(&bufferSize),
sizeof(bufferSize));
ofs.write(buffer.c_str(), bufferSize);

Al leer, se lee primero el tamaño del mensaje, después leer los bytes indicados en un buffer independiente y finalmente se deserializa el mensaje desde dicho buffer.

// Abrir el archivo de origen y leer el tamaño del mensaje
//
// std::ifstream ifs(...);
//
uint32 bufferSize;
ifs.read(&bufferSize, sizeof(bufferSize));

// Leer el mensaje
std::string buffer;
buffer.resize(bufferSize);
ifs.read(const_cast<char*>(buffer.c_str()), bufferSize);

// Deserializar
report.ParseFromString(buffer);

En la misma documentación de la librería se nos sugiere una solución más conveniente usando las clases CodedInputStream y CodedOutputStream:

If you want to avoid copying bytes to a separate buffer, check out the CodedInputStream class (in both C++ and Java) which can be told to limit reads to a certain number of bytes.

Referencias