加強版二進位讀寫類:BinaryDataReader/Writer

来源:https://www.cnblogs.com/conmajia/archive/2019/01/19/a-more-powerful-binary-reader-writer.html
-Advertisement-
Play Games

對 .NET 自帶的 BinaryReader、BinaryWriter 進行擴展. NuGet 上安裝 Syroot.IO.BinaryData 即可使用. ...


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,來自德國 .


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 元素事件:滑鼠按下事件/滑鼠移動事件/滑鼠鬆開事件 元素樣式:讓元素脫離文檔流,採用絕對定位的方式。 一、滑鼠按下事件 當滑鼠在元素上面按下時,保存元素的初始偏移量和滑鼠按下時的坐標,然後在狀態變數裡面標記當前狀態為按下狀態。 二、滑鼠移動事件 當滑鼠拖動元素移動時,我們通過計算滑鼠從起始位到移動位 ...
  • 不知不覺我的第一個小程式已經上線一周了,uv也穩定的上升著。 很多人說我的小程式沒啥用,我默默一笑,心裡說:“它一直敦促我學習,敦促我進步”。我的以一個小程式初衷是經驗分享,目前先把經驗分享到博客園,邊學習邊完善小程式。同時我會持續學習,持續更新,功能定會一天天的完善起來。 歡迎大家掃碼體驗。 閑話 ...
  • 在結構上多一個指向自身的constructor構造函數,這就是原型鏈夠簡單吧. 利用原型鏈結構實現繼承和向鏈表中插入節點,有區別嗎? 沒區別!! ...
  • 先上效果 開發環境要求 需要事先安裝node及npm 前期準備 1.創建文件夾react-echarts-editor2.在項目根目錄(以下稱根目錄)下創建src目錄3.在項目根目錄下創建dist目錄4.在src目錄下創建app.js文件(該文件就來一個react-echarts版的hello wo ...
  • 前言 此內容是個人學習筆記,以便日後翻閱。 非教程,如有錯誤還請指出 Webpack 打包文件 支持JS模塊化 模式: production(0配置預設), development(生產環境) 更詳細的請前往 webpack官網 安裝 npm i webpack webpack cli D 執行 w ...
  • 寫這篇文章之前,關於ubuntu14.04(Trusty)預設安裝的NodeJS版本是0.10.25百思不解(什麼鬼,哪一年的NodeJS) 寫這篇文章之時,NodeJS的LTS版本號都已經10.15.0,當然Ubuntu在2018年也都發行ubuntu18.04(我還沒打算用) 系統我可以用4... ...
  • Cropper.js是一款很好用的圖片裁剪工具,可以對圖片的尺寸、寬高比進行裁剪,滿足諸如裁剪頭像上傳、商品圖片編輯之類的需求。 github: https://github.com/fengyuanchen/cropperjs 網站: https://fengyuanchen.github.io/ ...
  • 最近在陸續做機房升級相關工作,配合DBA對產線資料庫鏈接方式做個調整,將原來直接鏈接讀庫的地址切換到統一的讀負載均衡的代理 haproxy 上,方便機櫃和伺服器的搬遷。 切換之後線上時不時的會發生 discard connection 錯誤,導致程式報 500 錯誤,但不是每次都必現的。 開發框... ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...