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:
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.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.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
Devuelvetrue
si el campofoo
tiene un valor.int32 foo() const
Devuelve el valor del campofoo
. 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 ahas_foo()
devolveríatrue
.void clear_foo()
Limpia el valor del campo. Después de llamar a este método, llamar ahas_foo()
devolveríafalse
.
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 objetoRepeatedField
que 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 objetoRepeatedPtrField
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 objetoRepeatedPtrField
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 argumentooutput
. Nótese que estos bytes son binarios, no texto, y que la clasestd::string
se usa como un mero contenedor.bool ParseFromString(const string& data)
Deserializa un mensaje codificado en la cadena especificada en el argumentodata
.bool SerializeToOstream(ostream* output) const
Escribe el mensaje serializado en el flujoostream
indicado.bool ParseFromIstream(istream* input)
Deserializa un mensaje leido del flujoistream
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
Protocol Buffers — Protocol Buffers Documentation
protobuf — GitHub
Are there C++ equivalents for the Protocol Buffers delimited I/O functions in Java? — Stack Overflow
Length prefix for protobuf messages in C++ — Stack Overflow