.NET中帶BOM字元編碼的讀寫
問題描述:
最近遇到下面這樣的問題,把一個UTF-8編碼的XML檔案上傳到伺服器,然後使用XmlDocument解析該XML檔案的時候,提示檔案格式錯誤,結果發現從上傳的檔案流中讀取出的XML字串前多了一個“?”,導致解析失敗。從上傳的XML檔案流中分析,該流的前三位是EF、BB和BF,這是UTF-8的BOM標識符。BOM到底是什嗎?該如何正確使用?遇到這樣的問題該如何避免?請看下文。
1、什麼是字元順序標記(BOM)
電腦內部資料存放區都是二進位的,只有知道一段資料的二進位儲存格式,這段資料才有意義。所謂的文字檔其實就是用一種特定的字元編碼來將二進位來源資料轉換成文字。多數文字編輯器都可以編輯不同編碼的文字檔,那麼文字編輯器是怎樣通過源位元據來得知這段資料的文本編碼呢?答案就是靠字元順序標記(Byte Order Mark),在文章裡面我們就統一用英文簡寫BOM指這一名詞。
下面是常用Unicode編碼的BOM
UTF-8: EF BB BF
UTF-16 big endian: FE FF
UTF-16 little endian: FF FE
UTF-32 big endian: 00 00 FE FF
UTF-32 little endian: FF FE 00 00
2、.NET中的Encoding類和BOM
在.NET的世界裡,我們經常用Encoding的靜態屬性來得到一個Encoding類,從這裡得到的編碼預設都是提供BOM的(如果支援BOM的話)。
如果你想讓指定編碼不提供BOM,那麼需要手動構造這個編碼類別。
//不提供BOM的Encoding編碼 Encoding utf8NoBom = new UTF8Encoding(false); Encoding utf16NoBom = new UnicodeEncoding(false, false); Encoding utf32NoBom = new UTF32Encoding(false, false);
Encoding類中的GetPreamble方法可以返回當前編碼提供的BOM
3、檔案讀寫和BOM
文本寫入時,StreamWriter類和File.WriteAllText方法的預設編碼都是不帶BOM的UTF8
當然我們可以通過建構函式來指定一個其他編碼,構造方法就像上面講的一樣。比如:
public static void Main() { Encoding utf32bigbom = new UTF32Encoding(true, true); Encoding utf32litbom = new UTF32Encoding(false, true); Encoding utf32litnobom = new UTF32Encoding(false, false); var content = "abcde"; WriteAndPrint(content, utf32bigbom); WriteAndPrint(content, utf32litbom); WriteAndPrint(content, utf32litnobom); } static void WriteAndPrint(string content, Encoding enc) { var path = Path.GetTempFileName(); File.WriteAllText(path, content, enc); PrintBytes(File.ReadAllBytes(path)); } static void PrintBytes(byte[] bytes) { if (bytes == null || bytes.Length == 0) Console.WriteLine("<無值>"); foreach (var b in bytes) Console.Write("{0:X2} ", b); Console.WriteLine(); }
輸出:
00 00 FE FF 00 00 00 61 00 00 00 62 00 00 00 63 00 00 00 64 00 00 00 65
FF FE 00 00 61 00 00 00 62 00 00 00 63 00 00 00 64 00 00 00 65 00 00 00
61 00 00 00 62 00 00 00 63 00 00 00 64 00 00 00 65 00 00 00
可以看出來:00 00 FE FF是UTF32 big endian的BOM,而FF FE 00 00是UTF32 little endian的BOM,第三行是沒有加BOM的UTF32的源位元據。
讀文本的時候,當構造StringReader類進指定字串路徑或者Stream對象的話,StringReader的表現是自動通過BOM來判定字元編碼,當然我們也可以手動指定一個編碼(尤其是沒有BOM的文本資料,不手動指定編碼是無法正確讀取文字檔的)。
同樣,File類的ReadAllText也具備同樣功能,不過,細心地讀者可能發現Reflector中File.ReadAllText的源碼是用UTF8編碼的StreamReader讀取檔案的,其實它調用了StreamReader中的這個建構函式:
public StreamReader(string path, Encoding encoding, bool detectEncodingFromByteOrderMarks, int bufferSize) { /* 內容省略*/ }
那麼雖然傳入的一個特定的編碼,但這個detectEncodingFromByteOrderMarks參數是true的,StreamReader還是會自動覺察BOM來讀檔案的。
代碼:
public static void Main() { var path1 = Path.GetTempFileName(); var path2 = Path.GetTempFileName(); string content = "abc"; //使用預設沒有BOM的UTF8編碼寫檔案 File.WriteAllText(path1, content); //使用帶BOM的UTF8編碼 File.WriteAllText(path2, content, Encoding.UTF8); PrintBytes(File.ReadAllBytes(path1)); PrintBytes(File.ReadAllBytes(path2)); Console.WriteLine(File.ReadAllText(path1)); Console.WriteLine(File.ReadAllText(path2)); } static void PrintBytes(byte[] bytes) { foreach (var b in bytes) Console.Write("{0:X2} ", b); Console.WriteLine(); }
輸出:
61 62 63
EF BB BF 61 62 63
abc
abc
可以看到上面雖然有檔案沒有BOM,但由於預設UTF8,所以沒有錯誤,但是其他編碼就不是這樣的情況了。
比如下面這段代碼,我們再用UTF32編碼:
public static void Main() { var path1 = Path.GetTempFileName(); var path2 = Path.GetTempFileName(); string content = "abc"; //使用帶BOM的UTF32編碼 File.WriteAllText(path1, content, Encoding.Unicode); //使用沒有BOM的UTF32編碼寫檔案 File.WriteAllText(path2, content, new UnicodeEncoding(false, false)); PrintBytes(File.ReadAllBytes(path1)); PrintBytes(File.ReadAllBytes(path2)); //自動覺察BOM讀檔案 string c1 = File.ReadAllText(path1); //path2沒BOM,實際上用預設UTF8讀檔案 string c2 = File.ReadAllText(path2); //path2沒BOM,用正確度UTF16讀檔案 string c3 = File.ReadAllText(path2, Encoding.Unicode); ShowContent(c1); ShowContent(c2); ShowContent(c3); } static void ShowContent(string content) { Console.WriteLine("讀入字元數:{0} 內容:{1}", content.Length, content); } static void PrintBytes(byte[] bytes) { foreach (var b in bytes) Console.Write("{0:X2} ", b); Console.WriteLine(); }
輸出:
FF FE 61 00 62 00 63 00 //檔案1 是有BOM的UTF16
61 00 62 00 63 00 //檔案2 是沒有BOM的UTF16
讀入字元數:3 內容:abc //自動讀取檔案1
讀入字元數:6 內容:a //自動讀取檔案2
讀入字元數:3 內容:abc //指定UTF16編碼讀取檔案2
看第四行,由於沒有BOM的UTF16檔案被當UTF8讀,原來3個字元被讀成6個字元。
4、關於怎樣去掉BOM
有些時候我們需要對文本位元據進行處理,這時我們需要得到全部文本的位元組,可讀取位元據時BOM是附在開頭的,不同編碼的BOM長度又不一樣(有的編碼沒有BOM),此時需要某種方法來將BOM過濾掉。
當你知道Encoding.GetPreamble方法後(在前面講到過),一切不都難。
這裡給出三個函數,也是較常見的情景。
一個是直接得到去除掉BOM的位元組數組。
二是將Stream的位置移動到BOM之後,這樣後續Stream操作直接針對每一個字元的位元據。
三是使用StreamReader(detectEncodingFromByteOrderMarks為true)自動檢測BOM,並跳過。
public static void Main() { var path = Path.GetTempFileName(); File.WriteAllText(path, "a123一", Encoding.UTF8); PrintBytes(File.ReadAllBytes(path)); //1 PrintBytes(GetBytesWithoutBOM(path, Encoding.UTF8)); //2 using (Stream stream = File.OpenRead(path)) { SkipBOM(stream, Encoding.UTF8); int data; while ((data = stream.ReadByte()) != -1) Console.Write("{0:X2} ", data); Console.WriteLine(); } //3 using (Stream stream = File.OpenRead(path)) { StreamReader reader = new StreamReader(stream, Encoding.UTF8, true); char[] cs = new char[64]; StringBuilder sb = new StringBuilder(); int len = 0; while ((len = reader.Read(cs, 0, cs.Length)) > 0) { sb.Append(cs, 0, len); } string str = sb.ToString(); byte[] bs = Encoding.UTF8.GetBytes(str); foreach (byte b in bs) { Console.Write("{0:X2} ", b); } } } static byte[] GetBytesWithoutBOM(string path, Encoding enc) { //LINQ return File.ReadAllBytes(path).Skip(enc.GetPreamble().Length).ToArray(); } static void SkipBOM(Stream stream, Encoding enc) { stream.Seek(enc.GetPreamble().Length, SeekOrigin.Begin); } static void PrintBytes(byte[] bytes) { foreach (var b in bytes) Console.Write("{0:X2} ", b); Console.WriteLine(); }
輸出:
EF BB BF 61 31 32 33 E4 B8 80
61 31 32 33 E4 B8 80
61 31 32 33 E4 B8 80
61 31 32 33 E4 B8 80
(結果均正確)
對於文章開頭提到的問題,最好的解決辦法是使用StreamReader從檔案上傳流中讀取字串,然後再使用XmlDocument來解析。