cc_tutorial/tutorials/tutorial1 at master · commschamp/cc_tutorial

Tutorial 1

Introducing basic protocol definition, polymorphic message interfaces, and dispatching message object into appropriate handling function.

CommsDSL Schema

The schema file contains very simple protocol definition. There are two messages without any internal fields. The enum MsgId field is used to define numeric IDs of the messages. The framing is also very simple, it just prefixes message payload (empty in this specific case) with numeric message ID.

Several highlights:

  • The name property of the <schema> XML node will be a default main namespace of the protocol code. The code generated has a command line option to overwrite it.
  • The protocol endian is defined as endian="big" property of the main <schema> XML node.
  • The global fields (that can be references by messages and/or other fields) are defined as members of <fields> XML node.
  • The enum field is defined by <enum> XML node.
  • When enum is used to define numeric message IDs it needs to be marked as such using semanticType="messageId" property assignment.
  • The messages are defined using <message> XML node.
  • The transport framing is defined using <frame> XML node.

SIDE NOTE: Almost every element in CommsDSL schema has one or more properties, such as name. Any property can be defined using multiple ways. In can be useful when an element has too many properties to specify in a single line for a convenient reading. Any of the described below supported ways of defining a single property can be used for any element in the schema.

The property can be defined as an XML attribute.

<message name="Msg1" id="MsgId.M1" displayName="Message 1" />

Or as child node with value attribute:

<message>
    <name value="Msg1" />
    <id value="MsgId.M1" />
    <displayName value="Message 1" />
</message>

Property value can also be defined as a text of the child XML element.

<message>
    <name>Msg1</name>
    <id>MsgId.M1</id>
    <displayName>Message 1</displayName>
</message>

It is allowed to mix ways of defining properties for a single element

<message name="Msg1">
    <id value="MsgId.M1" />
    <displayName>Message 1</displayName>
</message>

Generated Code

In general, the generated code uses COMMS Library, which was designed to allow having single and functionally correct protocol definition for all possible applications, while allowing the latter to compile-time customize their data structures, polymorphic interfaces, and/or relevant / efficient message processing logic.

Let's take a look at generated code relevant for this tutorial.

The numeric message IDs find their way to MsgId enum definition inside include/tutorial1/MsgId.h.

To allow polymorphic behavior of the message objects, there is a need for a common interface class for all the messages. The relevant interface class for all the messages resides in include/tutorial1/Message.h. Please take a look at its definition.

template <typename... TOpt>
using Message =
    comms::Message<
        TOpt...,
        comms::option::def::BigEndian,
        comms::option::def::MsgIdType<tutorial1::MsgId>
    >;

It uses comms::Message class definition and allows extension of its default functionality with various options. The options that are intended to be used for protocol definition reside in comms::option::def namespace, while options that are intended to be used by the end application for its customization are defined in comms::option::app namespace. In this tutorial, the used options specify the serialization endian (Big) and enum type used for message numeric IDs (MsgId). The TOpt variadic template parameter allows introducing additional application specific customization options. In this specific tutorial they are going to be used to introduce polymorphic interface (virtual functions) for protocol message objects.

All the defined message classes (Msg1 and Msg2) reside in message namespace and in include/tutorial1/message folder. Every MsgX.h file contains class definition of the relevant message and its fields. The corresponding MsgXCommon.h file contains common, template-parameter independent definitions relevant to the message and its fields.

The message definition class receives its common interface (base) class as the first template parameter.

template <typename TMsgBase, ... /* irrelevant for now */>
class Msg1 : public
    comms::MessageBase<
        TMsgBase,
        ... /* irrelevant for now */
    >

The defined message class extends comms::MessageBase, which in turn re-uses the first template parameter passed to extending class Msg1 as its first template parameter. It causes the comms::MessageBase to extend the provided interface class passed as first template parameter.

The inheritance hierarchy looks like this:

(Msg1<TMsgBase>) --> (MessageBase<TMsgBase, ...>) --> TMsgBase

