面向對象的程序設計(2019)第二單元總結
I 對問題的初體驗
在開始OO之旅前,對OO電梯早有耳聞。這一次終於輪到我自己實現OO電梯了。首先從頂層需求出發對電梯系統進行分析,對象包括電梯、任務和乘客。對於乘客而言,因為一個乘客由ID標識且僅會在一個生命周期中產生一個請求,因而可以和任務合並一體,作為一個輸入線程實現。經過上述簡化電梯調度模擬系統最核心的部分就落在了電梯模塊和任務調度模塊上。在每一次電梯作業的更迭里,慢慢尋找工程化和優化之間的微妙平衡。
II 三次的設計思路
A 單電梯 FCFS 調度算法實現
可以說這個作業算是電梯系統的開始。其本意可能是讓我們分析出這個問題的對象並且實現基本的線程思想。在這個任務中,我將主函數線程和輸入輪詢線程合並,賦予其初始化與輪詢獲取輸入的功能。對與調度器我認為沒有必要使其成為一個獨立的線程,而應該讓他成為一個共享對象在各個電梯之間共享。這一次的目的選層式的電梯設計,輸入輸出接口的簡化以及連續的正數樓層給了我們充分的思考和准備時間,讓我們更合理的設計電梯。在線程的安全性方面,電梯需要訪問調度器中的任務隊列完成任務的分配,且任務隊列還需要接受輸入線程的輸入請求,因而在每一次操作時都應加鎖。
B 單電梯多樓層捎帶調度算法實現
這一次的作業相較上一次,增加了調度算法的復雜度,也增加了地下樓層這一設定。在最開始就要牢記 0層不能停 的事實。對於捎帶的實現,我使用一個任務隊列存儲所有當前上線的任務,並且定義了電梯內部正在執行的計划類。對於電梯內部的計划,包含電梯當前的運行方向,需要停靠的樓層,以及在每一個樓層上下電梯的乘客號。在電梯到達或經過每層時,會向調度器請求捎帶任務。調度器負責過濾出可捎帶任務,之后加入電梯計划中執行。在測試中發現很多時候因為評測樣例喜歡在0秒鍾塞入成噸的數據,使得沒有來得及讀入的請求不能被很好的捎帶。多線程間的協同體現在輸入模塊讀取輸入,電梯線程獲取相關計划,和第一次作業類似,只需要對任務隊列加速保護即可。
B+ 單電梯多樓層捎帶調度算法優化
在優化中,我選擇在每個任務到達的時候,調度器會首先將未分配的任務按照一定的規則組合成電梯計划,當電器請求時一並交給電梯執行,這樣可以保證等待隊列的順序是貪心的最優解,提高算法的效率。但是在實際強測過程中因為時間間隔較短、評測用例較為規律化導致這種算法的效率不算很高,甚至有時會弱於掃描算法。
調度器組合請求的順序根據一個性能函數來判斷貪心最優解:對於每一個電梯計划的可插入位置,計算該電梯計划因為新添加的計划所導致的額外的開銷。若某一處加入后的開銷最小,且小於任務本身的開銷時間,則選擇在該位置插入任務。否則,將任務單獨作為新的電梯任務插入。
同時,在電梯運行過程中,也會不斷查詢調度隊列尋找可以加入的新計划。新計划需滿足:和當前電梯運行方向相同、電梯尚未到達起點樓層且計划間有樓層重疊。
當電梯執行好一個計划后,優先選擇調度器隊列中距離最長的反向任務執行。(受電梯掃描算法啟發)當當前任務執行完畢時,電梯可以偷窺下一個對應的任務的起始樓層是否和當前電梯所在樓層相同,若相同則可以省去一次開門的時間。
C 多電梯多樓層捎帶調度算法實現
第三次作業從體量和內容上都比第二次作業增加了不少。其中還最大的不同還屬於電梯能夠停靠的層數發生變化,且一個請求可能需要多個電梯之間的協作完成。對於這個問題,為了提供一個統一的解決方案,我決定使用一張圖來描述整個電梯系統的狀況。圖中的節點為電梯系統所有可以到達的樓層,樓層間的邊則代表可以在兩層間運行的電梯。對於一個請求,只需要在圖中計算最短路即可得到拆分后的任務隊列。
在前兩次作業的基礎上,電梯類可以說完全沿用了第一次作業的設計。為了適配多電梯協作任務的完成,為計划隊列增加待完成計划這個屬性。從設計上來講,我希望在調度隊列中的所有任務均是待命狀態,這就需要協同任務的后續請求需要在前序任務完成時出現在隊列中。這樣的設計可以極大地簡化調度隊列的維護和查詢,提高代碼簡潔度。多線程之間的協同產生於輸入線程為調度器提供輸入,電梯向調度器請求任務執行。為了保證線程安全性,需要確保共享的調度器中的關鍵對象——調度隊列在讀寫過程中加鎖。
C+ 多電梯多樓層捎帶調度算法優化
在完成基礎圖算法的基礎上,開始探尋優化的空間。對於圖算法,邊權重的設計就值得考慮了。在優化版本中,我考慮為圖的邊賦予一定意義的數值。具體而言,對於可以直達的邊,其時間開銷為一次開門時間附加該電梯在兩層之間的運行時間。對於不可達的邊,其權值為中介可達路徑的時間開銷總和。此外,還需要額外附加電梯當前位置到任務起始位置的響應時間,以確保局部的貪心算法。這樣,在圖中運行 Floyd-Warshall 算法獲得任意兩點間權值最小的路徑,即是在當前時刻最優的分配。
值得注意的是,圖算法僅能夠提供當前多個電梯協同任務的第一段分配。其他分配過程需要根據該任務完成時的電梯狀況而定,不應該提前划分。這種優化方式也帶來了一些潛在的問題。其中之一就是,不同的任務在不同的時間點可能被分配給不同的電梯來執行,這就要求當電梯在空閑狀態是需要以一定的時間間隔檢查是否有可以執行的任務來執行,而不能用通知的方式來實現。但是鑒於電梯運行時間較長,所以間隔查詢的時間也不需要很長,所以這個過程並不過分消耗CPU時間。
Bug
明明知道 LinkedList 線程不安全但是還是鬼使神差的在程序里用了,可能是哪天腦子抽風了寫進去的吧...哭暈,又一次錯慘了。
III 解決方案的評估
A 自動化測試
這一次,鑒於不同作業要求的電梯輸出和功能都略有差別,因而選擇搭建一個較為靈活可變的框架實現三次電梯作業的自動化測試。多線程問題錯誤的出現不可復現,不便於調試,因此選擇隨機生成測試集,利用測試系統的形式是使用終端腳本運行多個協同的程序並最后檢查結果。自動化測試的文件結構如下:
. ├── README.md ├── start.sh └── test_elevator ├── clean.sh ├── comm.py ├── elevator-input-hw3-1.4-jar-with-dependencies.jar ├── elevator_tester.jar ├── gen.py ├── test.sh └── timable-output-1.0-raw-jar-with-dependencies.jar
自動化測試由命令 bash start.sh 開始,執行目錄 ./test_elevator/test.sh 腳本。該腳本負責運行主要的 Java-Shell 交互程序 comm.py,由 gen.py 生成隨機數量、隨機間隔的請求數據並由 Python 作為橋梁輸入給待測試的 Java 電梯程序,捕獲輸出並交給 elevator_tester.jar 檢查結果,最終將運行結果返回給 test.sh 腳本。
為了方便不同參數下的自動測試,start.sh 被設計成可以將一些參數寫入文件中作為 cache 的特性。在第一次指定必要參數后,之后的運行不必重復進行。

