OO Unit2 单元总结

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 print 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经典度量

OO Unit2 单元总结_第1张图片

​ 可见,调度器的调度算法和电梯的自调度算法设计复杂度较高。电梯的move()reachedFarFloor() 等方法设计耦合度较高。

第二次作业

类的分析

​ 由于本次增加了多电梯,以及电梯的特性变化,调度器和电梯类的代码规模均增加。并且调度器需要更多了解电梯状态,电梯因此开放了更多publicgetter方法,其余代码规模变化不大。

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 print 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经典度量

​ 仍然选择复杂度高的几个方法进行观察,发现调度算法的复杂度显著增加。所以,如何合理地分割子过程,降低复杂度,是设计易维护代码必须考虑的部分。

OO Unit2 单元总结_第2张图片

第三次作业

类的分析

​ 由于之前使用了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 print 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经典度量

​ 调度方法仍然是复杂度集中的区域

OO Unit2 单元总结_第3张图片

UML类图

由于类的框架基本未改变,采用第三次作业类图进行分析,图和分析均如下

OO Unit2 单元总结_第4张图片

​ 第三次作业构造电梯时使用了工厂类ElevatorFactory,根据输入的参数类型和名字,构造不同参数的电梯。而MainClass主类则管理了托盘类Tray和分派器类Dispatcher,并负责将输入放入托盘。托盘在主线程和分派器线程之间共享,为一个主要的多线程共享类。而分派器则负责将请求独立地放入各个电梯的请求队列,每个电梯只知道Dispatcher给其提供的队列,而完全不访问其他电梯的数据,避免了各电梯之间的数据共享。

​ 分派器类获取电梯属性时,是直接使用内部保存的Elevator对象的ArrayList,通过调用getter方法进行访问。

​ Debug类设计较为简单,即在本地调试时调用其静态方法println()输出当前状态。

​ 优点:

  • 使用托盘类Tray和分派器Dispatcher每次只连接两个数据共享者(输入、分派器)(分派器、电梯),限制了数据共享的复杂度,很大程度上避免线程不安全问题。
  • 使用工厂类构建电梯,避免电梯构造方法复杂化以及内部属性复杂化,并且利于扩展

​ 缺点:

  • Dispatcher对象中直接保存了Elevator对象,并且大量使用Elevatorget方法获取其私有属性以获取电梯的状态。使得类之间耦合度提高,电梯复杂度提高。
  • 使用了更多线程,在线程终止时设计较为复杂(每个类都需要进行特殊判断)。基本思想是传播null请求使得所有收到请求的线程终止,但实际操作时还有诸多限制,如请求队列不为空时不能过早结束请求等,因此也产生了不少bug。

UML协作图

​ 类之间的协作关系如下图所示,用户请求根据如下图中数字与箭头顺序流动。发出用户请求之后,用户请求被解析放入托盘。分派器得到通知取出请求,根据是否能够直接到达将请求分解、或者直接传递,根据当前电梯的状态放入指定的电梯队列。当电梯没有主请求的时候,会被唤醒取得主请求,当电梯正在执行请求的时候,在到达某层时会根据运行状况取走这些捎带请求。

​ 被分解的主请求放入Hashmap容器中,先执行的请求为key,后执行的请求为value。(本题中任意楼层之间的请求恰好可以分解成为两个先后请求)当执行完成后,报告Dispatcher,若Hashmap中有这个key,将后续请求作为一个普通请求放入分派器进行分派;若无此key,一个请求的处理就此结束。

​ 线程结束方面,基本思想是传递null请求和标志位结合,使得收到null请求的线程了解自己完成任务后即将结束,但事实证明这种方法十分复杂,在这里以脚注的形式给出。

当Tray收到传入的null请求的时候,将托盘内的一个变量terminateFlag置为true,在托盘取请求的方法getRequest()中,若终止flag为真的时候,将向Dispatcher传递一个null对象。Dispatcher收到后,如果请求全部处理完毕,则将null作为主请求分发给每一个来获取请求的电梯,电梯收到后将终止运行。