The comms::MessageBase class is there to automatically implement various operations of message payload as well as implement all relevant virtual functions depending on the used message interface class extension options. In case the application doesn't really customize the common message interface (The TOpt variadic template parameter is empty) the message object is like a struct of stored fields without any polymorphic behavior.

The transport framing of the message payload is defined inside include/tutorial1/frame/Frame.h file. It manages all wrap / unwrap of the message payload with extra transport fields. This tutorial just prefixes it with numeric message ID, more complex framing will be introduced and demonstrated in later tutorial(s).

Client / Server Sessions

Every tutorial (not just this one) uses common I/O management code, which operates on Session object(s). Every tutorialX / howtoX is expected to extend it and implement all the relevant virtual functions to make the common code function properly. The sessions are split into server and client ones. The server side code is implemented in src/SeverSession.h and src/ServerSession.cpp. The client side code is implemented in src/ClientSession.h and src/ClientSession.cpp respectively.

NOTE that these client / server session classes are part of the end applications and perform their own application specific compile time configurations. They are NOT actual part of the CommsChampion Ecosystem and do NOT necessarily demonstrate the right and only way to implement protocol handling code.

Server

Let's take a look at definition of the message interface class inside SeverSession.h

using Message =
    tutorial1::Message<
        comms::option::app::ReadIterator<const std::uint8_t*>, // Polymorphic read
        comms::option::app::WriteIterator<std::uint8_t*>, // Polymorphic write
        comms::option::app::LengthInfoInterface, // Polymorphic length calculation
        comms::option::app::IdInfoInterface, // Polymorphic message ID retrieval
        comms::option::app::NameInterface // Polymorphic message name retrieval
    >;

The generated tutorial1::Message common interface class is extended with multiple options, which create various function with polymorphic behavior.


SIDE NOTE: Polymorphic behavior implies usage of virtual function(s). To implement it the COMMS Library uses Non-Virtual Interface Idiom. Something like:

class Message
{
public:
    void someFunc()
    {
        someFuncImpl();
    }

protected:
    virtual void someFuncImpl() = 0;
};

Polymorphic Read

Usage of comms::option::app::ReadIterator option adds the following type and functions to the message interface.

class Message
{
public:
    // Type of the iterator used for reading
    using ReadIterator = const std::uint8_t*;

    // Polymorphic read functionality
    comms::ErrorStatus read(ReadIterator& iter, std::size_t len)
    {
        return readImpl(iter, len);
    }

protected:
    // To be provided by the derived class
    virtual comms::ErrorStatus readImpl(ReadIterator& iter, std::size_t len) = 0;
};

Please pay attention to the following details:

  • The read() function is polymorphic, i.e. the read functionality is performed correctly when the message object is held by the pointer or reference to the interface class.
  • The read() function reports its success / failure status via error code defined as comms::ErrorStatus.
  • The read operation receives the iterator used for reading by reference and advances it when performing the read operation. In order to identify how many bytes were consumed, the caller is expected to store the initial value of the iterator and then compare it to the value of the advanced one passed as the parameter to the read() function. The caller is also responsible to maintain input data buffer and provides only an iterator for the message object to perform its read.

The comms::Message class defines constexpr bool hasRead() static member function which can be used at compile time to determine whether the interface class defines polymorphic read functionality.

static_assert(Message::hasRead(), "Missing polymorphic read");

Polymorphic Write

Usage of comms::option::app::WriteIterator option adds the following type and function to the message interface.

class Message
{
public:
    // Type of the iterator used for writing
    using WriteIterator = std::uint8_t*;

    // Polymorphic write functionality
    comms::ErrorStatus write(WriteIterator& iter, std::size_t len) const
    {
        return writeImpl(iter, len);
    }

protected:
    // To be provided by the derived class
    virtual comms::ErrorStatus write(WriteIterator& iter, std::size_t len) const = 0;
};

It is very similar to the polymorphic read mentioned earlier.

  • The write() function is polymorphic.
  • The success / failure status is reported via comms::ErrorStatus return value.
  • The iterator passed to write() function is advanced during write operation. The caller is responsible to maintain output data buffer and provides only an iterator to it for message object to perform its write.
  • The call to write() member function is not expected to change the message object, that's why it's defined to be const.

