面向对象第二单元作业个人总结与反思

架构设计与代码分析

由于我个人觉得将一整张图片放上来会影响博客整体的排版以及阅读者的观感,本博客试图减少图片与表格的大小和数量,并尽量维持可读性与精简性。

(实际上好像并没做到……)

(附注:以下黑幕内部内容大部分都是娱乐/自嘲性质的吐槽,切勿当真。)

第一次作业

调度器的设计

虽然自己确实没有接触过多线程编程,但是之前由于写过js坑人的回调函数以及python的伪多线程(只有一个全局变量需要共享 根本不需要考虑访问冲突)以及一些奇奇怪怪的原因,我没有完全参考课程组的架构老老实实写WaitQueue;在对BlockingQueue进行测试之后,发现其封装性太好以至于不满足我想要的一些功能(如遍历、排序等),于是决定直接用ArrayList+synchronized的笨方法来写这次的作业。

由于是第一次作业,功能上的压力还相对较小;但由于吸取了上次的经验教训,这次我尝试着在第一次作业时便搭好一个对之后的修改与优化兼容的、符合一些特定设计模式与设计原则的架构,以减小之后作业的压力。

综合上述考虑,我选择了类似于Dispatcher-Worker的方式来进行人物分发:每个WorkerThread(即电梯线程类,本次作业中仅一个)中有一个接受队列,负责接受Dispatcher派发而来的请求,并存到电梯内部的待处理请求队列中,以此来解决可能出现的线程安全问题;每个Worker(即电梯类)持有两个List,一个List储存待处理的请求(此时乘客未登上电梯),另一个List储存已经进入电梯的乘客所对应的请求。(我的设计中没有楼层容器)

因此,我的调度器的任务就变成了从InputThread中得到输入,再分发到各个不同的电梯线程中;等到输入结束后结束自身,并同时通知所有电梯线程准备结束。

同步块的设置与锁的设计

根据以上需求,我除了将所有的接受请求的List设置为共享对象之外,还在Dispatcher类中设置了一个boolean类型的isOver变量,标识输入是否结束,为各个线程提供结束的判据,也为各Strategy提供了优化的方式,更为我第二次作业的崩盘埋下了伏笔

在锁的设计上,对于队列的访问,我直接使用队列作为了加锁的对象;对于isOver变量,由于其类型为boolean,将其设为volatile类型即可解决所有麻烦。

在同步块的设计上,我的dispatcher会一直从InputThread中读取数据,并且在拿到队列的锁后将读到的请求放到电梯线程的待处理队列中(如果输入此时结束,则Dispatcher会将isOver置位),最后notifyAll。而对于电梯线程类来说,该指令会不断扫描队列是否为空,如果非空则取出一个请求并加入到电梯的待处理请求队列中去;而在每次检查之后,该类都会调用电梯策略类的step函数,让电梯运行一步。如果没有请求可以处理,则电梯线程会陷入睡眠状态;如果输入结束后电梯线程发现没有请求可以处理,则会自动退出,从而完成多线程的正常结束。

其他的基本架构

  • 策略模式。定义了三种策略,对应了三种不同的来访方式。所有策略都有两个接口:step() 和 isFinished(),前者表示让电梯运作一步,后者表示当前电梯没有请求可以处理
  • 状态模式。为电梯加上了各种状态,并写了一些防止错误的状态转换的语句。
  • 将电梯类完全架空,电梯只保存运行信息,只能开门、关门、接收乘客、送走乘客以及根据当前方向前进一步。(变向由策略类完成)
  • 对层数(1-20限定)进行了封装,在构造函数里进行检查以防止越界。此外还消除了绝大多数的magic number。
  • 优化。对于Random模式使用Look策略;对于Night采用先接高层再接低层的策略;对于Morning采取等电梯装满再走的策略。

UML类图:

标准的策略模式+状态模式,没什么可以细说的。一些工具类没有放进来。

UML顺序图:

省略了电梯部分,主要体现多线程的交互方法。

第二次作业

调度器的设计

