如何减少目前必需的序列化模版代码。

29

我们的软件正在抽象化硬件,我们有代表这些硬件状态的类,并拥有所有外部硬件属性的许多数据成员。我们需要定期更新其他组件的状态,为此我们通过MQTT和其他消息传递协议发送protobuf编码的消息。有不同的消息描述硬件的不同方面,因此我们需要发送这些类的不同数据视图。下面是一个草图:

struct some_data {
  Foo foo;
  Bar bar;
  Baz baz;
  Fbr fbr;
  // ...
};

假设我们需要发送两个消息,一个包含foobar,另一个包含barbaz。我们现在的做法是很繁琐的:

struct foobar {
  Foo foo;
  Bar bar;
  foobar(const Foo& foo, const Bar& bar) : foo(foo), bar(bar) {}
  bool operator==(const foobar& rhs) const {return foo == rhs.foo && bar == rhs.bar;}
  bool operator!=(const foobar& rhs) const {return !operator==(*this,rhs);}
};

struct barbaz {
  Bar bar;
  Baz baz;
  foobar(const Bar& bar, const Baz& baz) : bar(bar), baz(baz) {}
  bool operator==(const barbaz& rhs) const {return bar == rhs.bar && baz == rhs.baz;}
  bool operator!=(const barbaz& rhs) const {return !operator==(*this,rhs);}
};

template<> struct serialization_traits<foobar> {
  static SerializedFooBar encode(const foobar& fb) {
    SerializedFooBar sfb;
    sfb.set_foo(fb.foo);
    sfb.set_bar(fb.bar);
    return sfb;
  }
};

template<> struct serialization_traits<barbaz> {
  static SerializedBarBaz encode(const barbaz& bb) {
    SerializedBarBaz sbb;
    sfb.set_bar(bb.bar);
    sfb.set_baz(bb.baz);
    return sbb;
  }
};

这可以随后被发送:

void send(const some_data& data) {
  send_msg( serialization_traits<foobar>::encode(foobar(data.foo, data.bar)) );
  send_msg( serialization_traits<barbaz>::encode(barbaz(data.foo, data.bar)) );
}

考虑到要发送的数据集通常比两个项目大得多,我们还需要解码这些数据,并且我们有很多这样的消息,因此涉及到的模板代码比这个示意图中所示的要多得多。因此,我一直在寻找一种减少这种情况的方法。这是一个最初的想法:

typedef std::tuple< Foo /* 0 foo */
                  , Bar /* 1 bar */
                  > foobar;
typedef std::tuple< Bar /* 0 bar */
                  , Baz /* 1 baz */
                  > barbaz;
// yay, we get comparison for free!

template<>
struct serialization_traits<foobar> {
  static SerializedFooBar encode(const foobar& fb) {
    SerializedFooBar sfb;
    sfb.set_foo(std::get<0>(fb));
    sfb.set_bar(std::get<1>(fb));
    return sfb;
  }
};

template<>
struct serialization_traits<barbaz> {
  static SerializedBarBaz encode(const barbaz& bb) {
    SerializedBarBaz sbb;
    sfb.set_bar(std::get<0>(bb));
    sfb.set_baz(std::get<1>(bb));
    return sbb;
  }
};

void send(const some_data& data) {
  send_msg( serialization_traits<foobar>::encode(std::tie(data.foo, data.bar)) );
  send_msg( serialization_traits<barbaz>::encode(std::tie(data.bar, data.baz)) );
}

我已经成功实现了这个功能,并且大大减少了样板代码。(在这个小例子中没有体现,但是如果你想象一下十几个数据点被编码和解码,很多重复的数据成员列表消失会有很大的区别)。然而,这种方法有两个缺点:

  1. 这依赖于FooBarBaz是不同的类型。如果它们都是int,我们需要向元组中添加一个虚拟标记类型。

    虽然可以做到,但这确实使整个想法变得不太吸引人。

  2. 旧代码中的变量名变成了新代码中的注释和数字。这很糟糕,考虑到编码和解码中很可能存在混淆两个成员的错误,不能通过简单的单元测试来捕获,而需要通过其他技术创建测试组件(即集成测试)来捕获此类错误。

    我不知道如何解决这个问题。

有没有人有更好的想法来减少我们的样板代码?

