加强版二进制读写器
Ray Koopa 著
Conmajia 译
2019 年 1 月 17 日已获作者本人授权.
简介
本文讨论如何扩展 .NET 原生的 BinaryReader
和 BinaryWriter
类以支持更多新的常用的特性. 这些 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 自带的 BinaryReader
和 BinaryWriter
类. 普通数据还好,如果是某些甲方爸爸的特殊格式数据,就有点力不从心了. 处理的数据格式越复杂,我越觉得 .NET 类里还是少了一些常用又实用的东西,尤其是:
- 处理以不同于本机字节顺序存储的数据
- 处理非 .NET格式的字符串,比如以 0 结尾的字符串
- 读写重复的数据类型而不用一遍又一遍地循环
- 临时用不同编码的字符串读写数据流
- 文件内高级定位,例如临时定位新位置
一开始我只是写点扩展方法,作为原生 BinaryReader
、BinaryWriter
的外挂. 但是使用中我发现,这还是不足以实现以不同于本机的字节顺序读取数据这类问题. 于是我干脆在原生类的基础上创建了两个新的派生类,我给它们起名叫 BinaryDataReader
和 BinaryDataWriter
. 接下来看看我是如何实现上面列出的各个特性的吧.
实现和用法
字节顺序
.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();
}
}
我分别重写了 BinaryDataReader
和 BinaryDataWriter
的所有 Read
、Write
. 重写的方法由 _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 里,需要手动处理. 好在微软的百科上有大神写好了如何转换的技术资料可以直接使用.
用法
BinaryDataReader
、BinaryDataWriter
默认用的本机字节顺序. 要改变字节顺序,可以修改它们的 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 结尾,也叫空结尾. 比如 C/C++ 里用到的字符串基本都是以 \0
(即数字 0
)作为结束符结尾的. 我重载了 ReadString
、WriteString
,给它们增加了一个参数 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 字符串. 经过重载后,只需要调用 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
日期/时间类数据也常存为不同格式. 主要不同点在于初始时刻和时间粒度的差异,还有就是最小、最大时间的差异. 当前版本的 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);
}
高级流定位(临时定位)
临时查找另一个位置(向后),读写一些数据,然后回到当前位置,这是很常用的功能,而原生的读写类完全没有涉及. 我用 using
、IDisposable
的方式实现临时定位. 调用 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 个字节后,再跳回原来的位置. 用绝对位置跳转也没问题,我只是举个例子.
字节块对齐
有些文件格式为了配合硬件读取速度,进行了高度优化,通常按照特殊的字节尺寸成块组织数据. 从当前位置定位下一块的位置时,需要一些精细的计算,不过现在我已经把这些操作全部打包到 BinaryDataReader
、BinaryDataWriter
类里了,只要简单的指定数据块大小就行了.
/// <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); // 定位到下一个数据块位置
}
数据流属性的快捷方式
有些常用的属性、方法,比如 Length
、Position
、Seek
之类的,用原生的读写类会有点麻烦,要从基类 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,来自德国 .
if(jQuery('#no-reward').text() == 'true') jQuery('.bottom-reward').addClass('hidden');