65.9K
CodeProject 正在变化。 阅读更多。
Home

Protobuf-net:非官方手册

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (28投票s)

2013年8月24日

CC (ASA 3U)

17分钟阅读

viewsIcon

179872

Protobuf-net 是一个基于 Google Protocol Buffers 的快速且完整的 .NET 序列化库。这是一份非常简短的手册。

Protobuf-net 是一个基于 Google Protocol Buffers 的快速、多功能的 .NET 序列化库。它是那种被广泛使用但文档却很差的库之一,因此用法信息散落在互联网各处(即便如此,我还是要感谢作者在 StackOverflow 上对问题做出*令人难以置信的*快速响应)。大部分官方文档都在 入门指南 中,源代码的 XML 文档注释里也有一小部分信息(只要你的 protobuf-net.dll 文件旁边有 protobuf-net.xml 文件,这些信息就会自动出现在 Visual Studio 的智能感知中)。不过,文档注释通常只是对某个功能或含义的模糊提示。作者的博客也有各种信息。

因此,我正在将大量零散信息整合到这篇博文中。我提到了一些我**不知道**或者仅仅是推断出来的事情。如果你知道一些我不知道的内容,请留言,我会将这些信息整合到文章中。

目录

概述

Protobuf-net 与标准的 .NET 序列化系统不兼容。虽然可以配置它使其行为相当类似,但应该注意以下几点:
然而,它确实为序列化提供了几种基于逐个类型的方法(见下一节)。

当然,Protobuf 受限于 Protocol Buffers 的工作方式。Protocol Buffers 没有定义“记录类型”的概念(反序列化器应预先知道数据类型,因此在反序列化时必须在根级别指定数据类型),它只有少数几种数据的线路格式,并且 Protocol Buffer 中的所有字段都只有数字标识符(称为“标签”),而不是字符串。这就是为什么你应该在每个想要序列化的字段或属性上添加 **[ProtoMember(N)]** 特性。

标准的 Protocol Buffers 没有提供共享对象(在两个地方使用同一个对象)或支持引用循环的方法,因为它们的设计初衷就不是一个序列化机制。尽管如此,protobuf-net *确实*支持引用和循环(更多信息见下文)。支持引用和循环在时间和输出大小方面成本更高,所以默认是禁用的。至少有两种方式可以启用它:
  1. 如果你知道某个特定类的实例经常被共享,请使用 **[ProtoContract(AsReferenceDefault=true)]**。这将应用于该类型的所有字段以及包含该类型的集合。
  2. 你可以通过 **[ProtoMember(N, AsReference=true)]** 在单个字段上启用引用系统。引用将与任何其他同样使用 **AsReference=true** 的字段共享。如果某个对象同时被放置在具有 **AsReference=true** 的字段和没有该设置的字段中,那么 **AsReference=false** 的字段将持有该对象的副本,这些副本在反序列化时会变成独立的对象。我只能假设 **AsReference=false** 会覆盖 **AsReferenceDefault=true**。

当你在一个集合类型上使用 **AsReference=true** 时,集合内的实例会通过引用进行跟踪。然而,集合本身似乎*不会*被引用跟踪。如果同一个集合对象在多个地方使用,它将被多次序列化,并且这些副本在反序列化时不会被合并。

Protobuf 在序列化字段和属性方面同样得心应手,并且它可以序列化**公共**和**私有**的字段和属性(嗯,也许在 Silverlight 或部分信任环境中无法序列化私有成员?我怀疑 Silverlight 中有安全限制)。没有 **[ProtoMember(N)]** 特性的字段和属性通常不会被序列化,除非类有 **ImplicitFields** 选项,如 **[ProtoContract(ImplicitFields = ImplicitFields.AllPublic)]** 或 **[ProtoContract(ImplicitFields = ImplicitFields.AllFields)]**,这会导致数字被自动分配。

我找不到关于数字如何自动分配的文档,但它似乎是按字母顺序为你的字段/属性分配从1开始的数字。即使你的类在某些字段上显式使用了 **ProtoMember(N)**,它仍然会从1开始编号。**ProtoMember** 特性不会被忽略,但它不影响未使用 **ProtoMember** 的字段的编号,这可能导致标签号冲突(例如,如果你使用了 **ProtoMember(1)**,第一个自动分配的编号仍然是1,从而引发冲突)。

