加强版二进制读写器

Ray Koopa 著
Conmajia 译
2019 年 1 月 17 日

已获作者本人授权.

简介

本文讨论如何扩展 .NET 原生的 BinaryReaderBinaryWriter 类以支持更多新的常用的特性. 这些 API 可以通过 NuGet > Syroot.IO.BinaryData 安装:

PM> Install-Package Syroot.IO.BinaryData -Version 4.0.4
> dotnet add package Syroot.IO.BinaryData --version 4.0.4
> paket add Syroot.IO.BinaryData --version 4.0.4

GitHub 上的百科主要关注实现方面,不过也提到了它的演化过程和编写实现时需要注意的东西.

背景

每次我要用到二进制数据加载、解析、保存这类功能的时候,我都用的 .NET 自带的 BinaryReaderBinaryWriter 类. 普通数据还好,如果是某些甲方爸爸的特殊格式数据,就有点力不从心了. 处理的数据格式越复杂,我越觉得 .NET 类里还是少了一些常用又实用的东西,尤其是:

  • 处理以不同于本机字节顺序存储的数据
  • 处理非 .NET格式的字符串,比如以 0 结尾的字符串
  • 读写重复的数据类型而不用一遍又一遍地循环
  • 临时用不同编码的字符串读写数据流
  • 文件内高级定位,例如临时定位新位置

本机指的是运行 .NET 的计算机. 字节顺序指的是数据按比特位从低到高从高到低储存,也叫小端格式(little-endian)或大端格式(big-endian).

一开始我只是写点扩展方法,作为原生 BinaryReaderBinaryWriter 的外挂. 但是使用中我发现,这还是不足以实现以不同于本机的字节顺序读取数据这类问题. 于是我干脆在原生类的基础上创建了两个新的派生类,我给它们起名叫 BinaryDataReaderBinaryDataWriter. 接下来看看我是如何实现上面列出的各个特性的吧.

实现和用法

字节顺序

.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();
    }
}

我分别重写了 BinaryDataReaderBinaryDataWriter 的所有 ReadWrite. 重写的方法由 _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 类型有点怪,它的转换没有内置在 .NET 里,需要手动处理. 好在微软的百科上有大神写好了如何转换的技术资料可以直接使用.

用法

