面向对象的程序设计-电梯调度系统的设计、优化与测试
面向对象的程序设计(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 总结
这一次电梯作业是一次代码量突飞猛进的增长,多线程的不可复现、不可调试的特性也让我在编码的过程中多加谨慎,遇到问题首先从顶层结构入手思考,而不是盲目调试,大幅度的降低了在修复漏洞阶段的时间,也让我认识到了架构设计对后期减轻返工次数的必要性。对于各种工具的使用也更加得心应手,是一次对自己的历练。