当使用 **ImplicitFields** 时,protobuf-net 会忽略标记为 **[ProtoIgnore]** 的字段/属性。需要明确的是,如果你不使用 **ImplicitFields**,字段和属性默认是被忽略的,必须使用 **[ProtoMember(N)]** 显式进行序列化。

Protocol Buffers 本身没有继承的概念,但 protobuf-net 支持继承,**前提是**你在*基类上*指定 **[ProtoInclude(N, typeof(DerivedClass))]**。继承有多种可能的工作方式。Protobuf-net 的方法是为每个可能的派生类在基类中定义一个可选字段;**ProtoInclude** 中的字段编号 N 指的就是这些可选字段之一。例如,以下类:
[ProtoContract(ImplicitFields = ImplicitFields.AllFields)]
[ProtoInclude(100, typeof(Derived))]
[ProtoInclude(101, typeof(Derive2))]
class Base { int Old; }

[ProtoContract(ImplicitFields = ImplicitFields.AllFields)]
class Derived : Base { int New; }
[ProtoContract(ImplicitFields = ImplicitFields.AllFields)]
class Derive2 : Base { int Eew; }  
具有以下模式:
message Base {
   optional int32 Old = 1 [default = 0];
   // the following represent sub-types; at most 1 should have a value
   optional Derived Derived = 100;
   optional Derived Derive2 = 101;
}
message Derived {
   optional int32 New = 1 [default = 0];
}
message Derive2 {
   optional int32 Eew = 1 [default = 0];
}

protobuf-net 中的类型序列化形式

我认为 protobuf-net 支持五种基本的逐类型(反)序列化方式(不包括原始类型):
  1. 普通序列化。在这种模式下,会写入一个标准的 Protocol Buffer,其中 Protocol Buffer 中的每个字段对应于你用 **ProtoMember** 标记的或被 **ImplicitFields** 自动选中的每个字段或属性。在反序列化期间,默认会调用默认构造函数,但可以禁用此行为。我听说 protobuf-net 允许你反序列化到只读字段(?!),这应该能让你处理许多不可变对象的情况。
  2. 集合序列化。如果 protobuf-net 将特定数据类型识别为集合,则使用此模式进行序列化。值得庆幸的是,集合类型不需要任何 **ProtoContract** 或 **ProtoMember** 特性,这意味着你可以轻松序列化 **List<T>** 和 **T[]** 等类型。(我不知道如果你的“集合”类上存在任何此类特性,protobuf-net 会如何反应)。我听说也支持字典。
  3. 自动元组序列化。在某些条件下,protobuf-net 可以通过调用其非默认构造函数(参数多于零个的构造函数)来反序列化一个没有 **ProtoContract** 特性的不可变类型。幸运的是,这在链接中有完整文档。此功能自动应用于 **System.Tuple<...>** 和 **KeyValuePair**。
  4. 字符串(“可解析”)序列化。Protobuf-net 可以序列化一个具有静态 **Parse()** 方法的类。它调用 **ToString()** 进行序列化,然后调用 **Parse(string)** 来反序列化该字符串。但是,此功能默认是禁用的。通过设置 **RuntimeTypeModel.Default.AllowParseableTypes = true** 来启用它。我不知道 **[ProtoContract]** 是否会禁用此功能。
  5. “代理”序列化,这对于你不能修改的“封闭”类型(如 BCL(标准库)类型)非常有用。你可以通过 **RuntimeTypeModel.Default.Add(typeof(ClosedType), false) .SetSurrogate(typeof(SurrogateType))** 指定一个用户定义的类型作为代理,而不是直接序列化一个类型。为了在原始类型和代理类型之间进行转换,protobuf-net 会在代理类型上寻找转换运算符(**public static implicit operator ClosedType**,**public static explicit operator SurrogateType**);**implicit** 和 **explicit** 都可以。即使要转换的“对象”是 **null**,转换运算符也会被调用。