The comms::Message class defines constexpr bool hasWrite() static member function which can be used at compile time to determine whether the interface class defines polymorphic write functionality.

static_assert(Message::hasWrite(), "Missing polymorphic write");

Polymorphic Serialization Length Calculation.

Usage of comms::option::app::LengthInfoInterface option adds the following interface function to the defined class.

class Message
{
public:
    // Polymorphic serialization length calculation
    std::size_t length() const
    {
        return lengthImpl();
    }

protected:
    // To be provided by the derived class
    virtual std::size_t lengthImpl() const = 0;
};

The call to length() member function will return number of bytes required to serialize the message payload. It can be used to allocate output buffer of required size.

The comms::Message class defines constexpr bool hasLength() static member function which can be used at compile time to determine whether the interface class defines polymorphic length calculation functionality.

static_assert(Message::hasLength(), "Missing polymorphic length");

Polymorphic Message ID Retrieval

Usage of comms::option::app::IdInfoInterface option adds the following type and interface function to the defined class.

class Message
{
public:
    // Define type used to report message ID
    using MsgIdType = tutorial1::MsgId;

    // Polymorphic numeric ID retrieval
    MsgIdType getId() const
    {
        return getIdImpl();
    }

protected:
    // To be provided by the derived class
    virtual MsgIdType getIdImpl() const = 0;
};

The polymorphic message ID retrieval can be used in transport framing operation. There is a need to get the message ID when prefixing serialized message payload.

The comms::Message class defines constexpr bool hasGetId() static member function which can be used at compile time to determine whether the interface class defines polymorphic ID retrieval functionality.

static_assert(Message::hasGetId(), "Missing polymorphic getId");

Polymorphic Message Name Retrieval

Usage of comms::option::app::NameInterface option adds the following function to the message interface.

class Message
{
public:
    // Polymorphic name retrieval
    const char* name() const
    {
        return nameImpl();
    }

protected:
    // To be provided by the derived class
    virtual const char* name() const = 0;
};

The polymorphic name retrieval can be used in application when there is a need to print human readable name of the message. Note that such name is provided as displayName property of the message definition inside CommsDSL schema.

<message name="Msg1" id="MsgId.M1" displayName="Message 1"/>

The comms::Message class defines constexpr bool hasName() static member function which can be used at compile time to determine whether the interface class defines polymorphic name retrieval functionality.

static_assert(Message::hasName(), "Missing polymorphic name");

Virtual Destructor

Existence of polymorphic (virtual) functions in the common interface class definition also implies existence of the virtual destructor. It can be checked using standard type traits:

static_assert(std::has_virtual_destructor<Message>::value, "Destructor is not virtual");

Other Polymorphic Functions

The comms::Message supports other polymorphic functions that are not covered by this particular tutorial. They will be covered one by one along the way buy other tutorials when needed.

Processing I/O Input

The turorial1::ServerSession::processInputImpl() virtual function is invoked by the driving common I/O library to report unprocessed input. The arguments are pointer to the input buffer and its size. The function is expected to return number of consumed bytes. To help with such task COMMS Library provides comms::processAllWithDispatch() function (requires "comms/process.h" to be included).

std::size_t ServerSession::processInputImpl(const std::uint8_t* buf, std::size_t bufLen)
{
    return comms::processAllWithDispatch(buf, bufLen, m_frame, *this);
}

The first parameter to the function is pointer to input buffer. The second one is the size of the buffer. The third is the frame object that is responsible to wrap / unwrap the transport information. The last (fourth) parameter is reference to the handling object, which must implement handle() member function for every message type it needs to handle. In this particular tutorial ServerSession implement one common function for all the messages void ServerSession::handle(Message& msg).

The handling function (void ServerSession::handle(Message& msg)) uses polymorphic interface to report what message was received (using msg.name() and msg.getId() calls), serialize it into output buffer and send the same message back. In other words it's a simple "echo" server.

