© Conmajia 2012
May 15th, 2012
(注:本文使用 FileStream 類的 Seek() 和 Read() 方法完成文件讀取,沒有用其他的騷東西。)
我們在編程過程中,經常會和計算機文件讀取操作打交道。隨着計算機功能和性能的發展,我們需要操作的文件尺寸也是越來越大。在 .NET Framework 中,我們一般使用 FileStream 來讀取、寫入文件流。當文件只有數十 kB 或者數 MB 時,一般的文件讀取方式如 Read()、ReadAll() 等應用起來游刃有余,基本不會感覺到太大的延遲。但當文件越來越大,達到數百 MB 甚至數 GB 時,這種延遲將越來越明顯,最終達到不能忍受的程度。
通常定義大小在 2GB 以上的文件為超大文件(當然,這個數值會隨着科技的進步,越來越大)。對於這樣規模的文件讀取,普通方法已經完全不能勝任。這就要求我們使用更高效的方法,如內存映射法、分頁讀取法等。
內存映射(Memory Mapping)
內存映射的方法可以使用下面的 Windows API 實現。
LPVOID MapViewOfFile(HANDLE hFileMappingObject,
DWORD dwDesiredAccess,
DWORD dwFileOffsetHigh,
DWORD dwFileOffsetLow,
DWORD dwNumberOfBytesToMap);
雖然使用方便,但使用上限制較多,比如規定的分配粒度(Windows下通常為64KB)等。下面貼出內存映射法實例代碼供參考,但本文不做進一步討論。

