10

我们在 C# 游戏中使用 BinaryFormatter 来保存用户游戏进度、游戏关卡等。我们遇到了向后兼容的问题。

目的:

  • 关卡设计师创建活动(关卡和规则),我们更改代码,活动应该仍然可以正常工作。在发布之前的开发过程中每天都会发生这种情况。
  • 用户保存游戏,我们发布了游戏补丁,用户应该仍然可以加载游戏
  • 无论两个版本相距多远,不可见的数据转换过程都应该有效。例如,用户可以跳过我们的前 5 个小更新并直接获得第 6 个。尽管如此,他保存的游戏应该仍然可以正常加载。

该解决方案需要对用户和关卡设计师完全不可见,并且最小化想要更改某些内容的编码人员的负担(例如,重命名字段,因为他们想到了更好的名称)。

我们序列化的一些对象图植根于一个类,一些则植根于其他类。不需要前向兼容性。

潜在的重大变化(以及当我们序列化旧版本并反序列化到新版本时会发生什么):

  • 添加字段(获取默认初始化)
  • 更改字段类型(失败)
  • 重命名字段(相当于删除它并添加一个新的)
  • 将属性更改为字段并返回(相当于重命名)
  • 更改自动实现的属性以使用支持字段(相当于重命名)
  • 添加超类(相当于将其字段添加到当前类)
  • 以不同的方式解释字段(例如,以度为单位,现在以弧度为单位)
  • 对于实现 ISerializable 的类型,我们可能会更改 ISerializable 方法的实现(例如,开始在 ISerializable 实现中对一些非常大的类型使用压缩)
  • 重命名一个类,重命名一个枚举值

我读过:

我目前的解决方案

  • 我们通过使用诸如 OnDeserializing 回调之类的东西,尽可能多地进行非破坏性更改。
  • 我们每两周安排一次重大更改,因此需要保留的兼容性代码更少。
  • 每次在进行重大更改之前,我们都会将我们使用的所有[Serializable] 类复制到名为 OldClassVersions.VersionX 的命名空间/文件夹中(其中 X 是最后一个序号之后的下一个序号)。即使我们不会很快发布,我们也会这样做。
  • 当写入文件时,我们序列化的是这个类的一个实例:class SaveFileData { int version; 对象数据;}
  • 从文件读取时,我们反序列化 SaveFileData 并将其传递给执行以下操作的迭代“更新”例程:

.

for(int i = loadedData.version; i < CurrentVersion; i++)
{
    // Update() takes an instance of OldVersions.VersionX.TheClass
    // and returns an instance of OldVersions.VersionXPlus1.TheClass
    loadedData.data = Update(loadedData.data, i);
}
  • 为方便起见,Update() 函数在其实现中可以使用 CopyOverlappingPart() 函数,该函数使用反射将尽可能多的数据从旧版本复制到新版本。这样,Update() 函数只能处理实际更改的内容。

一些问题:

  • 反序列化器反序列化为类 Foo 而不是类 OldClassVersions.Version5.Foo - 因为类 Foo 是被序列化的。
  • 几乎不可能测试或调试
  • 需要保留很多类的旧副本,这容易出错、脆弱和烦人
  • 当我们想重命名一个类时,我不知道该怎么办

这应该是一个非常普遍的问题。人们通常如何解决它?

4

3 回答 3

3

艰难的一个。我会转储二进制文件并使用 XML 序列化(更易于管理,容忍不太极端的更改 - 例如添加/删除字段)。在更极端的情况下,更容易编写从一个版本到另一个版本的转换(也许是 xslt)并保持类干净。如果要求不透明度和小磁盘占用空间,您可以尝试在写入磁盘之前压缩数据。

于 2010-08-27T11:47:36.670 回答
2

我们在存储用户配置文件数据(网格列排列,过滤器设置......)的应用程序中遇到了同样的问题。

在我们的例子中,问题是 AssemblyVersion。

对于这个问题,我创建了一个SerializationBinder读取程序集的实际程序集版本(所有程序集在新部署中获得新版本号) Assembly.GetExecutingAssembly().GetName().Version

在覆盖方法BindToType中,类型信息是使用新的程序集版本创建的。

反序列化是“手动”实现的,这意味着

  • 通过普通 BinaryFormatter 反序列化
  • 获取所有必须反序列化的字段(用自己的属性注释)
  • 用反序列化对象中的数据填充对象

适用于我们所有的数据,自三四个版本以来。

于 2010-08-27T11:47:31.723 回答
1

这是一个非常古老的问题,但无论如何它都需要一个最新的答案;我知道这有点偏离主题,所以请耐心等待。2019 年的今天:我建议那些碰巧在你项目的某个阶段读到这篇文章的人认真考虑使用Protobuf而不是BinaryFormatter. 它具有二进制格式(确实如此)的大部分优点,但缺点较少。

  • 它可以轻松地在不同的语言和技术堆栈之间工作(Java、.NET、C++、Go、Python)
  • 它有一个深思熟虑的策略来处理重大更改(添加/删除字段等),这意味着您的软件的“版本x ”更容易处理“版本y ”生成的数据和其他方式周围。是的,这确实是真的:旧版本的应用程序将能够处理使用新版本的 Protobuf.proto接口定义序列化的数据。(反序列化时将简单地忽略不存在的字段。)

    相比之下,当运行较新版本的代码并反序列化旧数据时,数据中的“不存在”字段将设置为其特定于类型的默认值。从这个意义上说,处理旧数据并不是“全自动”的,但仍然比使用 Java 和 .NET 等平台附带的默认二进制序列化库要简单得多。

如果您更喜欢非二进制格式,JSON 通常是一个合适的选择。对于 RPC 和此类场景,Protobuf 更好,现在甚至被微软正式提及/认可:Introduction to gRPC on ASP.NET Core。(gRPC是建立在 Protobuf 之上的技术栈)

于 2019-09-07T19:11:25.793 回答