原文链接:https://commschamp.github.io/comms_protocols_cpp/#transport-transport
五、传输
除了定义消息及其内容之外,每个通信协议都必须确保消息能够通过 I/O 链路成功传递到另一端。序列化的消息有效负载必须封装在某种传输信息中,这通常取决于所用 I/O 链路的类型和可靠性。例如,设计用于 TCP/IP 连接的协议(例如 MQTT )可能会省略整个数据包同步和校验和逻辑,因为 TCP/IP 连接能够确保数据正确传递。此类协议通常被定义为仅使用消息 ID 和剩余大小信息来封装消息有效负载:
ID | SIZE | PAYLOAD
其他协议可能设计用于可靠性较低的 RS-232 链路,这可能需要更好的保护以防止数据丢失或损坏:
SYNC | SIZE | ID | PAYLOAD | CHECKSUM
最常见的包装“块”类型数量相当少。然而,不同协议可能对这些值的序列化方式有不同的规则。与字段非常相似。
不过,所有协议处理传入原始数据的主要逻辑都是相同的,即逐个读取并处理传输信息“块”。
- SYNC - 检查接下来的一个或多个字节是否符合预期的预定义值。如果符合预期,则继续处理下一个“块”。如果不是,则从传入数据队列的开头丢弃一个字节,然后重试。
- SIZE - 将剩余的预期数据长度与实际可用数据长度进行比较。如果数据足够,则继续处理下一个“块”。如果数据不足,则向调用者报告需要更多数据。
- ID - 读取消息 ID 值并创建适当的消息对象,然后继续下一个“块”。
- PAYLOAD - 让创建的消息对象读取其有效负载数据。
- CHECKSUM - 读取预期的校验和值并计算实际值。如果校验和不匹配,则丢弃创建的消息并报告错误。
每个“块”都独立运行,与之前和/或之后处理的信息无关。当某些操作似乎重复多次时,应将其抽象化并纳入我们的通用库 中。
那么,具体该如何实现呢?我的建议是使用独立的“块”类,这些类暴露预定义的接口,相互封装,并在适当的时候将请求的操作转发给下一个“块”。如前所述,传输信息值与字段非常相似,这立即使我们想到可以复用字段类来处理这些值:
template <typename TField, typename TNextChunk, ... /* 其他模板参数 */>
class SomeChunk {
public:
// 用于读取的迭代器
using ReadIterator = typename TNextChunk::ReadIterator;
// 用于写入的迭代器
using WriteIterator = typename TNextChunk::WriteIterator;
// 通用消息接口类的类型
using Message = typename TNextChunk::Message;
// 用于存储新创建的消息对象的智能指针
using MsgPtr = typename TNextChunk::MsgPtr;
ErrorStatus read(MsgPtr& msg, ReadIterator& iter, std::size_t len) {
TField field;
auto es = field.read(iter, len);
if (es != ErrorStatus::Success) {
return es;
}
... // 处理字段值
return m_next.read(msg, iter, len - field.length());
}
ErrorStatus write(const Message& msg, WriteIterator& iter, std::size_t len) {
TField field;
field.value() = ...; // 设置所需值
auto es = field.write(iter, len);
if (es != ErrorStatus) {
return es;
}
return m_next.write(msg, iter, len - field.length());
}
private:
TNextChunk m_next;
};
请注意, ReadIterator 和 WriteIterator 取自下一个块。负责处理 PAYLOAD 的其中一个块将接收消息接口的类作为模板参数,获取迭代器类型的信息,并将其重新定义为内部类型。此外,该类还将定义消息接口的类型为其内部 Message 类型。所有其他封装分块类将复用相同的信息。
此外,请注意,其中一个数据块必须定义指向已创建的消息对象的指针(MsgPtr)。通常,负责处理 ID 值的数据块会承担这一职责。
对传输信息“块”进行顺序处理,并逐一剥离这些块后再继续处理下一个块的过程,可能会让人联想到 OSI 概念模型 ,其中每一层都为上层提供服务,并由下层提供支持。
从现在开始,我将使用术语层来代替块 。 这些层的组合将被称为 协议栈 (层)。
让我们仔细看看上面提到的所有层类型。
5.1 有效载荷层
有效负载(PAYLOAD)的处理始终是协议栈中的最后一个阶段。所有前置层均已成功处理其传输数据,消息对象已创建并准备就绪,可读取其在有效负载中编码的字段。
该层必须接收消息接口类的类型作为模板参数,并重新定义读/写迭代器类型。
namespace comms {
template <typename TMessage>
class MsgDataLayer {
public:
// 定义消息接口的类型
using Message = TMessage;
// 消息指针的类型尚未定义,将在处理消息ID的层中进行定义
using MsgPtr = void;
// ReadIterator 与 Message::ReadIterator 相同(如果存在),否则为 void。
using ReadIterator = typename std::conditional<Message::InterfaceOptions::HasReadIterator,
typename TMessage::ReadIterator, void>::type;
// WriteIterator 与 Message::WriteIterator 相同(如果存在),否则为 void。
using WriteIterator = typename std::conditional<Message::InterfaceOptions::HasWriteIterator,
typename TMessage::WriteIterator, void>::type;
...
};
} // namespace comms
读/写操作只是将请求转发给消息对象。
namespace comms {
template <typename TMessage>
class MsgDataLayer {
public:
template <typename TMsgPtr>
static ErrorStatus read(TMsgPtr& msgPtr, ReadIterator& iter, std::size_t len) {
return msgPtr->read(iter, len);
}
static ErrorStatus write(const Message& msg, WriteIterator& iter, std::size_t len) {
return msg.write(iter, len);
}
};
} // namespace comms
请注意, read() 成员函数需要接收一个指向智能指针的引用作为第一个参数,该指针保存着已分配的消息对象。该指针的类型尚不清楚。因此,该指针的类型通过模板参数提供。
5.2 ID 层
该层的工作是处理消息 ID 信息。
- 当收到新消息时,需要在调用下一层(封装层)的读取操作之前,创建相应的消息对象。
- 当任何消息即将发送时,只需从消息对象中获取 ID 信息并在调用下一层的写入操作之前对其进行序列化。
该层的代码非常简单:
namespace comms {
// TField 是用于读取/写入消息 ID 的字段类型。
// TNext 是下一层,本层对其进行封装。
template <typename TField, typename TNext, ... /* 其他参数 */>
class MsgIdLayer {
public:
// 用于读取/写入消息ID值的字段对象类型。
using Field = TField;
// 从下一层获取 ReadIterator 的类型
using ReadIterator = typename TNext::ReadIterator;
// 从下一层获取 WriteIterator 的类型
using WriteIterator = typename TNext::WriteIterator;
// 从下一层获取消息接口的类型
using Message = typename TNext::Message;
// 消息ID的类型
using MsgIdType = typename Message::MsgIdType;
// 重新定义指向消息类型的指针(稍后描述)
using MsgPtr = ...;
ErrorStatus read(MsgPtr& msgPtr, ReadIterator& iter, std::size_t len) {
Field field;
auto es = field.read(iter, len);
if (es != ErrorStatus::Success) {
return es;
}
msgPtr = createMsg(field.value()); // 根据ID创建消息对象
if (!msgPtr) {
// 未知ID
return ErrorStatus::InvalidMsgId;
}
es = m_next.read(iter, len - field.length());
if (es != ErrorStatus::Success) {
msgPtr.reset(); // 丢弃已分配的消息;
}
return es;
}
ErrorStatus write(const Message& msg, WriteIterator& iter, std::size_t len) const {
Field field;
field.value() = msg.id();
auto es = field.write(iter, len);
if (es != ErrorStatus::Success) {
return es;
}
return m_next.write(msg, iter, len - field.length());
}
private:
MsgPtr createMsg(MsgIdType id){
...; // TODO: 创建消息对象(稍后描述)
}
TNext m_next;
};
} // namespace comms
为了正确完成上述实现,我们需要解决两个主要挑战:
- 实现
createMsg()函数,该函数接收消息的 ID 并创建消息对象。 - 定义
MsgPtr智能指针类型,负责保存分配的消息对象。在大多数情况下,将其定义为std::unique_ptr<Message>即可满足需求。然而,这里的主要问题在于动态内存分配的使用。裸机平台可能不具备这种便利性。必须有一种方法来支持“就地”分配。
5.2.1 创建消息对象
让我们首先根据数字消息 ID 创建合适的消息对象。它必须尽可能高效。
在许多情况下,消息的 ID 是连续的,并使用某种枚举类型定义。
enum MsgId {
MsgId_Message1,
MsgId_Message2,
...
MsgId_NumOfMessages
};
假设我们有一个 FactoryMethod 类,其中包含一个多态的 createMsg() 函数,该函数返回一个用 MsgPtr 智能指针包装的已分配消息对象。
class FactoryMethod {
public:
MsgPtr createMsg() const {
return createMsgImpl();
}
protected:
virtual MsgPtr createMsgImpl() const = 0;
};
在这种情况下,最有效的方法是使用一个指向多态类 FactoryMethod 的指针数组。数组单元的索引对应于消息 ID。