注意:

  • 目前,我们被困在C++03中。是的,你没看错。对我们而言,它是std::tr1::tuple。没有lambda,也没有auto
  • 我们有大量的代码使用这些序列化特性。我们不能抛弃整个方案并完全采用其他方法。我正在寻求一个解决方案,简化适合现有框架的未来代码。任何需要我们重新编写整个系统的想法很可能会被驳回。


2
听起来你想编写一个程序来读取一种简单语言的文件,然后为你生成所有的c++模板,最终进行编译。代码生成器大获全胜。使用一个简单的yacc/bison解析器和相应的文法就可以实现。 - Jesper Juhl
2
“……当现有的程序员都已经退休时,需要维护代码的责任就落在了其他人身上……” - 是其中的“现有程序员”之一。当你退休后,你就不再需要关心它了;-)(请注意这个眨眼笑脸)。 - Jesper Juhl
嗯,我记得的一件事是,按设计,Protobuf消息负载可以由负载的串联构建。你能从中利用什么吗? - Iwillnotexist Idonotexist
你为什么需要合并消息,例如为什么将foobar合并为一个foobar消息?这些数据集是否有关联?对我来说,它看起来更像是样板代码中不必要的数据组合!此外,您能否稍微更改数据结构(struct Foo、Bar等),例如添加一个函数?那么如何解码这些消息呢? - user1810087
看起来你基本上是在尝试编写一个泛化层,将抽象出许多SerializedXY类型,通过简化的接口(您选择的是serialization_traits的特化)来访问它们。这是一个公平的总结吗?从这个角度来看,你尝试的东西作为你正在寻找的东西的例子是有用的,但更有用的是关于这些SerializedXY类型的信息。你能否在问题中添加一些关于它们的信息,比如它们是如何生成的(为什么不能改变),以及它们的公共接口是什么? - JaMiT
显示剩余26条评论
5个回答

13
在我看来,最全面的解决方案是使用脚本语言中的外部C++代码生成器。它具有以下优点:
- 灵活性:可以随时更改生成的代码。这对于几个子原因非常好: - 可以轻松修复所有旧版本中的错误。 - 如果将来转移到C++11或更高版本,则可以使用新的C++功能。 - 为另一种语言生成代码。这非常有用(特别是如果您的组织很大和/或您有许多用户)。例如,您可以输出一个小型脚本库(例如Python模块),该库可用作与硬件进行交互的CLI工具。根据我的经验,这受到硬件工程师的青睐。 - 生成GUI代码(或GUI描述,例如XML / JSON;甚至是Web界面)- 对于使用最终硬件和测试人员非常有用。 - 生成其他类型的数据。例如,图表,统计数据等。甚至是protobuf描述本身。 - 维护性:比在C++中更容易维护。即使它是用不同的语言编写的,通常也比让新的C++开发人员深入研究C++模板元编程(尤其是在C++03中)更容易学习该语言。 - 性能:它可以轻松地减少C++端的编译时间(因为您可以输出非常简单的C++代码 - 甚至是纯C)。当然,生成器可能会抵消这种优势。在您的情况下,这可能不适用,因为看起来您无法更改客户端代码。

我在几个项目/系统中都使用了这种方法,效果非常好。特别是硬件使用的不同选择(C++库、Python库、CLI、GUI等)可以得到很高的评价。


顺便提一下:如果生成的部分需要解析已存在的 C++ 代码(例如,具有要序列化的数据类型的标头,就像在 OP 的情况下使用的Serialized 类型);那么一个非常好的解决方案是使用LLVM/clang的工具来实现。

在我参与的一个特定项目中,我们不得不自动序列化数十个C++类型(这些类型随时可能被用户更改)。我们成功地通过使用clang Python绑定并将其集成到构建过程中来自动生成所需的代码。虽然Python绑定没有暴露所有AST细节(至少当时是这样),但它们足以为我们所有类型(包括模板类、容器等)生成所需的序列化代码。


