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

更强大的 BinaryReader/Writer

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (21投票s)

2016年9月18日

CPOL

8分钟阅读

viewsIcon

41827

扩展 BinaryReader/Writer 以支持不同的字节序、字符串和日期格式,以及在二进制文件中的高级导航

引言

本文将讨论如何扩展标准的 .NET BinaryReaderBinaryWriter 类,以支持许多新的、普遍适用且有用的功能。

本文讨论的 API 已封装到一个 NuGet 包 中,方便您在现有的 .NET 4.5 项目中轻松实现。

NuGet 包的独立 Wiki 位于 GitLab 仓库,更侧重于实现者的角度,但本文也讨论了该包是如何演变的,以及我在编写其内部机制时需要考虑的事项。

背景

每当我编写一个处理自定义二进制文件格式(有时是其他公司的复杂专有格式)加载和保存的库时,我都会使用标准的 .NET BinaryReaderBinaryWriter 类来解析这些文件中的二进制数据。

然而,随着格式越来越复杂,我越来越怀念这些 .NET 类中缺少一些通用的任务和功能。特别是,我一直在寻找以下功能:

  • 处理以与执行机器不同的字节序存储的数据。
  • 理解非 .NET 格式存储的 `string`,例如,0 结尾的 `string`。
  • 简单地读取和存储重复的数据类型(例如,加载 12 个 `Int32`),而无需反复编写 `for` 循环。
  • 使用不同于读取器/写入器实例指定的编码来单次读取 `string`。
  • 更轻松地在文件中导航,例如临时跳转到某个偏移量,然后跳回,或者对齐到块大小。

起初,我编写了扩展方法,将新功能附加到现有的 `BinaryReader` 或 `BinaryWriter` 类上。然而,这不足以实现以与运行代码的系统不同的字节序读取数据的行为。最终,我创建了名为 `BinaryDataReader` 和 `BinaryDataWriter` 的新类,它们继承自 .NET 类。

让我们看看我如何实现上述不同方面,并从实现者的角度看看如何使用它。

实现与使用

字节序

实现以与运行代码的机器不同的字节序读取多字节数据的支持,需要对标准 .NET 类进行大量更改。

请记住,.NET 不定义其处理的数据的字节序。因此,它可能会以大端序或小端序解析数据,具体取决于您运行它的机器。

因此,首先,正确检测系统的字节编码很重要。这很简单,只需检查 `System.BitConverter.IsLittleEndian` 字段即可。

ByteOrder systemByteOrder = BitConverter.IsLittleEndian ? ByteOrder.LittleEndian : ByteOrder.BigEndian;

正如您所见,我还引入了一个 `ByteOrder` 枚举,其值(顺便说一句)映射到文件中看到的字节序标记,以防这对您有用。

public enum ByteOrder : ushort
{
    BigEndian = 0xFEFF,
    LittleEndian = 0xFFFE
}

为了让读取器/写入器遵循此设置,我引入了一个 `ByteOrder` 属性,可以为其设置其中一个枚举值。它会检查系统运行的字节序是否与设置的不同,如果不同则反转读取/写入的字节。

public ByteOrder ByteOrder
{
    get
    {
        return _byteOrder;
    }
    set
    {
        _byteOrder = value;
        _needsReversion = _byteOrder != ByteOrder.GetSystemByteOrder();
    }
}

然后,我必须覆盖每个 `Read*`(对于 `BinaryDataReader`)或 `Write(T)`(对于 `BinaryDataWriter`)方法,以遵循 `_needsReversion` 布尔值。

public override Int32 ReadInt32()
{
    if (_needsReversion)
    {
        byte[] bytes = base.ReadBytes(sizeof(int));
        Array.Reverse(bytes);
        return BitConverter.ToInt32(bytes, 0);
    }
    else
    {
        return base.ReadInt32();
    }
}

在所有 `BitConverter.ToXxx` 方法的帮助下,这进行得非常顺利,使我能够检索多字节值的字节或将字节转换为多字节值。然而,`Decimal` 值是个例外,需要一些手动字节转换工作,其代码以 此处代码 为基础。

用法

默认情况下,`BinaryDataReader/Writer` 使用系统字节序。

您只需将 `ByteOrder` 枚举值设置为 `BinaryDataReader/Writer` 的 `ByteOrder` 属性即可,即使在调用 `Read*` 或 `Write(T)` 之间,也可以随时切换字节序。

using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataReader reader = new BinaryDataReader(stream))
{
    int intInSystemOrder = reader.ReadInt32();

    reader.ByteOrder = ByteOrder.BigEndian;
    int intInBigEndian = reader.ReadInt32();

    reader.ByteOrder = ByteOrder.LittleEndian;
    int intInLittleEndian = reader.ReadInt32();
}

