应用SOLID原则对面向对象架构进行自我审视——以北航OO第二单元电梯作业为例

设计策略概述

第一次作业

    • 主类(MainClass)
    • 输入处理类(InputHandler, 线程形式)
    • 管理者类(Manager)
    • 电梯类(Elevator, 线程形式)
  • 设计模式

    • 使用生产者-消费者模式,输入处理类充当”生产者“,管理者类充当”托盘“,电梯类充当”消费者“,请求充当”产品“。

    • 使用单例模式

      • 构造单例的传统方法——饿汉法

        private static Manager instance = new Manager();
        
        private Manager() {
        
        }
        
        ;
        
        public static Manager getInstance() {
            return instance;
        }
        
      • 本次作业中使用的构造单例的方法——双重锁法

        private static volatile Manager instance;
        
        private Manager() {
        
        }
        
        ;
        
        public static Manager getInstance() {
            if (instance == null) {
                synchronized (Manager.class) {
                    if (instance == null) {
                        instance = new Manager();
                    }
                }
            }
            return instance;
        }
        
      • 两种单例构造方法的评价

        • 饿汉法:线程安全,但instance在Manager类加载时即实例化,导致内存垃圾的产生。
        • 双重锁法:线程安全,克服饿汉法产生内存垃圾的缺点,需使用volatile关键字修饰instance变量以消除重排序所致的风险。
  • 基本思路

    • 关键类说明

      • 电梯类
        • 属性:①物理性质:shiftTime、openAndCloseTime、state、curFloor,② 控制性质:mainRequest、mainRequestType,③管理者单例。
        • 方法:①动作方法:pickMainRequest、finishMainRequest等,②播报方法(制造stdout的方法):showPersonIn、showPersonOut等。
        • 责任:专注于自身动作的完成与播报。
      • 管理者类
        • 属性:①外部请求队列,②内部请求队列,③结束控制符,④管理者单例。
        • 方法:①外部队列相关方法:addOutRequest、isOutRequestsEmpty,②进出方法:getIn、getOut,③主请求分配方法:waitForMainRequest、lookForMainRequest,④时间预测检查方法:checkLookFloor。
        • 责任:专注于请求的管理与分配。
    • 调度策略:以主请求mainRequest为电梯的控制主线,电梯的工作始终围绕一个目标——完成当前主请求。在电梯完成一主请求后,若管理者暂不可为其分配新的主请求,则电梯陷入wait状态。当管理者接收到输入端传来的请求时,将请求加入外部请求队列,并notifyAll,唤醒处于wait状态的电梯向管理者申请新的主请求。

    • 捎带策略:当且仅当当前楼层存在目标方向与电梯运行方向相同的外部请求时,管理者让电梯进行捎带。每次捎带让当前楼层的所有外部请求均成为电梯内部请求。

    • 优化策略:采用一种紧扣局部优解的贪心算法——SSTF算法分配主请求,并辅以时间预测提升性能,具体说明如下:

      • SSTF算法流程示意图:

      在这里插入图片描述

      • 时间预测的例子:当前电梯要将主请求从10层运至1层,当电梯从5层到达4层时,判断6层、7层(即时间预测的范围为two floors)是否有向下的请求。若有,在电梯本应播报“到达4层”并将当前层号置为4时,让电梯”反抗“主请求的操纵,并以”光速“到达6层(即改为播报”到达6层“并将当前层号置为6)。PS:时间预测仅改变电梯一时的运行方向,电梯的主要运行方向仍由主请求操纵。此方法融合了一定的LOOK算法回头的思想~~,虽“量子电梯”有悖现实,但经过大量随机数据测试,可以有效提升SSTF算法在作业性能上的缺陷。~~
    • 结束策略:当输入处理器读入的请求为null时,将管理者的结束控制符toEnd由false置为true,并唤醒处于wait状态的电梯。此时被唤醒的电梯继续寻找新的主请求,若寻找不到,直接结束线程;否则,处理找到的新的主请求,直到寻找不到主请求为止。

    • 线程协同策略:本次作业中,将输入处理类与电梯类(即生产者与消费者类)作为线程,管理者仅作为生产者与消费者进行数据交互的非线程类。输入处理类与电梯类的共享变量仅有两个,一是外部请求队列outRequests,另一是结束控制符toEnd。考虑到管理者类中关于这两个变量的方法均不长,再加上完成本次作业时对object锁的理解还不太深入,故采用对这些方法进行synchronized加锁的方式确保线程安全。