我们已经使用了几个代码生成工具。所有这些都很麻烦。当有新成员加入团队时,他们必须安装各种任意的工具,这些工具存储在不同的仓库中(例如 Python 的 pip 仓库)而不是我们的代码仓库中。或者某个开发人员更新了一个工具,但忘记更新那个 Jenkins 构建节点,导致 Jenkins 作业似乎随机失败... 基本上,任何阻止您执行三步引导(1. 检出代码,2. 安装构建工具,3. 进行发布)的事情迟早会引起麻烦。越少越好。 - sbi
这不是手动运行代码生成器的问题。这是关于需要安装它们,安装所有依赖项,保持它们更新的问题。 - sbi
1
将您的代码生成器编写为(例如)Python。与其余代码(或构建系统的其余部分)一起检查codegen脚本。无需外部工具,无需安装。 - Useless
1
@sbi - 在我看来,你的说法是错误的。我之前写过完全可用的Python代码生成器,没有使用任何外部包,为预先存在的线协议生成了多个语言绑定。这些代码生成脚本与源代码一起提交,作为构建的一部分运行,而且并不困难。 - Useless
1
@Useless:同意。我也有完全相同的经历:Python代码生成,检入,运行构建,使用Linux发行版打包的任何Python版本。甚至在OS X上使用他们捆绑的Python也可以工作。实际上,Python是一个非常好的平台,可以编写整个构建系统(例如Meson、SCons...)。 - Acorn
显示剩余10条评论

7
我将在您提出的解决方案基础上进行改进,但是会使用boost::fusion::tuples(假设这是允许的)。让我们假设您的数据类型为
struct Foo{};
struct Bar{};
struct Baz{};
struct Fbr{};

你的数据是

struct some_data {
    Foo foo;
    Bar bar;
    Baz baz;
    Fbr fbr;
};

根据评论,我理解您无法控制SerialisedXYZ类,但它们确实具有一定的接口。我将假设这样的内容已经足够接近了(?):

struct SerializedFooBar {

    void set_foo(const Foo&){
        std::cout << "set_foo in SerializedFooBar" << std::endl;
    }

    void set_bar(const Bar&){
        std::cout << "set_bar in SerializedFooBar" << std::endl;
    }
};

// another protobuf-generated class
struct SerializedBarBaz {

    void set_bar(const Bar&){
        std::cout << "set_bar in SerializedBarBaz" << std::endl;
    }

    void set_baz(const Baz&){
        std::cout << "set_baz in SerializedBarBaz" << std::endl;
    }
};

我们现在可以将样板代码减少到每个数据类型排列一个typedef和每个SerializedXYZ类的set_XXX成员一个简单的重载,如下所示:
typedef boost::fusion::tuple<Foo, Bar> foobar;
typedef boost::fusion::tuple<Bar, Baz> barbaz;
//...

template <class S>
void serialized_set(S& s, const Foo& v) {
    s.set_foo(v);
}

template <class S>
void serialized_set(S& s, const Bar& v) {
    s.set_bar(v);
}

template <class S>
void serialized_set(S& s, const Baz& v) {
    s.set_baz(v);
}

template <class S, class V>
void serialized_set(S& s, const Fbr& v) {
    s.set_fbr(v);
}
//...

现在的好消息是您不再需要特化您的serialization_traits了。以下代码使用boost::fusion::fold函数,我认为在您的项目中使用它是可以的:
template <class SerializedX>
class serialization_traits {

    struct set_functor {

        template <class V>
        SerializedX& operator()(SerializedX& s, const V& v) const {
            serialized_set(s, v);
            return s;
        }
    };

public:

    template <class Tuple>
    static SerializedX encode(const Tuple& t) {
        SerializedX s;
        boost::fusion::fold(t, s, set_functor());
        return s;
    }
};

这里是一些它的工作原理示例。请注意,如果有人试图绑定不符合SerializedXYZ接口的some_data的数据成员,则编译器会向您发出通知:
void send_msg(const SerializedFooBar&){
    std::cout << "Sent SerializedFooBar" << std::endl;
}

void send_msg(const SerializedBarBaz&){
    std::cout << "Sent SerializedBarBaz" << std::endl;
}

void send(const some_data& data) {
  send_msg( serialization_traits<SerializedFooBar>::encode(boost::fusion::tie(data.foo, data.bar)) );
  send_msg( serialization_traits<SerializedBarBaz>::encode(boost::fusion::tie(data.bar, data.baz)) );
//  send_msg( serialization_traits<SerializedFooBar>::encode(boost::fusion::tie(data.foo, data.baz)) ); // compiler error; SerializedFooBar has no set_baz member
}

int main() {

    some_data my_data;
    send(my_data);
}

代码在这里

编辑:

不幸的是,这个解决方案没有解决OP的问题#1。为了解决这个问题,我们可以定义一系列标签,每个标签对应一个数据成员,并采用类似的方法。以下是标签,以及修改后的serialized_set函数:

