☄️ 加強版二進位讀寫類: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
更多相關文章
  • Date對象 創建Date對象 Date對象的方法—獲取日期和時間 顯示當前時間 History 對象 history_1.html history_2.html Location 對象 DOM 節點 節點(自身)屬性: 導航屬性: 推薦導航屬性: ...
  • 1. 變數的創建 首先,可以創建一個變數 這時候,並沒有給它賦值。這個變數還是空的。 然後,我們可以給這個變數賦值。 2. 變數的賦值 變數在創建時就可以被直接賦值。 上面的例子可以看出,變數是可以保存數值類型和表達式類型的數據。 變數也可以保存字元串類型數據,如下: 示例: js中有多少數據類型我 ...
  • 上一篇說到插值表達式有一個問題: 頁面頻繁刷新或者網速載入很慢的時候,頁面會先出現“{{ msg }}”,再一閃而過出現真實的數據。 對於這個問題Vue給予瞭解決辦法,看具體事例。 節點中我們定義了 Vue 的內置屬性 “v-cloak” 這裡我們定義了樣式:包含屬性“v-cloak”的節點預設隱藏 ...
  • javascript中幾種this指向問題   首先必須要說的是, this 永遠指向函數運行時所在的對象,而不是函數被創建時所在的對象 。 (1)、作為函數名調用   函數作為全局對象調用,this指向全局對象 (2)、作為方法調用    ...
  • 簡要概述索引 • 索引的特點 ○ 可以加快資料庫檢索的速度 ○ 降低資料庫插入 修改 刪除等維護的速度 ○ 只能創建在表上,不能創建到視圖上 ○ 既可以直接創建又可以間接創建 ○ 可以在優化隱藏中使用索引 ○ 使用查詢處理器執行SQL語句,在一個表上,一次只能使用一個索引 • 索引的優點 ○ 創建唯 ...
  • 先有面向過程,而後退出面向對象 面向過程和麵向對象兩者都是軟體開發思想,先有面向過程,後有面向對象。在大型項目中,針對面向過程的不足推出了面向對象開發思想。 打個比方 蔣介石和毛主席分別是面向過程和麵向對象的傑出代表,這樣充分說明,在解決複製問題時,面向對象有更大的優越性。 面向過程是蛋炒飯,面向對 ...
  • EFCore是微軟推出的跨平臺ORM框架,想較於EF6.X版本,更加輕量級。EFCore目前已經更新到2.x。 接下來用CodeFirst的方式來使用EFCore. 1.創建控制台程式 2.引入EFCore的Nuget包和Sqlserver的擴展(因為我這裡用的Sqlserver資料庫,若是別的數據 ...
  • MVC中獲取某一文件的路徑,來進行諸如讀取寫入等操作。 例:我要讀取的文件是新生模板.doc,它在如下位置。 獲取它的全路徑:string path = HttpContext.Current.Server.MapPath("~/download/新生信息確認模板.docx"); 方法的註解也很清楚 ...
一周排行
    -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.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...