本来,我的结构不用进行太多改动就可以直接完成第二次作业所要求的任务,正如我在计划第一次作业架构时所设想的一样;但由于我一方面思前想后觉得这种架构优化空间太小了(由于Dispatcher不能撤销掉已经分配好了的任务,灵活性会变差);另一方面经过一次研讨课之后受到了很大程度上的优化方面的启发,所以我决定进行重构。所以说研讨课是万恶之源(不是

基于研讨课上的分享以及课程指导书中的提示,我将我的调度结构改造为类似于公示板的结构,并引入了permission的概念:调度器负责得到输入后张贴到公示板上,并确定那些线程可以获得这个请求,为每一个请求分配一个permission;每一个线程不断扫描公示板,接收自己被允许处理的请求并完成电梯运送任务。与标准公示板不同的是,每当有{新请求加入,乘客进入电梯,乘客离开电梯}这三个事件其中之一发生,调度器就会重新计算目前公示板上的所有的请求的permission。此外,只有电梯将客人接收到电梯内部后,才能将对应请求从告示板上移除。

为了实现这一功能,我从调度器里抽出了redispatch函数作为对所有请求重新分配的函数,并在上述三个事件发生后对该函数进行调用。此外,我针对redispatch的方法也采用了策略模式,根据不同的输入模式采用不同的dispatch方法。具体来说:

  • 对于Random模式和Morning模式,我根据电梯当前位置、方向、请求位置等信息计算了一个权值,并针对该权值进行了一系列优化来尽量使得电梯的负载分配均匀并且高效。
  • 对于Night模式,我对所有的请求和所有电梯进行排序,将最高的请求分给最高的、还未饱和的电梯。

此外,为了让dispatcher能够直接与各个电梯线程交互,我将创建电梯线程的方法也移到了dispatche类的内部。

对于dispatcher中接受输入并将请求传递到队列中的部分,由于我将删掉了上一次架构的中转用List,并且将所有电梯的“未接受请求”List全部汇聚成了一个公示板List,dispatcher仍然只面向一个共享的队列进行请求传递,因此这一部分没有什么大的改变。

实际上,同时囊括了这两部分的dispatcher类已经不满足单一职责原则了;但是由于时间不够,我并没有去进行拆分。

同步块的设置与锁的设置

除了之前已有的同步块之外,由于所有电梯的未接收请求List都变成了同一个共享对象(即公示板),因此我对所有的访问这个共享对象的方法都加上了synchronized来进行同步,包括isEmpty、carryPassenger等函数。(实际上可以使用ReentrantReadWriteLock来提高效率,但是实在是没时间重构这么多了)

这次作业中,我的锁仍然只有一个:“公示板”(isOver变量仍然使用volatile维护)

其他的一些零碎设计

  • 加了一些javaBean用来满足电梯线程在redispatch时向dispatcher线程通信的需求。
  • 把PersonRequest包装成了TaggedRequest,该类中含有那些电梯可以处理此请求的信息。
  • 由于多线程的问题,把策略模式改成了特别奇怪的样子,甚至包含一些冗余代码;但是还是可以无误地运行。
  • 为所有的策略模式写了工厂模式。

UML类图:

双策略模式+状态模式+工厂模式。同样,一些工具类以及所有的工厂类没有被放进来,并且省略掉了与之前完全相同的状态模式部分。

UML顺序图:

省略了电梯部分,主要体现多线程的交互方法。
实际运行中dispatcher与LiftThread之间的交互会更加频繁,由于过于繁杂在此不予列出。

第三次作业

调度器设计以及锁

由于在第二次的作业中在得到的血与泪的惨痛教训之后我已经搭出了一个较为完善的结构,第三次作业中我便基本没有做什么修改。

对于不同型号的电梯,我早已经预留好了扩张空间,只需要在javabean里稍作修改,再改一下工厂模式即可;

对于换乘问题,我决定采用类似于回调函数的处理方法:任何电梯在将乘客送出去之后,都必须调用dispatcher的checkTransfer方法来检查乘客的出电梯楼层是否为其目的地;如果不是,则创造一个新请求并加入队列。为了配合这个方法,我修改了我的策略:电梯现在不必将乘客送到终点,而是送到可达的离终点最近的地方。再加上dispatcher中权限分配的修改,我便完成了换乘策略的处理。

此外还有一个小问题:由于可能会出现换成,dispatcher不应该过早的结束。鉴于加入请求和完成请求(需要调用checkTransfer)都要经过dispatcher这个类,我决定在dispatcher中加入一个现有未完成任务计数器,直到所有的任务都被处理完之后再通知所有进程结束。

为了维持任务计数器的多线程安全性,我将增减该计数器的方法设为synchronized方法,从而锁住唯一的dispatcher实例,避免多线程错误。

由于架构变化不大(只增加了一个“回调”函数作为通信方法),UML图片略。

自己程序的bug

  • 第一次作业
    • 写出了自动评测机的初稿,并进行了评测。由于没有时间写完,因此该评测机没有对乘客是否全部输出了这一点进行评判。
    • 通过了强测,但是由于Morning策略中的一个小于等于被我手残写成了大于等于,因此在互测中被刀了一刀。(在输入少于6个人时电梯会一动不动)
  • 第二次作业
    • 没进互测,大受打击。
    • 由于评测机被我重装pycharm时不小心删掉了,被迫重写;然而还是并没有写完。
    • 虽然确实有线程安全问题,但是经过我后来强忍着悲痛与无助的分析,主要原因还是我的代码体量太大了(过了千行),而我又没有良好的大型工程习惯,又缺乏重构经验,导致重构时搞得一塌糊涂。
    • 第一次体会到了泥沼一般的感觉:怎么改怎么不对,几行代码改十几遍……总时间真的应该在10-15h了
    • 我总结出了以下几个经验教训:
    • 变量命名要explicit。
      • 向上翻可以看到两处加粗的地方,对应着isOver和isFinished这两个名字不能代表真正意思的方法。
      • 在第二次作业中,我轻信了我自己,按照我对这两个方法的理解去调用了这两个我自己写的方法,结果WA得一塌糊涂。
      • 在第三次作业中,isOver被我改为isInputstreamOver,而isFinished被我改成了isIdle。
    • 接口功能一定要明确,实现一定要精准。
      • 我自已为在第一次作业中通过巧妙地应用设计模式以及进行抽象,我的代码之间的耦合度已经大大降低了;
      • 实际上并没有。
      • 一方面我在写代码的时候,脑子里自动将dispatcher的策略和lift的策略耦合到了一起,而没有做到看起来像是做到了的ODP(面向接口编程);
      • 另一方面,由于我并没有实质的去规定我的各个策略模式中接口的规范,再加之我在第二次作业中从第一次作业的各个策略类中提了一些通用接口出来而没有细假思索(应该不算错字)这些被我提出来的方法之间的细微区别,导致我的策略类和线程耦合的一塌糊涂:
        • 接口的返回值根本就不是我本来以为它应该返回的值。
        • 一旦依照我自认为的接口含义修改代码,整个策略类就会出问题。
        • 甚至我对这些接口的理解都在随着我debug的过程中不断发生演变。
      • 这导致我在debug时对这些接口内部各个策略类的实现反复反复反反复复复复反反地修改,真的是如同陷入了(どろ)(ぬま)一般。bgm:阿姨压一压
      • 此役之后,我在第三次作业中给我所有的让我痛不欲生的接口都加上了javadoc,详细说明了这个接口的约定,避免此类悲剧的再次发生。
      • 效果奇佳。
    • 不要卡点提交。
      • 我的少一个优化的代码过了中测;
      • 我卡点交了一发修复了四个多线程bug、新增了一个优化,并且用我没写完的评测机跑了五十轮的代码。
      • 结果WA了weak_5,一无所有。
    • 提前做好架构设计
      • 我本来没有做架构设计的习惯,所有的架构都是一边写代码一边形成的;甚至上学期的计组我也是这么干的。
      • 但是我确实地认识到了,这样来设计系统的话,系统的复杂性与不确定性根本不是一个大二学生所能够承受的。这是一个非常不好的习惯。
      • 在第三次作业中,我提前分析了两次作业之间的区别以及我应该修改的点、应该增加的功能,从而很好地完成了第三次作业。
    • 尽量将优化做得可拆分,并且每增加一个优化都应该进行一次测试。
      • 我经常习惯性的一边敲代码一边同时加入所有优化;代码写完之后优化也全部内嵌完成。
      • 但显然,这会使我的调试难度变得难以接受
      • 我应该先追求我的代码的正确性;在确保正确性没有问题的情况下,再追加可能带来问题的优化。
    • 以上是我在第二次作业后的痛定思过。如果不是确实没时间我已经报了上一次的研讨课了……
  • 第三次作业
    • 经过以上的痛定思过,我成功驾驭了我最后除去空行与注释近1200行的代码。
    • 在简单的修改与debug之后我便通过了所有的测试点。
  • 注:虽然我几乎将每一种错误都触发了一遍,但我在线程之间同步的设计上并没有出任何错;几乎全部的错误都集中在我的策略模式类中,以及对于模糊不清的接口的实现代码中。
  • 而且我的代码表面的解耦合度十分高,并不是因为一个函数写得太复杂复杂性而出错。
  • 此外,这里推荐一个jprofiler的轻量级开源替代品:virtualvm。可以直接从github上下载,可以直接监控轮询。

别人程序的bug

  • 前两次作业由于实在是太忙,并且评测机没有搭好,所以说没能进行hack;
  • 第三次作业搭好了评测机,使用本地查出了多个CTLE(轮询),但是提交后无法复现,且服务器多次判定我的数据无效,使我最后失去了耐心。
  • 随后才知道有ALS策略通杀数据,直接可以hack掉屋子里的五个人。
  • 这次我还是使用了造数据—喂数据-检查输出的模式,只不过由于CTLE难以测试,必须人工使用virtualvm监视各线程,显得有点鸡肋。

心得体会

  1. 骄傲自大是最大的敌人,而失败则是成功之母。这次失败让我的软件工程水平直接上升了一截,也让我彻底放平了心态。
  2. 课程中有关需求分析的那一讲听起来很简单,但实际上很有用。
  3. 设计模式固然优秀,但有时候也要注意场合。这道题我不推荐使用状态模式。
  4. 码量提高以后需要注意很多问题。具体上面已经写了很多了,这里不再赘述。
  5. 要写出SOLID的、可持续发展的代码甚至是架构不是那么容易的。还有很长的一段路要走。可以看一看博客园里面的一些开发者实录,如本周的头条,其实也会有一定的帮助。
  6. 我也不知道我为什么多线程安全部分没出什么错……可能是有两点原因:使用了设计模式,以及调度方式简单、复杂性低导致难以写出bug。此外,对于每一个共享变量,我都仔细思考了对于它的所有访问方法,这也有助于我降低多线程抢占的复杂性。
  7. 不要卡点提交!不要卡点提交!
posted @ 2021-04-26 00:54  tadshi  阅读(123)  评论(0编辑  收藏  举报