背景
今天在維護一箇舊專案的時候,看到一個方法把string
轉換為 byte[]
用的是寫入記憶體流的,然後ToArray()
,因為平常都是用System.Text.Encoding.UTF8.GetBytes(string)
,剛好這裡遇到一個安全的問題,就想把它重構了。
由於這個是已經找不到原來開發的人員,所以也無從問當時為什麼要這麼做,我想就算找到應該他也不知道當時為什麼要這麼做。
由於這個是線上跑了很久的專案,所以需要做一下測試,萬一真裡面真的是有歷史原因呢!於是就有了這篇文章。
重構過程
- 需要一個比較
byte
陣列的函式(確保重構前後一致),沒找到有系統自帶,所以寫了一個 - 重構方法(使用Encoding)
- 單元測試
- 基準測試(或許之前是為了效能考慮,因為這個方法呼叫次數也不少)
位元組陣列比較方法:BytesEquals
比較位元組陣列是否完全相等,方法比較簡單,就不做介紹
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)
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)
public static byte[] StringToBytes(string value)
{
if (value == null) throw new ArgumentNullException(nameof(value));
return System.Text.Encoding.UTF8.GetBytes(value);
}
單元測試
- BytesEquals 單元測試
- 新建單元測試專案
dotnet new xunit -n 'Demo.StreamWriter.UnitTests'
- 編寫單元測試
[Fact]
public void BytesEqualsTest_Equals_ReturnTrue()
{
...
}
[Fact]
public void BytesEqualsTest_NotEquals_ReturnFalse()
{
...
}
[Fact]
public void StringToBytes_Equals_ReturnTrue()
{
...
}
- 執行單元測試
dotnet test
StringToBytes_Equals_ReturnTrue
未能通過單元測試
這個未能通過,重構後的生成的位元組陣列與原始不一致
排查過程
- 除錯
StringToBytes_Equals_ReturnTrue
, 發現bytesWithStream
比bytesWithEncoding
在陣列頭多了三個位元組(很多人都能猜到這個是UTF8的BOM)
+ 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
從文章可以明確多出來位元組就是UTF8-BOM,問題來了,為什麼StreamWriter
會多出來BOM,而Encoding.UTF8
沒有,都是用同一個編碼
檢視原始碼
StreamWriter
public StreamWriter(Stream stream)
: this(stream, UTF8NoBOM, 1024, leaveOpen: false)
{
}
public StreamWriter(Stream stream, Encoding encoding)
: this(stream, encoding, 1024, leaveOpen: false)
{
}
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
寫入
private void Flush(bool flushStream, bool flushEncoder)
{
...
if (!_haveWrittenPreamble)
{
_haveWrittenPreamble = true;
ReadOnlySpan<byte> 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
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
, 修改如下
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這裡之前有介紹過
- 建立
BenchmarksTests
目錄並建立基準專案
mkdir BenchmarksTests && cd BenchmarksTests && dotnet new benchmark -b StreamVsEncoding
- 新增引用
dotnet add reference ../../src/Demo.StreamWriter.csproj
注意:Demo.StreamWriter需要Release編譯
- 編寫基準測試
[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);
}
- 編譯 && 執行基準測試
dotnet build && sudo dotnet benchmark bin/Release/netstandard2.0/BenchmarksTests.dll --filter 'StreamVsEncoding'
注意:macos 需要sudo許可權
- 檢視結果
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倍,差距還比較大。
總結
StreamWriter
預設是沒有BOM,若指定System.Text.Encoding.UTF8
,會在Flush
位元組陣列開頭新增BOM- 字串轉換位元組陣列使用
System.Text.Encoding.UTF8.GetBytes
要高效 System.Text.Encoding.UTF8.GetBytes
是不會自己新增BOM,提供Encoding.UTF8.GetPreamble()
獲取BOM- UTF8 已經不推薦推薦在前面加BOM
轉發請標明出處:https://www.cnblogs.com/WilsonPan/p/13524885.html
示例程式碼