OO Unit2 单元总结_第5张图片

四、程序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。这样就可能有多个线程DispatcherElevatorHashmap进行访问,而Hashmap不是线程安全类,故数据访问出错,导致Hashmap不能被清空,可能会导致进程无法结束(请求还未被完成)。

    这个时候,要么使用Hashmap的对象作为锁,锁住读写操作,要么使用线程安全的ConcurrentHashmap类。

五、测试用例设计

​ 由于多线程的复杂性,发现别人的问题主要靠测试一些常见情况以及边界情况,而大范围地引入评测机效果并不理想(可能是设计上存在缺陷)。第一次作业开始时就做好了以pythonsubprocess模块进行定时投放和简单的检查机制,但是发现投放的实时性缺乏,比如都是预定在1s时投放输入,最晚的输入可以延迟200ms才被输入。由于操作系统线程调度的不确定性,有时候执行的结果并不完全一样。这种测试方式可以检查基本的错误,但无法测试那种对时间要求精确的边界错误。

​ 样例构建采用了手动样例构建,主要测试每次作业的设计要点与边界情况。比如第一次作业的捎带请求,第二次作业的最大载客量,第三次作业的动态加入与楼层规划。事实上由于第二次作业做得太差(有一个非常严重的漏洞),测试非常有效,为了提交测试用例先发现了自己的漏洞,发现大家都有这个漏洞。而另外两次作业,由于手动构造样例的局限性,以及多线程调试的需要重复的特点,并没有很好地发现漏洞。

​ 本单元的测试策略和第一单元最大的区别就是不确定性,由于引入了多线程,线程安全、调度顺序都会影响运行结果。很多漏洞,如果不经过大量、长时间的测试,完全无法发现,给互测找漏洞带来了很大的压力,手动构造很多时候都是"闭着眼睛“测试。(当然这也和没有继续完善和维护评测机有一定的关系,但限于时间和构造难度,后面只使用了定时投放功能,进行人工检查)

六、心得体会

​ 第一次接触多线程编程,接触到了和之前完全不同的编程世界(没有玩过的船新版本)

​ 首先是线程安全问题。由于数据共享产生的线程不安全,不同线程之间的数据可见性,方法内部的执行顺序的不确定性。这些和传统单线程编程完全不同的思想,经过这次训练(写bug,debug)已经在脑海中形成了初步而稳定的印象。这些经验在之后的多线程相关开发中,是宝贵的经验。踩过坑,下次才不会再次栽倒在里面。

​ 其次是设计原则,接触了SOLID原则之后,我认识到继承、封装与接口的重要性。尽管这单元的重点是多线程,不代表这些程序设计的基本思想就不用应用在上面。荣老师在讨论课结尾提到,每次设计的时候,假设有一个团队合作完成这个工作。如何给每个人分工,这个过程其实也是设计的一部分,接口在这里就起到了很大的作用。同时一个开发好的单元,使用继承可以获得更强大的功能的同时,避免重写类引入新的漏洞。

​ 另外,多线程的设计模式,让我在很大程度上避免了过多的线程问题。这次应用的核心:生产者——消费者模式,以及了解到的Worker Thread模式,通过对经典模型的分析认识到了多线程设计的要领。还有许多其他的设计模式不一一举例,在多线程学习过程中,在实际问题分析过程中,将这些模式的思想应用在设计中,就像站在巨人肩膀上一样。

​ 同时,互测让我对自动化测试有了更深的认识。多线程需要的大量测试,为自动化测试引入了新的必要性,修改内容产生新bug,也让我意识到回归测试的重要性。

​ 总之,这单元的作业让我受益良多,总结经验,继续前行!

你可能感兴趣的:(OO Unit2 单元总结)