對 .NET 自帶的 BinaryReader、BinaryWriter 進行擴展. NuGet 上安裝 Syroot.IO.BinaryData 即可使用. ...
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 結尾的字元串
- 讀寫重覆的數據類型而不用一遍又一遍地迴圈
- 臨時用不同編碼的字元串讀寫數據流
- 文件內高級定位,例如臨時定位新位置
本機指的是運行 .NET 的電腦. 位元組順序指的是數據按比特位從低到高或從高到低儲存,也叫小端格式(little-endian)或大端格式(big-endian).
一開始我只是寫點擴展方法,作為原生 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,來自德國 .