【Net】StreamWriter.Write 的一點注意事項
阿新 • • 發佈:2020-08-20
# 背景
今天在維護一箇舊專案的時候,看到一個方法把`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)