1 using System; 2 using System.Collections.Generic; 3 using System.Text; 4 using System.Runtime.InteropServices; 5 6 namespace BlueVision.SaYuan.FileMapping 7 { 8 public class ShareMemory 9 { 10 [DllImport( "user32.dll", CharSet = CharSet.Auto )] 11 public static extern IntPtr SendMessage( IntPtr hWnd, int Msg, int wParam, IntPtr lParam ); 12 13 [DllImport( "Kernel32.dll", CharSet = CharSet.Auto )] 14 public static extern IntPtr CreateFileMapping( IntPtr hFile, IntPtr lpAttributes, uint flProtect, uint dwMaxSizeHi, uint dwMaxSizeLow, string lpName ); 15 16 [DllImport( "Kernel32.dll", CharSet = CharSet.Auto )] 17 public static extern IntPtr OpenFileMapping( int dwDesiredAccess, [MarshalAs( UnmanagedType.Bool )] bool bInheritHandle, string lpName ); 18 19 [DllImport( "Kernel32.dll", CharSet = CharSet.Auto )] 20 public static extern IntPtr MapViewOfFile( IntPtr hFileMapping, uint dwDesiredAccess, uint dwFileOffsetHigh, uint dwFileOffsetLow, uint dwNumberOfBytesToMap ); 21 22 [DllImport( "Kernel32.dll", CharSet = CharSet.Auto )] 23 public static extern bool UnmapViewOfFile( IntPtr pvBaseAddress ); 24 25 [DllImport( "Kernel32.dll", CharSet = CharSet.Auto )] 26 public static extern bool CloseHandle( IntPtr handle ); 27 28 [DllImport( "kernel32", EntryPoint = "GetLastError" )] 29 public static extern int GetLastError(); 30 31 [DllImport( "kernel32.dll" )] 32 static extern void GetSystemInfo( out SYSTEM_INFO lpSystemInfo ); 33 34 [StructLayout( LayoutKind.Sequential )] 35 public struct SYSTEM_INFO 36 { 37 public ushort processorArchitecture; 38 ushort reserved; 39 public uint pageSize; 40 public IntPtr minimumApplicationAddress; 41 public IntPtr maximumApplicationAddress; 42 public IntPtr activeProcessorMask; 43 public uint numberOfProcessors; 44 public uint processorType; 45 public uint allocationGranularity; 46 public ushort processorLevel; 47 public ushort processorRevision; 48 } 49 /// <summary> 50 /// 獲取系統的分配粒度 51 /// </summary> 52 /// <returns></returns> 53 public static uint GetPartitionsize() 54 { 55 SYSTEM_INFO sysInfo; 56 GetSystemInfo( out sysInfo ); 57 return sysInfo.allocationGranularity; 58 } 59 60 const int ERROR_ALREADY_EXISTS = 183; 61 62 const int FILE_MAP_COPY = 0x0001; 63 const int FILE_MAP_WRITE = 0x0002; 64 const int FILE_MAP_READ = 0x0004; 65 const int FILE_MAP_ALL_ACCESS = 0x0002 | 0x0004; 66 67 const int PAGE_READONLY = 0x02; 68 const int PAGE_READWRITE = 0x04; 69 const int PAGE_WRITECOPY = 0x08; 70 const int PAGE_EXECUTE = 0x10; 71 const int PAGE_EXECUTE_READ = 0x20; 72 const int PAGE_EXECUTE_READWRITE = 0x40; 73 74 const int SEC_COMMIT = 0x8000000; 75 const int SEC_IMAGE = 0x1000000; 76 const int SEC_NOCACHE = 0x10000000; 77 const int SEC_RESERVE = 0x4000000; 78 79 IntPtr m_fHandle; 80 81 IntPtr m_hSharedMemoryFile = IntPtr.Zero; 82 IntPtr m_pwData = IntPtr.Zero; 83 bool m_bAlreadyExist = false; 84 bool m_bInit = false; 85 uint m_MemSize = 0x1400000;//20M 86 long m_offsetBegin = 0; 87 long m_FileSize = 0; 88 FileReader File = new FileReader(); 89 90 91 /// <summary> 92 /// 初始化文件 93 /// </summary> 94 /// <param name="MemSize">緩沖大小</param> 95 public ShareMemory( string filename, uint memSize ) 96 { 97 // 分頁映射文件時,每頁的起始位置startpos,必須為64K的整數倍。 98 // memSize即緩存區的大小必須是系統分配粒度的整倍說,window系統的分配粒度是64KB 99 this.m_MemSize = memSize; 100 Init( filename ); 101 } 102 103 104 /// <summary> 105 /// 默認映射20M緩沖 106 /// </summary> 107 /// <param name="filename"></param> 108 public ShareMemory( string filename ) 109 { 110 this.m_MemSize = 0x1400000; 111 Init( filename ); 112 } 113 114 ~ShareMemory() 115 { 116 Close(); 117 } 118 119 /// <summary> 120 /// 初始化共享內存 121 /// 122 /// 共享內存名稱 123 /// 共享內存大小 124 /// </summary> 125 /// <param name="strName"></param> 126 protected void Init( string strName ) 127 { 128 //if (lngSize <= 0 || lngSize > 0x00800000) lngSize = 0x00800000; 129 130 if ( !System.IO.File.Exists( strName ) ) throw new Exception( "未找到文件" ); 131 132 System.IO.FileInfo f = new System.IO.FileInfo( strName ); 133 134 m_FileSize = f.Length; 135 136 m_fHandle = File.Open( strName ); 137 138 if ( strName.Length > 0 ) 139 { 140 //創建文件映射 141 m_hSharedMemoryFile = CreateFileMapping( m_fHandle, IntPtr.Zero, ( uint )PAGE_READONLY, 0, ( uint )m_FileSize, "mdata" ); 142 if ( m_hSharedMemoryFile == IntPtr.Zero ) 143 { 144 m_bAlreadyExist = false; 145 m_bInit = false; 146 throw new Exception( "CreateFileMapping失敗LastError=" + GetLastError().ToString() ); 147 } 148 else 149 m_bInit = true; 150 151 ////映射第一塊文件 152 //m_pwData = MapViewOfFile(m_hSharedMemoryFile, FILE_MAP_READ, 0, 0, (uint)m_MemSize); 153 //if (m_pwData == IntPtr.Zero) 154 //{ 155 // m_bInit = false; 156 // throw new Exception("m_hSharedMemoryFile失敗LastError=" + GetLastError().ToString()); 157 //} 158 159 } 160 } 161 /// <summary> 162 /// 獲取高32位 163 /// </summary> 164 /// <param name="intValue"></param> 165 /// <returns></returns> 166 private static uint GetHighWord( UInt64 intValue ) 167 { 168 return Convert.ToUInt32( intValue >> 32 ); 169 } 170 /// <summary> 171 /// 獲取低32位 172 /// </summary> 173 /// <param name="intValue"></param> 174 /// <returns></returns> 175 private static uint GetLowWord( UInt64 intValue ) 176 { 177 178 return Convert.ToUInt32( intValue & 0x00000000FFFFFFFF ); 179 } 180 181 /// <summary> 182 /// 獲取下一個文件塊 塊大小為20M 183 /// </summary> 184 /// <returns>false 表示已經是最后一塊文件</returns> 185 public uint GetNextblock() 186 { 187 if ( !this.m_bInit ) throw new Exception( "文件未初始化。" ); 188 //if ( m_offsetBegin + m_MemSize >= m_FileSize ) return false; 189 190 uint m_Size = GetMemberSize(); 191 if ( m_Size == 0 ) return m_Size; 192 193 // 更改緩沖區大小 194 m_MemSize = m_Size; 195 196 //卸載前一個文件 197 //bool l_result = UnmapViewOfFile( m_pwData ); 198 //m_pwData = IntPtr.Zero; 199 200 201 m_pwData = MapViewOfFile( m_hSharedMemoryFile, FILE_MAP_READ, GetHighWord( ( UInt64 )m_offsetBegin ), GetLowWord( ( UInt64 )m_offsetBegin ), m_Size ); 202 if ( m_pwData == IntPtr.Zero ) 203 { 204 m_bInit = false; 205 throw new Exception( "映射文件塊失敗" + GetLastError().ToString() ); 206 } 207 m_offsetBegin = m_offsetBegin + m_Size; 208 209 return m_Size; //創建成功 210 } 211 /// <summary> 212 /// 返回映射區大小 213 /// </summary> 214 /// <returns></returns> 215 private uint GetMemberSize() 216 { 217 if ( m_offsetBegin >= m_FileSize ) 218 { 219 return 0; 220 } 221 else if ( m_offsetBegin + m_MemSize >= m_FileSize ) 222 { 223 long temp = m_FileSize - m_offsetBegin; 224 return ( uint )temp; 225 } 226 else 227 return m_MemSize; 228 } 229 230 /// <summary> 231 /// 關閉內存映射 232 /// </summary> 233 public void Close() 234 { 235 if ( m_bInit ) 236 { 237 UnmapViewOfFile( m_pwData ); 238 CloseHandle( m_hSharedMemoryFile ); 239 File.Close(); 240 } 241 } 242 243 /// <summary> 244 /// 從當前塊中獲取數據 245 /// </summary> 246 /// <param name="bytData">數據</param> 247 /// <param name="lngAddr">起始數據</param> 248 /// <param name="lngSize">數據長度,最大值=緩沖長度</param> 249 /// <param name="Unmap">讀取完成是否卸載緩沖區</param> 250 /// <returns></returns> 251 public void Read( ref byte[] bytData, int lngAddr, int lngSize, bool Unmap ) 252 { 253 if ( lngAddr + lngSize > m_MemSize ) 254 throw new Exception( "Read操作超出數據區" ); 255 if ( m_bInit ) 256 { 257 // string bb = Marshal.PtrToStringAuto(m_pwData);// 258 Marshal.Copy( m_pwData, bytData, lngAddr, lngSize ); 259 } 260 else 261 { 262 throw new Exception( "文件未初始化" ); 263 } 264 265 if ( Unmap ) 266 { 267 bool l_result = UnmapViewOfFile( m_pwData ); 268 if ( l_result ) 269 m_pwData = IntPtr.Zero; 270 } 271 } 272 273 /// <summary> 274 /// 從當前塊中獲取數據 275 /// </summary> 276 /// <param name="bytData">數據</param> 277 /// <param name="lngAddr">起始數據</param> 278 /// <param name="lngSize">數據長度,最大值=緩沖長度</param> 279 /// <exception cref="Exception: Read操作超出數據區"></exception> 280 /// <exception cref="Exception: 文件未初始化"></exception> 281 /// <returns></returns> 282 public void Read( ref byte[] bytData, int lngAddr, int lngSize ) 283 { 284 if ( lngAddr + lngSize > m_MemSize ) 285 throw new Exception( "Read操作超出數據區" ); 286 if ( m_bInit ) 287 { 288 Marshal.Copy( m_pwData, bytData, lngAddr, lngSize ); 289 } 290 else 291 { 292 throw new Exception( "文件未初始化" ); 293 } 294 } 295 296 /// <summary> 297 /// 從當前塊中獲取數據 298 /// </summary> 299 /// <param name="lngAddr">緩存區偏移量</param> 300 /// <param name="byteData">數據數組</param> 301 /// <param name="StartIndex">數據數組開始復制的下標</param> 302 /// <param name="lngSize">數據長度,最大值=緩沖長度</param> 303 /// <exception cref="Exception: 起始數據超過緩沖區長度"></exception> 304 /// <exception cref="Exception: 文件未初始化"></exception> 305 /// <returns>返回實際讀取值</returns> 306 public uint ReadBytes( int lngAddr, ref byte[] byteData, int StartIndex, uint intSize ) 307 { 308 if ( lngAddr >= m_MemSize ) 309 throw new Exception( "起始數據超過緩沖區長度" ); 310 311 if ( lngAddr + intSize > m_MemSize ) 312 intSize = m_MemSize - ( uint )lngAddr; 313 314 if ( m_bInit ) 315 { 316 IntPtr s = new IntPtr( ( long )m_pwData + lngAddr ); // 地址偏移 317 Marshal.Copy( s, byteData, StartIndex, ( int )intSize ); 318 } 319 else 320 { 321 throw new Exception( "文件未初始化" ); 322 } 323 324 return intSize; 325 } 326 327 /// <summary> 328 /// 寫數據 329 /// </summary> 330 /// <param name="bytData">數據</param> 331 /// <param name="lngAddr">起始地址</param> 332 /// <param name="lngSize">個數</param> 333 /// <returns></returns> 334 private int Write( byte[] bytData, int lngAddr, int lngSize ) 335 { 336 if ( lngAddr + lngSize > m_MemSize ) return 2; //超出數據區 337 if ( m_bInit ) 338 { 339 Marshal.Copy( bytData, lngAddr, m_pwData, lngSize ); 340 } 341 else 342 { 343 return 1; //共享內存未初始化 344 } 345 return 0; //寫成功 346 } 347 } 348 internal class FileReader 349 { 350 const uint GENERIC_READ = 0x80000000; 351 const uint OPEN_EXISTING = 3; 352 System.IntPtr handle; 353 354 [DllImport( "kernel32", SetLastError = true )] 355 public static extern System.IntPtr CreateFile( 356 string FileName, // file name 357 uint DesiredAccess, // access mode 358 uint ShareMode, // share mode 359 uint SecurityAttributes, // Security Attributes 360 uint CreationDisposition, // how to create 361 uint FlagsAndAttributes, // file attributes 362 int hTemplateFile // handle to template file 363 ); 364 365 [System.Runtime.InteropServices.DllImport( "kernel32", SetLastError = true )] 366 static extern bool CloseHandle 367 ( 368 System.IntPtr hObject // handle to object 369 ); 370 371 372 373 public IntPtr Open( string FileName ) 374 { 375 // open the existing file for reading 376 handle = CreateFile 377 ( 378 FileName, 379 GENERIC_READ, 380 0, 381 0, 382 OPEN_EXISTING, 383 0, 384 0 385 ); 386 387 if ( handle != System.IntPtr.Zero ) 388 { 389 return handle; 390 } 391 else 392 { 393 throw new Exception( "打開文件失敗" ); 394 } 395 } 396 397 public bool Close() 398 { 399 return CloseHandle( handle ); 400 } 401 } 402 }
分頁讀取法(Paging)
另外一種高效讀取文件的方法就是分頁法,也叫分段法(Segmentation),對應的讀取單位被稱作頁(Page)和段(Segment)。其基本思想是將整體數據分割至較小的粒度再進行處理,以便滿足時間、空間和性能方面的要求。分頁法的概念使用相當廣泛,如嵌入式系統中的分塊處理(Blocks)和網絡數據的分包傳輸(Packages)。
在開始研究分頁法前,先來看看在超大文件處理中,最為重要的問題:高速隨機訪問。桌面編程中,分頁法通常應用於文字處理、閱讀等軟件,有時也應用在大型圖片顯示等方面。這類軟件的一個特點就是數據的局部性,無論需要處理的文件有多么大,使用者的注意力(也可以稱為視口ViewPort)通常只有非常局部的一點(如幾頁文檔和屏幕大小的圖片)。這就要求了接下來,我們要找到一種能夠實現高速的隨機訪問,而這種訪問效果還不能和文件大小有關(否則就失去了高速的意義)。事實上,以下我們研究的分頁法就是利用了「化整為零」的方法,通過只讀取和顯示用戶感興趣的那部分數據,達到提升操作速度的目的。
參考上圖,假設計算機上有某文件F,其內容為「01234567890123456」(引號「」中的內容,不含引號,下同),文件大小為FileLength=17字節,以PageSize=3對F進行分頁,總頁數PageCount=6,得到頁號為0~5的6個頁面(圖中頁碼=頁號+1)。各頁面所含數據如下表所示。
頁號 | 頁碼 | 內容 | 至頭部偏移量 (Hex) | 長度 |
0 | 1 | 012 | 00 01 02 | 3 |
1 | 2 | 345 | 03 04 05 | 3 |
2 | 3 | 678 | 06 07 08 | 3 |
3 | 4 | 901 | 09 0a 0b | 3 |
4 | 5 | 234 | 0c 0d 0e | 3 |
5 | 6 | 56 | 0f 10 | 2 |
可以看到,最后一頁的長度為2(最后一頁長度總是小於PageSize)。
當我們要讀取「第n頁」的數據(即頁碼=n)時,實際上讀取的是頁號PageNumber=n-1的內容。例如n=3時,PageNumber=2,數據為「678」,該頁數據偏移量范圍從0x06至0x08,長度為3(PageSize)。為便於講述,在此約定:以下文字中,均只涉及頁號,即PageNumber。
參考圖2,設當PageNumber=x時,頁x的數據范圍為[offsetStart, offsetEnd],那么可以用如下的代碼進行計算(C#2.0)。
1 offsetStart = pageNumber * pageSize; 2 3 if(offsetStart + pageSize < fileSize) 4 { 5 offsetEnd = offsetStart + pageSize; 6 } 7 else 8 { 9 offsetEnd = fileSize - 1; 10 }
我們常用的System.IO.FileStream類有兩個重要的方法:Seek()和Read()。
1 // 將該流的當前位置設置為給定值。 2 public override long Seek ( 3 long offset, 4 SeekOrigin origin 5 ) 6 7 // 從流中讀取字節塊並將該數據寫入給定緩沖區中。 8 public override int Read ( 9 [InAttribute] [OutAttribute] byte[] array, 10 int offset, 11 int count 12 )
利用這兩個方法,我們可以指定每次讀取的數據起始位置(offsetStart)和讀取長度(offsetEnd - offsetStart),這樣就可以讀到任意指定的頁數據。我們可以遍歷讀取所有頁,這就相當於普通讀取整個文件(實際操作中,一般不會有需求一次讀取上GB的文件)。

1 指定PageNumber,讀取頁數據 2 byte[] getPage(Int64 pageNumber) 3 { 4 if (fileStream == null || !fileStream.CanSeek || !fileStream.CanRead) 5 return null; 6 7 if (pageNumber < 0 || pageNumber >= pageCount) 8 return null; 9 10 // absolute offileStreamet of read range 11 Int64 offsetStart = (Int64)pageNumber * (Int64)pageSize; 12 Int64 offsetEnd = 0; 13 14 if (pageNumber < pageCount - 1) 15 // not last pageNumber 16 offsetEnd = offsetStart + pageSize - 1; 17 else 18 // last pageNumber 19 offsetEnd = fileSize - 1; 20 21 byte[] tmp = new byte[offsetEnd - offsetStart + 1]; 22 23 fileStream.Seek(offsetStart, SeekOrigin.Begin); 24 int rd = fileStream.Read(tmp, 0, (Int32)(offsetEnd - offsetStart + 1)); 25 26 return tmp; 27 }
由於每次讀取的數據長度(PageSize)遠遠小於文件長度(FileSize),所以使用分頁法能夠只讀取程序需要的那部分數據,最大化提高程序的運行效率。下表是筆者在實驗環境下對分頁法讀取文件的運行效率的測試。
CPU:Intel Core i3 380M @ 2.53GHz
內存:DDR3 2048MB x2
硬盤:TOSHIBA MK3265GSX (320 GB) @ 5400 RPM
為盡量保證測試質量,測試前系統進行了重裝、硬盤整理等維護操作。該硬盤性能測試結果如下圖所示。
下面是為了測試分頁法而制作的超大文件讀取器界面截圖,圖中讀取的是本次試驗的用例之一Windows8消費者預覽版光盤鏡像(大小:3.40GB)。
本次測試選擇了「大、中、小」3種規格的測試文件作為測試用例,分別為:
# | 文件名 | 文件內容 | 大小(KB) |
1 | AlishaHead.png | Poser Pro 6貼圖 | 11,611 |
2 | ubuntu-11.10-desktop-i386.iso | Ubuntu11.10桌面版鏡像 | 711,980 |
3 | Windows8-ConsumerPreview-64bit-ChineseSimplified.iso | Windows8消費者預覽版64位簡體中文版鏡像 | 3,567,486 |
通過進行多次讀取,采集到如下表A所示的文件讀取數據結果。表中項目「分頁(單頁)」表示使用分頁讀取法,但設置頁面大小為文件大小(即只有1頁)進行讀取。同樣的,為了解分頁讀取的性能變化情況,使用普通讀取方法(一次讀取)采集到另一份數據結果,如下表B所示。
對用例#1,該用例大小僅11MB,使用常規(單次)讀取方法,僅用不到20ms即將全部內容讀取完畢。而當采用分頁法,隨着分頁大小越來越小,文件被划分為更多的頁面,盡管隨機訪問文件內容使得文件操作更加方便,但在讀取整個文件的時候,分頁卻帶來了更多的消耗。例如當分頁大小為1KB時,文件被分割為11,611個頁面。讀取整個文件時,需要重復調用11,611次FileStream.Read()方法,增加了很多消耗,如下圖所示。(圖中數據僅為全文讀取操作對比)
從圖中可以看到,當分頁尺寸過分的小(1KB)時,這種過度追求微粒化反而導致了操作性能下降。可以看到,即實現了微粒化,能夠進行隨機訪問,同時仍保有一定量的操作性能,分頁大小為64KB和1MB是不錯的選擇。實際上,上文介紹的MapViewOfFile函數的推薦分頁大小正是64KB。
對用例#2,該用例大小為695.29MB,達到較大的尺寸,因此對讀取緩存(cache)需求較高,同時也對合適的分頁尺寸提出了要求。可以看到,和用例#1不同,當文件尺寸從11.34MB增加到近700MB時,分頁尺寸隨之相應的擴大,是提高操作性能的好方法(下圖中1MB分頁)。
對用例#3,該用例達到3.4GB大小,符合我們對超大文件的定義。通過前述2個用例的分析,可以推測,為獲得最佳性能,分頁大小需繼續提高(比如從1MB提高到4MB)。由於本次試驗時間倉促,考慮不周,未使用「邊讀取、邊丟棄」的測試算法,導致分頁讀取用例#3的數據時,數據不斷在內存中積累,最終引發System.OutOfMemoryException異常,使得分頁讀取完整文件這項測試不能正常完成。這一問題,需在下次的試驗當中加以解決和避免。
引發System.OutOfMemoryException
盡管如此,通過試驗,仍然可以清楚的看到,在常規文件(GB以下級別)操作中,分頁法具有高度靈活性,但額外開銷大,全文讀取速度慢的問題。當操作超大文件(GB以上級別)時,分頁法的優勢開始顯現。極高的數據讀取靈活性帶來的是和文件大小無關的隨機頁面訪問速度(僅和分頁大小有關)。在這個級別上,文件大小往往遠遠超過常規方法所能讀取的最大值(0x7FFFFFFF),因此只有使用分頁法,積少成多,才能完成讀取完整文件的工作。
分頁法使用簡單,思路清晰,具有很高的靈活性和與文件長度無關的隨機讀取能力,最大支持文件大小理論上能夠達到8,388,608 TB(Int64)。但同時它也具有額外開銷大的特點,因此不適合小文件的操作。
通過擴展該方法,我們可以幾乎在所有需要大量、重復、大范圍算法處理的程序中加以應用分頁法的「化整為零」思想,以減少計算粒度,實現計算的可持續進行。
分頁法,以及上文提到的內存映射法,其實均早已出現多年,更是廣泛應用於各個行業。筆者之所以仍舊撰寫此文,一則鍛煉自己的編程能力、語言歸納能力、文字寫作能力,二則加深對方法的理解,通過試驗得出的現象來深入方法的本質。鑒於筆者才疏學淺,在此妄言,有些詞不達意,甚至謬誤之處,還望各位讀者多加批評、指正。
(全文完)
© Conmajia 2012