使用 C++ 实现通信协议的指南(五·上)

原文链接: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;
};

请注意, ReadIteratorWriteIterator 取自下一个块。负责处理 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 选项,即可轻松实现此功能,该选项将强制使用“就地”分配。“就地”分配每次只能创建一条消息。为了能够创建新的消息对象,必须先析构并释放前一个消息对象。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值