第二次作业

  • 新增类

    • PersonRequest1类(继承自课程组提供的PersonRequest类)
  • 设计模式

    • 沿用第一次作业中的生产者-消费者模式和双重锁单例模式。
  • 基本思路

    • 关键类说明

      • PersonRequest1类(新增类)
        • PersonRequest类站在实际的角度看楼层,即楼层集合为([-3, -1] ∪ [1,16]) ∩ Z;而PersonRequest1类站在处理的角度看楼层,即楼层集合为[-2, 16] ∩ Z,实际的-3、-2、-1层被映射到-2、-1、0层,从而保证层号的连续性,便于处理。
        • 输入处理器将所有输入的PersonRequest对象转为PersonRequest1对象传给管理者;在各电梯与管理者中,只关注PersonRequest1对象,但要注意输出时层号的转化。
        • 属性:isOutMainRequest,标明该请求是否同时满足以下两个条件:①为一个电梯的主请求;②该电梯是从外部请求队列中寻得该请求作为主请求的。
        • 方法:①人员获取方法:继承自PersonRequest类的getPersonId,②相关楼层获取方法:重写的getFromFloor与getToFloor,③属性isOutMainRequest相关方法:becomeOutMainRequest与isOutMainRequest,④重置fromFloor的对象返回方法:setFromFloor。
      • 电梯类与管理者类(原有类)
        • 由于各电梯的内部请求队列(直观理解为电梯内的人)不尽相同,将内部请求队列及其相关方法放入管理者类中,显然不合适,故将这部分内容调整至电梯类中。此时,电梯可以看作是其物理存在与内部人员构成的整体。
        • 经过这一调整后,电梯类和管理者类的责任可总结为:
          • 电梯:自身动作的完成与播报、内部请求队列(内部人员情况)的更新
          • 管理者:外部请求的管理与分配、闲置电梯的调度选择
    • 调度策略:仍沿用第一次作业中的思路,将mainRequest作为电梯控制的主线。所不同的是,在引入多部电梯后,管理者类需要为每个新加入的外部请求,安排合适的闲置电梯。闲置电梯的安排仍然采用贪心的思想,即为外部请求安排最近的闲置电梯。

    • 捎带策略:电梯到达每一层时,先让到达目的地的所有乘客出电梯,后执行类似于下述的伪代码:

      if (主请求在当前层进入) {
          要捎带;
          if (电梯内满人) {
              用贪心思想选取电梯内一人暂时离开电梯,并投出新的外部请求;
          }
          主请求先进入电梯;
          让外部请求按一定优先顺序进入电梯,直到电梯内满人;
      } else {
          if (电梯内满人) {
              不捎带;
          } else {
              if (有同向请求) {
              	让外部请求按一定优先顺序进入电梯,最多到电梯内满人;    
              } else {
                  不捎带;
              }
          }
      }
      return;
      
    • 优化策略:保留第一次作业中的SSTF算法和时间预测。

    • 结束策略:与第一次作业相类似,略有不同的地方在于toEnd置为true后,管理者类要将所有的处于wait状态的电梯逐一唤醒。

    • 线程协同策略:本次作业中,仍然要关注toEnd与outRequests这两个生产者、消费者所共享的变量。与第一次作业不同的是,为了更灵活地使用锁,更方便地完整特定线程的唤醒,并更好地监控锁的重入深度以方便调试,我将所有的synchronized方法锁全部用ReentrantLock锁对象lock进行处理。

      • 特定线程唤醒的方法:在管理者类中设一conditions队列,与elevators队列中的每一个电梯相对应,充当每一个电梯的“唤醒师”。

        要让特定电梯睡眠时,采用语句:

        conditions.get(elevator.getEleId() - 'A').await();
        

        要唤醒特定电梯时,采用语句:

        conditions.get(elevator.getEleId() - 'A').signal();
        
      • 监控锁的重入深度的方法:使用ReentrantLock对象的getHoldCount方法。