struct foo_tag{};
struct bar1_tag{};
struct bar2_tag{};
struct baz_tag{};
struct fbr_tag{};

template <class S>
void serialized_set(S& s, const some_data& data, foo_tag) {
    s.set_foo(data.foo);
}

template <class S>
void serialized_set(S& s, const some_data& data, bar1_tag) {
    s.set_bar1(data.bar1);
}

template <class S>
void serialized_set(S& s, const some_data& data, bar2_tag) {
    s.set_bar2(data.bar2);
}

template <class S>
void serialized_set(S& s, const some_data& data, baz_tag) {
    s.set_baz(data.baz);
}

template <class S>
void serialized_set(S& s, const some_data& data, fbr_tag) {
    s.set_fbr(data.fbr);
}

锅炉板只针对每个数据成员限制一个“serialized_set”,并且与我的先前答案类似,呈线性扩展。这是修改后的serialization_traits:
// the serialization_traits doesn't need specialization anymore :)
template <class SerializedX>
class serialization_traits {

    class set_functor {

        const some_data& m_data;

    public:

        typedef SerializedX& result_type;

        set_functor(const some_data& data)
        : m_data(data){}

        template <class Tag>
        SerializedX& operator()(SerializedX& s, Tag tag) const {
            serialized_set(s, m_data, tag);
            return s;
        }
    };

public:

    template <class Tuple>
    static SerializedX encode(const some_data& data, const Tuple& t) {
        SerializedX s;
        boost::fusion::fold(t, s, set_functor(data));
        return s;
    }
};

以下是它的工作原理:

...

void send(const some_data& data) {

    send_msg( serialization_traits<SerializedFooBar>::encode(data,
    boost::fusion::make_tuple(foo_tag(), bar1_tag())));

    send_msg( serialization_traits<SerializedBarBaz>::encode(data,
    boost::fusion::make_tuple(baz_tag(), bar1_tag(), bar2_tag())));
}

这里有更新的代码点击查看


1
我非常高兴能够帮助,真心希望这对你有所帮助。赏金奖励是不错的,但如果你无法获得它,也不是世界末日 :) - linuxfever
从我所看到的,这只考虑了成员的类型,因此当存在两个相同类型的成员时就会失败。(想象一下 barbaz 都是相同类型的情况。)这是我问题中的第一个问题。(请参见此处。) - sbi
@sbi:好的,回到正题。你对 some_data 类有任何控制吗? - linuxfever
2
让我们在聊天中继续这个讨论 - linuxfever
1
因此,聊天中的讨论集中在这里,我对此感到满意。 - sbi
显示剩余2条评论

3
你需要的是类似元组(tuple-like)但不是真正的元组。假设所有实现了tie()函数(用于把成员变量捆绑在一起)的tuple_like类,以下是我假想代码:
template<typename T> struct tuple_like {
    bool operator==(const T& rhs) const {
        return this->tie() == rhs.tie();
    }
    bool operator!=(const T& rhs) const {
        return !operator==(*this,rhs);
    }        
};
template<typename T, typename Serialised> struct serialised_tuple_like : tuple_like<T> {
};
template<typename T, typename Serialised>
struct serialization_traits<serialised_tuple_like<T, Serialised>> {
    static Serialised encode(const T& bb) {
        Serialised s;
        s.tie() = bb.tie();
        return s;
    }
};

只要双方都实现了适当的tie(),这就没问题了。如果源类或目标类不直接在您的控制下,则建议定义一个继承类来实现tie()并使用它。对于合并多个类,请定义一个帮助类,该类根据其成员来实现tie()。