MsgIdLayer::createMsg() 函数的代码很简单:
namespace comms {
template <...>
class MsgIdLayer {
private:
MsgPtr createMsg(MsgIdType id) {
auto& registry = ...; // 对指向 FactoryMethod 的指针数组的引用
if ((registry.size() <= id) || (registry[id] == nullptr)){
return MsgPtr();
}
return registry[id]->createMsg();
}
};
} // namespace comms
此类代码的运行时复杂度为 O(1)。
然而,许多协议的 ID 映射非常稀疏,使用数组直接映射是不切实际的:
enum MsgId {
MsgId_Message1 = 0x0101,
MsgId_Message2 = 0x0205,
MsgId_Message3 = 0x0308,
...
MsgId_NumOfMessages
};
在这种情况下,前面描述的 FactoryMethod 数组必须被打包,并且使用二分查找算法来查找所需的方法。为了支持此类搜索, FactoryMethod 必须能够报告其创建的消息的 ID。
class FactoryMethod {
public:
MsgIdType id() const {
return idImpl();
}
MsgPtr createMsg() const {
return createMsgImpl();
}
protected:
virtual MsgIdType idImpl() const = 0;
virtual MsgPtr createMsgImpl() const = 0;
};
然后, MsgIdLayer::createMsg() 的代码需要应用二分搜索来找到所需的方法:
namespace comms {
template <...>
class MsgIdLayer {
private:
MsgPtr createMsg(MsgIdType id) {
auto& registry = ...; // 对指向FactoryMethod的指针数组的引用
auto iter = std::lower_bound(registry.begin(), registry.end(), id,
[](FactoryMethod* method, MsgIdType idVal) -> bool { return method->id() < idVal; });
if ((iter == registry.end()) || ((*iter)->id() != id)) {
return MsgPtr();
}
return (*iter)->createMsg();
}
};
} // namespace comms
请注意,std::lower_bound 算法要求“注册表”中的 FactoryMethod 对象按消息 ID 排序。此类代码的运行时 复杂度为 O(log(n)),其中 n 为注册表的大小。
某些通信协议定义了同一条消息的多个变体,这些变体通过其他方式(例如消息的序列化长度)进行区分。将这些变体实现为单独的消息类可能比较方便,但这需要单独的 FactoryMethod 来实例化它们。在这种情况下, MsgIdLayer::createMsg() 函数可以使用 std::equal_range 算法代替 std::lower_bound ,并使用附加参数指定从找到的相等范围中选择哪种方法:
namespace comms {
template <...>
class MsgIdLayer {
private:
MsgPtr createMsg(MsgIdType id, unsigned idx = 0) {
auto& registry = ...; // 对指向FactoryMethod的指针数组的引用
auto iters = std::equal_range(...);
if ((iters.first == iters.second) || (iters.second < (iters.first + idx))) {
return MsgPtr();
}
return (*(iter.first + idx))->createMsg();
}
};
} // namespace comms
请注意, MsgIdLayer::read() 函数也需要修改,以支持多次尝试创建具有相同 ID 的消息对象。每次尝试读取消息内容失败时,它都必须递增传递给 createMsg() 成员函数的 idx 参数,并不断尝试,直到找到相等的范围用尽为止。我将这个额外逻辑的实现留给读者作为练习。
要完成消息分配主题,我们需要设计一种自动生成之前使用的 FactoryMethod 注册表的方法。请注意,FactoryMethod 仅仅是一个多态接口。我们需要实现实际方法来实现虚函数。
template <typename TActualMessage>
class ActualFactoryMethod : public FactoryMethod {
protected:
virtual MsgIdType idImpl() const {
return TActualMessage::ImplOptions::MsgId;
}
virtual MsgPtr createMsgImpl() const {
return MsgPtr(new TActualMessage());
}
};
请注意,上面的代码假设 comms::option::StaticNumIdImpl 选项(在 [“通用消息实现 ](#4.2 通用消息实现)”一章中描述)用于在定义 ActualMessage* 类时指定数字消息 ID。
还要注意,上面的示例使用动态内存分配来分配实际的消息对象。这只是为了演示想法。 下面的[分配消息对象](#5.2.2 分配消息对象)部分将描述如何支持“就地”分配。
通过 I/O 链接接收的消息类型通常在编译时已知。如果我们将它们绑定到 std::tuple 中,就可以轻松地应用我们熟悉的元编程技术,对提供的类型进行迭代,并实例化合适的 ActualFactoryMethod<> 对象。
using AllMessages = std::tuple<
ActualMessage1,
ActualMessage2,
ActualMessage3,
...
>;
注册表的大小可以通过以下方式轻松识别 std::tuple_size 。
static const RegistrySize = std::tuple_size<AllMessages>::value;
using Registry = std::array<FactoryMethod*, RegistrySize>;
Registry m_registry; // 注册表对象
现在是时候(在编译时)遍历 AllMessages 元组中定义的所有类型,并为其中的每一个类型创建独立的 ActualFactoryMethod<>。还记得 tupleForEach 吗?这里我们需要类似的功能,但不需要 tuple 对象本身。我们只是在遍历类型,而不是 tuple 对象的元素。我们将它命名为 tupleForEachType()。有关实现细节,请参阅附录 D - tupleForEachType。
我们还需要一个函数对象类,该类将被调用以处理每种消息类型,并负责填充提供的注册表:
class MsgFactoryCreator {
public:
MsgFactoryCreator(Registry& registry) : registry_(registry) {}
template <typename TMessage>
void operator()() {
static const ActualFactoryMethod<TMessage> Factory;
registry_[idx_] = &Factory;
++idx_;
}
private:
Registry& registry_;
unsigned idx_ = 0;
};
初始化函数可能很简单:
void initRegistry() {
tupleForEachType<AllMessages>(MsgFactoryCreator(m_registry));
}
注意 : ActualFactoryMethod<> 工厂没有任何内部状态,并且被定义为静态对象。只需将指向它们的指针存储在注册表数组中即可。
为了总结本节,让我们重新定义 comms::MsgIdLayer 并添加消息创建功能。
namespace comms {
// TField 是用于读取/写入消息 ID 的字段类型。
// TAllMessages 是以 std::tuple 形式打包的所有消息。
// TNext 是下一层,这一层包装它。
template <typename TField, typename TAllMessages, typename TNext>
class MsgIdLayer {
public:
// 用于读取/写入消息ID值的字段对象类型。
using Field = TField;
// 从下一层获取ReadIterator的类型
using ReadIterator = typename TNext::ReadIterator;
// 从下一层获取WriteIterator的类型
using WriteIterator = typename TNext::WriteIterator;
// 从下一层获取消息接口的类型
using Message = typename TNext::Message;
// 消息ID的类型
using MsgIdType = typename Message::MsgIdType;
// 重新定义指向消息类型的指针:
using MsgPtr = ...;
// 构造函数
MsgIdLayer() {
tupleForEachType<AllMessages>(MsgFactoryCreator(m_registry));
}
// 读取操作
ErrorStatus read(MsgPtr& msgPtr, ReadIterator& iter, std::size_t len) {...}
// 写入操作
ErrorStatus write(const Message& msg, WriteIterator& iter, std::size_t len) const {...}
private:
class FactoryMethod {...};
template <typename TMessage>
class ActualFactoryMethod : public FactoryMethod {...};
class MsgFactoryCreator {...};
// 工厂注册表
static const auto RegistrySize = std::tuple_size<TAllMessages>::value;
using Registry = std::array<FactoryMethod*, RegistrySize>;
// 创建消息
MsgPtr createMsg(MsgIdType id, unsigned idx = 0) {
auto iters = std::equal_range(m_registry.begin(), m_registry.end(), ...);
...
}
Registry m_registry;
TNext m_next;
};
} // namespace comms
5.2.2 分配消息对象
目前,唯一缺失的信息是定义用于存储已分配的消息对象(MsgPtr)的智能指针类型,并允许进行“就地”分配而非使用动态内存。
当允许动态内存分配时,一切都很简单,只需使用带标准删除器的 std::unique_ptr。然而,当不允许此类分配时,情况就稍微复杂一些。
让我们首先计算缓冲区的大小,该大小足以容纳提供的 AllMessages 包中的任何消息。它的大小类似于下面 union 的大小。
union AllMessagesU {
ActualMessage1 msg1;
ActualMessage2 msg2;
...
};
但是,所有必需的消息类型都以 std::tuple 形式提供,而不是 union 形式。我们需要的是类似这样的 std::aligned_union ,但对于已经捆绑在 std::tuple 中的类型。事实证明,使用模板特化很容易实现:
template <typename TTuple>
struct TupleAsAlignedUnion;
template <typename... TTypes>
struct TupleAsAlignedUnion<std::tuple<TTypes...>> {
using Type = typename std::aligned_union<0, TTypes...>::type;
};
注意 ,某些编译器(gcc v5.0 及以下版本)可能无法实现 std::aligned_union 类型,但它们确实实现了 std::aligned_storage 。[ 附录 E - AlignedUnion](#附录 E - AlignedUnion) 展示了如何使用 std::aligned_storage 实现对齐联合体功能。
“就地”分配区域,可以容纳列出的任何消息类型 AllMessages 元组,可以定义为:
using InPlaceStorage = typename TupleAsAlignedUnion<AllMessages>::Type;
“就地”分配很简单:
InPlaceStorage inPlaceStorage;
new (&inPlaceStorage) TMessage(); // TMessage 是正在创建的消息的类型。
“就地”分配需要“就地”删除,即销毁已分配的元素。
template <typename T>
struct InPlaceDeleter {
void operator()(T* obj) {
obj->~T();
}
};
指向 Message 接口类的智能指针可以定义为 std::unique_ptr<Message, InPlaceDeleter<Message> > .
现在,让我们定义两个具有相似接口的独立分配策略。一个用于动态内存分配,另一个用于“就地”分配。
template <typename TMessageInterface>
struct DynMemAllocationPolicy {
using MsgPtr = std::unique_ptr<TMessageInterface>;
template <typename TMessage>
MsgPtr allocMsg() {
return MsgPtr(new TMessage());
}
};
template <typename TMessageInterface, typename TAllMessages>
class InPlaceAllocationPolicy {
public:
template <typename T>
struct InPlaceDeleter {...};
using MsgPtr = std::unique_ptr<TMessageInterface, InPlaceDeleter<TMessageInterface>>;
template <typename TMessage>
MsgPtr allocMsg() {
new (&m_storage) TMessage();
return MsgPtr(reinterpret_cast<TMessageInterface*>(&m_storage), InPlaceDeleter<TMessageInterface>());
}
private:
using InPlaceStorage = typename TupleAsAlignedUnion<TAllMessages>::Type;
InPlaceStorage m_storage;
};
请注意, InPlaceAllocationPolicy 的实现是最简单的。在生产质量代码中,建议在已使用的存储区域中插入防止重复分配的保护措施,方法是引入布尔标志,指示存储区域是否空闲。指向该标志的指针/引用也必须传递给删除器对象,该对象负责在删除操作发生时更新它。
可以使用已经熟悉的选项技术来实现 comms::MsgIdLayer 中使用的分配策略选项。
namespace comms {
template <typename TField, typename TAllMessages, typename TNext, typename... TOptions>
class MsgIdLayer {
...
};
} // namespace comms
如果未指定任何选项,则必须选择 DynMemAllocationPolicy 。要强制“就地”分配消息,可以定义一个单独的选项,并将其作为模板参数传递给 comms::MsgIdLayer 。
namespace comms {
namespace option {
struct InPlaceAllocation {};
} // namespace option
} // namespace comms
使用我们熟悉的选项解析技术,我们可以创建一个结构体,其中的布尔值 HasInPlaceAllocation 默认为 false ,如果使用了上面提到的选项,则可以将其设置为 true 。因此,策略选择可以实现如下:
namespace comms {
template <typename TField, typename TAllMessages, typename TNext, typename... TOptions>
class MsgIdLayer {
public:
// TOptions 解析为 struct
using ParsedOptions = ...;
// 从下一层获取消息接口的类型
using Message = typename TNext::Message;
// 分配策略的选择
using AllocPolicy = typename std::conditional<
ParsedOptions::HasInPlaceAllocation,
InPlaceAllocationPolicy<Message, TAllMessages>,
DynMemAllocationPolicy<Message>
>::type;
// 重新定义指向消息类型的指针
using MsgPtr = typename AllocPolicy::MsgPtr;
...
private:
AllocPolicy m_policy;
};
} // namespace comms
剩下要做的就是为 ActualFactoryMethod<> 类提供使用分配策略分配消息的功能。请记住, ActualFactoryMethod<> 对象是无状态静态对象。这意味着分配策略对象需要作为参数传递给其分配函数。
namespace comms {
template <typename TField, typename TAllMessages, typename TNext, typename... TOptions>
class MsgIdLayer {
public:
// 分配策略的选择
using AllocPolicy = ...;
// 重新定义指向消息类型的指针
using MsgPtr = typename AllocPolicy::MsgPtr;
...
private:
class FactoryMethod {
public:
MsgPtr createMsg(AllocPolicy& policy) const {
return createMsgImpl(policy);
}
protected:
virtual MsgPtr createMsgImpl(AllocPolicy& policy) const = 0;
};
template <typename TActualMessage>
class ActualFactoryMethod : public FactoryMethod {
protected:
virtual MsgPtr createMsgImpl(AllocPolicy& policy) const {
return policy.allocMsg<TActualMessage>();
}
}
AllocPolicy m_policy;
};
} // namespace comms
5.2.3 总结
ID 层的最终实现 ( comms::MsgIdLayer ) 是一段通用代码。它接收一个必须识别的消息类列表作为模板参数。根据消息的数字 ID 创建正确消息对象的整个逻辑由编译器自动生成,仅使用静态内存。当新消息添加到协议时,需要更新的是可用消息类的包 ( AllMessages ),无需进行其他任何操作。重新编译源代码将生成支持新消息的代码。上述 comms::MsgIdLayer 的实现具有 O(log(n)) 的时间复杂度,用于查找正确的工厂方法并创建适当的消息对象。它还支持同一消息的多个变体,这些变体实现为不同的消息类,但报告相同的消息 ID。默认情况下, comms::MsgIdLayer 使用动态分配新消息对象的内存。只需向其提供 comms::option::InPlaceAllocation 选项,即可轻松实现此功能,该选项将强制使用“就地”分配。“就地”分配每次只能创建一条消息。为了能够创建新的消息对象,必须先析构并释放前一个消息对象。
3089

被折叠的 条评论
为什么被折叠?