BinaryDataReaderBinaryDataWriter 默认用的本机字节顺序. 要改变字节顺序,可以修改它们的 ByteOrder 属性. 任何时候都可以修改这个属性,读/写语句之间也可以:

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(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,传入要读取的数量,得到的返回值就是读取到的数据数组. Write 则是把数组写到数据流.

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

不同的字符串格式

字符串可以保存为不同的二进制格式. 默认的读写器类只支持带无符号整数前缀的字符串. 工作中我处理的多数字符串都是 0 结尾Zero-Terminated,也叫空结尾Null-Terminated. 比如 C/C++ 里用到的字符串基本都是以 \0(即数字 0)作为结束符结尾的. 我重载了 ReadStringWriteString,给它们增加了一个参数 BinaryStringFormat,支持下面几种格式的字符串:

  • ByteLengthPrefix:无符号字节型前缀(uint8).
  • WordLengthPrefix: 有符号双字节型前缀(Int16).
  • DwordLengthPrefix:有符号四字节型前缀(Int32).
  • ZeroTerminated:没有前缀,0 数值(\0)作为结束符.
  • NoPrefixOrTermination:既没有前缀,也没有结束符,必须知道字符串长度才能操作.

用法

使用对应的重载方法就可以读取相应的格式:

using (MemoryStream stream = new MemoryStream(...))
using (BinaryDataReader reader = new BinaryDataReader(stream))
using (BinaryDataWriter writer = new BinaryDataWriter(stream))
{
    string magicBytes = reader.ReadString(4); // 没有前缀和结束符,需要知道长度
    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 重载.

临时字符串编码

可以在 .NET 默认的读写类构造函数里指定字符串的编码,但是指定后就不能变了,比如没法用 UTF8 编码的读写器读写 ASCII 字符串. 经过重载后,只需要调用 ReadStringWrite(string) 的时候把对应编码传入就好. 标准 .NET 读写类没法在运行时改变字符串编码,一旦创建了实例,甚至都没法读取它们使用的编码,更不可能妄想(用同一个读写器)读写不同的编码. 我的继承类有一个专门保存编码信息的 Encoding,这个属性是只读的,不可修改.

用法

调用 ReadStringWrite(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 日期/时间类数据也常存为不同格式. 主要不同点在于初始时刻时间粒度的差异,还有就是最小最大时间的差异. 当前版本的 API 用 BinaryDateTimeFormat 枚举指定时间格式,支持下面两种:

  • CTime:C 语言标准库的 time_t 格式.
  • NetTicks:.NET 默认的 DateTime 格式.

用法

用脚趾头也想得到,我可以按照和字符串操作差不多的的方式,往方法里传入 BinaryStringFormat 枚举来指定相应格式的类似方法处理日期/时间读写. 新方法定为 ReadDateTime()WriteDateTime()

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

高级流定位(临时定位)

临时查找另一个位置(向后),读写一些数据,然后回到当前位置,这是很常用的功能,而原生的读写类完全没有涉及. 我用 usingIDisposable 的方式实现临时定位. 调用 TemporarySeek(long),返回一个 SeekTask 类的实例,它“咻”的一下跳到指定的位置读写数据,完成之后,再“咻”的一下回到之前的位置.

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>
    /// 获取任务执行完后需要返回的绝对位置.
    /// </summary>
    public long PreviousPosition { get; private set; }

    /// <summary>
    /// 返回前一个位置.
    /// </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();
}

例子代码里,先读一个 int,得到要往后跳跃的位置,然后用 using 块创建一个临时定位实例,从新位置读取 128 个字节后,再跳回原来的位置. 用绝对位置跳转也没问题,我只是举个例子.

字节块对齐

有些文件格式为了配合硬件读取速度,进行了高度优化,通常按照特殊的字节尺寸成块组织数据. 从当前位置定位下一块的位置时,需要一些精细的计算,不过现在我已经把这些操作全部打包到 BinaryDataReaderBinaryDataWriter 类里了,只要简单的指定数据块大小就行了.

/// <summary>
/// 对齐到下一个多字节数据块位置.
/// </summary>
/// <param name="alignment">数据块大小</param>
public void Align(int alignment)
{
    Seek((-Position % alignment + alignment) % alignment);
}

用法

假设要处理的文件是按 0x200(512)字节分块的,Align 的用法如下:

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

    reader.Align(0x200); // 定位到下一个数据块位置
}

数据流属性的快捷方式

有些常用的属性、方法,比如 LengthPositionSeek 之类的,用原生的读写类会有点麻烦,要从基类 BaseStream 里访问. 我把这些东西都提炼成属性,可以直接调用,这样方便一点.

/// <summary>
/// 获取和设置在数据流中的位置.
/// 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) // 直接访问 Position
    {
        writer.Write(random.Next());
    }
}

要关注的

优化读写类的性能是最重要的,我自信已经做到极致了,除非用上 unsafe 的代码,走内存直读路线,那我没话说. 相信有大神能用各种招数来优化,请让我开开眼界!

别忘了检查 NuGet 上面的更新,还可以和各路高人交流. 最新的 API 文档可以看这里).

历史

  • 2016-09-18:首版发布.
  • 2019-01-17:更新了 NuGet 包链接.
  • 2019-01-17:中文版发布.

许可

本文以及任何相关的源代码和文件都是根据 GNU通用公共许可证(GPLv3)授权的.

关于作者

Ray Koopa,来自德国 .

posted @ 2019-01-19 10:49  Conmajia  阅读(...)  评论(...编辑  收藏