這個作業屬於哪個課程 | 2021春軟件工程實踐|W班 |
---|---|
這個作業要求在哪里 | 寒假作業2/2 |
這個作業的目標 | 閱讀《構建之法》提出問題 編寫程序,使用PSP進行時間管理與總結 |
其他參考文獻 | The Most Efficient Way To Find Top K Frequent Words In A Big Word Sequence |
閱讀《構建之法》並提問
如果你是病人, 你希望你的醫生是下面的那一種呢?
a) 剛剛在書上看到你的病例, 開刀的過程中非常認真嚴謹, 時不時還要停下來翻書看看…
b) 富有創新意識, 開刀時突然想到一個新技術, 新的刀法, 然后馬上在你身上試驗…
c) 已經處理過很多類似的病例, 可以一邊給你開刀, 一邊和護士聊天說昨天晚上放的 《非誠勿擾》的花絮…
d) 此醫生無正式文憑或醫院, 但是號稱有秘方, 可治百病。
e) 還有這一類, 給你開刀到一半的時候, 出去玩去了, 快下班的時候, 他們匆匆趕回來, 胡搞一氣, 給你再縫好, 打了很多麻葯,就把你送出了院, 說“治好了”!
我認為現在互聯網已經進入了"存量競爭",能脫穎而出的往往是那些富有創新意識, 開刀時突然想到一個新技術
的項目之后在加上穩定的運營
有一種意見認為作坊只能獨立存在,和其他機構都合不來。其實不然,在龐大的企業內部,
也有一些人構建了一個小作坊,自己做主,做自己感興趣的事
我感覺大機構反而可能是小作坊創新的阻礙。比如facebook,在他還是小作坊的時候,就是靠創新吸引了最初的用戶,但是現在,為了商業利益,他對於擁有新技術的小作坊,首先是收購,如果收購不成功,就仿照一個與之對應的軟件來壓垮小作坊,一定程度上是阻礙了創新。
白箱:在設計測試的過程中,設計者可以“看到”軟件系統的內部結構,並且使用軟件的內部知識來指導測試數據及方法的選擇。“白箱”並不是一個精確的說法,因為把箱子塗成白色,同樣也看不見箱子里的東西。有人建議用“玻璃箱”來表示。
如果白箱測試要知道程序的結構,那測試人員是不是要熟悉整個系統,不然不能進行白箱測試。
職業發展
幾種方法:
· PSP
· 考級
· Steve McConnell Construx
· Corporate Career Model
· Pragmatic Approach
正如文中所說,是很難量化一個工程師的技術和能力,但是我覺得上面的這些方法在國內有些不適用。之前也讀過這篇文章:怎樣花兩年時間去面試一個人。但是能做到文中的做法的公司,也是極少的,感覺在國內,衡量一個人的技術和能力還是靠他的項目經歷。
Lotus 1-2-3 占據了大部分市場份額, 不過, 它的日期計算功能有一個小Bug,就是把1900 年當作閏年。這類軟件在內部把日期保存為“從1900/1/1 到當前日期的天數”這樣的一個整數。Excel 作為后來者,要支持 Lotus 1-2-3 的數據文件格式,這樣才能正確處理別的軟件產生的格式文件。 這個錯誤就這么延續下來了,每一版本都有人報告,但是都沒有改正。
我自己在使用一些軟件的時候,或是寫代碼的時候,會遇到一些讓人有“為什么要這樣實現”的感覺,然后在網上尋找答案的時候常常是說為了“向后兼容”。有些軟件因為過重的歷史包袱導致被那些新進的軟件超越,而有一些軟件卻因“不能向后兼容”導致被用戶拋棄。那要如何在這兩者之間找到一個平衡點?
WordCount編程
Github項目地址
PSP表格
PSP2.1 | Personal Software Process Stages | 預估耗時(分鍾) | 實際耗時(分鍾) |
---|---|---|---|
Planning | 計划 | ||
• Estimate | • 估計這個任務需要多少時間 | 3Day | 4Day |
Development | 開發 | ||
• Analysis | • 需求分析 (包括學習新技術) | 60 | 90 |
• Design Spec | • 生成設計文檔 | 20 | 10 |
• Design Review | • 設計復審 | 10 | 20 |
• Coding Standard | • 代碼規范 (為目前的開發制定合適的規范) | 20 | 10 |
• Design | • 具體設計 | 60 | 70 |
• Coding | • 具體編碼 | 240 | 360 |
• Code Review | • 代碼復審 | 20 | 10 |
• Test | • 測試(自我測試,修改代碼,提交修改) | 60 | 180 |
Reporting | 報告 | ||
• Test Repor | • 測試報告 | 60 | 120 |
• Size Measurement | • 計算工作量 | 20 | 20 |
• Postmortem & Process Improvement Plan | • 事后總結, 並提出過程改進計划 | 30 | 10 |
合計 | 600 | 1000 |
代碼規范制定鏈接
解題思路
看過一遍題目,一開始我認為可以將代碼分為幾個大模塊
-
WordReader模塊:讀取合法單詞
-
TopK模塊:統計topK
-
Core模塊,將上兩個模塊整合,實現統計文檔信息和TopK的需求
思路就是Core通過WordReader模塊,讀取出合法單詞,統計文檔的字符數、單詞數和行數,同時將這個合法單詞存入TopK模塊統計TopK,最后將從TopK模塊中獲取出現頻率前十的數,並和文檔的統計信息一並輸出。
之后想了想,應該在讀取模塊中就可以統計文檔信息,那么Core模塊實際上就起到了一個將WordReader模塊和TopK模塊整合的功能
最后在網上查資料的時候,有看到在有限內存下對多數據(1億條左右)實現求TopK的思路,所以我希望我的程序也能對大文件進行處理。
設計與實現過程
整體設計
由於學習了Spring Boot框架,覺得它的IoC的思想確實能減少不同模塊之間的耦合,所以我的Core模塊默認是沒有對WordReader模塊的定義,而是通過set方式將其"注入"。
而如果我是通過在Core中new一個WordReader來實現的,這次的要求是從文件中讀取,那么我必然要在Core模塊中寫一個方法傳入File類來構造WordReader,如果需求改為要從數據庫讀取,那Core模塊還是要修改出一個傳遞數據庫Connect的方法,這樣對WordReader的修改傳遞到了Core,程序之間耦合度較高。
以上是我在整體設計的時候的思考。
WordReader模塊
首先,由於作業要求能把這個功能放到不同的環境中去,所以WordReader模塊不應只能從文件中讀取數據,所以我將設計了一個WordReader的接口,並實現了一個從InputStream中讀取單詞的實現類。
public interface WordReader {
//返回下一個有效的單詞
public String nextWord();
public boolean setInputStream(InputStream is);
public long getLineNumber();
public long getWordNumber();
public long getCharNumber();
public void clear();
}
設置讀入流的工作交給了main函數,main函數將文件路徑轉換成流,通過流新建一個WordReader,之后set進Core中。這樣可以很容易復用這個功能。
public static void main(String[] args){
...
InputStream is = new FileInputStream(file);
InputStreamWordReader wr = new InputStreamWordReader(is);
Core core = new Core();
core.setWordReader(wr);
core.start();
...
}
而在實現的時候,我第一個想到的就是用Scanner類來讀取單詞,它提供了豐富的讀取功能,但是它按單詞讀取的時候並不能統計換行,而如果按行讀取,對於大文件來說,如果數據都在一行,那么就不能將內存控制在可以控制的訪問內
綜上缺點,我決定還是自己手寫一個從流中獲取有效單詞的類。
我實現了一個InputStreamWordReader類,由於我源代碼中有詳細的注解,這里就不放代碼來解釋了,而這個類有以下的特點
- 繼承自WordReader類,可以整合到Core中
- 統計了讀出文件的字符數、單詞數和行數
- 可以通過next方法獲取下一個有效的單詞,即作業要求中對單詞的定義
- 可以統計有效的行數,跳過只包含空字符的行,如果最后一行沒有換行也可以統計到
- 這個類的實現是完全通過流的思想,不會緩存整個文件、一整行的字符串,最多緩存一個單詞,以控制整個程序的內存使用,也因為這個功能的實現,所以整體邏輯比較復雜
TopK模塊
剛開始查閱資料的時候,發現大概的思路是將單詞存入HashMap或TrieTree中,在統計頻率最高的單詞時,通過小頂堆來維護出現頻率最高的k個字符串,最后返回結果。
結合這個作業的實際來看,他除了26個字母,數字也是合法的,如果使用TrieTree所以可能導致數的深度很深,而且每個節點的字節點也很多,如果在稀疏的情況下,TrieTree實際上的內存用量更多。所以我使用了HashMap+PriorityQueue來實現TopK模塊。
由於使用的都是java封裝的算法,實際代碼實現也很簡短,所以在這方面也不過多贅述。
性能改進
性能改進的方法
上面說到,我希望我的程序也能對大文件進行處理。而要實現這一要求,有兩個檻:
- 在讀入的時候必須嚴格按流讀入,不能加載整個文件到內存中,這一要求我上面已經實現了
- 在處理TopK的時候,也不能直接處理整個文件,不然也是內存不足,這個是要改進的地方
而之前查詢到的思路是將整個文件的單詞按hash分到不同的小文件中,之后再對每個小文件進行處理。因為是按hash分割的,所以相同的單詞必然在同一個文件之中,所以最后只要取出每個小文件的前k高出現頻率的詞,再取出k*n的詞中最高的k個,就用有限的內存實現了功能。
我本人是不喜歡直接貼大段代碼的,所以我這里只貼方法名並且說說我他的流程。
public class HugeDataCore{
...
//分割的每個小文件
private File files[];
//緩存的文件夾
private File cacheDir;
//是否啟用分割文件
private boolean div=false;
//如果分割文件,記錄要分割成幾份文件
private int divNum;
//獲取最高詞頻單詞的個數
private long topCount =10;
//文件最大閾值
private int eachFileSize;
//優先隊列
private PriorityQueue<Node> pq = new PriorityQueue<>();
...
//通過文件來構造,這樣閾值默認是50mb
public HugeDataCore(File inputFile){...}
//可以設置閾值的構造
public HugeDataCore(File inputFile,int eachFileSize){...}
//判斷並分割文件,超過閾值則啟用分割文件,反之不啟用
private void divFile(){...}
//開始處理的函數,在開始之前判斷並分割文件
public void start() throws ExecutionException, InterruptedException {
divFile();
...
}
//設置獲取的TopK的個數K
public void setTopCount(int topCount) {...}
//設置緩存文件夾,結束會自動刪除文件
public void setCacheDir(String cacheDir) {...}
//獲取各種信息
public long getWordNumber() {...}
public long getCharNumber() {...}
public long getLineNumber() {...}
...
}
- 實現了一個HugeDataCore類,這個類只能通過File來構建,因為大文件只能從文件中讀取。
- 在開始調用start來開始處理文件,處理之前會先判斷文件是否超過一個閾值,當然這個閾值可以你自己來設置。如果超過閾值就分割文件,如果不超過就不分割文件。分割文件可以自己設置緩存的路徑。
- 因為分割文件要讀入一次文件,所以統計字符數、單詞數和函數再分割文件中記錄。
- 分割完文件后,會將分割的文件存到一個File數組中,之后再start中順序處理生成的文件。
- 這里處理小文件的邏輯和之前實現的不分割處理文件的Core的邏輯是一樣的,所以直接用文件構的流構造Core,復用了代碼。
- 最后也是優先隊列類處理處理每個小文件的前k個詞頻最高的詞,獲取到頻率最高的k個詞。
所以理論上,這個程序在空間上是可以對非常大的文件進行處理的
性能優化后的結果
在github上找到了一個隨機生成字符串的Java代碼:wordnet-random-name,我是用他來生成隨機字符串的。因為他是用詞庫的,而且詞庫有限,所以認為如果生成非常大量的字符串,生成的字符串是非常稠密的。
對於隨機生成的150mb大小的文件,大概1000w個單詞,我分別用了不分割文件和分割文件的方法跑了一遍:
這里解釋一下這個圖,上面的柱狀圖是這段時間內分配的內存,而下面折線圖這是實際使用的內存,橙色豎條則是
可以看到,如果不分割文件,內存在處理TopK的時候是到達了1g左右,而分割文件就內存則被控制在了200mb左右。這還是在我按流讀入的情況下,我讓其他同學用他緩存整個文件后進行處理的讀入實現運行,在讀入階段的內存使用就到達了1g左右。在分割文件的情況下,程序大概跑了9秒。
同樣的,我生成了一個1500mb左右的數據,大概1億個單詞,在生成數據的時候,在每個單詞后面添加一個1000以下的隨機數,讓數據變得比較稀疏:
可以看到,如果是不分割文件,內存使用了接近4g,而且一直觸發gc導致cpu占用率也非常高。而分割了文件后,內存占用不超過400mb,而且程序也不會因為一直gc而陷入死鎖。在分割文件情況下,程序大概跑了2分半。
最后,因為對文件進行了分割,所以肯定是比不上直接對文件進行處理的耗時短,而且這還與機器硬盤的讀取速寫有關,所以性能是沒有優勢的。但是我認為在這里用一定的時間換取空間是非常划算的。
單元測試
對於判斷單詞合法性的單元測試
@Test
void isWord(){
//一定是一個只包含字母和數字的字符串
Assert.assertEquals(true,wr.isWord("abce"));
Assert.assertEquals(false,wr.isWord("abc1e"));
Assert.assertEquals(false,wr.isWord("12abce"));
Assert.assertEquals(false,wr.isWord("3"));
Assert.assertEquals(false,wr.isWord(""));
Assert.assertEquals(true,wr.isWord("asodjf123abce"));
}
分別是測試了
- 連續四個字母為合法
- 數字對連續字符的隔斷
- 數字開頭
- 純數字
- 空字符
- 一個字母+數字的合法字符
對有效行數的統計
@Test
void getLineNumber() {
String string = new String("\n \naisodjo \t \n ???---\n\ni\nrr");
wr.setInputStream(new ByteArrayInputStream(string.getBytes(StandardCharsets.UTF_8)));
while (wr.nextWord()!=null);
Assert.assertEquals(4,wr.getLineNumber());
}
分別是測試了
- 開頭空行
- 空白字符換行
- 字符+空白字符+換行
- 非空白字符非有效字符的換行
- 連續換行
- 最后無換行的一行
異常處理說明
這個程序異常較少,主要是文件的異常和用戶輸入的異常。
如果用戶輸入的文件不存在或者權限不足,會拋出FileNotFoundException
。
同樣的,在分割文件時,文件夾的權限不足也會拋出這個異常。
心路歷程與收獲
心路歷程
剛開始感覺這個程序比起之前的什么管理系統簡單的太多,但是當我真的想要按上述的要求編寫程序時,還是發現了許多以前我沒有注意到的點。在之前,我編寫程序的時候,感覺很難控制每個方法的粒度,但是這次在寫單元測試的時候,發現要做到'單元'的話,我之前設計的方法粒度可能太大。
收獲
在之前的編程中,我從來沒有用過單元測試,基本上都是手動輸入樣例然后人工對比結果。這次學到了單元測試的使用。