The serialization of the message uses polymorphic interface to determine size of the output buffer (call to m_frame.length(msg) will result in call to polymorphic msg.length()) as well as write message payload (call to m_frame.write(...) will result in call to polymorphic msg.write(...)).

Client

The client side is implemented in ClientSession.h and ClientSession.cpp. The common interface class the client side has chosen is a bit different to the server.

using Message =
    tutorial1::Message<
        comms::option::app::ReadIterator<const std::uint8_t*>, // Polymorphic read
        comms::option::app::WriteIterator<std::back_insert_iterator<std::vector<std::uint8_t> > >, // Polymorphic write
        comms::option::app::LengthInfoInterface, // Polymorphic length calculation
        comms::option::app::IdInfoInterface, // Polymorphic message ID retrieval
        comms::option::app::NameInterface, // Polymorphic message name retrieval
        comms::option::app::Handler<ClientSession> // Polymorphic dispatch
    >;

Polymorphic Write

The client side also uses polymorphic write similar to the server. However the iterator used for writing is std::back_insert_iterator<std::vector<std::uint8_t> >. It results in a bit different serialization code for the message that needs to be sent out:

void ClientSession::sendMessage(const Message& msg)
{
    ... // Printing what is being sent

    std::vector<std::uint8_t> output;

    // Use polymorphic serialization length calculation to reserve
    // needed capacity
    output.reserve(m_frame.length(msg));

    // Serialize message into the buffer (including framing)
    // The serialization uses polymorphic write functionality.
    auto writeIter = std::back_inserter(output);

    // The frame will use polymorphic message ID retrieval to
    // prefix message payload with message ID
    auto es = m_frame.write(msg, writeIter, output.max_size());
    if (es != comms::ErrorStatus::Success) {
        assert(!"Write operation failed unexpectedly");
        return;
    }

    ... // Sending serialized buffer
}

The capacity of the output buffer is reserved rather than the buffer being resized (compared to the server). The chosen iterator will result in usage of push_back() member function of the used output vector when message being serialized into it.

Polymorphic Dispatch

Another difference is that client side chose to add polymorphic dispatch functionality to the message interface by using comms::option::app::Handler option. The template parameter specifies type of the handling object. Usage of this object results in adding the following type and polymorphic member function to the message interface class.

class Message
{
public:
    // Handler class (parameter passed to comms::option::app::Handler)
    using Handler = tutorial1::ClientSession;

    // Polymorphic dispatch
    void dispatch(Handler& handler)
    {
        return dispatchImpl(handler);
    }

protected:
    virtual void dispatchImpl(Handler& handler) = 0;
};

The handler class (tutorial::ClientSession) is expected to define handle() member function for any message type it is expected to handle. It also needs to define handle() member function for the common interface class which is going to be called when there is no appropriate handle() member function being defined for the real message type.

class ClientSession : public Session
{
public:

    // Common interface class for all the messages
    using Message = tutorial1::Message<...>;

    // Definition of all the used message classes
    using Msg1 = tutorial1::message::Msg1<Message>;
    using Msg2 = tutorial1::message::Msg2<Message>;

    // Handling functions for all the dispatched message objects
    void handle(Msg1& msg);
    void handle(Msg2& msg);
    void handle(Message& msg);

    ... // Irrelevant code
};

The comms::Message class defines constexpr bool hasDispatch() static member function which can be used at compile time to determine whether the interface class defines polymorphic dispatch functionality.

static_assert(Message::hasDispatch(), "Missing polymorphic dispatch");

SIDE NOTE: The COMMS Library provides an ability to return values from message handling (handle()) member functions, but this is subject for another tutorial.


Processing I/O Input

