1. 程式人生 > >【Net】StreamWriter.Write 的一點注意事項

【Net】StreamWriter.Write 的一點注意事項

# 背景 今天在維護一箇舊專案的時候,看到一個方法把`string` 轉換為 `byte[]` 用的是寫入記憶體流的,然後`ToArray()`,因為平常都是用`System.Text.Encoding.UTF8.GetBytes(string)` ,剛好這裡遇到一個安全的問題,就想把它重構了。 由於這個是已經找不到原來開發的人員,所以也無從問當時為什麼要這麼做,我想就算找到應該他也不知道當時為什麼要這麼做。 由於這個是線上跑了很久的專案,所以需要做一下測試,萬一真裡面真的是有歷史原因呢!於是就有了這篇文章。 # 重構過程 1. 需要一個比較`byte`陣列的函式(確保重構前後一致),沒找到有系統自帶,所以寫了一個 2. 重構方法(使用Encoding) 3. 單元測試 4. 基準測試(或許之前是為了效能考慮,因為這個方法呼叫次數也不少) ## 位元組陣列比較方法:`BytesEquals` > 比較位元組陣列是否完全相等,方法比較簡單,就不做介紹 ```cs public static bool BytesEquals(byte[] array1, byte[] array2) { if (array1 == null && array2 == null) return true; if (Array.ReferenceEquals(array1, array2)) return true; if (array1?.Length != array2?.Length) return false; for (int i = 0; i < array1.Length; i++) { if (array1[i] != array2[i]) return false; } return true; } ``` ## 重構方法 > 原始方法(使用StreamWriter) ```cs public static byte[] StringToBytes(string value) { if (value == null) throw new ArgumentNullException(nameof(value)); using (var ms = new System.IO.MemoryStream()) using (var streamWriter = new System.IO.StreamWriter(ms, System.Text.Encoding.UTF8)) { streamWriter.Write(value); streamWriter.Flush(); return ms.ToArray(); } } ``` > 重構(使用Encoidng) ```cs public static byte[] StringToBytes(string value) { if (value == null) throw new ArgumentNullException(nameof(value)); return System.Text.Encoding.UTF8.GetBytes(value); } ``` ## 單元測試 - **BytesEquals 單元測試** 1. 新建單元測試專案 ```cs dotnet new xunit -n 'Demo.StreamWriter.UnitTests' ``` 2. 編寫單元測試 ```cs [Fact] public void BytesEqualsTest_Equals_ReturnTrue() { ... } [Fact] public void BytesEqualsTest_NotEquals_ReturnFalse() { ... } [Fact] public void StringToBytes_Equals_ReturnTrue() { ... } ``` 3. 執行單元測試 ```cs dotnet test ``` 4. `StringToBytes_Equals_ReturnTrue` 未能通過單元測試 這個未能通過,重構後的生成的位元組陣列與原始不一致 ## 排查過程 1. 除錯`StringToBytes_Equals_ReturnTrue` , 發現`bytesWithStream` 比 `bytesWithEncoding` 在陣列頭多了三個位元組(很多人都能猜到這個是UTF8的BOM) ``` diff + bytesWithStream[0] = 239 + bytesWithStream[1] = 187 + bytesWithStream[2] = 191 bytesWithStream[3] = 72 bytesWithStream[4] = 101 bytesWithEncoding[0] = 72 bytesWithEncoding[0] = 101 ``` 不瞭解BOM,可以看看這篇文章[Byte order mark](https://en.wikipedia.org/wiki/Byte_order_mark) 從文章可以明確多出來位元組就是UTF8-BOM,問題來了,為什麼`StreamWriter`會多出來BOM,而`Encoding.UTF8` 沒有,都是用同一個編碼 ## 檢視原始碼 `StreamWriter` ```cs public StreamWriter(Stream stream) : this(stream, UTF8NoBOM, 1024, leaveOpen: false) { } public StreamWriter(Stream stream, Encoding encoding) : this(stream, encoding, 1024, leaveOpen: false) { } ``` ```cs private static Encoding UTF8NoBOM => EncodingCache.UTF8NoBOM; internal static readonly Encoding UTF8NoBOM = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); ``` 可以看到`StreamWriter`, 預設是使用`UTF8NoBOM` , 但是在這裡指定了`System.Text.Encoding.UTF8`,根據`encoderShouldEmitUTF8Identifier`這個引數決定是否寫入BOM,最終是在`Flush`寫入 ```cs private void Flush(bool flushStream, bool flushEncoder) { ... if (!_haveWrittenPreamble) { _haveWrittenPreamble = true; ReadOnlySpan preamble = _encoding.Preamble; if (preamble.Length > 0) { _stream.Write(preamble); } } int bytes = _encoder.GetBytes(_charBuffer, 0, _charPos, _byteBuffer, 0, flushEncoder); _charPos = 0; if (bytes > 0) { _stream.Write(_byteBuffer, 0, bytes); } ... } ``` `Flush`最終也是使用`_encoder.GetBytes`獲取位元組陣列寫入流中,而`System.Text.Encoding.UTF8.GetBytes()`最終也是使用這個方法。 `System.Text.Encoding.UTF8.GetBytes` ```cs public virtual byte[] GetBytes(string s) { if (s == null) { throw new ArgumentNullException("s", SR.ArgumentNull_String); } int byteCount = GetByteCount(s); byte[] array = new byte[byteCount]; int bytes = GetBytes(s, 0, s.Length, array, 0); return array; } ``` 如果要達到和原來一樣的效果,只需要在最終返回結果加上`UTF8.Preamble`, 修改如下 ``` diff public static byte[] StringToBytes(string value) { if (value == null) throw new ArgumentNullException(nameof(value)); - return System.Text.Encoding.UTF8.GetBytes(value); + var bytes = System.Text.Encoding.UTF8.GetBytes(value); + var result = new byte[bytes.Length + 3]; + Array.Copy(Encoding.UTF8.GetPreamble(), result, 3); + Array.Copy(bytes, 0, result, 3, bytes.Length); + return result; } ``` 但是對於這樣修改感覺是沒必要,因為這個最終是傳給一個對外介面,所以只能對那個介面做測試,最終結果也是不需要這個BOM ## 基準測試 排除了`StreamWriter`沒有做特殊處理,可以用`System.Text.Encoding.UTF8.GetBytes()`重構。還有就是效率問題,雖然直觀上看到使用`StreamWriter` 最終都是使用`Encoder.GetBytes` 方法,而且還多了兩次資源對申請和釋放。但是還是用基準測試才能直觀看出其中差別。 基準測試使用BenchmarkDotNet,[BenchmarkDotNet](https://www.cnblogs.com/WilsonPan/p/12904664.html)這裡之前有介紹過 1. 建立`BenchmarksTests`目錄並建立基準專案 ```cs mkdir BenchmarksTests && cd BenchmarksTests && dotnet new benchmark -b StreamVsEncoding ``` 2. 新增引用 ```cs dotnet add reference ../../src/Demo.StreamWriter.csproj ``` > **注意**:Demo.StreamWriter需要Release編譯 3. 編寫基準測試 ```cs [SimpleJob(launchCount: 10)] [MemoryDiagnoser] public class StreamVsEncoding { [Params("Hello Wilson!", "使用【BenchmarkDotNet】基準測試,Encoding vs Stream")] public string _stringValue; [Benchmark] public void Encoding() => StringToBytesWithEncoding.StringToBytes(_stringValue); [Benchmark] public void Stream() => StringToBytesWithStream.StringToBytes(_stringValue); } ``` 4. 編譯 && 執行基準測試 ```cs dotnet build && sudo dotnet benchmark bin/Release/netstandard2.0/BenchmarksTests.dll --filter 'StreamVsEncoding' ``` > **注意**:macos 需要sudo許可權 5. 檢視結果 | Method | _stringValue | Mean | Error | StdDev | Median | Gen 0 | Gen 1 | Gen 2 | Allocated | | -------- | ----------------------- | -------: | ------: | -------: | -------: | -----: | ----: | ----: | --------: | | Encoding | Hello Wilson! | 107.4 ns | 0.61 ns | 2.32 ns | 106.9 ns | 0.0355 | - | - | 112 B | | Stream | Hello Wilson! | 565.1 ns | 4.12 ns | 18.40 ns | 562.3 ns | 1.8196 | - | - | 5728 B | | Encoding | 使用【Be(...)tream [42] | 166.3 ns | 1.00 ns | 3.64 ns | 165.4 ns | 0.0660 | - | - | 208 B | | Stream | 使用【Be(...)tream [42] | 584.6 ns | 3.65 ns | 13.22 ns | 580.8 ns | 1.8349 | - | - | 5776 B | 執行時間相差了4~5倍, 記憶體使用率相差 20 ~ 50倍,差距還比較大。 # 總結 1. `StreamWriter` 預設是沒有BOM,若指定`System.Text.Encoding.UTF8`,會在`Flush`位元組陣列開頭新增BOM 2. 字串轉換位元組陣列使用`System.Text.Encoding.UTF8.GetBytes` 要高效 3. `System.Text.Encoding.UTF8.GetBytes` 是不會自己新增BOM,提供`Encoding.UTF8.GetPreamble()`獲取BOM 4. UTF8 已經不推薦推薦在前面加BOM --- 轉發請標明出處:[https://www.cnblogs.com/WilsonPan/p/13524885.html](https://www.cnblogs.com/WilsonPan/p/13524885.html) [示例程式碼](https://github.com/WilsonPan/Net.Demos/tree/master/Demo.StreamWriter)