在第二单元的作业中,良好的架构设计变得十分重要。第一单元中架构设计的重要性没有得到完全凸显,即使架构设计的不好对于正确性的影响也不大,只会使得编码和优化变得困难。而第二单元的架构设计甚至可以认为是整个作业的核心,如果第二单元架构设计不佳,软件的正确性就难以保证,更不用说基于正确性的性能优化。所以本博客对第二单元的总结将以架构设计为核心,探讨我在三次作业中的架构设计变化。
第一次作业
设计策略
第一次作业的架构设计和电梯调度算法的耦合度非常高,可以说第一次作业的架构就是为FAFS电梯调度算法设计。我将FAFS算法抽象为特殊的读写者模型:输入线程是写者,不断地将新得到的指令写到缓冲区结尾;电梯是读者,从缓冲区的头部开始依次读取指令。缓冲区只有一个写者和一个读者,如果设计的巧妙则不用加锁。所以我的第一次作业设计如下:输入线程将读取的指令添加到缓冲区结尾;电梯是读者,依次从缓冲区中读出指令并执行。
架构设计图如下:
如何保证缓冲区的线程安全?缓冲区维护读指针和写指针,根据这两个指针可以判断缓冲区中是否有内容未读。电梯不断轮询缓冲区有无未读指令,如果有则会取出指令。在第一次作业的情景下,可以做到线程安全。缓冲区查询、添加和读取代码如下:
public boolean isEmpty() {
return (end - start == 1);
}
public void add(PersonRequest person) {
queue[end] = person;
end++;
}
public PersonRequest out() {
start++;
return queue[start];
}
代码度量
method |
CONTROL |
LOC |
elevator.Elevator.changeFloor(int) |
1.0 |
9.0 |
elevator.Elevator.closeDoor() |
1.0 |
9.0 |
elevator.Elevator.Elevator(Queue) |
0.0 |
6.0 |
elevator.Elevator.openDoor() |
1.0 |
9.0 |
elevator.Elevator.passenferIn() |
0.0 |
3.0 |
elevator.Elevator.passengerOut() |
0.0 |
3.0 |
elevator.Elevator.run() |
5.0 |
24.0 |
elevator.Main.main(String[]) |
4.0 |
22.0 |
elevator.Queue.add(PersonRequest) |
0.0 |
4.0 |
elevator.Queue.isEmpty() |
0.0 |
3.0 |
elevator.Queue.out() |
0.0 |
4.0 |
elevator.Queue.Queue() |
0.0 |
4.0 |
Total |
12.0 |
100.0 |
Average |
1.0 |
8.333333333333334 |
class |
CSA |
LOC |
elevator.Queue |
3.0 |
20.0 |
elevator.Main |
0.0 |
24.0 |
elevator.Elevator |
4.0 |
69.0 |
Total |
7.0 |
113.0 |
Average |
2.3333333333333335 |
37.666666666666664 |
本次作业我的代码总行数113行 。方法的控制流不复杂,规模不大。类的属性数量适当,方法数量适当。可以看出在代码结构层面,第一次作业质量尚佳,有一部分原因是第一次作业采用FAFS调度算法和指导书要求并不复杂。
第一次作业中一共有两个线程:主线程(输入线程)、电梯线程。我的第一次作业耦合度较高,类和类之间深度依赖。在设计策略部分所阐述的特殊读写者模型和代码实现之间层次差异不大,造成这个现象的原因有二。一是FAFS算法本身比较简单,代码实现和算法描述之间所差的抽象层次并不多。二是没有考虑程序的可维护性和可拓展性,完全按照FAFS和指导书要求编程,导致架构和算法的耦合度很高,架构本身的可拓展性很差。Queue类只能在其尾部添加指令,功能太弱,也没有实现线程安全,复用性几乎没有。
参考SOLID原则,还能发现第一次作业设计的更多问题:
- S—单一功能原则:电梯类有两个功能:调度策略和执行指令。实际上应该将调度策略和指令执行拆开,单独实现调度策略类。
- O—开闭原则:使用新的调度算法、增加电梯以及增加额外楼层限制时,第一次作业的设计完全不适用,基本上需要从头开始编程。
- L—里氏替换原则:第一次作业中没有继承关系,里氏替换原则在此没有体现 。
- I—接口隔离原则:第一次作业中没有接口,接口隔离原则没有体现。
- D—依赖反转原则:第一次作业中没有很好的体现出依赖反转原则,因为第一次作业中没有抽象层次的分离,所以没有体现出高层和底层之间的依赖问题。没有抽象的代码页几乎没有复用性和和拓展性。
bug分析
第一次作业并不复杂,我的代码在强测和互测中都没有bug出现。在互测中寻找同学的bug时,采用评测机+代码逻辑检查的策略。评测机生成随机数据,主要检测程序的鲁棒性;代码逻辑检查主要针对线程安全。
第二次作业
设计策略
第二次作业增加楼层限制,并提高调度算法的性能要求。ALS算法作为基准要求使得第一次作业中特异于FAFS算法的架构不再适用,第一次作业中的类也不能复用。第二次作业几乎从零开始,需要对需求、架构和模型重新进行分析。
第一步是确定第二次作业的新需求。捎带需求使得指令执行序列不再与输入时间序列相同,导致电梯的指令缓冲区是任意位置可修改、删除和插入的。在此前提下,缓冲区需要实现线程安全。第一次作业中电梯类并没有体现单一功能原则,在第二次作业中需要对功能进行分离,即独立出调度器类执行调度算法。那么调度器是作为一个类还是一个线程呢?这个问题将会在第二步解决。因为有捎带的需求,在每条新指令到来时,需要更新电梯的指令缓冲区;电梯每完成一条指令,也需要更新电梯指令的缓冲区。就此我们可以得出,整个系统是指令状态更新驱动的,新指令输入和旧指令完成都属于指令状态更新。
第二步是模型抽象。根据第一步的需求分析,抽象出“竞争更新”模型。所谓更新是指改变电梯的指令序列和电梯的运行状态。每当有新指令输入,输入线程会申请更新;每当完成一层的指令,电梯会申请更新。输入线程申请的更新会改变电梯的指令序列,电梯申请的更新会改变电梯的指令序列和运行状态。不论是谁申请更新,在更新时都需要查询电梯的运行状态,所以电梯只能在获得更新权之后才能改变自身的运行状态。
第三步是架构设计。根据第二步的模型抽象,决定不将调度器作为一个线程,而是作为输入线程和电梯线程竞争锁的对象。调度器是更新行为的具体实现,所以用对调度器锁的竞争实现对更新权的竞争。架构设计图如下:
共有4个类:输入线程类、调度器类、电梯线程类、Command类
从信息流的角度描述第二次作业架构设计,其中涉及到调度类的信息流都需要在拿到调度器锁的情况下进行。:
1. 输入线程将新读取到的请求写入输入缓冲
2. 一级流水线从输入缓冲中读取请求
3. 一级流水线将请求转化为指令写入指令缓冲
4. 二级流水线从指令缓冲读取指令
5. 二级流水线根据电梯调度算法生成新的指令序列传给电梯
6. 电梯将指令写入指令缓冲
从职能的角度解读第二次作业的架构:
- 一级流水线:将输入解析为Command类
- 二级流水线:计算LOOK调度算法
- 输入类:读取输入
- 电梯类:执行指令序列
- Command类:为保证电梯运行逻辑的正确,只有在执行上人指令之后,电梯才能得到下人指令,并且电梯得到的新下人指令会在电梯获得更新权之后被写入到指令缓冲中。Command类实际上是对 PersonRequest类的包装,从而使得电梯在执行完上人指令之前对下人指令是不可见的。调度器完全可见Command类,能获得Command类完整的指令序列,用以计算调度算法。实现生成新Command类的方法如下:
public Command nextCommand() {
if (act == Command.IN) {
return new Command(request.getPersonId(), Command.OUT,
request.getToFloor(), request);
} else {
return null;
}
}
每次更新都会给电梯一个新的指令序列,输入线程发起的更新是从信息流步骤1开始,电梯发起的更新是从信息流步骤4开始。
代码度量
class |
CSA |
LOC |
elevator.Command |
6.0 |
46.0 |
elevator.Elevator |
5.0 |
158.0 |
elevator.Main |
0.0 |
16.0 |
elevator.Scheduler |
4.0 |
193.0 |
Total |
15.0 |
413.0 |
Average |
3.75 |
103.25 |
method |
CONTROL |
LOC |
elevator.Command.Command(int,int,int,PersonRequest) |
0.0 |
6.0 |
elevator.Command.Command(PersonRequest) |
0.0 |
4.0 |
elevator.Command.getAct() |
0.0 |
3.0 |
elevator.Command.getFloor() |
0.0 |
3.0 |
elevator.Command.getId() |
0.0 |
3.0 |
elevator.Command.nextCommand() |
1.0 |
8.0 |
elevator.Command.toString() |
1.0 |
10.0 |
elevator.Elevator.addNextCommand(Command) |
1.0 |
6.0 |
elevator.Elevator.arrive() |
0.0 |
3.0 |
elevator.Elevator.check() |
7.0 |
35.0 |
elevator.Elevator.closeDoor() |
0.0 |
3.0 |
elevator.Elevator.Elevator(LinkedList) |
0.0 |
6.0 |
elevator.Elevator.executive() |
5.0 |
21.0 |
elevator.Elevator.floorIndex(int) |
1.0 |
7.0 |
elevator.Elevator.getDirection() |
0.0 |
3.0 |
elevator.Elevator.gofloor(int) |
1.0 |
9.0 |
elevator.Elevator.openDoor() |
1.0 |
8.0 |
elevator.Elevator.run() |
6.0 |
19.0 |
elevator.Elevator.setScheduler(Scheduler) |
0.0 |
3.0 |
elevator.Elevator.stop() |
0.0 |
3.0 |
elevator.Elevator.timeFloor() |
1.0 |
8.0 |
elevator.Elevator.updateDirection() |
3.0 |
11.0 |
elevator.Elevator.updateFloor() |
1.0 |
6.0 |
elevator.Main.debug(String) |
0.0 |
3.0 |
elevator.Main.main(String[]) |
0.0 |
11.0 |
elevator.Scheduler.downLook() |
6.0 |
26.0 |
elevator.Scheduler.downNear(int) |
12.0 |
37.0 |
elevator.Scheduler.floorIndex(int) |
1.0 |
7.0 |
elevator.Scheduler.floorListAdd(Command) |
0.0 |
3.0 |
elevator.Scheduler.floorListHave() |
2.0 |
8.0 |
elevator.Scheduler.initFloorList() |
2.0 |
8.0 |
elevator.Scheduler.isRun() |
0.0 |
3.0 |
elevator.Scheduler.listPrint() |
1.0 |
7.0 |
elevator.Scheduler.run() |
10.0 |
37.0 |
elevator.Scheduler.Scheduler(LinkedList,Elevator) |
1.0 |
9.0 |
elevator.Scheduler.updateList() |
4.0 |
16.0 |
elevator.Scheduler.upLook() |
6.0 |
26.0 |
Total |
74.0 |
389.0 |
Average |
2.0 |
10.513513513513514 |
本次作业我的代码总行数413行 。复杂的控制流主要集中在电梯调度算法部分,需要对不同的电梯运行状况和指令序列进行处理。类属性的数量和方法的数量适当。在代码结构层面,第二次作业整体合格。将调度策略实现的机制集中在调度器中,对电梯线程类进行解耦,从而实现代码复杂度的控制。
第二次作业中一共有三个线程:主线程、输入线程、电梯线程。主线程负责初始化输入线程和电梯线程。第二次作业中输入类和电梯类都具有一定的复用性,而调度器类复用性很差。这是因为调度器集成太多功能,没有进行模块化所导致。其次电梯类和调度器类耦合度过高,这是因为调度器在计算电梯调度策略时需要检查电梯的运行状态,导致调度器和电梯互相依赖。应该抽象出电梯用来发布更新的类,从而接触电梯和调度器的互相依赖。
参考SOLID原则,分析第二次作业的设计问题:
- S—单一功能原则:调度器类功能过多,太过臃肿。调度器类将二级流水线完全继承,导致代码逻辑不清晰。后来debug时我看自己调度器的代码都有些力不从心——方法和属性数量太多。
- O—开闭原则:使用新的调度算法,增加新的楼层限制和增加电梯时,输入类和电梯类基本不用改动,而调度器类则需要大改。实际上应抽象出调度算法类应对调度算法的变化,并抽象出楼层类处理与楼层限制相关的内容。
- L—里氏替换原则:第二次作业中没有继承关系,里氏替换原则在此没有体现 。
- I—接口隔离原则:第二次作业中没有接口,接口隔离原则没有体现。
- D—依赖反转原则:第二次作业中虽然有抽象层次存在,却没有在代码中体现出来,没有继承和接口机制。所以依赖反转原则在第二次作业中没有体现。
bug分析
第二次作业在强测和互测中均没有被测出bug。在互测中寻找同学的bug时,采用评测机+代码逻辑检查的策略。评测机生成随机数据,主要检测程序的鲁棒性;代码逻辑检查主要针对线程安全。
互测时一共找到一个bug,其现象是电梯会到达17楼,超出楼层的范围限制。出现该bug是因为当电梯下行时,输入向上到16楼的指令,电梯在接到该指令的乘客后,会忽略在16楼下人的指令,从而到达17层。电梯在16层没有检测出下人的指令,所以也就不会改变自身的运行状态。单进行逻辑检查很难检查出该bug,最高效的办法是进行指令运行分类覆盖测试。
第三次作业
设计策略
第三次作业将电梯增加到3部,并为每一部电梯设置楼层限制,运行速度限制和容量限制。在完成作业按照需求、模型抽象和架构设计的顺序进行分析。
第一步时确定第三次作业新的需求。关于对电梯速度和容量的限制,需要修改电梯类。电梯楼层的限制比较复杂,需要建立类进行单独处理。有些指令单部电梯不能完成,需要在调度时考虑电梯之间的协作。关于电梯之间的协作主要考虑以下几点:指令怎样拆分?何时拆分?拆分后二段指令是固定的么?
第二步是模型抽象。根据第一步的需求分析,发现”竞争更新“模型仍然适用。在第三次作业中仍然采用”竞争更新“模型
第三步是架构设计。仍然不将调度器作为一个线程。架构设计图如下:
共有6个类:输入线程类、调度器类、电梯线程类、Command类、Floors类、Action类、SafeOutput类
从信息流的角度描述第三次作业的架构设计,其中涉及到调度类的信息流都需要在拿到调度器锁的情况下进行。:
1. 输入线程将新读取的请求写入输入缓冲
2. 一级流水线将请求处理为指令写入到指令缓冲
3. 二级流水线将指令分发给二级指令缓冲
4. 三级流水线将生成的指令序列传给电梯
5. 电梯将指令写入指令缓冲
从职能的角度解读第三次作业的架构设计:
- 一级流水线:对请求进行解析,进行指令拆分,主要解决电梯协作问题
- 二级流水线:考虑三部电梯的运行状态,将指令进行分发,主要解决负载均衡问题
- 三级流水线:实现单个电梯内部的调度算法,调度算法采用LOOK算法。
- 电梯类:顺序执行指令序列
- 输入类:将输入请求写入输入缓冲中
- Floors类:处理与楼层有关的信息,给出能完成运输任务的电梯对和换乘点列表。
- Command类和Action类:为保证电梯运行逻辑的正确,以及实现电梯间协作。Command类包含Action序列,并维护一个序列指针。电梯类只能看到Command类中指针所指的Action,当完成当前Action后才能看到下一个Action。调度器能看到Command类中的Action序列,用以计算调度算法。
代码度量
class |
CAS |
LOC |
CBO |
elevator.Action |
2.0 |
14.0 |
1.0 |
elevator.Command |
6.0 |
46.0 |
3.0 |
elevator.Elevator |
9.0 |
179.0 |
4.0 |
elevator.Floors |
2.0 |
78.0 |
2.0 |
elevator.Input |
1.0 |
29.0 |
2.0 |
elevator.Main |
0.0 |
19.0 |
3.0 |
elevator.SafeOutput |
0.0 |
5.0 |
1.0 |
elevator.UpdateScheduler |
6.0 |
220.0 |
6.0 |
Total |
26.0 |
590.0 |
|
Average |
3.25 |
73.75 |
2.75 |
method |
CONTROL |
LOC |
elevator.Action.Action(int,int) |
0.0 |
4.0 |
elevator.Action.getAct() |
0.0 |
3.0 |
elevator.Action.getFloor() |
0.0 |
3.0 |
elevator.Command.addAction(int,int,int) |
0.0 |
5.0 |
elevator.Command.Command(int) |
0.0 |
6.0 |
elevator.Command.getAct() |
0.0 |
4.0 |
elevator.Command.getElevatorId() |
0.0 |
3.0 |
elevator.Command.getFloor() |
0.0 |
3.0 |
elevator.Command.nextStatus() |
1.0 |
8.0 |
elevator.Command.toString() |
1.0 |
9.0 |
elevator.Elevator.arrive() |
0.0 |
3.0 |
elevator.Elevator.check() |
6.0 |
37.0 |
elevator.Elevator.closeDoor() |
0.0 |
3.0 |
elevator.Elevator.Elevator(int,int,int,UpdateScheduler) |
0.0 |
11.0 |
elevator.Elevator.executive() |
6.0 |
28.0 |
elevator.Elevator.getCapacity() |
0.0 |
3.0 |
elevator.Elevator.getDirection() |
0.0 |
3.0 |
elevator.Elevator.getId() |
0.0 |
3.0 |
elevator.Elevator.getList() |
0.0 |
3.0 |
elevator.Elevator.getPersonAmount() |
0.0 |
3.0 |
elevator.Elevator.goNextfloor() |
1.0 |
7.0 |
elevator.Elevator.id2Char() |
0.0 |
3.0 |
elevator.Elevator.isFull() |
0.0 |
3.0 |
elevator.Elevator.makeNextCommand(Command) |
2.0 |
9.0 |
elevator.Elevator.openDoor() |
1.0 |
8.0 |
elevator.Elevator.run() |
6.0 |
19.0 |
elevator.Elevator.timeFloor() |
1.0 |
8.0 |
elevator.Elevator.updateDirection() |
3.0 |
11.0 |
elevator.Elevator.updateFloor() |
0.0 |
3.0 |
elevator.Floors.crossFloors(int[]) |
2.0 |
9.0 |
elevator.Floors.elevatorPair(int,int) |
3.0 |
15.0 |
elevator.Floors.f2i(int) |
1.0 |
7.0 |
elevator.Floors.i2f(int) |
1.0 |
7.0 |
elevator.Floors.initFloors() |
5.0 |
17.0 |
elevator.Floors.isOneElevator(int,int,int) |
0.0 |
5.0 |
elevator.Floors.oneElevator(int,int) |
0.0 |
4.0 |
elevator.Floors.oneElevator(LinkedList) |
2.0 |
10.0 |
elevator.Input.Input(UpdateScheduler) |
0.0 |
3.0 |
elevator.Input.run() |
5.0 |
23.0 |
elevator.Main.debug(String) |
0.0 |
3.0 |
elevator.Main.main(String[]) |
0.0 |
14.0 |
elevator.SafeOutput.println(String) |
0.0 |
3.0 |
elevator.UpdateScheduler.checkCapacity(Elevator) |
3.0 |
15.0 |
elevator.UpdateScheduler.checkStop() |
2.0 |
9.0 |
elevator.UpdateScheduler.chooseElevator(LinkedList,PersonRequest) |
6.0 |
24.0 |
elevator.UpdateScheduler.closeInput() |
0.0 |
4.0 |
elevator.UpdateScheduler.dealElevatorBuffer() |
1.0 |
9.0 |
elevator.UpdateScheduler.downLook(Elevator) |
4.0 |
17.0 |
elevator.UpdateScheduler.elevatorAdd(Command) |
0.0 |
3.0 |
elevator.UpdateScheduler.elevatorUpdate(int) |
0.0 |
5.0 |
elevator.UpdateScheduler.floorListAdd(Command,int) |
0.0 |
4.0 |
elevator.UpdateScheduler.floorListHave(Elevator) |
2.0 |
9.0 |
elevator.UpdateScheduler.initElevators(Elevator[]) |
0.0 |
3.0 |
elevator.UpdateScheduler.initFloorList(Elevator) |
2.0 |
9.0 |
elevator.UpdateScheduler.inputAdd(PersonRequest) |
0.0 |
3.0 |
elevator.UpdateScheduler.inputUpdate() |
7.0 |
50.0 |
elevator.UpdateScheduler.isRun() |
0.0 |
3.0 |
elevator.UpdateScheduler.updateOneList(int) |
3.0 |
14.0 |
elevator.UpdateScheduler.UpdateScheduler() |
2.0 |
12.0 |
elevator.UpdateScheduler.upLook(Elevator) |
4.0 |
19.0 |
Total |
83.0 |
548.0 |
Average |
1.3833333333333333 |
9.133333333333333 |
第三次作业代码总行数590行。复杂的控制流集中在调度器类中:指令拆分,负载均衡,LOOK算法。虽然三级流水线有明确的功能,但却没有被分为三个类,而是集成在一个类中。这导致调度器类极度臃肿,后来出现的bug也都集中在调度器类中。
第三次作业中一共有5个线程:主线程、输入线程、三个电梯线程。主线程负责初始化输入线程和电梯线程。在编写第三次作业的代码时,对第二次作业的输入类、电梯类以及调度器类的部分代码进行复用,大大减小了工作量。调度器的实现可以说是硬编程,有很多分支控制语句,强行对每种情况编写相应的控制流,代码非常丑陋。电梯类和调度器类耦合度过高、相互依赖的问题仍然没有解决。
参考SOLID原则,分析第三次作业的设计问题:
- S—单一功能原则:调度器继承三级流水线的功能,太过臃肿,方法属性太多。
- O—开闭原则:虽然用Floors类抽离出楼层相关处理的功能,但当改变调度算法时仍然要对调度器进行大改。应将调度算法抽象剥离出来成为独立的类。
- L—里氏替换原则:第三次作业中没有继承关系,里氏替换原则在此没有体现 。
- I—接口隔离原则:第三次作业中没有接口,接口隔离原则没有体现。
- D—依赖反转原则:第三次作业中虽然有抽象层次存在,却没有在代码中体现出来,没有继承和接口机制。所以依赖反转原则在第二次作业中没有体现。
bug分析
第三次作业在强测和互测中均没有被测出bug和发现同学的bug。在互测中寻找同学的bug时,采用评测机+代码逻辑检查的策略。评测机生成随机数据,主要检测程序的鲁棒性;代码逻辑检查主要针对线程安全。
心得体会
代码未动,设计先行。这三次作业尤其是最后两次作业从指导书到具体实现经差两个步骤:模型抽象和架构设计。
模型抽象是对问题的逻辑抽象,实质上是刨去与需求无关的信息,让解决问题的思路变得清晰。简单来说,模型抽象就是屏蔽我们在解决问题时的干扰,然我们能轻松的看见问题的本质。什么是好的模型抽象?根据模型抽象就能轻松的进行架构设计,很容易就很确定每个类的功能和理清类之间的信息流。
根据设计原则进行架构设计则是应用设定系统行为的过程。在抓住问题主干的前提下,进行较高抽象层次的架构设计,能够让我们时刻围绕着解决问题进行思考,而不是陷入到具体实现的泥沼中。人的大脑是有限的,很难在解决具体实现问题的同时思考软件系统整体的结构,架构设计能让我们着力于整体而不是细节,从而不至于在编程中迷失方向。
SOLID设计原则是面向对象界的编程原则,主要针对工程化的软件编写。应用SOLID原则,我们不必再从头开始重复思考如何保证软件的易维护性、思考如何进行封装和解耦、思考如何提高大规模软件工程的效率。SOLID是前任留下总结出的宝贵设计经验,为我们指明设计的方向。S—单一功能原则:提高软件粒度,增加可复用性,对抗复杂度。O—开闭原则:提高拓展性,提升软件系统稳定性和安全性。L—里氏替换原则:规范化继承机制,指出父类子类抽象的方向和原则。I—接口隔离原则:提升软件粒度,接触不必要的接口实现,降低耦合度。D—依赖反转原则:抽象设计的大杀器,提高软件的拓展性和可维护性,降低类之间的耦合度,使得自上而下的编程更加自然。
到这里就是我的第二单元博客作业的全部内容,感谢阅读。