The turorial1::ClientSession::processInputImpl() virtual function is invoked by the driving common I/O library to report unprocessed input. The arguments are pointer to the input buffer and its size. The function is expected to return number of consumed bytes. It is possible to invoke comms::processAllWithDispatch() function provided by the COMMS Library, which will strip off the transport framing, create appropriate message object and will dispatch it to appropriate handling function. However, just to show the usage of dispatch() member function of the message object, the manual processing code (similar to one inside the comms::processAllWithDispatch() has been written. Please pay closer attention on the dispatch statement:

if (es == comms::ErrorStatus::Success) {
    assert(msg); // Message object must be allocated

    msg->dispatch(*this); // Uses polymorphic dispatch, appropriate
                          // handle() member function will be called.
}

The internals of comms::processAllWithDispatch() (when used) perform compile time evaluation of the message interface class options and perform similar polymorphic dispatch if it is supported (comms::option::app::Handler option is used). In case this option is not provided different dispatch method is used. Various dispatch methods will be covered in details in later tutorial(s).

Non-Polymorphic Message Interface

As was mentioned earlier the comms::MessageBase class is used (inherited from) to define proper message definition class. As the result the latter automatically defines the following NON-polymorphic (non-virtual) member functions, which can be used when actual message type is known to avoid unnecessary redirection of polymorphic (virtual) functions.

class Msg1 : public comms::MessageBase<...>
{
public:
    // NON-polymorphic read of payload
    template <typename TIter>
    comms::ErrorStatus doRead(TIter& iter, std::size_t len);

    // NON-polymorphic write of payload
    template <typename TIter>
    comms::ErrorStatus doWrite(TIter& iter, std::size_t len) const;

    // NON-polymorphic message ID retrieval
    MsgIdType doGetId() const;

    // NON-polymorphic payload serialization length calculation
    std::size_t doLength() const;

    // NON-polymorphic human readable name of the message
    static const char* doName();

    // NON-polymorphic validity check
    bool doValid() const;

    // NON-polymorphic bring message to consistent state
    // (will be covered in later tutorial(s).
    bool doRefresh();
}

The usage of these functions is demonstrated inside handling function(s):

void ClientSession::handle(Msg1& msg)
{
    // The report below uses NON-polymorphic name and ID retrievals
    std::cout << "Received " << msg.doName() << " with ID=" << msg.doGetId() << std::endl;

    ... // Irrelevant code
}

In addition to providing the NON-polymorphic (non-virtual) member functions the comms::MessageBase automatically implements all the virtual functions inherited from the interface definition by redirecting them to the non-polymorphic ones:

class Msg1 : public comms::MessageBase<...>
{
protected:
    virtual comms::ErrorStatus readImpl(ReadIterator& iter, std::size_t len) override
    {
        return doRead(iter, len);
    }

    virtual comms::ErrorStatus writeImpl(ReadIterator& iter, std::size_t len) const override
    {
        return doWrite(iter, len);
    }

    virtual std::size_t lengthImpl() const override
    {
        return doLength();
    }

    virtual MsgIdType getIdImpl() const override
    {
        return doGetId();
    }

    virtual const char* nameImpl() const override
    {
        return doName();
    }

    virtual bool validImpl() const override
    {
        return doValid();
    }

    virtual bool refreshImpl() override
    {
        return doRefresh();
    }

    virtual void dispatchImpl(Handler& handler) override
    {
        return handler.handle(*this);
    }
}

Summary

  • COMMS Library allows creation of common protocol definition code which is customized (what features are compiled in) by the end application.
  • One of the application customizations is extending common interface class with multiple options, which define polymorphic interface for every message.
  • The CommsDSL allows definition of various transport frames and generates appropriate code to wrap / unwrap message payload with relevant fields.
  • The COMMS Library provides comms::processAllWithDispatch() helper function that can be used to unwrap transport framing and dispatch detected messages to relevant handling functions.
  • The input / output buffers are responsibility of the end application. The COMMS Library as well as generated code use only provided iterators to the input / output data.
  • By default the message object is dynamically allocated and held by std::unique_ptr. How to modify this default behavior will be explained in one of the later tutorials.
  • The common message interface class, specific for the application is defined by passing relevant options to the definition of the comms::Message class.
  • Every message class is defined by extending comms::MessageBase, which automatically defines non-polymorphic interface to operate on message fields as well as automatically implements all the necessary virtual functions, presence of which is controlled by the defined interface class.
  • Every message object has also non-polymorphic interface functions named doX(), which should be used when real message type is known to avoid unnecessary indirection of polymorphic calls.

Read Next Tutorial