Skip to main content

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

!!! tip "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);
};