我们来谈谈反序列化。因为它需要一个对象才能进行反序列化。它有三种方式可以获得一个对象:
  1. “类似 XML 序列化”。在这种默认模式下,你的类必须有一个默认构造函数。在开始反序列化之前,你的默认(即无参数)构造函数会被调用。然而,与 XML 序列化不同,protobuf-net 可以调用私有构造函数。
  2. “类似标准序列化”。选项 **[ProtoContract(SkipConstructor = true)]** 将在不调用构造函数的情况下进行反序列化,就像标准序列化一样。太神奇了!如果你添加 **ImplicitFields = ImplicitFields.AllFields**,protobuf-net 的行为会更接近标准序列化。
  3. 显然,你可以反序列化到你自己创建的对象中,至少在根级别是这样。调用(非静态的)**RuntimeTypeModel.Deserialize(Stream, object, Type)** 方法(例如 **RuntimeTypeModel.Default.Deseralize()**)。我不知道为什么它既需要一个对象*又*需要一个 **Type**。也许它对子对象也能做同样的事情,我不确定。无论如何,如果你想连续反序列化、检查并丢弃多个对象,这会很方便;你可以避免为垃圾回收器制造不必要的工作。

应该注意的是,protobuf-net 支持的一些技术仅在使用完整 .NET 框架和完全信任模式时可用。例如,如此处所述,如果你使用预编译器,你将无法反序列化到私有只读字段。我个人使用的是完整 .NET 框架,不知道在其他环境中会有什么坑。

至少在使用标准序列化模式时(我对其他模式不清楚),你可以编写标记有 `[ProtoBeforeSerialization]`、`[ProtoAfterSerialization]`、`[ProtoBeforeDeserialization]` 或 `[ProtoAfterDeserialization]` 的特殊无参数方法。最重要的可能是 `[ProtoAfterDeserialization]`,它能让你确保对象有效或初始化任何不属于数据流的字段。例如:

[ProtoContract]
struct LatLonPoint
{
    [ProtoMember(1)] double X;
    [ProtoMember(2)] double Y;
    [ProtoAfterDeserialization] 
    void Validate() { 
        if (!(Math.Abs(X) <= 180 && Math.Abs(Y) <= 90)) 
            throw new FormatException("Invalid latitude or longitude enountered");
    }
}

你也可以使用 `System.Runtime.Serialization` 中的标准特性:`OnSerializingAttribute``OnSerializedAttribute``OnDeserializingAttribute``OnDeserializedAttribute`。微软规定使用这些特性的方法需要一个 `StreamingContext` 参数,但 protobuf-net 不需要。如果该参数存在,你会得到一个 `Context` 属性为 `null` 且 `State==StreamingContextStates.Persistence` 的 `StreamingContext`。

Protobuf-net 集合处理

我的印象是,像 `IReadOnlyList` 这样的非标准集合接口(或者我个人使用的自定义只读接口)是不被支持的。你不能为 `IReadOnlyList` 定义一个代理,因为与代理之间的转换需要一个 C# 转换操作符,而 C# 编译器禁止你定义与接口之间的转换。

现在,如果你可以修改包含 `IReadOnlyList` 属性的类,那么你可以创建一个 `private` 的虚拟属性,仅用于帮助 protobuf-net。这个虚拟属性将是 `IList` 类型,并带有一个 `[ProtoMember(N)]` 特性,而原始属性则没有。然后,这个虚拟的 setter 方法必须为 protobuf-net 提供的 `List` 创建一个只读包装器。这是一个例子:

[ProtoContract]
public class MyClass
{
    ...
    public IReadOnlyList<int> MyList;
        
    [ProtoMember(1, OverwriteList=true)]
    private IEnumerable<int> PB_MyList { 
        get { return MyList; } 
        set { MyList = ((IList<int>)value).AsReadOnly(); }
    }
    ...
}

在这里,`AsReadOnly()` 应该为列表创建一个只读包装器(我把这作为一个“读者练习”)。你可能会发现 `OverwriteList` 选项是必需的,否则 protobuf-net 似乎会尝试调用 `PB_MyList` 的 getter 并尽可能将其视为一个 `IList`。

如果你不能修改使用 `IReadOnlyList` 的类,最后的办法是为整个类创建一个代理类型。