第三次作业

  • 新增类

    • 安全输出类(SafeOutput)
  • 设计模式

    • 沿用前两次作业中的生产者-消费者模式和双重锁单例模式。
  • 基本思路

    • 关键类说明

      • 安全输出类(新增类)
        • 设置加锁的println静态方法,起到确保输出安全的作用。
        • 事实上课程组提供的TimableOutput类的println方法已有synchronized锁的保护,故SafeOutput类按理可以省去~~,但出于心理作用还是加上了~~。
      • PersonRequest1类
        • 将映射后的fromFloor与toFloor作为自身属性保存,新增属性nextRequest,表示该请求是否包含换乘后请求。
        • 对以下类型的请求进行静态换乘处理:
          • 起点与终点中的一者不在1 ~ 15层的范围内,另一者在该范围内
          • 起点与终点均在1 ~ 15层的范围内,其中的一者为3,另一者为偶数
        • 将1层、5层、15层作为请求拆分点。
        • 请求拆分的例子:
          • PersonRequest对象的起止情况:-2 ~ 10(中间不含第0层)
          • 若不拆分,PersonRequest1对象的起止情况:-1 ~ 10(中间含第0层)
          • 若拆分,PersonRequest1对象的起止情况:-1 ~ 1,其nextRequest属性对象的起止情况:1 ~ 10
    • 调度策略:仍沿用第二次作业中的思路,所不同的是电梯与外部请求的距离衡量公式发生变化,变为Math.abs(楼层差) * 电梯移动一层的时间。

    • 捎带策略:电梯到达每一层时,先让到达目的地的所有乘客出电梯,后执行类似于下述的伪代码:

      if (主请求在当前层) {
          要捎带;
          if (电梯内满人) {
              用贪心思想选取电梯内一人暂时离开电梯,并投出新的外部请求;
          }
          主请求先进入电梯;
          让可完成的外部请求按一定优先顺序进入电梯,直到电梯内满人;
      } else {
          if (电梯内满人) {
              不捎带;
          } else {
              if (有可完成的同向请求) {
                  if (电梯类型非A) {
                  	让可完成的外部请求按一定优先顺序进入电梯,最多到电梯内满人;    
                  } else {
                      让可完成的同向外部请求按一定优先顺序进入电梯,最多到电梯内满人;
                  } 
              } else {
                  不捎带;
              }
          }
      }
      return;
      
    • 优化策略:保留前两次作业中的SSTF算法和时间预测,并进行如下调整:

      • SSTF算法:对于B类型、C类型电梯不作更改。对于A类型电梯,为防止出现“饿死现象”,当仅有一架此类型电梯的时候,若电梯内部无请求且另一端(将A可停靠的底部楼层作为一端,上部楼层作为另一端)有请求,则直接将另一端的最远请求作为主请求。当有两架以上此类型电梯的时候,若有两电梯分散在两端,则遵循原有的SSTF算法,尽可能让两电梯保持在两端;否则,按照有一架电梯时的改良版SSTF算法,尽可能将一架电梯分派到另一端。
      • 时间预测:时间预测的范围改至3层,并且3层时间预测只对电梯内人数小于4情况有效。
    • 结束策略

      if (输入处理器读入请求null || 一架电梯在某一层完成一次进出) {
          输入处理器将管理者的结束控制符toEnd由false置为true;
          唤醒所有处于wait状态的电梯;
          被唤醒的电梯继续寻找新的主请求;
          if (寻找不到新的主请求) {
              if (Condition1:所有电梯的内部请求nextRequest && Condition2:外部请求队列为空) {
                  直接结束电梯线程;
              } else {
                  电梯陷入wait状态;
              }
          } else {
              处理找到的新的主请求,直到寻找不到主请求且满足Condition1与Condition2为止;
          }
      }
      
    • 线程协同策略:本次作业沿用第二次作业中的lock线程同步机制以及condition唤醒特定线程的方法,所不同的是取消管理者的conditions队列,而在每个电梯中增加一Condition对象作为属性,相当于将电梯的“唤醒师”封装至电梯中。

    • 一种优化策略:基于“流水线”的思想,设一合适的时间阈值,一个包含换乘后请求的请求加入外部请求队列时,估计MAX(换成前请求的执行时间,MIN(闲置电梯到达换乘后请求出发楼层的时间)),若该值小于所设定的阈值,则表明适合提前安排换乘后请求为一闲置电梯的新的主请求。若所安排的电梯到达换乘后请求的出发楼层后,换乘前请求还未完成,则wait等待。多次随机模拟表明,这种做法能在一定程度上提升性能(当然阈值要设好),但由于本人在设计中出现了死锁,最终难以复现,又没有时间Debug了,最后只好铩羽而归。