1 #!/bin/bash 2 if [ ! -d "test_elevator" ]; then 3 echo "Dependency Directory test_elevator Not Found!" 4 exit 1 5 fi 6 if [ $# -gt 0 ]; then 7 echo "Setting Cached Parameters: Test_Rounds Max_Requests Max_Interval Java_Main_Path Java_Package_Name" 8 9 echo "$1" > test_elevator/num.cache 10 if [ $# -gt 1 ]; then 11 echo "$2" > test_elevator/request.cache 12 fi 13 if [ $# -gt 2 ]; then 14 echo "$3" > test_elevator/interval.cache 15 fi 16 if [ $# -gt 3 ]; then 17 echo "$4" > test_elevator/project.cache 18 fi 19 if [ $# -gt 4 ]; then 20 echo "$5" > test_elevator/package.cache 21 fi 22 23 uname > test_elevator/system.cache 24 else 25 if [ ! -f "test_elevator/num.cache" ]; then 26 echo "Parameters Test_Rounds Unset!" 27 echo "Try Setting Parameters By:" 28 echo -e "\t./start.sh Test_Rounds Max_Requests Max_Interval Java_Main_Path Java_Package_Name" 29 exit 1 30 fi 31 if [ ! -f "test_elevator/request.cache" ]; then 32 echo "Parameters Max_Requests Unset!" 33 echo "Try Setting Parameters By:" 34 echo -e "\t./start.sh Test_Rounds Max_Requests Max_Interval Java_Main_Path Java_Package_Name" 35 exit 1 36 fi 37 if [ ! -f "test_elevator/interval.cache" ]; then 38 echo "Parameters Max_Interval Unset!" 39 echo "Try Setting Parameters By:" 40 echo -e "\t./start.sh Test_Rounds Max_Requests Max_Interval Java_Main_Path Java_Package_Name" 41 exit 1 42 fi 43 if [ ! -f "test_elevator/project.cache" ]; then 44 echo "Parameters Java_Main_Path Unset!" 45 echo "Try Setting Parameters By:" 46 echo -e "\t./start.sh Test_Rounds Max_Requests Max_Interval Java_Main_Path Java_Package_Name" 47 exit 1 48 fi 49 if [ ! -f "test_elevator/project.cache" ]; then 50 echo "Parameters Java_Package_Name Unset!" 51 echo "Try Setting Parameters By:" 52 echo -e "\t./start.sh Test_Rounds Max_Requests Max_Interval Java_Main_Path Java_Package_Name" 53 exit 1 54 fi 55 echo "Starting Elevator Autotest..." 56 cd test_elevator 57 num=`cat num.cache` 58 request=`cat request.cache` 59 interval=`cat interval.cache` 60 project=`cat project.cache` 61 package=`cat package.cache` 62 echo -e "Current Parameters:\n\tRounds :\t${num}\n\tRequests :\t${request}\n\tInterval :\t${interval}s\n\tMain : \t\"${project}\"\n\tPackage :\t${package}" 63 ./test.sh 64 fi
./start.sh Test_Rounds Max_Requests Max_Interval Java_Main_Path Java_Package_Name
隨機請求的產生程序 gen.py 的實現基於 Python,最重要的是在輸出之后一定要清空緩沖區才可以正確的按時間輸出給電梯進程:

1 import random 2 import time, sys 3 def input_generator(testNum): 4 tests = [] 5 realNum = random.randint(int(testNum*0.8), testNum) 6 for i in range(realNum): 7 fromFloor = random.randint(-3, 20) 8 while fromFloor == 0: 9 fromFloor = random.randint(-3, 20) 10 toFloor = random.randint(-3, 20) 11 while toFloor == 0 or fromFloor == toFloor: 12 toFloor = random.randint(-3, 20) 13 if fromFloor != toFloor: 14 inputString = str(i+1) + '-FROM-' + str(fromFloor) + '-TO-' + str(toFloor); 15 tests.append(inputString) 16 return tests 17 18 with open("interval.cache","r") as file: 19 interval = int(file.readline().strip('\n')) 20 21 with open("request.cache","r") as file: 22 total = int(file.readline().strip('\n')) 23 24 tests = input_generator(total) 25 for each in tests: 26 time.sleep(random.randint(0, 1000 * interval)/1000) 27 print(each) 28 sys.stdout.flush()
該程序作為一個 Python 子程序在核心交互腳本中調用。這個腳本負責 Java 類的運行,輸入輸出記錄和結果的返回。設計這個程序最復雜的一點就是如何在等待進程結束的過程中判斷進程是否超過200秒的運行時間限制。經過查閱資料發現可以使用 os.WNOHANG 參數實現非阻塞的 wait 等待,加上輪詢即可實現超時終止服務。具體代碼如下:

1 from subprocess import Popen, PIPE 2 import os 3 import signal 4 import time 5 import re 6 7 talkpipe = Popen(['python', 'gen.py'], 8 shell=False, stdout=PIPE) 9 with open("project.cache","r") as file: 10 project = str(file.readline().strip('\n')) 11 with open("package.cache","r") as file: 12 package = str(file.readline().strip('\n')) 13 with open("run.res","wb") as out, open("run.err","wb") as err: # , open('comp.res',"wb") as comp, open('comp.err',"wb") as comperr: 14 elevator_fast = Popen(['java', '-classpath', project + ':elevator-input-hw3-1.4-jar-with-dependencies.jar:timable-output-1.0-raw-jar-with- dependencies.jar', package], stdin=PIPE, stdout=out, stderr=err, shell=False) 15 start = time.time() 16 try: 17 while True: 18 line = talkpipe.stdout.readline() 19 if line: 20 elevator_fast.stdin.write(line) 21 elevator_fast.stdin.flush() 22 with open("run.check","ab+") as check: 23 check.write(str.encode("[{:.1f}]".format(time.time() - start))) 24 check.write(line) 25 else: 26 elevator_fast.stdin.close() 27 break 28 with open("run.tst","ab+") as test: 29 test.write(line) 30 except KeyboardInterrupt: 31 print("[!] ERROR:\t Terminating...") 32 os.kill(talkpipe.pid, signal.SIGTERM) 33 34 try: 35 timeout = 200 36 t_beginning = time.time() 37 seconds_passed = 0 38 while True: 39 ef = os.wait4(elevator_fast.pid, os.WNOHANG)[2] 40 if elevator_fast.poll() is not None: 41 break 42 seconds_passed = time.time() - t_beginning 43 if timeout and seconds_passed > timeout: 44 elevator_fast.terminate() 45 print("[!] ERROR:\t Elevator Running Timeout!") 46 raise TimeoutError("Elapsed For " + str(timeout) + " Seconds") 47 time.sleep(0.1) 48 elapsed = time.time() - start 49 except ChildProcessError: 50 print("[!] WARNING:\t Real Time Limit Exceeded!") 51 os._exit(0) 52 except KeyboardInterrupt: 53 print("[!] ERROR:\t Terminating...") 54 os.kill(elevator_fast.pid, signal.SIGTERM) 55 if elevator_fast.poll() != 0: 56 print("[!] ERROR:\t Error Status On Exit Fast Elevator!") 57 with open("run.res","r") as file: 58 lines = file.readlines() 59 time_fast = float(re.search(r"\d+\.?\d*", lines[-1]).group()) 60 61 time_max = 200 62 time_bse = 10 63 64 print("[i] Baseline Refer:\t Base :{0:>7.3f} | Upper:{1:>7.3f}".format(time_bse, time_max)) 65 print("[i] Fast Scheduler:\t Total:{0:>7.3f} | CPU :{1:>7.3f} | Kernel:{2:>7.3f}".format(elapsed, ef.ru_utime, ef.ru_stime)) 66 print("[-] Time Ratio:\t {0:>7.3f}".format((time_max)/(time_fast))) 67 68 with open("summary.log","a+") as log: 69 log.write(str(time_fast) + "\n") 70 if (time_fast / time_max) > 1 or ef.ru_utime+ef.ru_stime > time_bse: 71 with open("run.check","r") as test: 72 print("[-] Bad Results:") 73 for line in test.readlines(): 74 print(line.strip('\n'))
在獲取到程序輸出后,還需要交還運行腳本來比對結果並在終端給予反饋:
1 #!/bin/bash 2 num=`cat num.cache` 3 for ((i=1;i<=num;i++)) 4 do 5 # current=`date +%d%H%M%S` 6 test_file="run.tst" 7 result_file="run.res" 8 error_file="run.err" 9 catch1=$(rm run.*) 10 # catch2=$(rm comp.*) 11 python comm.py 12 cat $test_file >> run.txt 13 echo "END" >> run.txt 14 cat $result_file >> run.txt 15 echo "END" >> run.txt 16 java_start_test="java -jar elevator_tester.jar" 17 success=$(cat run.txt | $java_start_test) 18 if [ "$success" = 'Success!' ]; 19 then 20 echo -e "[*] SUCCESS:\t $i/$num" 21 else 22 echo -e "$success" 23 echo -e "[!] ERROR:\t Fast Scheduler Failure!" 24 break 25 fi 26 done
這樣就可以保證在程序運行出現問題時將后續的測試停止,保留錯誤的輸入結果供檢查。
完整的工程可以參考 Github 倉庫 https://github.com/BXYMartin/Java-Elevator/tree/test_multi
B 度量評估
a 類圖繪制
這一次還是着重分析最后一次作業,基於 UML 度量工具進行類圖的繪制:
從類圖可以看出,這一次作業的體量和代碼規模相較上一次的多項式作業有了顯著的提升,尤其是多個對象共同享有的 Scheduler 調度者以及在多個電梯之間協同的 Plan、Route 類路徑規划都是需要非常精心的構造和設計。我這種設計的優點在於,共享對象少,實現邏輯簡單,代碼出錯的概率較低。但是同時也帶來的缺點就是封鎖粒度太大,某些時候將不得不采用輪詢的方式為空閑的電梯分配最佳的任務,算是這種設計的缺陷吧。
b 經典度量分析
接下來分析經典的 OO 度量,分析 CK 度量組,基於類設計的六種度量:
可以看出,各個類的內聚程度較高,對象間的耦合度較低。部分類由於功能極為有限,僅僅用於輸入輸出,因而類的響應值較低,類內部的有權方法也較低。對於路徑規划和電梯運行的類,對象的響應值和耦合程度都相對比較高。
之后來分析類內部的復雜度:
其中 Path 和 SmartElevator 類的平均類間、類內復雜度都較高,對其中的方法着重分析:
上表中省略了值較低或輔助功能的函數,僅保留復雜度較高的方法。着重分析復雜度,電梯的運行函數因為沒有拆分成幾個獨立的階段,所以內部復雜度較高,而對於調度器的分配函數,也有較高的方法間復雜度。再就是圖中的規划路徑函數具有較高的循環復雜度,也在情理之中。
接下來對類與方法的代碼規模進行統計:
可以看到對於核心的路徑規划類,類代碼規模和屬性個數都比較多,對於其他功能簡單的類而言則並不復雜。
將上述數字可視化可以得到更直觀的結果:
對於電梯類其核心的 run 方法是代碼量最大的,應該考慮將其划分為幾個功能較為分散的小函數執行,提升擴展性。僅次於電梯運行函數的就是關於圖的計算函數等,這些函數的復雜性因其功能的專一性而變得很高,個人感覺也較為合理。代碼評價工具在分析函數名的過程中存在錯誤,已在 Github 提交 Pull Request 並在 master 的最新版本中修復。
c 線程協作圖
繪制線程間的協作圖:
可以看到,各個模塊之間的協作邏輯較為簡單,Passenger 負責接受由標准輸入讀入的數據,經過 Plan 模塊和 Elevator 模塊的處理后輸出結果。
d 設計原則檢查
基於 S.O.L.I.D. 原則(SRP 單一責任原則、OCP 開放封閉原則、LSP 里氏替換原則、ISP 接口分離原則、DIP 依賴倒置原則)進行評估:
1)SRP 原則:每一個類都各司其職。在程序設計中,電梯只負責簡單的運送,規划模塊負責路線規划,調度部分負責任務調度,最大化的分割了任務,做到了SRP 原則。
2) OCP 原則:在這一次作業中,電梯模塊從始至終都沒有發生重構,可以說最大程度的滿足了 OCP 原則。但是對於任務規划類而言,則不可避免的進行了多次重構,但是也通過模塊化的手段盡可能簡化了重構流程。
3) LSP 原則:在本次作業中不涉及繼承
4) ISP 原則:在本次作業中不涉及接口
5) DIP 原則:在這次作業中我抽象出多個交互類用來將復雜的信息抽象出本質,在不同類之間傳遞。我抽象了包括電梯計划(Plan),路徑規划(Route)以及請求(Request)三類信息傳遞類用來簡化模塊和模塊之間的耦合。但是對於路徑規划類和電梯類,我還是硬編碼了電梯的樓層信息,因為電梯和規划之間的實時通信限制了我對他們的抽象,應該維護一個公共的狀態類去實現。
IV 總結
這一次電梯作業是一次代碼量突飛猛進的增長,多線程的不可復現、不可調試的特性也讓我在編碼的過程中多加謹慎,遇到問題首先從頂層結構入手思考,而不是盲目調試,大幅度的降低了在修復漏洞階段的時間,也讓我認識到了架構設計對后期減輕返工次數的必要性。對於各種工具的使用也更加得心應手,是一次對自己的歷練。