重复数据类型

在使用 3D 文件格式时,我经常需要读取转换矩阵,这些矩阵是 16 个连续的浮点数。当然,我可以编写一个高度特定的“`ReadMatrix”方法,但我希望它可重用,并添加了 `ReadSingles` / `Write(T[])` 等方法,您在其中传入要读取的值的数量。它内部只是运行一个 `for` 循环并调用相应单个名称的方法。

public Int32[] ReadInt32s(int count)
{
    return ReadMultiple(count, ReadInt32);
}

private T[] ReadMultiple<T>(int count, Func<T> readFunc)
{
    T[] values = new T[count];
    for (int i = 0; i < values.Length; i++)
    {
        values[i] = readFunc.Invoke();
    }
    return values;
}
用法

只需调用任何 `Read*s()` 方法,并传入要作为数组返回的值的数量,或者调用 `Write(T[])` 方法,并将要写入的数组写入流中。

using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataReader reader = new BinaryDataReader(stream))
{
    int[] fortyFatInts = reader.ReadInt32s(40);
}

不同的字符串格式

`String` 可以以不同的二进制表示形式存储,默认情况下,`BinaryReader/Writer` 类仅支持 以无符号整数前缀确定其长度 的存储方式。

我处理过的大多数文件格式都使用 0 结尾的 `string`,例如,那些没有长度前缀,并且在读取到值为 `0` 的字节时就结束的字符串。因此,我为 `ReadString`(或 `Write(string)`)方法添加了重载,您可以向其中传递 `BinaryStringFormat` 枚举的值,该枚举支持以下表示:

  • `ByteLengthPrefix`:`string` 有一个无符号字节前缀,用于确定后续字符的数量。
  • `WordLengthPrefix`:`string` 有一个有符号两字节值(例如 `Int16` / `short`)的前缀,用于确定后续字符的数量。
  • `DwordLengthPrefix`:`string` 有一个有符号四字节值(例如 `Int32` / `int`)的前缀,用于确定后续字符的数量。
  • `ZeroTerminated`:`string` 没有前缀,但在遇到的第一个值为 `0` 的字节处结束。
  • `NoPrefixOrTermination`:`string` 既没有前缀也没有后缀,并且必须知道长度才能读取它。
用法

每当您想以相应的格式读取或写入 `string` 时,请使用相应的方法重载。

using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataReader reader = new BinaryDataReader(stream))
using (BinaryDataWriter writer = new BinaryDataWriter(stream))
{
    string magicBytes = reader.ReadString(4); // No prefix or termination just needs to know the length
    if (magicBytes != "RIFF")
    {
        throw new InvalidOperationException("Not a RIFF file.");
    }

    string zeroTerminated = reader.ReadString(BinaryStringFormat.ZeroTerminated);
    string netString = reader.ReadString();
    string anotherNetString = reader.ReadString(BinaryStringFormat.DwordLengthPrefix);

    writer.Write("RAW", BinaryStringFormat.NoPrefixOrTermination);
}

由于 `NoPrefixOrTermination` 需要您知道要读取的字符数,因此只有一个需要长度的重载,而不是 `BinaryStringFormat`。您不能直接在 `ReadString` 重载中使用它,后者需要传递 `BinaryStringFormat` 枚举值。

一次性字符串编码

标准的 .NET `BinaryReader/Writer` 类允许您在构造函数中指定 `string` 编码,但它们不允许您,例如,为一个使用 UTF8 编码创建的实例写入一次性的 ASCII `string`。我添加了重载,可以通过将编码简单地传递给 `ReadString` 或 `Write(string)` 调用来覆盖此编码。

标准 .NET 读取器或写入器的编码无法在运行时更改。事实上,甚至无法检索 在创建实例后。但是,我的派生类会记住该编码,并且可以通过 `Encoding` 属性进行查询 - 但不能设置。

用法

只需将一次性编码传递给 `ReadString` 或 `Write(string)` 方法。

using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataReader reader = new BinaryDataReader(stream, Encoding.ASCII))
using (BinaryDataWriter writer = new BinaryDataWriter(stream, Encoding.ASCII))
{
    string unicodeString = reader.ReadString(BinaryStringFormat.DwordLengthPrefix, Encoding.UTF8);
    string asciiString = reader.ReadString();
    Console.WriteLine(reader.Encoding.CodePage);
}

不同的日期/时间格式

`DateTime` 实例不像 `string` 那样只有不同的二进制表示形式,它们也可以以不同的常见方式存储。这些主要区别在于 `0` 索引的 tick 发生的时间点以及这些 tick 的粒度,这也决定了 `DateTime` 的最小和最大值。目前,API 只支持几种表示形式,`BinaryDateTimeFormat` 枚举会详细说明:

  • `CTime`:C 标准库的 time_t 格式。
  • `NetTicks`:默认的 .NET `DateTime` 格式。
用法

正如您可能猜到的,此枚举可以像接受 `BinaryStringFormat` 的 `string` 方法一样使用。新方法是 `ReadDateTime(BinaryDateFormat)` / `Write(DateTime value, BinaryDateTimeFormat)`。

using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataReader reader = new BinaryDataReader(stream))
{
    DateTime cTime = reader.ReadDateTime(BinaryDateTimeFormat.CTime);
}

高级流导航

默认 `BinaryReader/Writer` 类完全不覆盖的另一个常见任务是临时跳转到另一个位置,在那里读取或存储数据,然后再返回到之前的位置。

我使用 `using` / `IDisposable` 模式实现了临时跳转。当您调用 `TemporarySeek(long)` 时,它会返回 `SeekTask` 类的一个实例,该实例会立即将 `stream` 的位置传送到您指定的位置。在它被释放后,它会返回到跳转之前的初始位置。

public class SeekTask : IDisposable
{
    public SeekTask(Stream stream, long offset, SeekOrigin origin)
    {
        Stream = stream;
        PreviousPosition = stream.Position;
        Stream.Seek(offset, origin);
    }

    public Stream Stream { get; private set; }

    /// <summary>
    /// Gets the absolute position to which the <see cref="Stream"/> will be rewound after this task is
    /// disposed.
    /// </summary>
    public long PreviousPosition { get; private set; }

    /// <summary>
    /// Rewinds the <see cref="Stream"/> to its previous position.
    /// </summary>
    public void Dispose()
    {
        Stream.Seek(PreviousPosition, SeekOrigin.Begin);
    }
}
用法

使用 `TemporarySeek` 比上面的类看起来要容易得多。只需与 `using` 块一起调用它,就像在此示例中一样:

using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataReader reader = new BinaryDataReader(stream))
{
    int offset = reader.ReadInt32();
    using (reader.TemporarySeek(offset, SeekOrigin.Begin))
    {
        byte[] dataAtOffset = reader.ReadBytes(128);
    }
    int dataAfterOffsetValue = reader.ReadInt32();
}

这首先从文件本身读取一个偏移量,然后跳转到该读取的偏移量以从中获取另外 128 个字节。之后,`using` 块结束,流返回到最初读取偏移量之后的位置。

当然,您也可以使用绝对偏移量进行跳转;这只是一个常见示例,如许多文件格式中所见。

块对齐

某些文件格式经过高度优化,以便硬件能够快速加载它们,因此它们将数据组织成特定大小的块(以字节为单位)。从当前位置跳转到下一个块的开头需要一些细微的计算,但 `BinaryDataReader/Writer` 类将其直接封装在一个调用中,您只需将块的大小传递给它即可。

/// <summary>
/// Aligns the reader to the next given byte multiple.
/// </summary>
/// <param name="alignment">The byte multiple.</param>
public void Align(int alignment)
{
    Seek((-Position % alignment + alignment) % alignment);
}
用法

假设您知道您的文件以 `0x200` 字节大小的块组织。像这样使用 `Align`:

using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataReader reader = new BinaryDataReader(stream))
{
    string header = reader.ReadString(4);

    reader.Align(0x200); // Seek to the start of the next block of 0x200 bytes size.
}

流属性的快捷方式

一些重要的 `stream` 属性或方法,如 `Length`、`Position` 或 `Seek`,在默认的 .NET `BinaryReader/Writer` 中有点隐藏,因为您必须通过 `BaseStream` 访问它们。我的类转发了这些属性,您可以直接在读取器/写入器实例上访问它们。

/// <summary>
/// Gets or sets the position within the current stream. This is a shortcut to the base stream Position
/// property.
/// </summary>
public long Position
{
    get { return BaseStream.Position; }
    set { BaseStream.Position = value; }
}
用法

这应该很简单,所以让我们做一个“随机”示例。

Random random = new Random();
using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataWriter writer = new BinaryDataWriter(stream))
{
    while (writer.Position < 0x4000) // Directly accessing 'Position' here.
    {
        writer.Write(random.Next());
    }
}

关注点

优化读取器和写入器的性能当然是高度优先的,我在不使用不安全代码的情况下尽力而为。也许有人知道进一步优化它的方法,我很想了解可以加快二进制数据处理速度的“技巧”!

如果您想立即开始使用讨论的功能,请不要忘记查看 NuGet 包(请注意,API 经过多年已发生很大变化,您可以在 此处 找到新的文档)。

历史

  • 2016-09-18:首次发布
  • 2019-01-17:更新到新 NuGet 包的链接
© . All rights reserved.