OO Unit2 单元总结
第二单元的电梯作业,总体而言还是难度比较平缓。第一次接触多线程,直观感受就是难调试,不能像平常的单线程程序可以非常简单方便地使用断点全部搞定,同时有一些线程安全的bug也非常难复现。
一、设计策略
本单元的作业特点非常符合生产者——消费者模式,故从第一次作业开始,就将整体框架设计成了生产者——消费者模式。之后也在这个结构的基础上进行改进,整体框架没有做过大的改变,之后也只是在框架的基础上加了一些辅助类。
在听完作业分析课之后,我发现我的Dispatcher类是通过内部保管Elevator类来获取其状态的,这样加大了Elevator与Dispatcher的直接耦合度,当功能发生变化时,两边都需要进行修改。而使用发布当前状态的方式,则更有利于解耦。
第一次作业
总体设计分为4个部分,输入线程(主线程)、托盘、分派线程、电梯。主线程调用输入模块,读取到输入后放入Tray托盘类,Dispatcher作为分派线程,当托盘上有新的请求时,获取请求并根据电梯的情况分发到电梯,电梯则将顺路请求从请求队列中取出等待执行。
由于多线程调试难度较高,加入了Debug类使用带条件的println
语句输出关键信息。
第二次作业
相比第一次作业,增加了多电梯的支持与载客量的限制。多电梯分派方面,根据之前的设计结构,在Dispatcher
分派类上做了较大的改动,为每一部电梯静态设置了等待队列,这样电梯之间不产生数据共享,所有的线程安全都由Dispatcher
类来保证。同时,由于多部电梯的引入,在Dispatcher
类中加入了更加复杂的调度策略。
第三次作业
第三次作业引入了动态电梯加入和不同楼层停靠。这对我第二次的设计又带来了挑战,由于之前使用的静态数组保管等待队列,这时只能使用Hashmap
,使用
对来分别保管不同电梯的队列。(没有使用线程安全类,这里虽然数据没有共享,但是Hashmap
的操作无法保证原子性,可能导致问题,之后会提到)。
电梯换乘方面,采用了静态策略。在Dispatcher
类中额外增加了DispatchMap
内部类,在初始化时生成换乘策略保存到表格中,之后获取请求后根据表格分解。
同时由于本次有不同型号的电梯,故加入了第一单元中使用过的工厂类来生产不同类型的电梯对象。
二、第三次作业架构设计可扩展性
整体作业架构使用了生产者——消费者的设计模式,同时使用了工厂模式等设计模式。从三次作业迭代的角度来看,整体类的架构没有太大的改变,但其中的细节变化非常大。
在设计时贪图一时方便,而放弃了可扩展性,是导致这个问题的一个原因。举例来说:由于第二次作业电梯数量是先前给定的,采用了静态数组。写第一次分派策略的时候,只按照一台电梯的策略去写。这样当新需求到来时,各个方法内的逻辑都发生了很大的变化,特别是之前写的代码的细节,可能都已经忘记。因为没有注意到原来的一些细节也可能产生很多bug,比如之后的第二次作业的bug就是忘记了处理细节导致的。
按照SOLID原则对作业进行分析
-
SRP: Single Responsibility Principle
第三次作业的每个类,基本都具有一个明确的职责,(虽然有些类的职责开始变重了)。每个方法也基本履行其自己的职责,没有出现过多的交叉关系。
-
OCP: Open Close Principle
从三次作业的修改来看,该程序确实一点都不具备OCP特性,或者说当时修改程序的时候就没有向这个方向思考。需求扩展之后理所当然地应该修改程序的细节,以实现应有的功能。缺点也很明显,之前没有bug的程序,在迭代开发之后产生了新的bug,若采用扩展来实现新功能,则至少在单个模块内部不会产生新的bug。迭代到第三次作业后,逻辑密集的部件已经无法通过扩展来实现新功能了,因为其已经高度分化到只能实现这一特定功能的地步。若在第一次作业时采用OCP,或许还有较大空间施展。
-
LSP: Liskov Substitution Principle
与OCP相同,由于本次作业没有考虑到通过扩展来实现,这项原则从来没有被实践过,同样由于部分功能已经高度分化,已经难以通过继承的方式来实现额外的修改的功能(或许在父类中重写方法能达到一点效果,但大面积修改已经违背了这项原则本身的目的)。
-
ISP: Interface Segregation Principle
这次设计几乎完全没有考虑接口,同样的,之后在这方面也不具备扩展性。但若采用重构,或许电梯、分派器这两个核心部件,都可以定义接口,因为其对外实现的核心功能几乎都没有改变(载人和分配)。通过继承这些接口,可以构建更具有扩展性的层次结构。
-
DIP: Dependency Inversion Principle
由于不存在ISP,故在程序中完全不存在DIP。
总之,由于最初设计时只关注了框架(类与类的协作),而没有关注设计细节(方法之间的协作和方法内的实现)。故虽然多次迭代整体框架都没变,但内部细节修改之多和重构也没有区别,程序的可扩展性仍然需要提高。
三、基于度量分析
由于整体框架(类)几乎无变化,三次作业对请求的分派与控制均使用基本一致的框架,故UML类图和UML协作图将仅仅通过第三次作业来进行说明。
第一次作业
类的分析
可见,Elevator类的方法较多,且代码行数较多,但对外公开的方法并不是太多;其他类的规模都保持较小。
Type Name | NOF | NOPF | NOM | NOPM | LOC |
---|---|---|---|---|---|
Constant | 5 | 5 | 0 | 0 | 9 |
Time | 3 | 3 | 0 | 0 | 5 |
Debug | 1 | 0 | 3 | 3 | 15 |
Dispatcher | 5 | 0 | 4 | 4 | 71 |
Elevator | 7 | 0 | 11 | 4 | 181 |
ElevatorMotionException | 0 | 0 | 1 | 1 | 5 |
MainClass | 5 | 0 | 1 | 1 | 26 |
Tray | 2 | 0 | 3 | 3 | 34 |
方法分析
最长的方法是电梯的move方法与running方法,电梯内部有一个请求队列缓存,并且电梯的调度是自行决定的。即将LOOK算法集成在电梯内部,分派器只负责将请求传送到电梯即可。
Type Name | MethodName | LOC | CC | PC |
---|---|---|---|---|
Debug | 6 | 2 | 1 | |
Debug | enableDebug | 3 | 1 | 0 |
Debug | isDebug | 3 | 1 | 0 |
Dispatcher | Dispatcher | 5 | 1 | 1 |
Dispatcher | run | 26 | 3 | 0 |
Dispatcher | getMajorRequest | 13 | 3 | 1 |
Dispatcher | getAdditionalRequest | 20 | 5 | 1 |
Elevator | Elevator | 8 | 1 | 1 |
Elevator | run | 24 | 3 | 0 |
Elevator | running | 31 | 5 | 0 |
Elevator | reachedFarFloor | 15 | 5 | 0 |
Elevator | move | 33 | 5 | 0 |
Elevator | close | 12 | 2 | 0 |
Elevator | open | 12 | 2 | 0 |
Elevator | loadAllAt | 15 | 3 | 1 |
Elevator | unloadAllAt | 14 | 3 | 1 |
Elevator | getFloor | 3 | 1 | 0 |
Elevator | getState | 3 | 1 | 0 |
ElevatorMotionException | ElevatorMotionException | 3 | 1 | 1 |
MainClass | main | 19 | 4 | 1 |
Tray | Tray | 3 | 1 | 0 |
Tray | putRequest | 12 | 2 | 1 |
Tray | getRequest | 15 | 3 | 0 |
OO经典度量
可见,调度器的调度算法和电梯的自调度算法设计复杂度较高。电梯的move()
和reachedFarFloor()
等方法设计耦合度较高。
第二次作业
类的分析
由于本次增加了多电梯,以及电梯的特性变化,调度器和电梯类的代码规模均增加。并且调度器需要更多了解电梯状态,电梯因此开放了更多public
的getter
方法,其余代码规模变化不大。
Type Name | NOF | NOPF | NOM | NOPM | LOC |
---|---|---|---|---|---|
Constant | 4 | 4 | 0 | 0 | 8 |
Time | 3 | 3 | 0 | 0 | 5 |
Debug | 1 | 0 | 3 | 3 | 15 |
Dispatcher | 7 | 0 | 6 | 5 | 112 |
Elevator | 11 | 3 | 14 | 7 | 204 |
ElevatorMotionException | 0 | 0 | 1 | 1 | 5 |
MainClass | 7 | 0 | 2 | 1 | 51 |
Tray | 2 | 0 | 3 | 3 | 34 |
方法分析
本次由于电梯的数量增多,最多代码行数出现在调度器的dispatchRequest
中,获取各电梯的状态,以便合理分配请求所在的队列。其他方法的规模没有显著增长。
Type Name | MethodName | LOC | CC | PC |
---|---|---|---|---|
Debug | 6 | 2 | 1 | |
Debug | enableDebug | 3 | 1 | 0 |
Debug | isDebug | 3 | 1 | 0 |
Dispatcher | Dispatcher | 9 | 2 | 2 |
Dispatcher | registerElevators | 3 | 1 | 1 |
Dispatcher | run | 24 | 4 | 0 |
Dispatcher | dispatchRequest | 45 | 7 | 1 |
Dispatcher | getMajorRequest | 14 | 3 | 1 |
Dispatcher | getAdditionalRequest | 8 | 1 | 1 |
Elevator | Elevator | 9 | 1 | 2 |
Elevator | run | 24 | 3 | 0 |
Elevator | getCapacity | 3 | 1 | 0 |
Elevator | running | 32 | 5 | 0 |
Elevator | reachedFarFloor | 15 | 5 | 0 |
Elevator | move | 33 | 5 | 0 |
Elevator | close | 12 | 2 | 0 |
Elevator | open | 12 | 2 | 0 |
Elevator | loadAllAt | 23 | 5 | 1 |
Elevator | unloadAllAt | 14 | 3 | 1 |
Elevator | getFloor | 3 | 1 | 0 |
Elevator | getState | 3 | 1 | 0 |
Elevator | getId | 3 | 1 | 0 |
Elevator | getNumId | 3 | 1 | 0 |
ElevatorMotionException | ElevatorMotionException | 3 | 1 | 1 |
MainClass | main | 17 | 4 | 1 |
MainClass | configureElevator | 25 | 2 | 0 |
Tray | Tray | 3 | 1 | 0 |
Tray | putRequest | 12 | 2 | 1 |
Tray | getRequest | 15 | 3 | 0 |
OO经典度量
仍然选择复杂度高的几个方法进行观察,发现调度算法的复杂度显著增加。所以,如何合理地分割子过程,降低复杂度,是设计易维护代码必须考虑的部分。
第三次作业
类的分析
由于之前使用了Dispatcher通过其持有的Elevator
对象的getter
方法获取状态,当需求进一步增加的时候,Elevator
不得不开放更多getter
方法以提供更多状态。使用发布到状态板的方法,可以简化发布状态的流程。
调度器的代码量大增,主要原因是分层调度。由于使用了静态调度策略,为了减少时间复杂度,使用了程序内打表的方法,在Dispatcher
类初始化时使用DispatchMap
生成静态调度表格。调度器的代码量急剧增长,此时应当把复杂的类分解为子功能,更细化职责以获得更清晰的类结构。
Type Name | NOF | NOPF | NOM | NOPM | LOC |
---|---|---|---|---|---|
Debug | 1 | 0 | 3 | 3 | 15 |
Dispatcher | 26 | 1 | 21 | 12 | 227 |
DispatchMap(Inner class) | 13 | 0 | 8 | 6 | 128 |
Strategy | 2 | 0 | 2 | 2 | 10 |
Elevator | 18 | 4 | 18 | 11 | 235 |
ElevatorFactory | 16 | 15 | 2 | 2 | 41 |
ElevatorMotionException | 0 | 0 | 1 | 1 | 5 |
MainClass | 5 | 0 | 2 | 1 | 65 |
SafeOutput | 0 | 0 | 1 | 1 | 5 |
Tray | 2 | 0 | 3 | 3 | 34 |
方法分析
方法最长代码行为47行,仍然为调度相关算法,并且长方法主要出现在调度器中。调度器的功能变得复杂之后,其包含的方法数量也变得极多,方法代码行也逐渐膨胀,逻辑变得逐渐复杂且耦合度高。电梯由于需要适应更复杂的需求,提供了更多getter方法,也增加了方法行数。
Type Name | MethodName | LOC | CC | PC |
---|---|---|---|---|
Debug | 6 | 2 | 1 | |
Debug | enableDebug | 3 | 1 | 0 |
Debug | isDebug | 3 | 1 | 0 |
Dispatcher | Dispatcher | 12 | 1 | 1 |
Dispatcher | registerElevator | 18 | 4 | 1 |
Dispatcher | run | 24 | 4 | 0 |
Dispatcher | dispatchRequest | 47 | 12 | 1 |
Dispatcher | dispatchToElevator | 44 | 8 | 2 |
Dispatcher | isElevatorsFull | 8 | 3 | 1 |
Dispatcher | getAverageWorkLoad | 7 | 2 | 1 |
Dispatcher | resolveRequest | 13 | 2 | 1 |
Dispatcher | getMajorRequest | 17 | 4 | 1 |
Dispatcher | getAdditionalRequest | 7 | 1 | 1 |
Dispatcher | reportFinishedRequest | 7 | 2 | 1 |
DispatchMap | DispatchMap | 34 | 10 | 0 |
DispatchMap | canDirReach | 3 | 1 | 2 |
DispatchMap | canDirReach | 17 | 5 | 2 |
DispatchMap | getStrategyArray | 3 | 1 | 2 |
DispatchMap | getOrAnd | 10 | 2 | 0 |
DispatchMap | getTransfer | 38 | 11 | 4 |
DispatchMap | Strategy | 3 | 1 | 1 |
DispatchMap | Strategy | 3 | 1 | 1 |
Strategy | Strategy | 3 | 1 | 1 |
Strategy | Strategy | 3 | 1 | 1 |
Elevator | Elevator | 12 | 1 | 5 |
Elevator | run | 24 | 3 | 0 |
Elevator | getWorkLoad | 3 | 1 | 0 |
Elevator | getCapacity | 3 | 1 | 0 |
Elevator | setReachableFloor | 5 | 1 | 3 |
Elevator | getType | 3 | 1 | 0 |
Elevator | running | 32 | 5 | 0 |
Elevator | reachedFarFloor | 15 | 5 | 0 |
Elevator | move | 33 | 5 | 0 |
Elevator | close | 12 | 2 | 0 |
Elevator | open | 12 | 2 | 0 |
Elevator | loadAllAt | 22 | 5 | 1 |
Elevator | unloadAllAt | 15 | 3 | 1 |
Elevator | getFloor | 3 | 1 | 0 |
Elevator | getState | 3 | 1 | 0 |
Elevator | getId | 3 | 1 | 0 |
Elevator | equals | 10 | 3 | 1 |
Elevator | hashCode | 3 | 1 | 0 |
ElevatorFactory | ElevatorFactory | 3 | 1 | 1 |
ElevatorFactory | getElevator | 20 | 4 | 2 |
ElevatorMotionException | ElevatorMotionException | 3 | 1 | 1 |
MainClass | main | 30 | 6 | 1 |
MainClass | initialElevators | 28 | 3 | 0 |
SafeOutput | println | 3 | 1 | 1 |
Tray | Tray | 3 | 1 | 0 |
Tray | putRequest | 12 | 2 | 1 |
Tray | getRequest | 15 | 3 | 0 |
OO经典度量
调度方法仍然是复杂度集中的区域
UML类图
由于类的框架基本未改变,采用第三次作业类图进行分析,图和分析均如下
第三次作业构造电梯时使用了工厂类ElevatorFactory
,根据输入的参数类型和名字,构造不同参数的电梯。而MainClass
主类则管理了托盘类Tray
和分派器类Dispatcher
,并负责将输入放入托盘。托盘在主线程和分派器线程之间共享,为一个主要的多线程共享类。而分派器则负责将请求独立地放入各个电梯的请求队列,每个电梯只知道Dispatcher给其提供的队列,而完全不访问其他电梯的数据,避免了各电梯之间的数据共享。
分派器类获取电梯属性时,是直接使用内部保存的Elevator
对象的ArrayList
,通过调用getter
方法进行访问。
Debug类设计较为简单,即在本地调试时调用其静态方法println()
输出当前状态。
优点:
- 使用托盘类
Tray
和分派器Dispatcher
每次只连接两个数据共享者(输入、分派器)(分派器、电梯),限制了数据共享的复杂度,很大程度上避免线程不安全问题。 - 使用工厂类构建电梯,避免电梯构造方法复杂化以及内部属性复杂化,并且利于扩展
缺点:
Dispatcher
对象中直接保存了Elevator
对象,并且大量使用Elevator
的get
方法获取其私有属性以获取电梯的状态。使得类之间耦合度提高,电梯复杂度提高。- 使用了更多线程,在线程终止时设计较为复杂(每个类都需要进行特殊判断)。基本思想是传播null请求使得所有收到请求的线程终止,但实际操作时还有诸多限制,如请求队列不为空时不能过早结束请求等,因此也产生了不少bug。
UML协作图
类之间的协作关系如下图所示,用户请求根据如下图中数字与箭头顺序流动。发出用户请求之后,用户请求被解析放入托盘。分派器得到通知取出请求,根据是否能够直接到达将请求分解、或者直接传递,根据当前电梯的状态放入指定的电梯队列。当电梯没有主请求的时候,会被唤醒取得主请求,当电梯正在执行请求的时候,在到达某层时会根据运行状况取走这些捎带请求。
被分解的主请求放入Hashmap
容器中,先执行的请求为key,后执行的请求为value。(本题中任意楼层之间的请求恰好可以分解成为两个先后请求)当执行完成后,报告Dispatcher
,若Hashmap
中有这个key,将后续请求作为一个普通请求放入分派器进行分派;若无此key,一个请求的处理就此结束。
线程结束方面,基本思想是传递null请求和标志位结合,使得收到null请求的线程了解自己完成任务后即将结束,但事实证明这种方法十分复杂,在这里以脚注的形式给出。
当Tray收到传入的
null
请求的时候,将托盘内的一个变量terminateFlag
置为true
,在托盘取请求的方法getRequest()
中,若终止flag为真的时候,将向Dispatcher
传递一个null对象。Dispatcher
收到后,如果请求全部处理完毕,则将null
作为主请求分发给每一个来获取请求的电梯,电梯收到后将终止运行。
四、程序Bug分析
第二次作业
第二次作业的bug是一个细节错误,但出现在一个非常致命的地方,即当电梯坐满之后,该请求将被丢弃。产生原因是在人员上电梯时,需要从请求队列requests
删除对应人员,加入装载队列carries
,而遍历时删除无法使用普通的for
循环或者foreach
循环解决,故引入了中间变量保存不被删除的元素。但当人坐满的时候,由于忘记了原有的删除逻辑,认为只要终止掉循环即可。便产生了非常严重的错误,但由于中测时未卡这个点,加上自测不充分,观察到人满时拒绝进入便认为程序正确,导致了错误的发生。
private void loadAllAt(int floor) {
ArrayList newRequests = new ArrayList<>();
for (int i = 0; i < requests.size(); i++) {
if (carries.size() >= MAX_CAPACITY) {
// newRequests在此时为空,将导致之后的request被覆盖为空。
Debug.print("Elevator " + id + " cannot take more!");
if (carries.size() > MAX_CAPACITY) {
System.err.println("More than 7 people loaded!");
}
break;
}
// ...
}
this.request = newRequests;
}
所以,不要盲目相信中测,应该将其作为帮助自己基本搭建程序逻辑框架和找到少量bug的帮手,而不是证明正确性的唯一标准。在强测中我也吃到了教训,只勉强达到及格分。
第三次作业
第三次作业的问题出在线程安全上,强测没有发现漏洞,在互测环节被发现了一个漏洞,具体表现为电梯输出突然停止40s,然后再开始运行,之后又突然停止几十秒,最后RTLE
。
既然程序可以继续运行,那么应该不是死锁导致的问题,但是在本地跑了十几次,都无法复现这个bug。将代码原样提交,这个测试点就这样通过了。由于实在无法复现这个bug,这里根据确实存在的问题进行分析。
-
使用的前序、后序
Hashmap
线程不安全由于将请求分解后,存放在
Hashmap
中,而电梯回报任务完成时,如果前序任务已经完成,将从Hashmap
获取后序请求并移除这个key。这样就可能有多个线程Dispatcher
和Elevator
对Hashmap
进行访问,而Hashmap
不是线程安全类,故数据访问出错,导致Hashmap
不能被清空,可能会导致进程无法结束(请求还未被完成)。这个时候,要么使用
Hashmap
的对象作为锁,锁住读写操作,要么使用线程安全的ConcurrentHashmap
类。
五、测试用例设计
由于多线程的复杂性,发现别人的问题主要靠测试一些常见情况以及边界情况,而大范围地引入评测机效果并不理想(可能是设计上存在缺陷)。第一次作业开始时就做好了以python
的subprocess
模块进行定时投放和简单的检查机制,但是发现投放的实时性缺乏,比如都是预定在1s时投放输入,最晚的输入可以延迟200ms才被输入。由于操作系统线程调度的不确定性,有时候执行的结果并不完全一样。这种测试方式可以检查基本的错误,但无法测试那种对时间要求精确的边界错误。
样例构建采用了手动样例构建,主要测试每次作业的设计要点与边界情况。比如第一次作业的捎带请求,第二次作业的最大载客量,第三次作业的动态加入与楼层规划。事实上由于第二次作业做得太差(有一个非常严重的漏洞),测试非常有效,为了提交测试用例先发现了自己的漏洞,发现大家都有这个漏洞。而另外两次作业,由于手动构造样例的局限性,以及多线程调试的需要重复的特点,并没有很好地发现漏洞。
本单元的测试策略和第一单元最大的区别就是不确定性,由于引入了多线程,线程安全、调度顺序都会影响运行结果。很多漏洞,如果不经过大量、长时间的测试,完全无法发现,给互测找漏洞带来了很大的压力,手动构造很多时候都是"闭着眼睛“测试。(当然这也和没有继续完善和维护评测机有一定的关系,但限于时间和构造难度,后面只使用了定时投放功能,进行人工检查)
六、心得体会
第一次接触多线程编程,接触到了和之前完全不同的编程世界(没有玩过的船新版本)。
首先是线程安全问题。由于数据共享产生的线程不安全,不同线程之间的数据可见性,方法内部的执行顺序的不确定性。这些和传统单线程编程完全不同的思想,经过这次训练(写bug,debug)已经在脑海中形成了初步而稳定的印象。这些经验在之后的多线程相关开发中,是宝贵的经验。踩过坑,下次才不会再次栽倒在里面。
其次是设计原则,接触了SOLID原则之后,我认识到继承、封装与接口的重要性。尽管这单元的重点是多线程,不代表这些程序设计的基本思想就不用应用在上面。荣老师在讨论课结尾提到,每次设计的时候,假设有一个团队合作完成这个工作。如何给每个人分工,这个过程其实也是设计的一部分,接口在这里就起到了很大的作用。同时一个开发好的单元,使用继承可以获得更强大的功能的同时,避免重写类引入新的漏洞。
另外,多线程的设计模式,让我在很大程度上避免了过多的线程问题。这次应用的核心:生产者——消费者模式,以及了解到的Worker Thread
模式,通过对经典模型的分析认识到了多线程设计的要领。还有许多其他的设计模式不一一举例,在多线程学习过程中,在实际问题分析过程中,将这些模式的思想应用在设计中,就像站在巨人肩膀上一样。
同时,互测让我对自动化测试有了更深的认识。多线程需要的大量测试,为自动化测试引入了新的必要性,修改内容产生新bug,也让我意识到回归测试的重要性。
总之,这单元的作业让我受益良多,总结经验,继续前行!