.NET 中与 BinaryFormatter 的向后兼容性

发布于 2024-09-16 02:40:07 字数 2254 浏览 3 评论 0原文

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

目标:

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

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

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

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

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

我读过:

我当前的解决方案

  • 我们通过使用 OnDeserializing 回调等内容,尽可能多地进行不间断的更改。
  • 我们每两周安排一次重大更改,因此需要保留的兼容性代码较少。
  • 每次进行重大更改之前,我们都会将我们使用的所有 [Serialized] 类复制到名为 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 类是被序列化的。
  • 几乎不可能测试或调试
  • 需要保留许多类的旧副本,这很容易出错,脆弱且烦人
  • 我不知道当我们想要重命名类时该怎么办

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

We use BinaryFormatter in a C# game, to save user game progress, game levels, etc. We are running into the problem of backwards compatibility.

The aims:

  • Level designer creates campaign (levels&rules), we change the code, the campaign should still work fine. This can happen everyday during development before release.
  • User saves game, we release a game patch, user should still be able to load game
  • The invisible data-conversion process should work no matter how distant the two versions are. For example an user can skip our first 5 minor updates and get the 6th directly. Still, his saved games should still load fine.

The solution needs to be completely invisible to users and level designers, and minimally burden coders who want to change something (e.g. rename a field because they thought of a better name).

Some object graphs we serialize are rooted in one class, some in others. Forward compatibility is not needed.

Potentially breaking changes (and what happens when we serialize the old version and deserialize into the new):

  • add field (gets default-initialized)
  • change field type (failure)
  • rename field (equivalent to removing it and adding a new one)
  • change property to field and back (equivalent to a rename)
  • change autoimplemented property to use backing field (equivalent to a rename)
  • add superclass (equivalent to adding its fields to the current class)
  • interpret a field differently (e.g. was in degrees, now in radians)
  • for types implementing ISerializable we may change our implementation of the ISerializable methods (e.g. start using compression within the ISerializable implementation for some really large type)
  • Rename a class, rename an enum value

I have read about:

My current solution:

  • We make as many changes as possible non-breaking, by using stuff like the OnDeserializing callback.
  • We schedule breaking changes for once every 2 weeks, so there's less compatibility code to keep around.
  • Everytime before we make a breaking change, we copy all the [Serializable] classes we use, into a namespace/folder called OldClassVersions.VersionX (where X is the next ordinal number after the last one). We do this even if we aren't going to be making a release soon.
  • When writing to file, what we serialize is an instance of this class: class SaveFileData { int version; object data; }
  • When reading from file, we deserialize the SaveFileData and pass it to an iterative "update" routine that does something like this:

.

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);
}
  • For convenience, the Update() function, in its implementation, can use a CopyOverlappingPart() function that uses reflection to copy as much data as possible from the old version to the new version. This way, the Update() function can only handle stuff that actually changed.

Some problems with that:

  • the deserializer deserializes to class Foo rather than to class OldClassVersions.Version5.Foo - because class Foo is what was serialized.
  • almost impossible to test or debug
  • requires to keep around old copies of a lot of classes, which is error-prone, fragile and annoying
  • I don't know what to do when we want to rename a class

This should be a really common problem. How do people usually solve it?

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(3

屌丝范 2024-09-23 02:40:14

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

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

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

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

This is a really old question, but it needs an up-to-date answer anyway; I know this strays slightly off topic so bear with me. Today, in 2019: I would suggest to people who happen to read this at a stage in your project where this is reasonably feasible to seriously consider using Protobuf instead of BinaryFormatter. It has most of the advantages of a binary format (which it is) but fewer of its disadvantages.

  • It works between different languages and technology stacks with ease (Java, .NET, C++, Go, Python)
  • It has a well-thought-through strategy for handling breaking changes (adding/removing fields, etc) in a way that means it's much easier for "version x" of your software to handle "version y"-generated data and the other way around. Yes, this is actually true: an older version of your app will be able to handle data serialized with a newer version of the Protobuf .proto interface definition. (Non-present fields will simply be ignored when deserializing.)

    By comparison, when running a newer versions of the code and deserializing old data, "not-present" fields in the data will be set to their type-specific default value. In that sense, handling old data is not "fully automatic" in that sense, but still a lot simpler than when using the default binary serialization libraries included with platforms like Java and .NET.

If you prefer a non-binary format, JSON is often a suitable choice. For RPC and such scenarios, Protobuf is better though and is even officially being mentioned/endorsed by Microsoft nowadays: Introduction to gRPC on ASP.NET Core. (gRPC is a technology stack built on top of Protobuf)

那一片橙海, 2024-09-23 02:40:13

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

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

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

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

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

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

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

We got the same problem in our application with storing user profile data (grid column arrangement, filter settings ...).

In our case the problem was the AssemblyVersion.

For this problem i create a SerializationBinder which reads the actual assembly version of
the assemblies (all assemblies get a new version number on new deployment)
with Assembly.GetExecutingAssembly().GetName().Version.

In the overriden method BindToType the type info is created with the new assembly version.

The deserialization is implemented 'by hand', that means

  • Deserialize via normal BinaryFormatter
  • get all fields which have to be deserialized (annotated with own attribute)
  • fill object with data from the deserialized object

Works with all our data and since three or four releases.

梦幻的味道 2024-09-23 02:40:11

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

Tough one. I would dump binary and use XML serialization (easier to manage, tolerant to changes that are not too extreme - like adding / removing fields). In more extreme cases it is easier to write a transform (xslt perhaps) from one version to another and keep the classes clean. If opacity and small disk footprint are a requirement you can try to compress the data before writing to disk.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文