嗯。使用继承类似乎可以解决元组标记问题,对于在语法上相等但在语义上不同的元组。因此这是一个好主意(我赞成)。然而,我无法在Serialized中添加tie(),因为它是一个(protobuf生成的)不在我的控制之下的类。(基本上,serialization_traits<>::encode()就是这个tie()函数,这将使我们回到我的问题#2。你能想到任何解决这个问题的方法吗? - sbi

3
如果你的样板代码只是一堆普通的数据结构,且比较操作十分简单,那么你可能可以使用一些宏来实现。
#define POD2(NAME, T0, N0, T1, N1) \
struct NAME { \
    T0 N0; \
    T1 N1; \
    NAME(const T0& N0, const T1& N1) \
        : N0(N0), N1(N1) {} \
    bool operator==(const NAME& rhs) const { return N0 == rhs.N0 && N1 == rhs.N1; } 
\
    bool operator!=(const NAME& rhs) const { return !operator==(rhs); } \
};

使用方法如下:

POD2(BarBaz, Bar, bar, Baz, baz)

template <>
struct serialization_traits<BarBaz> {
    static SerializedBarBaz encode(const BarBaz& bb) {
        SerializedBarBaz sbb;
        sbb.set_bar(bb.bar);
        sbb.set_baz(bb.baz);
        return sbb;
    }
};

你需要N个宏,其中N是你所拥有的参数计数的排列组合数量,但这将是一次性的前期成本。
或者你可以利用元组来完成很多繁重的工作,就像你建议的那样。这里我创建了一个“NamedTuple”模板来命名元组的getter。
#define NAMED_TUPLE2_T(N0, N1) NamedTuple##N0##N1

#define NAMED_TUPLE2(N0, N1) \
template <typename T0, typename T1> \
struct NAMED_TUPLE2_T(N0, N1) { \
    typedef std::tuple<T0, T1> TupleType; \
    const typename std::tuple_element<0, TupleType>::type& N0() const { return std::get<0>(tuple_); } \
    const typename std::tuple_element<1, TupleType>::type& N1() const { return std::get<1>(tuple_); } \
    NAMED_TUPLE2_T(N0, N1)(const std::tuple<T0, T1>& tuple) : tuple_(tuple) {} \
    bool operator==(const NAMED_TUPLE2_T(N0, N1)& rhs) const { return tuple_ == rhs.tuple_; } \
    bool operator!=(const NAMED_TUPLE2_T(N0, N1)& rhs) const { return !operator==(rhs); } \
    private: \
        TupleType tuple_; \
}; \
typedef NAMED_TUPLE2_T(N0, N1)

使用方法:

NAMED_TUPLE2(foo, bar)<int, int> FooBar;

template <>
struct serialization_traits<FooBar> {
    static SerializedFooBar encode(const FooBar& fb) {
        SerializedFooBar sfb;
        sfb.set_foo(fb.foo());
        sfb.set_bar(fb.bar());
        return sfb;
    }
};

很遗憾,我无法控制Serialized…类型。它们是自动生成的。 - sbi

2

您是否考虑过稍微不同的方法?与其拥有单独的FooBar和BarBaz表示,不如考虑类似于FooBarBaz的方式。

message FooBarBaz {
  optional Foo foo = 1;
  optional Bar bar = 2;
  optional Baz baz = 3;
}

然后在您的应用程序代码中,您可以像这样利用它:

FooBarBaz foo;
foo.set_foo(...);
FooBarBaz bar;
bar.set_bar(...);
FooBarBaz baz;
baz.set_baz(...);
FooBarBaz foobar = foo;
foobar.MergeFrom(bar);
FooBarBaz barbaz = bar;
barbaz.MergeFrom(baz);

另外,您还可以利用protobuf编码并序列化消息。(实际上protobuf本身没有被序列化,您可以通过调用其中一个ToString方法来获得它)。

// assume string_foo is the actual serialized foo from above, likewise string_bar
string serialized_foobar = string_foo + string_bar;
string serialized_barbaz = string_bar + string_baz;

FooBarBaz barbaz;
barbaz.ParseFromString(serialized_barbaz);

这意味着您可以将大多数API从显式字段集移动到具有可选字段的常见消息,以仅发送所需内容。您可能希望包装系统的边缘,以断言在尝试使用特定过程所需的字段之前已设置这些字段,但这可能会导致其他地方的样板代码减少。字符串连接技巧在通过实际上不关心其中内容的系统时也很方便。


我对消息没有控制权。虽然目前protobuf是最常用的编码方案,但JSON也被使用。而且谁知道明年我们会使用什么。不,序列化特性的整个重点在于将我们的代码与此解耦。 - sbi
我猜我读到的是你有某种系统状态和某种部分系统状态。我的建议是使用不完整的系统状态来表示部分状态,而不是具有多个重叠定义的本质上是部分状态的情况。Protobuf具有一些使此成为可能的功能(它们仍然可以隐藏在编码器实现中)。但是,一旦涉及到json,这种方法就不太适用了,尽管有protobuf到json库。理论上,您可以在内部使用protobuf,使用反射进行序列化。 - Charlie

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接