The automation of read/write operations of the message required fields to expose predefined minimal interface:
classSomeField{public: // Value storage type definitionusingValueType= ...; // Provide an access to the stored valueValueType&value();constValueType&value() const; // Read (deserialise) and update internal valuetemplate <typenameTIter>ErrorStatusread(TIter& iter, std::size_t len); // Write (serialise) internal valuetemplate <typenameTIter>ErrorStatuswrite(TIter& iter, std::size_t len) const; // Get the serialisation length std::size_tlength() const;private: ValueType m_value;}
The read/write operations will probably require knowledge about the serialisation endian used for the protocol. We need to come up with the way to convey the endian information to the field classes. I would recommend doing it by having common base class for all the fields:
namespace comms{template <boolTHasLittleEndian>classField{protected: // Read value using appropriate endiantemplate <typenameT,typenameTIter>staticTreadData(TIter& iter) {...} // Read partial value using appropriate endiantemplate <typenameT, std::size_tTSize,typenameTIter>staticTreadData(TIter& iter) {...} // Write value using appropriate endiantemplate <typenameT,typenameTIter>staticvoidwriteData(T value,TIter& iter) {...} // Write partial value using appropriate endiantemplate <std::size_tTSize,typenameT,typenameTIter>staticvoidwriteData(T value,TIter& iter)}} // namespace comms
The choice of the right endian may be implemented using Tag Dispatch Idiom.
namespace comms{template <boolTHasLittleEndian>classField{protected: // Read value using appropriate endiantemplate <typenameT,typenameTIter>staticTreadData(TIter& iter) { // Dispatch to appropriate read functionreturnreadDataInternal<T>(iter,Tag()); } ...private: BigEndianTag {}; LittleEndianTag {};usingTag=typename std::conditional< THasLittleEndian, LittleEndianTag, BigEndianTag>::type; // Read value using big endiantemplate <typenameT,typenameTIter>staticTreadBig(TIter& iter) {...} // Read value using little endiantemplate <typenameT,typenameTIter>staticTreadLittle(TIter& iter) {...} // Dispatch to readBig()template <typenameT,typenameTIter>staticTreadDataInternal(TIter& iter,BigEndianTag) {returnreadBig<T>(iter); } // Dispatch to readLittle()template <typenameT,typenameTIter>staticTreadDataInternal(TIter& iter,LittleEndianTag) {returnreadLittle<T>(iter); } };} // namespace comms
Every field class should receive its base class as a template parameter and may use available readData() and writeData() static member functions when serialising/deserialising internal value in read() and write() member functions.
When the endian is known and fixed (for example when implementing third party protocol according to provided specifications), and there is little chance it's ever going to change, the base class for all the fields may be explicitly defined:
usingMyProjField= comms::Field<false>; // Use big endian for fields serialisationusingMyIntField= comms::IntValueField<MyProjField>;
However, there may be the case when the endian information is not known up front, and the one provided to the message interface definition (comms::Message) must be used. In this case, the message interface class may define common base class for all the fields:
The Common Field Types chapter described most common types of fields with various serialisation and handling nuances, which can be used to implement a custom communication protocol.
Let's take the basic integer value field as an example. The most common way to serialise it is just read/write its internally stored value as is. However, there may be cases when serialisation takes limited number of bytes. Let's say, the protocol specification states that some integer value consumes only 3 bytes in the serialised bytes sequence. In this case the value will probably be be stored using std::int32_t or std::uint32_t type. The field class will also require different implementation of read/write/length functionality.
Another possible case is a necessity to add/subtract some predefined offset to/from the value being serialised and subtracting/adding the same offset when the value is deserialised. Good example of such case would be the serialisation of a year information, which is serialised as an offset from year 2000 and consumes only 1 byte. It is possible to store the value as a single byte (comms::IntValueField<..., std::uint8_t>), but it would be very inconvenient. It is much better if we could store a normal year value (2015, 2016, etc ...) using std::uint16_t type, but when serialising, the values that get written are 15, 16, etc... NOTE, that such field requires two steps in its serialisation logic:
add required offset (-2000 in the example above)
limit the number of bytes when serialising the result
Another popular way to serialise integer value is to use Base-128 encoding. In this case the number of bytes in the serialisation sequence is not fixed.
What if some protocol decides to serialise the same offset from year 2000, but using the Base-128 encoding? It becomes obvious that having a separate field class for every possible variant is impractical at least. There must be a way to split the serialisation logic into small chunks, which can be applied one on top of another.
Using the same idea of the options and adapting the behaviour of the field class accordingly, we can generalise all the fields into a small subset of classes and make them also part of our generic library.
The options described earlier may be defined using following option classes:
namespace comms{namespace option{// Provide fixed serialisation lengthtemplate<std::size_tTLen>structFixedLength {};// Provide numeric offset to be added to the value before serialisationtemplate<std::intmax_tTOffset>structNumValueSerOffset {};// Force using variable length (base-128 encoding) while providing// minimal and maximal allowed serialisation lengths.template<std::size_tTMin, std::size_tTMax>structVarLength {};} // namespace option} // namespace comms
Parsing the Options
In a very similar way to parsing options of the message interface (comms::Message) and message implementation (comms::MessageBase) described in earlier chapters, we will create a struct, that will contain all the provided information to be used later.
Before parsing the options and assembling the right functionality there is a need to start with basic integer value functionality:
namespace comms{template <typenameTFieldBase,typenameTValueType>classBasicIntValue:publicTFieldBase{public:usingValueType=TValueType; ... // rest of the interfaceprivate: ValueType m_value;};} // namespace comms
Such field receives its base class and the type of the value it stores. The implementation of read/write/length functionalities are very basic and straightforward.
Now, we need to prepare various adaptor classes that will wrap or replace the existing interface functions:
namespace comms{template <std::intmax_tTOffset,typenameTNext>classSerOffsetAdaptor{public: ... // public interfaceprivate: TNext m_next;};template <std::size_tTLen,typenameTNext>classFixedLengthAdaptor{public: ... // public interfaceprivate: TNext m_next;};... // and so on} // namespace comms
NOTE, that the adaptor classes above wrap one another (TNext template parameter) and either replace or forward the read/write/length operations to the next adaptor or final BasicIntValue class, instead of using inheritance as it was with message interface and implementation chunks. The overall architecture presented in this book doesn't require the field classes to exhibit polymorphic behaviour. That's why using inheritance between adaptors is not necessary, although not forbidden either. Using inheritance instead of containment has its pros and cons, and at the end it's a matter of personal taste of what to use.
Now it's time to use the parsed options and wrap the BasicIntValue with required adaptors:
The final stage is to actually define final IntValueField type:
namespace comms{template <typenameTBase,typenameTValueType,typename... TOptions>classIntValueField{usingBasic=BasicIntValue<TBase,TValueType>;usingAdapted=typename FieldBuilder<Basic, TOptions...>::Type;public:usingValueType=typename Adapted::ValueType; // Just forward all the API requests to the adapted field.ValueType&value() {returnm_adapted.value(); }constValueType&value() const {returnm_adapted.value(); }template <typenameTIter>ErrorStatusread(TIter& iter, std::size_t len) {returnm_adapted.read(iter, len); } ...private: Adapted m_adapted;};} // namespace comms
The definition of the year field which is serialised using offset from year 2000 may be defined as:
usingMyFieldBase= comms::Field<false>; // use big endianusingMyYear= comms::IntValueField< MyFieldBase, std::uint16_t, // store as 2 bytes unsigned value comms::option::NumValueSerOffset<-2000>, comms::option::FixedLength<1>>;
Other Options
In addition to options that regulate the read/write behaviour, there can be options which influence how the field is created and/or handled afterwards.
For example, there may be a need to set a specific value when the field object is created (using default constructor). Let's introduce a new options for this purpose:
Please note, that both comms::option::DefaultValueInitialiser option and DefaultValueInitAdaptor adaptor class are completely generic, and they can be used with any type of the field.
For numeric fields, such as IntValueField defined earlier, the generic library may provide built-in setter class:
And then, create a convenience alias to DefaultValueInitialiser option which receives a numeric value as its template parameter and insures that the field's value is initialised accordingly:
As the result, the making the year field to be default constructed with value 2016 may look like this:
usingMyFieldBase= comms::Field<false>; // use big endianusingMyYear= comms::IntValueField< MyFieldBase, std::uint16_t, // store as 2 bytes unsigned value comms::option::NumValueSerOffset<-2000>, comms::option::FixedLength<1>, comms::option::DefaultNumValue<2016>>;
Other Fields
The Common Field Types chapter mentions multiple other fields and several different ways to serialise them. I'm not going to describe each and every one of them here. Instead, I'd recommend taking a look at the documentation of the COMMS library which was implemented using ideas from this book. It will describe all the fields it implements and their options.
Eliminating Dynamic Memory Allocation
Fields like String or List may contain variable number of characters/elements. The default internal value storage type for such fields will probably be std::string or std::vector respectively. It will do the job, mostly. However, they may not be suitable for bare-metal products that cannot use dynamic memory allocation and/or exceptions. In this case there must be a way to easily substitute these types with alternatives, such as custom StaticString or StaticVector types.
Let's define a new option that will provide fixed storage size and will force usage of these custom types instead of std::string and std::vector.
Now, let's implement the logic of choosing StaticString as the value storage type if the option above is used and choosing std::string if not.
// TOptions is a final variant of FieldParsedOptions<...>template <typenameTOptions,boolTHasFixedStorage>structStringStorageType;template <typenameTOptions>structStringStorageType<TOptions,true>{typedef comms::util::StaticString<TOptions::FixedSizeStorage> Type;};template <typenameTOptions>structStringStorageType<TOptions,false>{typedef std::string Type;};template <typenameTOptions>usingStringStorageTypeT=typename StringStorageType<TOptions, TOptions::HasFixedSizeStorage>::Type;
comms::util::StaticString is the implementation of a string management class, which exposes the same public interface as std::string. It receives the fixed size of the storage area as a template parameter, uses std::array or similar as its private data member the store the string characters.
The implementation of the String field may look like this:
template <typenameTBase,typename... TOptions>classStringField{public: // Parse the option into the structusingParsedOptions=FieldParsedOptions<TOptions...>; // Identify storage type: StaticString or std::stringusingValueType=StringStorageTypeT<ParsedOptions>; // Use the basic field and wrap it with adapters just like IntValueField earlierusingBasic=BasicStringValue<TBase,ValueType>;usingAdapted=typename FieldBuilder<Basic, TOptions...>::Type; // Just forward all the API requests to the adapted field.ValueType&value() {returnm_adapted.value(); }constValueType&value() const {returnm_adapted.value(); }template <typenameTIter>ErrorStatusread(TIter& iter, std::size_t len) {returnm_adapted.read(iter, len); } ...private: Adapted m_adapted;};} // namespace comms
As the result the definition of the message with a string field that doesn't use dynamic memory allocation may look like this:
And what about the case, when there is a need to create a message with a string field, but substitute the underlying default std::string type with StaticStringonly when compiling the bare-metal application? In this case the ActualMessage3 class may be defined to have additional template parameter which will determine the necessity to substitute the storage type.
Thanks to the fact that StaticString and std::string classes expose the same public interface, the message handling function doesn't need to worry about actual storage type. It just uses public interface of std::string:
classMsgHandler{public:voidhandle(ActualMessage3& msg) {auto& fields =msg.fields();auto& stringField = std::get<0>(fields); // The type of the stringVal is either std::string or StaticStringauto& stringVal =stringField.value();if (stringVal =="string1") { ... // do something }elseif (stringVal =="string2") { ... // do something else } }};
Choosing internal value storage type for List fields to be std::vector or StaticVector is very similar.