杂项

  • Protobuf.net 的预编译器让你可以在不支持运行时代码生成或反射的平台上使用 protobuf-net(例如 .NET CF、iOS、Silverlight,甚至 .NET 1.1)。它也可以在完整的 .NET 框架上使用,以避免一些运行时的工作。
  • 默认情况下,protobuf-net 会接受 **[XmlType]** 和 **[XmlElement(Order = N)]** 来替代 **[ProtoContract]** 和 **[ProtoMember(N)]**,如果你已经在使用 XML 序列化或者想避免显式依赖 protobuf-net,这会很方便。类似地,它也接受 WCF 特性 **[DataContract]** 和 **[DataMember(Order = N)]**。**Order** 选项是支持 protobuf 所必需的。
  • **[XmlInclude]** 和 **[KnownType]** 不能用来代替 **[ProtoInclude]**,因为它们没有一个整数参数来用作标签号。
  • 标签值必须为正数。**[ProtoMember(0)]** 是非法的。
  • 非常有用:打印 **RuntimeTypeModel.GetSchema(typeof(Root))** 的结果,以了解将使用什么 Protocol Buffers 来表示你的数据(例如 **RuntimeTypeModel.Default.GetSchema**)。**注意**:我假设这将是用于序列化的实际模式,但文档仅说明它将“建议一个 .proto 定义”。
  • 带有默认参数的构造函数,如 **Constr(int x = 0)**,不被识别为无参数构造函数。
  • **SkipConstructor** 和 **ImplicitFields** 选项不会被继承,可能其他选项也不会被继承。因此,例如,如果你在基类上使用 **SkipConstructor**,派生类的构造函数仍然会被调用(并且,因此,基类的构造函数也会被调用)。
  • 你可能已经注意到下载页面上的“Visual Studio 2008 / 2010 support”下载包,但这到底是*干什么用的*?我没亲自试过,但根据这篇博文,我怀疑它是一个用于从 .proto 文件自动生成 C# 代码的工具。
  • 有时 protobuf-net 可以序列化某些东西但无法反序列化;请务必测试双向操作。
  • **RuntimeTypeModel.DeepClone()** 是一个测试序列化和反序列化是否都正常工作的便捷方法。此方法通常会序列化对象并立即再次反序列化它。
  • 我怀疑你可以使用标准的 **[NonSerialized]** 特性作用于字段或属性,作为 **[ProtoIgnore]** 的替代方案。
  • 在序列化子对象时,protobuf-net 可以写入一个带长度前缀的缓冲区,或者如果包含子对象的字段有 **[ProtoMember(N, DataFormat = DataFormat.Group)]** 选项,它可以使用Marc Gravell所称的“组分隔”记录,这避免了预先测量记录大小的开销。Google 称此功能为“组”,但已弃用该功能,并且据我所知,已删除了过去可能存在的任何相关文档。
  • 在 **[ProtoMember]** 中,**IsRequired** 的默认值是 **false**。我只能假设它的意思与 .proto 文件中的 **required** 相同。我猜想一个必需字段总是会被写入,并且如果在反序列化过程中缺少一个必需字段,将会抛出某种异常。
  • Protobuf-net 支持 **Nullable<T>**。我听说类型为 **int?** 或任何其他可空类型的值如果为 null,将不会被写入流中(因此,我*完全不知道*如果你在一个可空字段上使用 **IsRequired=true** 会发生什么——这也包括引用类型的字段。)
  • Protobuf-net 会(合理地)拒绝序列化一个没有 setter 的属性,并提示“无法将更改应用于属性”。然而,它会序列化一个 setter 为私有的属性,并在反序列化期间调用该 setter。
  • 如果你通过 subversion 下载 protobuf-net 的源代码,你将可以访问 Examples 项目,其中演示了使用该库的各种方法。

不使用特性进行序列化

如果你无法更改现有类以添加 **[ProtoContract]** 和 **[ProtoMember]** 特性,你仍然可以对该类使用 protobuf-net。但在我告诉你如何做之前,需要说明的是,protobuf-net 的配置存储在一个名为 **RuntimeTypeModel** 的类中(位于 **Protobuf.Meta** 命名空间)。有一个全局模型 **RuntimeTypeModel.Default**,你也可以使用静态方法 **TypeModel.Create()** 创建其他模型。这使得在同一个程序中,可以用不同的 Protocol Buffers 以不同的方式序列化同一个类。
假设你有一个名为 **model** 的 **RuntimeTypeModel** 对象。那么 **model.Add(typeof(C), true)** 会为类型 C 创建一个配置,由一个 **MetaType** 对象表示。你也可以调用 **model[typeof(C)]** 来获取或创建一个 MetaType,尽管即使在反编译了它们之后,我也不确定 **model[type]** 和 **model.Add(type, flag)** 之间的关系是什么。
最后,调用 **model.Serialize(Stream, object)** 或 **model.Deserialize(Stream, object, Type)** (或其他重载方法)。