SOLID原则评述

  • 架构总结:第三次作业中的管理者,既充当生产者消费者模式中的“托盘”,即外部请求队列放置的地方,又充当所谓的调度器,即将请求分配给合适的电梯。所有的分配均围绕主请求mainRequest进行,既包括输入处理器向外部请求队列加入新请求时,将新请求分配给合适的闲置电梯并作为其主请求,又包括电梯完成主请求后,按SSTF算法为电梯指定合适的新的主请求。同时,管理者亦可对时间预测的结果进行判断,即决定电梯是否要掉头“反悔”。
  • 可扩展点总结:管理者中的调度相关方法,如addOutRequest、lookForMainRequest,是本次作业细节调整的重要扩展点。相应的,时间预测的范围亦可根据电梯传给管理者的checkRange参数进行调整。但由于SSTF算法下电梯的工作与主请求关系过紧,基本大框架不易变更,但可以根据架构作调整。例如在第三次作业中,我本想将A类型电梯的调度算法改为LOOK,但受程序框架的限制,发现彻底的调整的代价较大,故直接在SSTF算法的基础上进行调整,亦可达到类似的效果。
  • SRP原则:本原则意为“每个类或方法都只有一个明确的职责”。从下一部分“程序结构分析”中可以看出,本次设计迭代到第三次,Elevator类与Manage类已经臃肿不堪,显然是十分不良的设计。之所以会设计成这样,很重要的一个原因是在做第一次作业的设计时,考虑到Elevator类和Manager类的方法都不算太多,所以将许多职责塞给了二者。而随着开发迭代的过程中代码量的不断上升,这两个类内的新方法不断加入,原有方法细节不断地完善,造成类所承受的负担越来越重。因此,我根据之前总结的类的职能,将第三次作业中的这两个类做如下的拆分调整,让设计更加符合SRP原则的要求:
    • 新增一播报器类,将与产生stdout信息的方法,例如showPersonIn等、showPersonOut、showArrive、showOpenDoor、showCloseDoor等封装至该类中,并单例化后作为电梯类及管理者类内部的一个属性。虽然在本次作业中,stdout信息较少,不采用这一设计方法也没啥问题,但实际迭代开发过程中如果有更多的需要播报的stdout信息,单独抽出来管理显然会比较舒服。
    • 新增一内部请求队列类,将与内部请求队列本身相关的方法,例如isInRequestsEmpty、addInRequest、getFirstFinishInRequest(配合SSTF算法)、containNextRequest等封装至该类中,并实例化一对象作为电梯类内部的一个属性。
    • 新增一电梯队列类,将与电梯的加入、撤销相关的方法,例如addElevator等封装至该类中(注意锁的保护),并单例化后作为管理者类及唤醒器类(后续介绍)内部的一个属性。虽然本次作业中,该类仅需设置一个方法addElevator,但考虑到后期,可能会有电梯的撤销,甚至是电梯的检修(在该类中分出正常电梯和检修电梯两类电梯队列),所以这种设计对提升程序扩展体验是有意义的。
    • 新增一外部请求队列类,将与外部请求队列本身相关的方法,例如isOutRequestsEmpty、addOutReqeust(此时变为单纯的outRequests.add(request),不融入闲置电梯唤醒的部分)、getFirstPickOutRequest、getOtherEndsOutRequest、checkLookFloor等封装至该类中(注意锁的保护),并单例化后作为管理者类及分配器类(后续介绍)内部的一个属性。
    • 新增一唤醒器类,将原来管理者类addOutRequest方法中的闲置电梯选取部分封装至该类中,并单例化后作为管理者类内部的一个属性,让新加入的外部请求去选择成为哪个闲置电梯的主请求
    • 新增一分配器类,将原来管理者类的主请求分配方法,例如lookForMainRequest、outLookforMainRequest等封装至该类中(注意锁的保护),并实例化一对象(可单例化)作为管理者类内部的一个属性,为刚完成主请求的电梯分配新的主请求
    • 经过上述处理后,电梯类更加专注于自身动作的完成,对于内部人员信息的更新仅保留getOut、getInAndOut这两个宏观方法,将具体细节的完成交给内外部请求队列类。而管理者类相当于唤醒器类与分配器类的“总师”,起到总指挥的作用,安排唤醒器与分配器在合适的时机完成工作任务,对于外部请求队列的维护仅保留getIn这一宏观方法,将具体细节的完成交给外部请求队列类。
  • LSP原则:本原则意为“任何父类出现的地方都可以使用子类来代替,并不会导致使用相应类的程序出现错误”。第三次作业的设计中PersonRequest1类继承自PersonRequest类,电梯与管理者所看到的所有人的请求均为PersonRequest1类对象。在输入处理类中出现的所有PersonRequest类对象输入,均被替换为子类PersonRequest对象,并且在子类中重写getFromFloor与getToFloor这两个重要方法。
  • ISP原则:本原则意为“通过接口来建立行为抽象层次具有更好的灵活性”。第三次作业中,由于仅对A类型电梯采用不同类型的主任务分配算法,分配方式在管理者类的相应方法中通过if-else语句即加以区别了。但如果未来要加入更多类型的电梯,不同类型的电梯有不同的主任务分配方法,甚至同一电梯在不同场景下的分配方法都不同,那么我认为完全可以利用ISP原则可以做如下处理:
    • 建立多个分配器类,统一实现分配接口的方法。
    • 建立分配器工厂,根据传入的电梯型号及状态返回合适的分配器对象。若特定型号的电梯在运行过程中分配方法始终不变,则可将该分配器对象作为电梯本身的属性,在电梯的构造函数中生成。
    • 相较于电梯而言,分配器处于工作量较小的状态,仅在电梯闲置时发挥作用。因此,可以考虑将同一类的分配器做成单例,让一个分配器管理相匹配的所有电梯,即分配器与电梯是“一对多”的形式。这样既可减少分配器对象的数量,又能更加充分地利用每一分配器。
    • 对于唤醒器亦可有类似的处理。
  • DIP原则:即依赖倒置原则。由于本单元作业的变化较小,故此原则的遵循情况对迭代开发的影响不大。但如果在未来开发中要不断变动电梯的唤醒或分配方法,则引入接口让多个类去实现是一个不错的主意,具体方案见ISP原则的分析部分。由此可见,ISP原则与DIP原则存在一定的相通之处。

一个Bug分享

本人在第二次作业中,对一个Bug印象颇深,至今还没有弄清楚其中的原因,在此作下介绍:

  • 我原来采用的条件唤醒方法是,不设Condition对象,而采用synchronized(电梯对象)的方式,让电梯陷入wait和被唤醒均在此synchronized区块中。按理来说各电梯对象不尽相同,wait对应的monitor自然不尽相同,这和为各电梯对象分配不尽相同的Condition对象是类似的。但实测中极少部分样例出现类似死锁的现象,经过println大法以及getHoldCount方法发现。两架并不处于wait状态的电梯在执行电梯类内部代码时,要进入管理者类中执行两个不同的lock(管理者类的属性锁)区域内的代码,此时lock的holdCount为0,按理来说会有一架电梯先获得管理者类的属性锁,获得管理者单例这个监控器,执行相应区块代码,然而两架电梯均发生了阻塞现象。但此时的holdCount明明为0,而且换成类似的Condition大法后就没有这样的问题的了,所以个人表示一脸懵逼
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值