Transports

The transport abstraction models a communication channel between two entities using which one side can send/receive data to/from the other side.

A transport has the following attributes -

  • A source
  • A destination
  • A method to send data to the destination
  • A method to receive and process data from the destination
  • A method to close the communication channel

Common transport types

Transports can be divided into types based on the communication semantics that they provide.

Datagram based transports

Usually send and receive small packets of data.

Sample interface:

struct DatagramTransport {
    SocketAddress src;
    SocketAddress dst;

    void send(Buffer &&packet);
    void did_recv(Buffer &&packet);
    void close();
};

Example: UDP transport

Stream based transports

Usually send and receive byte streams.

Sample interface:

struct StreamTransport {
    SocketAddress src;
    SocketAddress dst;

    void send(Buffer &&bytes);
    void did_recv(Buffer &&bytes);
    void close();
};

Example: TCP transport

Message based transports

Usually send and receive delimited messages.

Sample interface:

struct MessageTransport {
    SocketAddress src;
    SocketAddress dst;

    void send(Buffer &&message);
    void did_recv(Buffer &&message);
    void close();
};

Example: Length-prefix framed transport

Higher-order transports

Transports of a particular type can also be built by wrapping another type of transport. For example, StreamTransport can provide stream semantics by wrapping a base transport providing datagram semantics. See the Higher-order transports section for more info.

Reusable transports

We can make our transports reusable by isolating the application-specific parts and using behaviour injection to modify them based on application requirements. Behaviour injection can take any of the following forms:

Inheritance

Isolate the application-specific parts into their own functions. Subclass and override these functions as needed.

struct Transport {
    SocketAddress src;
    SocketAddress dst;

    void send(Buffer &&data);
    void close();

    void did_recv(Buffer &&data); // Can subclass and override
};

Not that flexible. Makes writing higher-order transports difficult.

Callbacks

Isolate the application-specific parts into their own callbacks. Set these callbacks as needed.

struct Transport {
    SocketAddress src;
    SocketAddress dst;

    void send(Buffer &&data);
    void close();

    typedef void (*RecvFunc)(Buffer &&data);
    RecvFunc did_recv; // Can set
};

More of a C paradigm than C++. The callbacks need to be globally and statically addressible which imposes significant restrictions on what can be set as a callback(crucially, no normal member functions, only static ones).

Delegates

Outsource the application-specific parts to an external object. The object can be set as needed.

struct TransportDelegate {
    void did_recv(Buffer &&data); // Can implement custom processing
};

template<typename DelegateType>
struct Transport {
    SocketAddress src;
    SocketAddress dst;

    void send(Buffer &&data);
    void close();

    DelegateType *delegate; // Can set
};

We use the delegate pattern for its flexibility and usability while remaining performant. It makes it easy to define a contract between a Transport and its delegate and as a bonus, makes the design easily portable to languages which have enforced contracts(Go interfaces, Rust traits, etc).

Event notifications

Now that we have a delegate pattern in place, we can use it to notify the delegate of significant events occuring in the transport.

Sample interface:

struct TransportDelegate {
    void did_close(); // Notify transport close
};

template<typename DelegateType>
struct Transport {
    DelegateType *delegate;

    void close() {
        ...
        delegate->did_close();
        ...
    }
};

Better delegates

In the delegates that we have above, there are significant design deficiencies:

  • We have no way to know which transport the delegate call is coming from
  • We need a delegate per transport to have custom logic per transport
  • We have no way to directly respond based on the data received

We can fix this by simply passing the transport as a parameter to the delegate functions:

struct TransportDelegate {
    void did_recv(Transport<TransportDelegate> &transport, Buffer &&data) {

        // Can implement custom processing based on transport attributes
        if(transport.dst == X) {
            ...
        }

        // Can directly respond
        transport.send(response_data);
    }
};

template<typename DelegateType>
struct Transport {
    DelegateType *delegate;

    void recv_cb() {
        ...
        delegate->did_recv(*this, data);
        ...
    }
};

Canonical transports

Based on the design choices made above, our transports(and delegates) usually look something like this:

struct TransportDelegate {
    // Notify data sent
    void did_send(Transport<TransportDelegate> &transport, Buffer &&data);

    // Notify data receive
    void did_recv(Transport<TransportDelegate> &transport, Buffer &&data);

    // Notify successful dial
    void did_dial(Transport<TransportDelegate> &transport);

    // Notify close
    void did_close(Transport<TransportDelegate> &transport);
};

template<typename DelegateType>
struct Transport {
    // Source
    SocketAddress src;

    // Destination
    SocketAddress dst;

    // Delegate
    DelegateType *delegate;

    // Send data to dst
    void send(Buffer &&data);

    // Close the transport
    void close();
};

Higher-order transports

Higher-order transports are built by wrapping another transport as a base and customizing its behaviour. Given the use of the delegate pattern, we can simply insert the higher-order transport as a delegate for the base transport and intercept all delegate calls to modify the behaviour as needed.

Example - StreamTransport

Our higher-order transports usually look something like this:

template<typename DelegateType, template<typename> class SomeTransport>
struct HigherOrderTransport {
    // Source
    SocketAddress src;

    // Destination
    SocketAddress dst;

    // Delegate
    DelegateType *delegate;

    // Base transport
    typedef SomeTransport<HigherOrderTransport<DelegateType, SomeTransport>> BaseTransport;

    BaseTransport &transport;

    // Send data to dst
    // Will eventually call transport.send to actually send the data
    void send(Buffer &&data);

    // Close the transport
    // Will eventually call transport.close to actually close the transport
    void close();

    //-------- BaseTransport delegate functions below --------//

    // Intercept data sent and do custom processing if needed
    // Will eventually call delegate->did_send to notify
    void did_send(BaseTransport &transport, Buffer &&data);

    // Intercept data receive and do custom processing if needed
    // Will eventually call delegate->did_recv to notify
    void did_recv(BaseTransport &transport, Buffer &&data);

    // Intercept successful dial and do custom processing if needed
    // Will eventually call delegate->did_dial to notify
    void did_dial(BaseTransport &transport);

    // Intercept close and do custom processing if needed
    // Will eventually call delegate->did_close to notify
    void did_close(BaseTransport &transport);
};