一、任务需求
本单元围绕电梯系统的模拟进行。第一次作业模拟单部可稍带电梯;第二次作业模拟多电梯系统;第三次作业同样模拟多电梯系统,但是与第二次作业相比,不同电梯之间在停靠楼层、运行速度参数上有差别,并且由此引入了请求的换乘需求,同时系统还增加了动态增加电梯的需求。
在特定的输入后,程序需要正常退出。
二、实现方案
1.整体架构
整个系统总体上围绕PersonRequest对象采用生产者-消费者模式作为架构基础:由输入及解析线程充当生产者,产生请求PersonRequest,并按照楼层放入请求池RequestsPool中。电梯Elevator作为消费者,从RequestsPool中取出请求,放入内部容器中并进行服务。
三次作业的UML图如下:
2.电梯调度
本次设计中,调度器都安排在电梯内部。调度器依据电梯Elevator自身的状态以及RequestsPool提供的获取其内部请求信息的接口来对电梯进行调度。这样做有好有坏:好的一面是采用这样的设计可以解耦每一个电梯的调度逻辑,使得后两次作业实现从单电梯到多电梯的迭代实现非常方便,只要做好请求池RequestsPool的多线程控制,让各个电梯去“抢人”即可;坏的一面是这样做使得多电梯系统中电梯间的协同比较困难。因此在此次设计的系统中,调度上并未进行电梯间协同的设计。
在具体的实现上,调度并不是面向具体的某个PersonRequest请求的,而是综合电梯的运行状态,电梯内部的请求以及RequesPool请求池中的情况对电梯的运行状态进行调整,关键的时间节点为电梯到新的楼层的时候。电梯Elevator的主要运行逻辑如下:
while (notEnd) { /* notEnd含义会在下文中进行解释 */
RequestsPool.findRequest(); /* 当RequestsPool中没有请求的时候,电梯阻塞 */
do {
if (judgeStop()) { /* 到达某一个楼层以后,结合电梯运行状态,电
梯内部的请求以及RequestsPool提供的信息获
取接口判断是否在该层停下,若停下则依次执行
开门、下客、上客和关门。 */
openDoor();
peopleOut();
peopleIn();
closeDoor();
}
adjustDirection(); /* 更新了电梯的状态以后,判断接下来电梯需要运行的方向并且执行移动 */
tr = move();
} while (tr) /* 如果电梯不移动(说明电梯没有需要相应的请求)则重新阻塞直至有新请求来后再次被唤醒 */
}
严格上来说,在这样的调度设计中,没有主请求和捎带请求的概念,电梯的运行状态是取决于该电梯可知的所有信息的,并且在每到达一个新的楼层以后进行更新。
3.换乘实现
在第三次作业中,不同种类的电梯有不同的停靠楼层,因此有部分请求的实现需要多部电梯协同进行。本系统中换乘的实现是基于请求PersonRequests在系统中的存放方式的,因此首先介绍后者。
在整体架构中已经提到了,在被服务的过程中,一个请求PersonRequest在被服务的过程中的不同阶段会分别出现在RequestsPool和Elevator的内部容器中。而无论是在请求池RequestsPool还是电梯Elevator中,请求PersonRequest都是按照楼层来存放的,也就是说,无论是在RequestsPool还是Elevator中,有几个楼层,就有几个对应的请求队列,具体来说:在RequestsPool中,请求存放在其起始楼层fromFloor所对应的队列(从这个视角来看RequestsPool映射了现实中电梯所在的大楼);在Elevator中,请求存放在其到达楼层toFloor所对应的队列中。
另外,在电梯调度中伪代码中的peopleIn()
和peopleOut()
也是基于这样的存储结构加上电梯当时具体所处的楼层进行的,又或者说,如果一个服务请求PersonRequest被放置在了某个楼层对应的请求池队列中,那么它就一定会在这个对应楼层上电梯;如果被放置在了某个电梯中某个楼层对应的队列中,那么就一定会在这个对应楼层下电梯。
有了上面的基础,换乘的实现就比较容易实现了。首先,对于某一种电梯,所有停靠楼层被分为一般楼层和换乘楼层。接下来分别讨论请求进入电梯和离开电梯的实现:
- 进入电梯:对于一般楼层,电梯会让该楼层电梯外的所有请求都进入电梯内,而对于换乘楼层,电梯只会让目的楼层为本电梯所能到达的楼层的请求进入电梯。
- 选择电梯内队列:对于进入到了电梯的请求,如果其目的楼层是该电梯所能到达的,则放入目的楼层对应的队列中,否则需要换乘,放入较近的换乘楼层对应的队列中。
- 离开电梯:依次检查电梯内该楼层所对应的队列中的请求,如果其目的楼层为本楼层,则直接将其移除,否则为需要换乘的请求,将其放入请求池RequestsPool中该楼层所对应的队列中。
当然,这样的设计是有限制的,即每一个换乘楼层都必须被所有种类的电梯所共享。从更本质上来说,这个限制的产生原因在于该设计只能实现至多一次的换乘,除非对换乘楼层的停靠及上下客逻辑进行额外的特别设计,否则请求到达第一个换乘楼层以后就无法继续移动了。当然,这也引出了进行下一步迭代的一个可能性。
在本系统中,设置了1层和15层两个换乘楼层。根据上面的分析,可以知道在本系统的实现方式下,虽然还有其他的一些楼层可以停靠两种类型的电梯,但是不能被设计为换乘楼层,如-1层、-2层、5层等。
4.程序退出
当输入线程解析到停止请求以后,所有线程都需要正常结束,否则会出现运行超时的问题。在本系统中,这是通过电梯调度代码中所提到的Elevator中的notEnd
变量实现的。每次电梯将要从停止的状态(此时电梯内部一定没有请求)转为运行状态时,都以该变量判断是否进入下一次循环。程序初始化时,该变量设置为true
,表示系统还未收到停止请求;当输入线程解析到停止请求后,通过Elevator向外提供的通知接口将notEnd
设置为false
,当电梯服务完当前需要服务的请求后准备进行下一次循环时,就会跳出主循环,结束线程。
在具体的实现细节上,程序退出还有很多值得注意的地方,比如说需要考虑到输入线程通知电梯结束时电梯线程可能处于的各种状态,需要在通知电梯结束时对所有处于RequestsPool等待池中的电梯线程;又比如说在第三次迭代中由于需要保证最后一批需要换乘乘客的换乘需求,需要保证请求用于换乘的电梯线程不提前结束。
5.多线程协同和控制
基于上面的设计,可以知道本系统中各线程都围绕请求池RequestsPool开展协同工作。在线程间的控制方面,采用生产者-消费者模型中典型的方式,若消费者在尝试从请求池中获取请求时发现请求池中没有请求了,则wait()进入请求池的等待池中从而避免轮询;每次生产者将请求放入请求池时,唤醒处于等待池中的线程。由于系统的调度逻辑都放在电梯Elevator的内部,即每个电梯的运行由自身控制,因此不涉及更复杂的线程间控制。
系统中临界资源可以分为两种:
- RequestsPool中的请求队列
- Elevator中用于判断是否跳出主循环的
notEnd
变量
通过将相应的类RequestsPool和Elevator设计为线程安全类保证了不会线程安全问题。
三、可扩展性分析
由于本系统的设计中电梯之间的运行逻辑直接没有代码上的直接耦合,因此,在不涉及电梯协作的功能上扩展性是比较强的,比如说可以比较方便地增加电梯的种类,支持动态停止某部电梯的运行模拟运行故障等。
然而,对于需要电梯间协同工作的功能,该系统拓展性较弱,可能需要在现有的调度实现基础上,增加一个宏观的全局调度器模块以解决相应问题,比如说将一些必须经过多次换乘才能实现的出发-目的楼层对引入系统需求中。
四、基于OO度量的程序分析
task1 | task2 | task3 | |
---|---|---|---|
LOC (lines of code) |
373 | 487 | 799 |
ev(G)avg (average essential cyclomatic complexity) |
2.04 | 2.00 | 1.90 |
iv(G)avg (average design complexity) |
2.39 | 2.17 | 1.98 |
v(G)avg (average cyclomatic complexity) |
2.71 | 3.44 | 3.06 |
WMCavg (weighted method cmplexity) |
12.33 | 19.40 | 14.91 |
OCavg (Average operation complexity) |
2.64 | 2.69 | 2.65 |
CF (Coupling factor) |
46.67% | 70.00% | 43.64% |
MHF (Method hiding factor) |
53.57% | 63.89% | 46.98% |
从上表可以看出,无论是从方法内的逻辑复杂度还是从类之间的协同上来看,task2都是三次作业中较为失败的,原因是在进行迭代时增加系统功能时,并未能很好地划分Elevator和RequestsPool的职责,特别是用于结束程序所用的逻辑较为复杂。后续在task3中针对这些问题进行了相应的调整。
五、程序bug分析
本单元第二次作业中出现了一个严重又低级的bug。电梯从请求池RequestsPool中取走请求时,应使用List.remove(0)
函数,在获取请求的同时将其从请求池中移除。然而编程过程中在请求人数大于电梯容量的分支下,我将其错写为了List.get()
,导致了同一个请求会被多次服务。这是一个很低级的bug,而且也是很容易发现的,然而由于自己心急想快点完成作业,没有进行充分的本地测试就提交了中测。而中测中的测试可能恰巧没有进行密集的定时投放,导致没有测试到这个错误的分支。然而,最后在强测中有近一半的点因为这个错误WA。这是一个很好的教训,今后的学习和工作中一定要稳扎稳打,做好自己的测试。
六、心得体会
在本单元中,除了初步了解了多线程编程的各种概念以及其在Java的实现以外,我觉得对面向对象的设计方式有了更深的理解。从最初,我就尝试以在现实生活中的电梯系统去对系统的整体架构以及架构中的各个模块所负责的工作进行分析和设计,这样子做不仅使自己在从零构建系统的各个阶段因为有了现实中对象的映射而一直能够保持较为清晰的逻辑,而且也方便了后续的迭代顺序。究其原因,一个可能的电梯系统是建立在现实的物质基础之上的,任何可能地拓展亦是如此。如果设计从一开始就遵循与现实相同地结构和机制,那么后续的拓展将更有可能可以顺畅地进行。
借助于面向对象的思想,我们可以更加自然地借鉴其他领域乃至日常生活中的已有的智慧,在不同层次上进行抽象,然后模式化地应用到程序设计中,事半功倍。
更新:2020.4.19
未注意到有对UML协作图的要求,现补充如下:
补充如下: