Conmajia

Stop stealing sheep!

导航

< 20253 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5

统计

加强版二进制读写器

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 枚举指定时间格式支持下面两种

  • CTimeC 语言标准库的 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);
}

用法

假设要处理的文件是按 0x200512字节分块的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 on2019-01-19   Conmajia  阅读(1214)  评论(0编辑  收藏  举报

编辑推荐:
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示