不知不觉又做了三次作业,容我在本文胡言乱语几句2333。
第五次作业是前面的电梯作业的多线程版本,难度也有了一些提升。(点击就送指导书)
程序的类图结构如下:
程序的逻辑时序图结构如下:
可以看出,这次的程序依然存在部分类或方法代码较为集中的情况,这样的情况在类Lift
、LiftController
,甚至笔者自己的第三方库DebugLogger
中较为明显。甚至在Lift
和LiftController
类中可以发现其实业务逻辑已经非常的密集。
不出意料,我方公测不存在问题。
这次对方的公测也不存在问题,可以说是赏心悦目了。
不出意料,我方互测不存在问题(然而出了点别的状况23333,为了保证笔者博客的干净,笔者将在文末讲述,此处不做讨论)
这次,笔者给对方找出了两个bug。
这两个bug分别是:
ER
这样的请求进入调度器之后将直接转进对应电梯的子消息队列。FR
这样的请求将被暂存在调度器的消息队列,等可以接受这一请求的电梯出现后,将其发送至该电梯的请求队列中。于是,在一般的架构中,在请求队列里,一般都会有这样的一个轮询逻辑:不断轮询各个电梯的状态,等可以合法接受请求的电梯出现后,将其进行分配。然而就是在这个地方,如果,在轮询的一半时,某个电梯的状态突然改变了,该怎么办。
假设,有这样的三个请求:
某电梯即将到达一层,目前还在运行中。其他电梯均正忙。
于是,假设这个时候开始了一个轮询:
然而,最不巧的是,偏在这个时候,这个电梯已经到达了一层且处于空闲状态,而且请求3还没判断,于是就会出现这样的尴尬局面:
这还不算完,由于该电梯当前在一层,于是:
灾难性的后果发生了,这三个本来多半可以有效负载均衡的请求,因为一个线程同步问题,就这么被一波带走了。
事实上,这个bug在笔者自己进行测试的时候,就已经发现。笔者思考后,觉得有两种思路解决这一问题:
这次作业可以说是笔者在多线程上一次工程化的尝试。
笔者之前主要写的是C++
(用于算法竞赛)、C#
(GUI桌面应用编程)、Python
(用于各种脚本)、Ruby
(用于同袍和Questionor后端的维护)、php
(偶尔也会用到)以及前端的html
+css
+javascript
。虽然以前接触过多线程编程,不过也大都用于脚本编程(实际上,多线程的这种特性在网络请求等待的时候可以极大提高脚本效率),而且也大都是简单的并发+阻塞。在这次作业中,笔者真正在强类型OOP语言中进行系统化工程化的多线程编程还是头一次。
这次笔者认真研究了线程相关的锁(lock)、监视器(monitor)等机制,并且仔细思考了在这样的一个工程中如何通过这些机制来避免因为同步问题导致的错误,且兼顾并发效率。可以说收获不小。
此外,笔者的程序结构依然存在高内聚的问题,再加上这是第一次设计真正的多线程工程代码,太多的时间花在了如何让程序没有bug上,于是代码风格还是较差。
第六次作业叫做IFTTT,大致意思就是基于IF
... THEN
... 逻辑的文件系统监视器(点击就送指导书)
这次代码的类图结构如下:
这次的UML时序图结构如下:
这次的代码质量分析报告:
可以看见,总体的代码质量有较大的改观,不过还是存在少数类的行数过大。
不出意料,公测没有出现错误。
不过这里有一个有趣的小插曲,笔者一开始公测被报了一个bug,理由是监视器没有做出响应。笔者打开了这位同学提供的测试输入,用的是D:\
这样的根目录,于是笔者想起来指导书上貌似规定过不要用规模过大的路径进行测试,并对于这一情况向这位测试人员进行了解释,于是呢,改成了通过。(至于文件系统监视器相关的问题,笔者将在这一节的总结中稍微讲些自己的看法)
对方的程序,在公测环节似乎出现了很多的bug。而且似乎完全不具备对于整个目录树的监视反应能力。(也许是功能就没设计全?)
不出意料,我方在互测环节没有出现任何bug。
这次互测中,对方被笔者查出了两个bug:
这次作业是笔者所写的第二次多线程工程代码,从代码分析数据来看,整体代码风格有了较大的改观,不再有很明显高度集中的类设计,主要方法的代码密集程度也有所下降。
然而,笔者自己心里清楚,很多地方的代码仍旧不够成熟。同时,笔者已经开始探索着开发一套可以提供各类快速搭建和管理功能的java工程代码框架,并且已经从第七次作业开始使用这样的框架。
以及,这次貌似是指导书被吐槽最严重的一次。这次的issue也可以说是前所未有的庞杂,其中笔者觉得一部分原因是指导书没有去匹配大部分人对文件系统以及IFTTT的认知水平。
说到文件系统监视器,一般来说有两种主要的解决方案:
显然,这次作业一般采用的策略是前者。这是一种很廉价且在数据规模不大的情况下很靠谱的策略。
但是,各位应该也已经发现这一方法的一些明显弱点:
关于第三点,笔者举例说明下。
例如,在两次快照拍摄之间,a.txt
重命名为a1.txt
,b.txt
目录更改到了dir\a.txt
,这么一来,快照增量检测到的应该是这样的四条信息:
a.txt
b.txt
a1.txt
dir\a.txt
假如我们的a.txt
和b.txt
文件大小和修改时间再完全一致的话(本次作业中判断文件等价的唯二依据是文件大小和修改时间,并没采用各类文件指纹算法),问题就来了——a.txt
和b.txt
到底去了哪呢?
a.txt
和b.txt
等价,所以解释为a.txt
--> dir\a.txt
, b.txt
--> a1.txt
,这样是说得通的。a.txt
和b.txt
等价,所以解释为a.txt
--> a1.txt
,b.txt
消失,dir\a.txt
出现,其实也说得通。实际上,快照+增量机制所带来的一个无解的难题就是增量事件变得不再唯一可判定。
然而,这还不算完。我们这次采用的是多线程机制检测文件系统变化。于是呢,这样又不得不引入了线程同步问题。因为,按照原来课程组的要求,似乎还得保证在出现同质文件的情况下事件不可以发生冲突(例如,对于上述的例子,不可以同时检测到a.txt
--> a1.txt
和b.txt
--> a1.txt
)。实际上这样的要求本身完全合理,这件事在单线程内的判定也很好办,HashMap判重一下即可。然而要是这样的判定分散在多个线程内呢?于是又有了一连串的问题:
于是这个问题又成了一个无底洞(于是,后来课程组决定不对这种极端情况进行测试)。
根据个人了解,在实际应用中,这样的问题常常是基于另外一种思路——根据文件系统底层事件来检测文件系统变化。
Java
和C#
中均有现成的FileSystemWatcher
类可供直接使用。这次作业中笔者所检查的程序存在程序不能正常结束的情况。笔者打开了这份程序进行了查看,这份程序在顶层,打开了各个线程后,就不再对各个线程进行控制。
笔者觉得,多线程程序设计的一个基本原则是——任何线程在任何时候均不应该处于脱离控制的状态。无论是消息队列,还是各个业务逻辑线程,甚至是GUI,在任何阶段都应该在上层线程的控制之下。即上层需要结束线程的时候可以随时正常下达指令,且下达指令后需要用join等命令进行阻塞等待,直到各个线程安全关闭,再结束程序。
第七次作业是出租车系统模拟。不得不说,事情终于开始变得有趣了(点击就送指导书)
本次作业的类图结构:
本次作业的UML时序图:
本次作业的代码质量分析报告:
可以看见,排除GUI模块之外(GUI模块并非出自笔者之手),代码局部复杂度已经得到了一定程度上的控制(三个红色的那个函数点开看了下,是由于代码重复性较高导致的)
不出意料,公测我方未被测出bug。
对方的公测存在一个bug,即没有对于起点终点为同一个节点的情况进行判定。这样的bug添加一处判定即可。
最终,不出意料,我方未被测出bug。
不过中途也还是出现了一些有趣的小插曲。这位测试者试了下在缺少map.txt
的情况下运行程序,然后看到笔者的程序输出了红色字,于是认为笔者的程序crash
了。
然而实际上,笔者的程序外部包裹了try catch,只是在catch外面使用了printStackTrace
。并且程序的实际返回值也是0
,也就是说是正常且平稳的结束的。于是笔者摆事实讲道理,进行了申诉之后,对方撤回了这个bug。
然而程序也的确显示了红色字,这又是为啥呢?笔者通过研究java源码,找到了问题所在。
我们知道,一般高级语言程序一般会带三种自带的Stream:
Java
中的System.in
Java
中的System.out
Java
中的System.err
接下来我们来看看一切异常类的祖先类——Throwable
类的部分源码:
public void printStackTrace() {
printStackTrace(System.err);
}
/**
* Prints this throwable and its backtrace to the specified print stream.
*
* @param s <code>PrintStream</code> to use for output
*/
public void printStackTrace(PrintStream s) {
synchronized (s) {
s.println(this);
StackTraceElement[] trace = getOurStackTrace();
for (int i=0; i < trace.length; i++)
s.println("\tat " + trace[i]);
Throwable ourCause = getCause();
if (ourCause != null)
ourCause.printStackTraceAsCause(s, trace);
}
}
该源码片段截取自Throwable
类,可以看到,默认不带参数的printStackTrace
类,其实是在调用System.err
进行输出。所以难怪输出的会是红色字,因为的确输出到了异常流内。
说到这里问题就解决了,以后如果需要避免类似的误解,调用printStackTrace(System.out)
而非printStackTrace()
即可。
这位同学的代码总体而言写的还是挺不错的,不过在测试的过程中发现有一个很坑爹的设定。
这份程序只有在按照指定的方式结束程序后,才会有detail.txt
细节信息输出(也就是说用其他的方式,即便平稳结束程序还没有文件输出)。
这样一来,虽然实际上输出了,但也等于完全不具备实时交互的特性。虽然指导书上并没有明令禁止,但实际上已经违背了这个设计的初衷。
于是笔者向多名助教求证过之后,报了一个imcomplete
。
在这次作业中,笔者在开始动工之前,准备了一个简单的程序框架模板。使得程序搭建效率有了略微的提高(关于程序模板,笔者将在下文继续讲述)。
同时,笔者自我感觉,从这次开始,笔者的多线程程序设计框架开始变得日趋成熟。
笔者从这三次作业开始,真正接触了系统化工程化的多线程OOP程序设计,开始从零开始一步步思考,如何充分利用多线程的并发机制,协同各个进程,同时充分兼顾多线程并发效率。
从中,笔者还是看到了自身的一些不足:
笔者将会从接下来进一步的实践当中,进一步改善代码风格,设计更完善更符合规范,人机性能更好的程序。
据笔者观察,貌似很多的同学至今仍热衷于使用静态数组来进行数据的存储。
起初,笔者十分不解,在Java
这样的OOP语言中,相关的数据结构封装类可谓相当完备,为啥还要使用数组呢?
经过一些观察,很多人仍离不开静态数组的原因大抵如下:
0
、1
、2
之类的数字表示(然而,静态数组实际上有很致命的缺陷:
grep
命令将对方程序里头的静态数组一口气揪出来,只要能找到,基本上很快就能开开心心的拿下这次的一血。这招一抓一个准,屡试不爽,而且基本是抓住的都是crash,一般人我不告诉他23333静态数组这样的东西只在极少数特定场合下稍微方便些,然而带来的却是很多性能和工程性上的不可控,可谓得不偿失。
建议使用Java
内置的数据结构,诸如List
、Vector
、ArrayList
类,这些类均进行过有效的代码封装和性能优化,各方面性能均有保证,且不会很容易的出现错误。
从第七次作业开始,引入了一些代码规范相关的考察。个人觉得,其实这是一件很好的事情,毕竟真正的工程永远离不开维护,也很难离开teamwork。
然而,据笔者观察,似乎很多人对这件事颇为反感与不解,诸如以下的论调:
是的,上述的想法可以说非常普遍,笔者在第七次作业正式发布后的客服群里,基本每天都能看到这样的论调。感觉相当多的人觉得这个要求很不合理。
首先,关于代码规范的重要性,笔者在上次博客作业已经有说过,不想再重复唠叨一遍(或者说,唠叨了估计也没人爱听。。。)。
不过这样估计说服不了任何人,那容笔者来举几个亲自遇到的案例吧。
这个图,来自于第六次作业笔者测试的这位同学的summary.txt
恩,没错,这就是summary.txt
。为了防止各位看了一脸懵逼,我还可以告诉你们,冒号前的0
、1
、2
、3
表示的是四种不同的事件。
那么,请你现在告诉我,这个summary.txt
是在表达什么?
是的,没错,看不懂的不止你一个,因为笔者当时看到这个的时候也还是一脸懵逼(即便猜到了前面的数字表示的是各个事件类型)。
于是,笔者只好开始研究他的源代码。然后很惊喜的发现,这位老哥的所有代码全都写在了一个文件里头,而且右边滚动条上还密密麻麻的都是各种warning。
终于,功夫不负有心人,笔者终于找到了一丝线索:
恩,就是这里
public void addSummary(String trigger) {
lock.lock();
summary[trigger.equals("renamed") ? 0 : trigger.equals("Modified") ? 1 : trigger.equals("path-changed") ? 2 : 3]++;
PrintWriter output;
try {
output = new PrintWriter("summary.txt");
for (int i = 0; i < 4; i++) output.println(i + ":" + summary[i]);
output.close();
} catch (FileNotFoundException e) {
System.out.println("Fail to output to summary.txt!");
}
lock.unlock();
}
就是这句
summary[trigger.equals("renamed") ? 0 : trigger.equals("Modified") ? 1 : trigger.equals("path-changed") ? 2 : 3]++;
我们来研究下这个超长的三元表达式在表达什么(emmm,笔者作为多年的老码农,表示愣是一眼没看懂):
renamed
,返回0
,否则继续Modified
,返回1
,否则继续path-changed
,返回2
,否则继续3
到这里,笔者费尽千辛万苦,终于看明白这个文件了。
想到这里,假如,我不是一个测试人员,而是这位老哥的teammate,想要一起开发一个项目。
如果,需要对接的时候要是遇到了这样的情况,得费多大的劲才能搞清楚?
试想想,如果你每次开发项目,都要花一堆的时间在这种无谓的事情上,你觉得值得么?有何效率可言?
如果,输出的不是
0:1
1:0
2:0
3:0
而是
renamed: 1
Modified: 0
path-changed: 0
size-changed: 0
如果,这个程序能够满足我们所规定的:
这么一来可以节省多少的时间。
在第七次作业中,笔者分配到的测试程序,依然存在和上面类似的情况。
当笔者一次性输入多个请求的时候,等待3秒后,出现了一片的报错:
笔者当时瞬间就懵了,到底哪些指令执行了,哪些指令分配失败了?
然后对detail.txt
仔细研究了好半天后,才终于确认程序运行的是对的。
其实吧,在这种时候顺带输出下错误的详细情况(甚至不用太详细,输出一下哪条指令出错也是好的),对于编程者而言真的就是几秒钟的事情。
然而如果不加,不仅给别人会带来很大的困扰,你们自己debug的时候,也会处于完全摸不着头脑的状态——因为无论哪里错了,输出的都一样。
说了以上这些,其实我只想说明一点:
当然,可能很多人还是无法理解,这也正常。等有一天你们真正参与项目维护(尤其是多人团队项目)的时候,你们就会明白这些事情的重要性了。
笔者在之前的多次程序作业中,发现每次都要花上好半天的时间搭建程序框架,而且做得基本都是重复工作。
作为一个聪明的懒人,笔者于是自己写了一个简单的的工程代码模板。Git仓库,欢迎star
这样的一个框架能较好的符合笔者本人每次写代码的工程结构需要。同时对于一些常见需求也都进行了以行为逻辑为主的封装,可以通过类继承等方式快速构建功能模块(尤其是多线程功能模块)。
这个库将会不断地维护和更新,希望可以帮助到大家。
笔者作为一个写了很多年代码(至今已有10年有余),且实际维护过不止一个工程项目的程序猿,每当想在群里分享一些个人对于程序、对于代码规范、对于工程的理解和看法时,永远会有一些人站出来针锋相对。
他们的主要逻辑如下:
其实,笔者对于这样的想法还是表示可以理解的。毕竟每个人站的角度不同,格局自然也有天壤之别(俗称:屁股决定脑袋)。
不过,笔者还是希望各位能好好想想你们是为何而学习的。仅仅就是为了赶快毕业拿文凭?
当然了,如果这就是你的全部想法,而且以后不想从事这方面的工作的话,那的确随意,只能说你和咱们技术发烧友(或者最起码是打算靠这个吃饭的人)不是一路的人。
如果不是这样,那么你就该站在更高的格局上想问题:
结论很简单,能有所收获的,就是对的;能收获更大的,就是更好的。
接下来我来逐个回应下这三个常见逻辑:
我们就是想完成作业啊
请想一想仅仅就是完成一个作业的话,你能学到的东西有多少。。。我的程序是不能运行了还是怎么着了
请想想,程序能运行但是毫无维护性和可合作性,这种代码除了糊弄一下作业有任何价值么。。。你说的再多代码规范啥的一样会被钻牛角尖啊
请相信我们的助教团,他们会给你公道的。你只管做好自己就是了。另外,可能有些人(甚至包括一些著名大佬)受到知乎上一些所谓的6系人的影响,认为北航的OO课程就是一无是处毫无收获的。对于这样的无脑黑,我只能对您表示深深的同情和怜悯,因为您在用您的未来前途消费,而目的,仅仅只是为了证明一个帖子,和一些前人片面的话语的正确性。
前方高能。接下来的章节可能会引起部分人的不适,请非战斗人员迅速撤离。
时间过得真快,一不小心又过去了三次作业。不过这其中自然也有些不爽的事情发生。
虽然呢,笔者很清楚,这样的东西写进自己的技术博客实在是非常不雅,简直可以说弄脏了笔者的博客(事实上这也是笔者没在上面说这件事的原因)。但是有些事情嘛。。。还是不吐不快。
所以接下来说件事,请大家自行评判。
笔者在第五次作业截止后的一个晚上,突然被告知,自己被无效了???
然后,到群里问了下助教原因,原因是,我在readme.md
的指导书url链接中包含了"个人信息"
链接地址全文如下:
https://files.cnblogs.com/files/HansBug/OO%E7%AC%AC05%E6%AC%A1%E4%BD%9C%E4%B8%9A%E6%8C%87%E5%AF%BC%E4%B9%A62018v1.2.pdf
当时,懵逼了。然而再仔细一想,发现事情并不简单。
越想越觉得不对劲。于是笔者事后仔细一琢磨,可以推测出这个人的逻辑流程图:
我的天啊,老哥诶,您老人家为了不测试别人的程序为了让自己睡大觉,可真是煞费苦心啊。走了这么多步终于找到了我,我佩服你那为了偷懒不怕艰难险阻过五关斩六将的精神,是在下输了。
或者,我们甚至还可以在这个的基础上进一步扩展下:
也许可能各位还有点疑惑,甚至觉得我是在用恶意揣测别人。很明显,有如此耐心历经那么多个环节,只是为了找别人的无效作业痕迹的人,我并不相信他有可能一上来就是想好好测试的。
然而这样的人物,一个对学术毫无敬畏之心只想着偷懒万岁的人,居然还能和笔者分到了相近的段位,笔者只能哀叹——“知人知面不知心”啊。
综上,我只想对这人说一句:你有种给我站出来,别玩阴的!!!
不过,再一想想,人家如何如何,关我何事。笔者还是想认真的对学术和工程负责到底的,以及,无法带来任何实质性改变的怒火是毫无意义的。
正如上文所言,这类人挖地三尺的一般性动机,就是在于一旦把对方无效了,自己就可以不用测试了。
事实上这次事情的后续进展也表明笔者的猜测完全正确——截至互测时间结束,笔者这份被无效的作业依然一个点都没有进行测试(包括公测)。最后还是在笔者的一再要求下,课程组的助教们帮笔者完成了测试(在此感谢默默支持的老师和助教们,真的非常感谢)。
至此,这人的动机可以说是非常明显了。如果他只是一个按照规则办事且对学术和工程质量负责的人,那如何解释到最后都一个点都没测试的情况?
说到这里,笔者对于课程有个改进的思路,以遏制这种表面矫枉过正实则恶意满满的行为:
理由其实也非常简单:
以上是我的看法。