大文本文件閱讀器設計

     我們項目中需要實現一個日誌查看控件,這本是一個很簡單的需求:寫一個通用的控件,將字符串綁定到RichTextBox, 如果要查看日誌,將日誌賦值給字符串即可。這個控件很簡單,在絕大多數情況下工作的都很好。但是最近經常有客戶報告說日誌打不開,或者打開後就無法響應了。檢查後發現這些無法打開的日誌都很巨大,文件長度大多都超過幾千萬行。顯然不帶任何優化的文本閱讀器都撐不住這個級別的文本。通過觀察及與客戶的溝通,他們的典型的操作是鼠標上下滾動,翻閱前後內容。拖拽滾動條到某一個段落,然後在局部操作,如果文件很大,他們幾乎不會全文閱讀。客戶希望操作的過程能夠儘量不卡,或者少卡。

      要實現這個需求並不容易。首先想到的就是不能一口氣將所有字符都載入內存。只能一段一段的載入,同時這些段落又不能是離散的, 因爲他們還需要上下滾動鼠標也能夠平滑。通過比較簡單的需求分析,我們發現最初的設計一個文本閱讀器已經分裂成兩個子任務了:1)文件操作 2)界面顯示。

1)文件操作

  我們需要實現一個類似於StreamReader的類,但是需要另外增加ReadNextLine(), ReadPreviousLine(), Locate() 方法。即讀下一行的內容,讀上一行的內容,定位到某一行。我們想到了以行爲單位做索引。比如說,一個文件有一千萬行,類初始化時,我們先將每行的偏移值算出,當要定位(Locate)到某一行時,直接通過索引找到這個偏移值,然後file seek。這個操作是瞬時的。但是前面的索引太耗時了。我做了一個實驗,在我的筆記本上(lenovo T430s)計算一千萬行文件的索引需要將近2分鐘。而且如果所有的索引都保存在內存,需要將近400MB內存。這對於客戶是不可以接受的。

     建立索引這個思路應該是不錯的,不過我們可以做進一步的優化。仔細研究一下一般使用習慣不難發現打開文件永遠是從頭開始的,然後鼠標上下滾滾查看臨近的內容,偶爾需要跳轉。這裏,上下滾動鼠標是一個連續的動作,跳轉是一個離散的動作。連續動作(ReadNextLine, ReadPreviousLine)顯然用戶不希望有卡的感覺,但是跳轉(Locate),有一個停頓的過程應該是可以接受的。基於這個假設,我們又有了進一步的優化:類初始化時不需要建立全部的索引,只需要將文件的開始幾行索引建立即可(在我們項目中先建立前100行)。如果ReadNextLine,就再建立後面連續多行的索引,ReadPreviousLine則建立前面多行的索引。這是基於局部原理:如果調用過一次,下一次也繼續調用同樣的操作的可能性也很大。通過這個方法能夠確保連續的向前向後滾動鼠標不會有卡頓的現象。那如何做到Locate呢?比如說,在一個一千萬行的文件,我們現在想跳轉到第30萬行,顯然在計算偏移量之前我們需要確認這一行是否已經被索引過了?這樣就要求存儲索引的數據結構查詢的時間複雜度儘可能的低,最好能夠達到O(1),這裏我們偷了下懶,直接使用Dictionary。如果索引沒有建立好,我們就不得不重新建立。最理想的情況是,如果知道第299999行已經建立過索引了, 我們只需要再多計算一行即可。如果我們只使用Dictionary來存儲索引,我們並不知道前面有哪幾行已經建立過了,不得不從第一行開始掃描,這是很低效的。於是想到了用鏈表和字典來配合:鏈表用來保存索引的前後關係,字典用來隨機檢查索引是否已經建立。所以邏輯就是:

      

long FindOffset(int target)
{
     long offset;
     if (IsIndexCreated(target, out offset) == true)
{
        return offset;
}
else
{
      int nearest = FindNearestIndex(target);
      GenerateIndex(nearest, target);
      return FindOffset(target);
}

IsIndexCreated 即檢查Dictionary是否包含這個index

FindNearestIndex 即檢查LinkedList 找到離target最近的index結點

GenerateIndex 創建從nearest到target結點的所有索引


通過兩個數據結構能夠快速定位並更新,但是又有新的問題了:存索引的內存加倍了。存一千萬個索引現在需要800MB內存。在極端情況下,比如說,打開文件後,直接跳轉到第一千萬行。這樣GenereateIndex(1, 10000000)會很耗時:不僅要計算偏移量,還要插入Dictionary。 當數據量很大的時候,插入Dictionary的時間也是需要考慮的。所以爲了減少內存使用,提高速度,GenereateIndex的過程我們不會將計算的所有偏移量都保存,僅僅從距離target最近的100個結點開始保存。這樣效果非常顯著。 這裏還有一個地方是可以優化的:如果有人有這個耐心,從第一行連續的滾動鼠標直到最後一行(一千萬行),那麼真個文件的索引都將生成。基於這個我們可以使用LRU算法僅保留最近的10000個索引。

2)界面顯示

    粗看上去這個方案已經很完美了,但是我們迴避了一個問題。由於初始化類的時候我們並沒有創建整個文件的索引,我們並不知道這個文件到底有多少行。文件操作中,如果對於一個只有5千行的文件調用Locate(1000000),我們只索引到最大行數。這在界面顯示就有問題了,因爲滾動條在讀取下一行時每次滾動多少是取決於最大行數的。我們的做法是在界面處理中提供一個CalcuateMaxiumLineNumber函數,這個函數可以設置一個timeout參數(例如100毫秒),如果最大行數能在timeout內算出,直接返回最大行數,如何算不出來,先返回一個一百萬,然後啓動一個線程在後臺慢慢算,每隔一段時間(例如500毫秒)更新一下最大行數。這樣就不會影響界面的打開了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章