数据类型

以下类型说明了 protobuf-net 如何将原始类型映射到 Protocol Buffers:
C#
[ProtoContract]
class DefaultRepresentations
{
  [ProtoMember(1)] int Int;
  [ProtoMember(2)] uint Uint;
  [ProtoMember(3)] byte Byte;
  [ProtoMember(4)] sbyte Sbyte;
  [ProtoMember(5)] ushort Ushort;
  [ProtoMember(6)] short Short;
  [ProtoMember(7)] long Long;
  [ProtoMember(8)] ulong Ulong;
  [ProtoMember(9)] float Float;
  [ProtoMember(10)] double Double;
  [ProtoMember(11)] decimal Decimal;
  [ProtoMember(12)] bool Bool;
  [ProtoMember(13)] string String;
  [ProtoMember(14)] DayOfWeek Enum;
  [ProtoMember(15)] byte[] Bytes;
  [ProtoMember(16)] string[] Strings;
  [ProtoMember(17)] char Char;
}
.proto
message DefaultRepresentations {
  optional int32 Int = 1 [default = 0];
  optional uint32 Uint = 2 [default = 0];
  optional uint32 Byte = 3 [default = 0];
  optional int32 Sbyte = 4 [default = 0];
  optional uint32 Ushort = 5 [default = 0];
  optional int32 Short = 6 [default = 0];
  optional int64 Long = 7 [default = 0];
  optional uint64 Ulong = 8 [default = 0];
  optional float Float = 9 [default = 0];
  optional double Double = 10 [default = 0];
  optional bcl.Decimal Decimal = 11 [default=0];
  optional bool Bool = 12 [default = false];
  optional string String = 13;
  optional DayOfWeek Enum = 14 [default=Sunday];
  optional bytes Bytes = 15;
  repeated string Strings = 16;
  optional uint32 Char = 17 [default = 

(**GetSchema** 中有一个 bug;在 char 类型的字段之后输出被截断了。)

你可以查看
Protocol Buffer 文档,特别是编码部分,以获取更多信息。所有的整数类型都使用 **varint** 线路格式。因为 protobuf-net 使用 **int32/int64** 而不是 **sint32/sint64**,负数存储效率低下,但你可以使用 **DataFormat** 选项选择不同的表示方式,如下所示。**sint32/sint64**(在 protobuf-net 中称为 **ZigZag**)更适合经常为负数的字段;**sfixed32/sfixed64**(在 protobuf-net 中称为 **FixedSize**)更适合大多数时候数值很大的数字(或者如果你只是偏爱更简单的存储表示)。

顺便说一下,string 和 byte[](在 .proto 中为 "bytes")都使用长度前缀表示法。子对象(即 "messages")也使用长度前缀表示法(这有在任何地方被记录吗?),除非你使用 "group" 格式。
[ProtoContract]
class ExplicitRepresentations
{
  [ProtoMember(1, DataFormat = DataFormat.TwosComplement)] int defaultInt;
  [ProtoMember(2, DataFormat = DataFormat.TwosComplement)] int defaultLong;
  [ProtoMember(3, DataFormat = DataFormat.FixedSize)] int fixedSizeInt;
  [ProtoMember(4, DataFormat = DataFormat.FixedSize)] long fixedSizeLong;
  [ProtoMember(5, DataFormat = DataFormat.ZigZag)] int zigZagInt;
  [ProtoMember(6, DataFormat = DataFormat.ZigZag)] long zigZagLong;
  [ProtoMember(7, DataFormat = DataFormat.Default)] SubObject lengthPrefixedObject;
  [ProtoMember(8, DataFormat = DataFormat.Group)] SubObject groupObject;
}
[ProtoContract(ImplicitFields=ImplicitFields.AllFields)]
class SubObject { string x; }
.proto
message ExplicitRepresentations {
   optional int32 defaultInt = 1 [default = 0];
   optional int32 defaultLong = 2 [default = 0];
   optional sfixed32 fixedSizeInt = 3 [default = 0];
   optional sfixed64 fixedSizeLong = 4 [default = 0];
   optional sint32 zigZagInt = 5 [default = 0];
   optional sint64 zigZagLong = 6 [default = 0];
   optional SubObject lengthPrefixedObject = 7;
   optional group SubObject groupObject = 8;
}
message SubObject {
   optional string x = 1 [default = 0];
}
顺便说一下,既然 Google 似乎没有文档说明“group”格式,我将向你展示这两种子对象格式在二进制中的样子:
[ProtoContract]
class SubMessageRepresentations
{
  [ProtoMember(5, DataFormat = DataFormat.Default)] 
  public SubObject lengthPrefixedObject;
  [ProtoMember(6, DataFormat = DataFormat.Group)]
  public SubObject groupObject;
}
[ProtoContract(ImplicitFields=ImplicitFields.AllFields)]
class SubObject { public int x; }
/*
message SubMessageRepresentations {
   optional SubObject lengthPrefixedObject = 5;
   optional group SubObject groupObject = 6;
}
message SubObject {
   optional int32 x = 1 [default = 0];
}
*/

using (var stream = new MemoryStream()) {
  _pbModel.Serialize(
    stream, new SubMessageRepresentations {
      lengthPrefixedObject = new SubObject { x = 0x22 },
      groupObject = new SubObject { x = 0x44 }
    });
  byte[] buf = stream.GetBuffer();
  for (int i = 0; i < stream.Length; i++)
    Console.Write("{0:X2} ", buf[i]);
}

// Output: 2A 02 08 22 33 08 44 34 
// Interpretation:
// 0x2A: ((field=5) << 3) | (wire_type=2) // length-prefixed
// 0x02: length=2
// 0x08: ((field=1) << 3) | (wire_type=0) // varint
// 0x22: value of x
// 0x33: ((field=6) << 3) | (wire_type=3) // start group
// 0x08: ((field=1) << 3) | (wire_type=0) // varint
// 0x44: value of x
// 0x34: ((field=6) << 3) | (wire_type=4) // end group

版本控制

引用如何工作

当序列化一个应用了 **AsReference** 或 **AsReferenceDefault** 的类 Foo 时,Protocol Buffer 中字段的类型会从 **Foo** 变为 **bcl.NetObjectProxy**,它在 protobuf-net 的源代码中定义如下 (Tools/bcl.proto):
message NetObjectProxy {
  // for a tracked object, the key of the **first** 
  // time this object was seen
  optional int32 existingObjectKey = 1; 
  // for a tracked object, a **new** key, the first 
  // time this object is seen
  optional int32 newObjectKey = 2;
  // for dynamic typing, the key of the **first** time 
  // this type was seen
  optional int32 existingTypeKey = 3; 
  // for dynamic typing, a **new** key, the first time 
  // this type is seen
  optional int32 newTypeKey = 4;
  // for dynamic typing, the name of the type (only 
  // present along with newTypeKey) 
  optional string typeName = 8;
  // the new string/value (only present along with 
  // newObjectKey)
  optional bytes payload = 10;
}
所以看起来:

  • 当第一次遇到一个对象时,会写入 **newObjectKey** 和一个 **payload** 字段;据推测,**payload** 会像它的类型是 **Foo** 一样被存储。
  • 当再次遇到该对象时,只写入 **existingObjectKey**。
我不知道那个“动态类型”是怎么回事。
当然,字符串是一种引用类型。在此阅读关于 protobuf-net 如何处理字符串。

更多我尚不了解的东西

  • 我不知道 protobuf-net 是否能够反序列化一个类型为 object 的字段。默认情况下,它不能。
  • 我不知道 protobuf-net 如何*反序列化*接口类型的字段(序列化很容易,当然,它只需使用 GetType() 就能知道类型。)
  • 我不知道根对象是否允许是集合或原始类型。
  • 我不知道如何在特定类型序列化前运行“准备”代码,或在对象反序列化后运行验证/清理代码,但我知道存在某种“回调”机制用于此目的。

其他信息来源:

© . All rights reserved.