普通视图

发现新文章,点击刷新页面。
昨天以前风萧古道

35岁找不到工作,绝对不会是软件开发人员的结局

2024年6月23日 19:30

最近在Bilibili刷到了一个视频,是一个38岁老程序员找不到工作后,发了个视频发发牢骚。视频链接。看了之后很有感慨,因为我自己在2023年也经历了总计4个多月的失业时光。在那段时间的自我怀疑让我印象深刻。不过本文并不是在此忆苦思甜,而是借由此视频,探讨35岁是否真的是软件开发人员的终点,软件人是否真的到了35岁就必须“退役”,找不到工作或者转行。

35岁不是软件人的终点

目前由于经济下滑等多方面因素,各行各业都在经历裁员潮。大家都在勒紧腰带过日子。对于外部因素,我们能做的事非常少。人才市场饱和,企业从优中选优,导致面试难度不断上升也是不争的事实。有人说这是内卷,但我觉得这是人才市场的调节所产生的结果,并不是因为有部分人主动自发地“想拼命努力”,而连带着大家学习那些平时用不到的知识,而是作为企业在某些时刻需要高级人才时,拔高难度筛选的过程中,人才市场对这种现象的回应。

然而,并不是所有软件开发人员都有这样的学习习惯和技术嗅觉。2010年中国进入互联网腾飞时代,“互联网+”连接万物,涌现了一批新兴企业。而程序员这个群体踩着风口,获得了高额的薪水。有程序员因此懈怠,没有持续学习前进,没有追随时代潮流,导致经济一旦下行后被迫淘汰。如视频中那位UP主自己承认自己过去休息时间花了大量时间玩游戏,不愿意研究底层,觉得实际工作中没有直接使用就没必要学等等,这就是一种懈怠。而后续他说看到技术就厌烦,也许是他真的不喜欢软件技术,仅仅是机缘巧合之下选择了当时最热门的互联网行业。风口过去,面试碰壁,企业对38岁开发人员的画像无法满足时,自然会对“高耸入云的缺漏的知识”感到厌烦,而又因为没有良好的学习习惯和积累,导致短时间内也无法快速转行。又因为英语积累不足而无法尝试海外Remote工作或者出海找工作,进入了困兽之斗的状态。

话说回来,就算我们上知天文下知地理,也有可能会被淘汰。2015年以前几乎全世界网页前端都在手搓jQuery,而2015年后全世界的网页前端都在往三大框架Angular,Vue,React上升级。后来呢,发展到这一步,tailwind CSS帮大家把CSS里的迂腐废话文学重新修订,这一代前端程序员终于不需要再背那些复杂的经验主义CSS了。如果你在2024年还只会手搓jQuery,那么市场有什么理由不抛弃你呢。

虽然上述说了这么多内因外因,说了很多个人的局限性,但是我还是想抛出我的观点:“35岁不是软件人的终点”。

软件行业的游戏规则:简单的活为什么不给年轻人做?

当拥有了3-5年的软件工作经验之后,就会发现平时的工作并不复杂,日常的功能开发,Bug调试并不是什么复杂的事情。真正有难度的是一些程序员知识区范围以外的内容,也是整个行业前沿的,还未解决的问题等。那么如果按一个人23岁进入软件行业,12年都还在做普通的工作,没有寻求一些突破,或者说没有对那些各种瓶颈进行一些思考分析,没有尝试去理解代码背后的运行逻辑,那么是很容易原地踏步的。表面上看,企业会为了能留住“可用之人”,每年稍微加一些薪水,让程序员觉得今年有所提升,老板才会帮忙涨薪,但也许自己已经原地踏步,失去对行业深层次的思考,失去对技术的热情,成为一个机器而毫不自知。

那么简单的活为什么不给年轻人做?如果到了35岁没有足够的积累,还仅仅只能做简单的活,得过且过,那么招一个年轻人不也可以吗。如果到了35岁无法给年轻人提供指导,仅仅停留在代码表面,玩文字游戏,那么你的存在对于公司又有什么意义呢?

所以我觉得真正的问题在于,大部分人没有明白软件行业发展的速度远超其他行业。一台不到5000的电脑就可以学习各色新的编程技术,接上网线就能阅读世界上最强大最优质的操作系统和框架源码,这在其他行业是无法想象的。事实上,这对于软件行业人员来说,是一个很好的条件,真正做到了学习机会的“人人平等”。但是我有看到有些所谓的软件行业劝退视频,告诉大家这个行业变化太快,需要不断学习,太过辛苦。说到辛苦,软件人真的有医生、学校老师辛苦吗?真的有工厂打工的那些人们辛苦吗?躺在空调房里的办公椅,喝着茶水听着歌,看看源码看看文档,并且连实验室都不用进就可以做实验即时得到反馈,这到底有什么好辛苦的?我反而觉得能实时看到全世界最优秀的软件开发人员讨论技术内容,站在旁边听他们斗嘴吵架,本身就已经是一件特别有意思的事情。

如果到了35岁还没有理解软件行业的游戏规则,不能接收软件行业变化的速度,那么被淘汰是非常理所当然的事情。一个人要在某个行业坚持下来,都必须要有自己的价值观、梦想或者热情。失去了那些想法,仅仅因为“软件行业好挣钱”入行,也早晚会因为“软件行业挣不到钱”而离开。

负责,一个大人必须要会的事

负责有点像背锅,是一件吃力不一定讨好的事情。在职业生涯中,总有那么几个时刻需要我们站出来承担责任。小到一次Review对代码质量的要求负责,大到对一个项目上线后的结果负责。负责是在职场中向前发展必不可少的必修课。如何选择正确的有利的责任来担负,如何负好自己的责任,在被迫背锅的时候如何善后,其实都不是软件代码上的问题,而是沟通问题或者利益分配问题。在面对这些技术书上没有提到的,但是工作上经常遇到的问题时,该怎么调整自己心态面对那些“乱麻”,该怎样做好心里建设,知道自己想要什么,保持自己的心态,是需要花费时间修行的。

我看过很多人在升职加薪后,并没有做好负责的准备。当他们预估事情超过自己能力范围时,会产生“战逃反应”,即远古人类遇到危险时,要么战斗、要么逃跑。在办公室肯定不能“战斗”,于是他们会表现得很愤怒,或者就直接请假逃避问题。他们既想得到好处,又只想承担之前的“有限责任”,这听起来就显得很不合理。但是事实上有很多人会将责任带来的焦虑写在脸上,然后影响整个团队的氛围。

负责不一定会带来利益,但是只要我们知道自己负责的目的,负责可能的收益,知道自己在这个过程中能有什么提升,那么就是值得去做的。如果负责的结果暂时没有直接的利益,也可以以此去找其他人谈条件。如果负责没有任何好处,那么就应该尽早抽身,这样才能让这件事抛出来,让大家知道这件事是有问题的,不值得做的。

在我国的义务教育和高中教育中,很少教过如何为整个团队负责。大家都在聊自己的成绩,聊自己的前途等等,遇到团队合作的内容可能会选择缩头乌龟,老师也选择只看结果,那么最后就变成某个能力特别强的同学,或者某个最被欺负的同学来完成任务的大部分。大家并不知道负责具体是什么意思,也不知道如何培养大家的责任心。当然我并不是说每个人都必须无私奉献,而是大家团队合作的意识没有被培养,导致总有人带着一丝侥幸心理,觉得团队里有特别厉害的成员,自己就可以放松一些,这不是一个好的念头。

学会负责,包括以下几点:

  1. 清楚理解自己的责任范围,然后在自己的责任范围中,做好自己该做的;
  2. 明白自己负责后的收益,收益是否能让自己心甘情愿地负责。如果暂时没有办法让自己满足,要和上级沟通争取自己的利益;
  3. 更进一步,如果团队中需要自己,那么可以做一些力所能及的事情帮助更多的人。站在团队的角度上思考问题,理解更高层面的责任,并且尝试通过自己的力量做一些事情。

经历得越多,越能成长

这一路走来,我听了太多道理。有些道理,别人说了,自己不一定懂;有些道理,别人不会告诉你;有些道理,别人说给你听,是为了他们自己。但是这些所有的道理,自己没有经历过,就永远也无法完全理解。纸上得来终觉浅,绝知此事要躬行。

我们生存的世界,时间与空间不断变化。我们遇到的问题是千奇百怪的,不再是上学时有标准答案。有时候工作上的一些问题,也许永远也没有办法做到标准答案,会让人很气馁。但是只要我们能够正确区分工作不等于自我价值,知道离开了工作,它们就什么都不是,那么我们就能用更积极证明的方向来思考自己面对的问题,及问题背后更深层次的原因,而不是将产生问题的原因全部指向自己。

当你自以为懂了很多道理,踩过不少的坑,也要记得不要“好为人师”。每个人都有自己的人生,有自己必须要踩的坑,也就是“人生的劫难”。虽然有些玄学,但是由于每个人性格、出身等一系列客观原因,每个人都有自己不得不面对的棘手问题。直接告诉他们答案,并不能让他们真的学会那些道理。不论是对长辈上级、同辈亲友还是晚辈孩子,都要记得尊重他们经历和成长的机会。

永不自我设限

我们讨论过35岁不好找工作的原因,但是每个行业都会有一些“更高难度”的部分,需要先行者去攻克。而35岁恰好就是一个黄金年龄:它正好积累了一部分在学校时学习的理论经验,也有在工作中积累的工作经验,直接面对过一些行业内棘手的问题;同时,35岁正值青壮年,虽不像刚毕业的毛头小子那样活力无限,但是连肝几周攻坚难题还是绰绰有余。我认为让35岁成为门槛的另一部分原因,是有一部分人看到年纪渐长,自觉竞争不过年轻人,于是自我设限,将自己难找工作归咎于年龄。

Wine软件是一款能在Mac、Linux系统上运行Windows软件的一个框架。他们常年在全世界范围内招聘员工,进行远程开发Wine软件。他们正在招聘General Wine Developer (https://www.codeweavers.com/about/jobs),招聘要求大致如下:

[Requirements]

Strong C language skills. Compilers, linkers, macros and function pointers don't scare you.
Excellent debugging skills. You will be debugging applications whose source you do not have.
Familiarity with developing on and administering a Linux system (bash, unix utilities, package management, etc)
Ability to understand and debug build systems (Makefiles, autotools, etc)
Understanding of good development practices (source control, atomic commits, code readability, test-driven development)
Self-motivated to find work and learn new skills
No exposure to Microsoft code or reverse-engineering of Microsoft software

[Bonus skills we want] not required, but it helps

Win32 programming experience
Systems-level programming experience
Previous contributions to open source software

试问又有多少程序员能做到呢?而Wine软件是开源软件,任何人不需要登录就可以获得源码,又有多少人愿意自己研究代码,参与开发呢?

如果在中国没有办法找到工作,那么是否考虑过学习英语,去海外发展呢?真正对软件行业充满热忱,愿意投入时间和精力在行业中深耕,或者愿意花时间分析可能存在的需求,通过自己的软件能力去解决,也不至于无法以此为生。在上一个10年里,软件行业培训催生出一批为了钱而来的程序员,在经济下行的时候自然要稍微淘汰一部分人。但是总体来看,软件行业、计算机行业,正在经历AI的变革,还在影响着我们生活的方方面面,我们又如何能直接下一个“软件行业没啥前途”的结论呢?

就算真的做不到满怀热忱推动行业发展,那么做一名先行者,带领行业中的新人少走弯路,何尝不是功德一件呢?诸如左耳朵耗子、阮一峰、廖雪峰、游戏行业的云风等前辈,几十年如一日愿意分享自己的见识和理解,他们正在推动大家编写更加优质的软件,带领大家进行更为深度的思考。

用爱发电项目开发两个月的心得体会

2024年4月14日 18:59

前阵子我参加了一个用爱发电的独立游戏项目,是由一批网友聚集起来想要复刻某三国单机游戏的团队。“用爱发电”,即不获取酬劳,为了自己的梦想去努力奋斗。从24年2月4日开始得知这个组,2月7日进组,到4月4日离开,共经历了两个月。

我不想过多讨论这个游戏开发组中遇到的问题,因为在这个组的问题有其特殊性,并不具有普遍性。我想探讨的是个人开发者面对用爱发电项目时需要注意的内容,和一些心得体会。

进组时间线

  • 2.4 了解这个项目组
  • 2.6 通过面试,成功进组
  • 2.7-2.17 过年回家,没有学习项目知识,被轻微pua
  • 2.18-2.24 开始学习项目知识
  • 2.24 修复第一个bug
  • 2.25-3.30 修复大大小小6个bug
  • 3.30-4.4 发现自己做的很多是帮人擦屁股的活,觉得没有意义,进这个组浪费太多个人时间
  • 4.4 提出离开

团队的问题

这个团队的问题主要是文档不齐全,大量依赖视频教程,但是视频信息密度太低,学习起来难度很大。另外,之前项目的代码中,很多功能实际仅完成了50%大概一半,另一半作为bug处理,但是这样会让处理bug的人很难下手,因为无法明确知道功能边界在哪里,改起来心里没有底。加上文档不齐全,修改难度非常之大。最后,用爱发电项目没有直接利益刺激,做一段时间就觉得是帮人打工义务劳动,甚至没有github星星之类的荣誉,让人做久了有倦意。

用爱发电的心态

经过这个项目的体验,对于我个人,有以下几点体会:

  1. 一个人的空闲时间,比想象中更多一些。有时候,高效率的一两个小时就能做很多事。如果对自己要做的事情有正确的预期,那么行动起来之后并不会有什么心理负担,并且完成一点小小的里程碑也会让人感到非常愉悦。
  2. 对于一个大的项目,千万不要马上就想着把功能都做完。一定要花时间对模块进行整理。并且,在做功能模块的时候,一定要划分好什么是“功能未开发”,什么是“Bug”。如果是未开发功能,则要对功能进行分析,评估工时,逐步推进;如果是Bug,则要对Bug的成因进行分析,分析结果正确的原因和结果错误的原因,分析后再讨论解决方案。这是一个很漫长的过程,千万不能因为没有收入没有好处而松懈。
  3. 如果是不能获得直接报酬的项目,一定要控制好预期,一定要找到自己继续下去的理由。并且,没有直接报酬的话,想放弃,也不要有任何的愧疚感。

而在选择什么样的项目组,我也有以下几点体会:

  1. 如果一个项目不直接提供报酬(如这个组或者为github公开仓库贡献),那么就一定要索取其他东西(如github星星、行业内名声、资源、人脉),又或者是这件事本身已经足够有意思、有价值、有希望。一定不能什么都不要,那样是违背客观规律的。 什么都不能给予 = 无价值 = 无用之事。
  2. 只靠画饼,一定是坚持不下来的。能坚持下来的一定是在有价值的情况下,成为习惯,一点一点做出来的。
  3. 尽量要有主业。有自己谋生的手段,才能真正支撑自己的理想。或者,这份没有报酬的项目能提供资源和人脉,那么它必然很有价值。
  4. 随时调转船头,不要一条路走到底,不要害怕试错和犯错。没有报酬,也就意味着没有责任。虽然我们普世价值中建议大家负责,但是前提是要保障自己的自身利益。

项目总结

我不能说我在组里一点错都没有。事实上我从零开始学lua,学习整套代码框架的速度比我想象中要慢,弱类型语言,游戏客户端开发,确实和游戏服务器开发略有不同。我花了比较多的时间才完成基础项目的了解。这一点让我体会到,当我们在讨论某个开源项目的时候,能真正理解开源项目代码,并且帮忙处理问题的人其实是很稀少的。阅读别人的代码是有门槛的。门槛不止在于语言、框架和设计方式,还在于当没有人能实时解答问题。当没有人能正确提供解答时,如何仅靠源码和运行环境(有的项目甚至没有运行环境)进行代码的学习。这个门槛甚至包含我们是否有足够的业余时间来分配给这些项目进行研究。

所以,“为什么我要做XXX”,其实是一个非常有价值的问题。如果是为了“找工作时简历能好看点”,那么直接背面经,刷题,学习框架细节和各种常用设计,甚至是向更有经验的人讨教吃个饭,可能会更有用;如果是觉得“有意思”,那么这个项目的有趣程度,是否达到值得替代你原有的休闲方式(出游、玩游戏、喝酒KTV等),如果没有,那么也没什么必要去做;如果是为了学习某个技术热点,那么这个技术热点真的有意义、有必要学吗?也许在开始动手之前,先想明白这些事才是首要之事。

另外,学习别人项目的代码,是一件非常乏味的事情。由于编程开发的自由性,每个人都有自己的设计方案。虽然有代码规范,但是人会经常受限于自己的思维之中。这时候务必要有一种开放的心态,尽力去理解代码。当然,更重要的是在遇到困难时能否对项目价值做出合理的判断,能否利用好每一分每一秒。因为业余时间就应该属于自己。

最后,关于远程沟通,有很多噪音在里面。一个词,一句话,说出来就有可能变味。这也是为什么技术文档是如此重要。好的文档应该是没有歧义的,可以用来传递大量信息的介质,甚至比代码本身更重要。特别是无法面对面沟通的两个人,如何处理好沟通的节奏,如何“心往一处使”,是一个非常复杂的问题。关于这一点,在这两个月的实践中,我在这方面的想法是,双方都要有很高的技术水平、沟通能力、且拥有足够的耐心和责任心,才能让这件事推进下去。所以这应该也是远程工作都倾向于招聘高级程序员的原因吧。

关于开源项目

接下来的内容并不与这两个月用爱发电项目相关,但也确实是这两个多月的心得体会。

自23年5月开始,我重新开始游玩Counter Strike反恐精英这款游戏,它的缩写是CS,刚好和Computer Science的缩写一致。所以有时候我觉得自己也挺应景的,我在大学时主动选择CS,然后工作时候从事CS的职业,现在又几乎“沉迷”CS这款游戏。

最近的体会来自于CS主播莱昂凯讨论什么样的人能成为CS职业选手,这个视频 。在这个视频中,主播莱昂凯提到了很多CS天才少年,他们游玩CSGO大概700多小时的时候就已经打到了天梯第一等成就,然后才会有职业战队邀请他们去当职业选手。而大部分带着所谓“打职业”的梦想的路人玩家,其实根本没有足够的天赋去成为CSGO的职业选手。最后,莱昂凯说,大多数职业选手是因为自己真的很喜欢CSGO,打着打着发现自己可以打得更好,可以和更厉害的人一起对决,所以他们才去当职业选手。

这一点我在开源项目的维护者身上看到了类似的影子。远的不说,Vue的作者尤雨溪正是在谷歌工作的业余时间开发了Vue,然后由于其设计简单易用而引起大部分开发者注意。但前期核心团队也只有4个人 这个视频的15:06处。也就是说,大部分人其实根本无法真正踩进github贡献的大门,因为他们并没有足够的热爱和天赋(是的,我说的就是我)。基于不同的原因,将自己的代码上传开源,并且无偿为社区维护,就类似于现在在CSGO打职业的职业哥们,他们不像LOL项目有完整的LPL、LDL赛事体系能提供足够的温饱,他们兢兢业业的练枪、学道具、跑战术、报名各个大小比赛拿奖金。这真的需要热爱和天赋。只有足够的热爱才会让他们面对一件虚无缥缈的事不断努力,只有天赋才能让他们在自己的技术上登峰造极,让全世界能看到并且提供支持。

找到有价值的项目,然后坚持下来,最后等待一个结果。结果可能有好有坏,但愿意尝试和坚持的过程也总会有收获。

为了星星而做开源的开发者,就像那些为了当职业选手而玩CSGO的玩家,一定是坚持不下来的。这种没有直接利益的事情,能坚持下来的动力来源,只有热爱和习惯。除了那些外界能提供的金钱、名声、荣誉之外,你在业余中做的事,是否真的让自己感到快乐,是否是自己特别想做特别喜欢的事?业余时间的这一秒是否真的属于自己?如果是的话,那么太好了,你没有浪费这一秒,真正做到了热爱生命。

我想起Linux的作者Linus写的自传的书名叫《Just For Fun》,我想起EDG大满贯辅助Meiko接收采访的时候说“英雄联盟真的很好玩”(大概是类似的话)。Linus不是带着“我要做一个比UNIX更好的操作系统”的想法开始编写Linux,Meiko也不是因为有“我要成为世界第一辅助”的信念才开始打LOL。但是最后他们真的慢慢做到了,这背后充满了热爱和坚持。

扪心自问,我目前既不能快速上手一个不熟悉领域的项目,又暂时没有遇到自己特别想做的、热爱的开源项目,所以我还是找个班上,然后多看看书、看看开源项目开拓视野,等待属于我的“那个项目”吧。

以魏延“子午谷奇谋”讨论软件需求可行性问题

2024年3月10日 11:59

摘自某百科:《魏略》蜀军大将魏延认为,长安守将夏侯楙年少,娇生惯养、怯而无谋。今日假若我魏延领精兵五千,背负粮草五千,直由山道深入,寻秦岭而向东,到达子午再往北行。不过十日就能抵达长安。夏侯楙见我军突然出现,必然乘船而逃。如此一来,长安城内只剩下御史、京兆太守而已。长安北门有散民之谷,足以供应我军粮食。而由东方相会合,需要二十日,届时丞相您在由斜谷赶来,必定可以在此与我会师,这么一来,可以一举平定咸阳以西。

子午谷奇谋乱谈

年少时期,我第一次听说魏延的“子午谷奇谋”,也曾感叹可惜诸葛亮没有采纳。十几年后,当我在办公室听着游戏策划和开发吵需求的时候,逐渐理解1800年前诸葛亮没有采纳的理由。

古时候行军打仗,是一件残酷的事。老百姓被官府征召入伍,放下自己的农活,随意培训几天就上战场成为所谓的士兵。而每征召一个士兵,就会失去一个农夫。一旦征召过多,必然出现粮草不足的情况。而没有足够的农民从事农业,必然导致粮食匮乏。为了获取粮食,就只能去“东征西讨”。所谓的“一将功成万骨枯”既是如此。而为了能在破城后获取最大的收益,部队长官一般会放任士兵进城烧杀掳掠,到了史书上就只剩两个字“屠城”。

而刘备和诸葛亮一系,在三国时期的记载中,没有一次“屠城”。客观来说刘备真正发家的地方是从荆州请到诸葛亮后,而荆州益州平原辽阔常年没有战乱,粮食充足,刘备他们不需要屠城,也可以获得足够的粮食;但主观上刘备可能是出于理想主义或者是汉室宗亲的旗号,做到了那个时期下真正的“爱民如子”。到了季汉时期,诸葛亮六出祁山时,诸葛亮没有选择持续的连年征战,而是让士兵有规律的务农,储备粮食。尽管蜀道难,难于上青天,但成都开阔的平原,让当时的成都老百姓过得不错,还有艳丽的特产“蜀锦”远销“国内外”,他们生活的水平比200年初的北方老百姓幸福多了。诸葛亮的善良在史书里没有明写,但是从一行行文字的字里行间中跨越1800年传递给了每一个热爱三国历史的人。

这样一位爱兵如子的统帅,真的愿意让那五千士兵冒险吗?那五千士兵,脱下战甲,也不过就是山间农夫。也许他们背后还有五千对父母,五千个妻子和孩子?这样冒险的决策,对那些后方的亲眷而言,是否太不负责?

可是对于魏延来说,五虎将之后,姜维(当然那时候还不认识)之前,没有人能比他实力和威望更大。对于军人而言,战争年代就是建功立业的黄金时间。倘若能一举攻下长安,他就会成为季汉武官第一名。这样的利益驱使,提出这种冒险的策略也就不难理解了。

当我年纪渐长,体会到战争残酷,感受到人心难测之后,只觉得魏延所谓的“奇谋”不过是异想天开,拿五千条生命赌一个名垂青史的微弱可能。这当然是不被诸葛亮采纳的。就算真的占领长安,那又如何?曹魏已经占领了几乎所有的北方地区,又有一批与曹操出生入死、经验丰富的老将。魏延真能守得住吗?答案也是显而易见的。

那么为什么到了二十一世纪,总会有人讨论“如果”?我想,首先是因为大家真真切切为诸葛亮和姜维连年征战失利感到惋惜;其次,魏延的策略确实有微弱的可能性,“也许”会让季汉取得一定的优势。而他们从没有仔细想过,成功了下一步怎么办,失败了又有多少损失。

软件工程学就是软件开发行业的兵法

目前几乎所有稳定运行的软件,背后都有一个维护团队。这个维护团队一般就做两件事:开发新功能和修复Bug。如何稳定地进行版本迭代、已开发的功能如何保持可用、新功能如何确保不影响旧功能,以及如何让用户对新功能的学习成本降到最低,这些都在软件工程学的讨论范围之中。如果把开发一个软件比作一场战役,那么设计软件即拟定战术,开发软件即招兵买马,上线发布软件即行军出征,软件面对用户时即战场前线。如前线告急,则需要指挥官进行调度,及时补足资源,修复问题。

为了能做到稳定度过开发、上线、维护等流程,软件工程学包含各种开发模型。如瀑布模型、快速原型模型、增量模型、敏捷原型等。初学时会觉得那些模型过于拗口难以理解,但简单来说就是如何分配人力以完成需求分析、软件设计、软件开发、软件发布、软件维护等一系列流程。由于前人使用瀑布模型时,客户无法再软件期间看到预期结果,在软件开发完成后才与客户见面,一旦客户不满意就不得不推倒重来。所以后人提出先设计原型给客户检阅,拟定术语表,确保讨论的内容和目标是一致的。(这也是DDD领域驱动设计的思想);又或者是将需求细分,完成一个需求的开发后,进行一次发布,即增量模型;又或者,提高交付的次数,使用敏捷模型,以某一个时间长度作为产出周期,每周进行一个反馈(并不要求一定要完成整个功能,而是每周完成一部分成果)。这些优化后的开发模型,思路都是让客户尽早观察到软件开发的过程。

但是目前国内外的软件行业中,大部分团队是不遵循以上的开发模型的。这背后有太多复杂的因素。如客户作为甲方,对自己想要的软件不明确;开发方作为乙方,由于是拿钱办事的一方,为了竞拍得到机会不得不夸下一些海口;而作为程序员,对于需求的理解可能也会和甲方乙方领导略有偏差;当然还有技术选型,性能指标等等一系列需要花费时间进行分析的内容。能够完全按照比较先进的开发模型,是需要甲方乙方及开发整体团队整体实力达到合格线以上(当然笔者无法定义何为“合格线”),并且进行有效的、充分的沟通。

再来说说需求变更。需求变更几乎是软件行业无法避免的事情。或者说,在地球上就没有什么需求是不会变更的。但是软件行业的需求变更,成本往往很难准确评估。软件,实际上是看不见摸不着的数据,我们不过是用显示器窥探里面的内容罢了。对于需求的理解,对于需求变动需要的工作量的评估,不同的人总有不同的评判。但领导并不一定能接受一线开发者的需求变更时间评估,这当然有多方面的因素,如客户要求、如领导和开发人员能力差距或者理解偏差等等。需求变更都会带来大量难以评估的成本。而这正是敏捷模型提出的原因之一。如需求变更,就临时改变方向即可。

关于时间评估,在充分相信程序员的情况下,接受程序员对开发时间的评估是最合理的。但是由于领导对程序员的不信任、或是希望短期内多开发更多内容,强行缩减程序员的开发时间,这在表面上会让功能上线并且看起来还不错,但长期埋的暗雷也就只有开发团队内部人员知晓。但是在这个行业中,每个开发都对着自己的显示器输入英文字母进行编程,领导并不能在短期内判断程序员是否在“摸鱼”忙里偷闲,因此一旦领导遇到几个不够称职敬业的程序员,会让他对这个行业其他程序员产生偏见。

而从这个角度看,开源软件天然接受所有人的Review代码审查,遇到问题就算主库不合并,也可以fork复制一个新仓库进行开发,天然保证这些暗雷总会被找到。这也是为什么程序员们总是更偏向于使用星星很多,知名度广,背后有高手维护的项目。

魏延不会是一个好“开发经理”

回到“子午谷奇谋”,从软件工程学的角度,即可以发现魏延想要在软件上线(大军已出征)的情况下,不顾开发成本(五千士兵),临时调整需求,并且这个需求很有可能导致软件崩溃(引发曹魏愤怒,倾全国之力讨伐蜀国)。而当前网络上关于子午谷奇谋的讨论,也总是停留在“成功了如何如何”。我想这也是为什么在软件开发过程中,领导总会提出“不太合理”的需求吧。

当然,我们目前谈论的是在软件工程学方面,为何临时修改需求且需求难度大时,需要避免做出这样冒险的决定。但这并非绝对。三国时期即有张辽带着陷阵营在孙权大营搅了天翻地覆;而进入二十一世纪前20年,商机遍地,赶着做软件先上线的公司都赚的盆满钵满。从这个角度想,软件工程学之下的合理,到了现实情况又不一定合理,团队能力,甲乙双方的需求和期望,成功的时间窗口,运气等等因素,是那样无法捉摸。一味抱着软件工程学(兵书)照本宣科,也许最后只能落得马谡失街亭的下场。

也许找到了加班的理由?(笑

Java并发编程中上锁的几种方式

2023年12月4日 17:27

前言:本文想要介绍Synchronized,ReentrantLock和ReentrantLock的Condition的相关用法。

Synchronized上锁

Synchronized可以修饰实例方法、静态方法和代码块。修饰代码块时,可以对具体的对象上锁,也可以对某个类(.class)上锁。

Synchronized是非公平锁

以下代码是通过给一个多线程能访问到的变量使用synchronized进行上锁,实现有序打印数字的功能。并且在最后会统计不同线程打印数字的次数:

package com.windypath.lockcondition;

public class Syn {
    int count = 0;
    final Object sth = new Object();
    void play() {
        int loopTimes = 1000;
        SynThread t1 = new SynThread(loopTimes, "t1");
        SynThread t2 = new SynThread(loopTimes, "t2");
        SynThread t3 = new SynThread(loopTimes, "t3");
        SynThread t4 = new SynThread(loopTimes, "t4");
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }

    public static void main(String[] args) {
        Syn syn = new Syn();
        syn.play();
    }
    class SynThread extends Thread {
        int loopTimes;

        public SynThread(int loopTimes, String threadName) {
            super(threadName);
            this.loopTimes = loopTimes;
        }
        @Override
        public void run() {
            int times = 0;
            while (count <= 200000) {
                synchronized (sth) {
                    count++;
//                    System.out.println(getName() + " 输出 " + count);
                    times++;
                }
            }
            System.out.println(getName() + "一共输出了 " + times + " 次");
        }
    }
}

输出结果如下:

t2一共输出了 103061 次
t1一共输出了 37174 次
t4一共输出了 33751 次
t3一共输出了 26018 次

可以看到线程t2输出的次数比其他三个线程加起来还要多。因为synchronized是非公平锁。

synchronized的等待队列

使用synchronized上锁的对象的等待队列位于ObjectMonitor中的_waitSet。这个ObjectMonitor是底层native(也就是C/C++)的内容。

synchronized锁升级

但并不是一开始就上重量级锁,而是先优化成偏向锁,如有竞争才会升级为轻量级锁,大量的线程参与锁的竞争时,才会从轻量级锁升级到重量级锁。

上锁的对象使用其对象头中的MarkWord来存储锁的信息。

一个Java对象在内存中的存储结构包括三个部分:

  • 对象头
  • 实例变量
  • 填充字节

其中对象头中主要存储一些运行时的数据:

  • MarkWord
  • Class Metadata Address (指向对象类型数据的指针)
  • Array Length (是数组的话,记录长度)

锁的信息记录在对象头的MarkWord中。下图是不同的锁的MarkWord的不同位的信息:

  • 偏向锁(biased lock) 偏向锁是为了避免在非多线程环境下,执行synchronized上锁时使用轻量级锁等更高等级的锁消耗资源。

偏向的意思是,被上锁的对象偏向于某个线程。其对象头会存储偏向的线程id。

  • 轻量级锁(lightweight lock) 轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况。

轻量级锁和偏向锁的区别

轻量级锁的加锁过程需要多次CAS操作,而偏向锁仅需要一次CAS操作。 轻量级锁所适应的场景是线程交替执行同步块的情况。而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

synchronized锁升级观察

尝试使用一个对象,多个线程在不同的时间段为其上synchronized锁,来观察其锁状态。 thread1:马上获取,马上释放 thread2:等500ms获取,然后使用1500毫秒再释放 thread3:等待1000ms获取,然后马上释放。

此时,thread2和thread3会出现锁竞争。

源代码如下:

package com.windypath;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openjdk.jol.info.ClassLayout;

/**
 * 观察synchronized从偏向锁 -> 轻量级锁 -> 重量级锁 的过程
 * 项目使用log4j2
 */

public class BiasdLock {
    final static Logger log = LogManager.getLogger();
    public static void main(String[] args) throws InterruptedException {
        log.debug(Thread.currentThread().getName() + "最开始的状态:\n"
                + ClassLayout.parseInstance(new Object()).toPrintable());
        // HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
        Thread.sleep(4000);
        // 创建一个对象,用于多个不同的线程上锁用
        Object obj = new Object();
        log.debug(Thread.currentThread().getName() + "等待4秒后的状态(新对象):\n"
                + ClassLayout.parseInstance(obj).toPrintable());
        //线程1,马上上锁马上释放
        new Thread(() -> {
            log.debug(
                    Thread.currentThread().getName() + "开始执行准备获取锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
            synchronized (obj) {
                log.debug(Thread.currentThread().getName() + "获取锁执行中:\n"
                        + ClassLayout.parseInstance(obj).toPrintable());
            }
            log.debug(Thread.currentThread().getName() + "释放锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
        }, "thread1").start();
        // 线程2,等线程1释放锁后再上锁
        new Thread(() -> {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.debug(
                    Thread.currentThread().getName() + "开始执行准备获取锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
            synchronized (obj) {
                log.debug(Thread.currentThread().getName() + "获取锁执行中:\n"
                        + ClassLayout.parseInstance(obj).toPrintable());
                try {
                    Thread.sleep(1500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }
            log.debug(Thread.currentThread().getName() + "释放锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
        }, "thread2").start();
        // 线程3,在线程2拥有锁的时候尝试上锁
        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.debug(
                    Thread.currentThread().getName() + "开始执行准备获取锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
            synchronized (obj) {
                log.debug(Thread.currentThread().getName() + "获取锁执行中:\n"
                        + ClassLayout.parseInstance(obj).toPrintable());
            }
            log.debug(Thread.currentThread().getName() + "释放锁:\n" + ClassLayout.parseInstance(obj).toPrintable());
        }, "thread3").start();
        //主线程等待所有线程运行结束,查看状态
        Thread.sleep(5000);
        log.debug(Thread.currentThread().getName() + "结束状态:\n" + ClassLayout.parseInstance(obj).toPrintable());
    }
}

输出如下(精简之后):

15:53:58.436 [main] main最开始的状态:non-biasable
15:54:02.854 [main] 等待4秒后的状态(新对象):biasable
15:54:02.858 [thread1] thread1开始执行准备获取锁:biasable
15:54:02.858 [thread1] thread1获取锁执行中:biased
15:54:02.859 [thread1] thread1释放锁:biased
15:54:03.367 [thread2] thread2开始执行准备获取锁:biased
15:54:03.368 [thread2] thread2获取锁执行中:thin lock
15:54:03.869 [thread3] thread3开始执行准备获取锁:thin lock
15:54:04.872 [thread3] thread3获取锁执行中:fat lock
15:54:04.872 [thread2] thread2释放锁:fat lock
15:54:04.873 [thread3] thread3释放锁:fat lock
15:54:07.868 [main] main结束状态:non-biasable

可以分析得到以下结论:

  • 对于hotspot虚拟机,刚启动时创建的对象是不可偏向(non-biasable)的
  • 4秒后创建的对象,状态为可偏向(biasable)
  • thread1获取锁时,由于仅有一个线程为此对象上synchronized锁,因此转为偏向锁状态(biased)
  • thread1释放锁时,锁对象状态依旧为偏向锁(biased),并不会回到可偏向(biasable)
  • 500ms后,thread2获取锁时,锁对象的状态会升级为轻量级锁(thin lock)
  • 再过500ms后,thread3也开始获取锁,未执行到synchronized代码块时,状态为轻量级锁(thin lock),执行到synchronized时,阻塞,直到thread2释放的同时马上获取锁(倒数第三第四行的日志时间一模一样都是15:54:04.872)
  • thread3马上释放锁,这一刻还是重量级锁(fat lock)
  • 主线程等待5秒后,锁状态恢复,但是是变为不可偏向(non-biasable)状态。
  • 可以尝试把前面的等待4秒注释,这样的话一上来就会获取轻量级锁

ReentrantLock上锁

ReentrantLock是轻量级、可重入锁。在创建时可指定是否是公平锁。 ReentrantLock可以和Condition配套使用。 ReentrantLock提供了多个并发编程相关的函数可供使用,相比于synchronized而言,灵活性更高。

  • ReentrantLock可支持锁是否是公平锁
  • ReentrantLock提供了常规的lock()上锁的函数之外,还提供了用于轮询使用的tryLock()函数和可被打断的lockInterruptly()函数
  • ReentrantLock上锁之后,可以根据业务等待不同的Condition

ReentrantLock,可以是公平锁

package com.windypath.lockcondition;

import java.util.concurrent.locks.ReentrantLock;

public class Reen {
    int count = 0;
    final ReentrantLock lock = new ReentrantLock(true);
    void play() {
        int loopTimes = 1000;
        ReenThread t1 = new ReenThread(loopTimes, "t1");
        ReenThread t2 = new ReenThread(loopTimes, "t2");
        ReenThread t3 = new ReenThread(loopTimes, "t3");
        ReenThread t4 = new ReenThread(loopTimes, "t4");
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }

    public static void main(String[] args) {
        Reen reen = new Reen();
        reen.play();
    }
    class ReenThread extends Thread {
        int loopTimes;

        public ReenThread(int loopTimes, String threadName) {
            super(threadName);
            this.loopTimes = loopTimes;
        }
        @Override
        public void run() {
            int times = 0;

            while (count <= 200000) {
                try {
                    lock.lock();
                    count++;
//                    System.out.println(getName() + " 输出 " + count);
                    times++;
                } finally {
                    lock.unlock();
                }
            }
            System.out.println(getName() + "一共输出了 " + times + " 次");
        }
    }
}

输出结果如下:

t3一共输出了 49953 次
t4一共输出了 49988 次
t1一共输出了 50077 次
t2一共输出了 49986 次

可以看到4个线程的输出基本都在50000左右。

ReentrantLock不使用Condition模拟哲学家就餐

哲学家就餐问题,即5个哲学家围在一个圆桌吃饭,但桌上只有5只筷子。哲学家思考结束后,需要同时获取左手边的筷子和右手边的筷子才能吃饭。 在这里,我们使用线程来模拟哲学家,使用ReentrantLock模拟筷子。

package com.windypath;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class DiningPhilosopher {
    public static void main(String[] args) {
        int numPhilosophers = 5;
        Philosopher[] philosophers = new Philosopher[numPhilosophers];
        Chopstick[] chopsticks = new Chopstick[numPhilosophers];

        for (int i = 0; i < numPhilosophers; i++) {
            chopsticks[i] = new Chopstick();
        }

        for (int i = 0; i < numPhilosophers; i++) {
            Chopstick leftChopstick = chopsticks[i];
            Chopstick rightChopstick = chopsticks[(i + 1) % numPhilosophers];
//            philosophers[i] = new Philosopher(i, leftChopstick, rightChopstick);
            if (i % 2 == 0) {
                philosophers[i] = new Philosopher(i, leftChopstick, rightChopstick);
            } else {
                philosophers[i] = new Philosopher(i, rightChopstick, leftChopstick);
            }
            Thread thread = new Thread(philosophers[i]);
            thread.start();
        }
    }

    static class Philosopher implements Runnable {
        private final int id;
        private final Chopstick leftChopstick;
        private final Chopstick rightChopstick;

        private int eatTimes = 0;

        public Philosopher(int id, Chopstick leftChopstick, Chopstick rightChopstick) {
            this.id = id;
            this.leftChopstick = leftChopstick;
            this.rightChopstick = rightChopstick;
        }

        private void think() throws InterruptedException {
            System.out.println("Philosopher " + id + " is thinking.");
            Thread.sleep((long) ( 1000));
        }

        private void eat() throws InterruptedException {
            leftChopstick.pickUp();
            rightChopstick.pickUp();

            System.out.println("Philosopher " + id + " picks up both chopsticks and eats.");
            Thread.sleep((long) ( 1000));
            System.out.println("Philosopher " + id + " puts down both chopsticks.");

            rightChopstick.putDown();
            leftChopstick.putDown();
            eatTimes++;
            if (eatTimes % 10 == 0) {
                System.out.println("Philosopher " + id + " 目前吃了" + eatTimes + "次");
            }
        }

        @Override
        public void run() {
            try {
                while (true) {
                    think();
                    eat();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    static class Chopstick {
        private final Lock lock = new ReentrantLock();

        public void pickUp() {
            lock.lock();

        }

        public void putDown() {
            lock.unlock();
        }
    }
}

在上面的代码中,筷子只需要在被哲学家拿起时调用lock()函数,在放下时调用unlock()函数即可完成“同时拥有左手边的筷子和右手边的筷子”的目标。

使用Condition的代码:

package com.windypath;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class DiningPhilosophers {
    public static void main(String[] args) {
        int numPhilosophers = 5;
        Philosopher[] philosophers = new Philosopher[numPhilosophers];
        Chopstick[] chopsticks = new Chopstick[numPhilosophers];

        for (int i = 0; i < numPhilosophers; i++) {
            chopsticks[i] = new Chopstick();
        }

        for (int i = 0; i < numPhilosophers; i++) {
            Chopstick leftChopstick = chopsticks[i];
            Chopstick rightChopstick = chopsticks[(i + 1) % numPhilosophers];
//            philosophers[i] = new Philosopher(i, leftChopstick, rightChopstick);
            if (i % 2 == 0) {
                philosophers[i] = new Philosopher(i, leftChopstick, rightChopstick);
            } else {
                philosophers[i] = new Philosopher(i, rightChopstick, leftChopstick);
            }
            Thread thread = new Thread(philosophers[i]);
            thread.start();
        }
    }

    static class Philosopher implements Runnable {
        private final int id;
        private final Chopstick leftChopstick;
        private final Chopstick rightChopstick;

        private int eatTimes = 0;

        public Philosopher(int id, Chopstick leftChopstick, Chopstick rightChopstick) {
            this.id = id;
            this.leftChopstick = leftChopstick;
            this.rightChopstick = rightChopstick;
        }

        private void think() throws InterruptedException {
            System.out.println("Philosopher " + id + " is thinking.");
            Thread.sleep((long) ( 1000));
        }

        private void eat() throws InterruptedException {
            leftChopstick.pickUp();
            rightChopstick.pickUp();
            
            System.out.println("Philosopher " + id + " picks up both chopsticks and eats.");
            Thread.sleep((long) ( 1000));
            System.out.println("Philosopher " + id + " puts down both chopsticks.");
            
            rightChopstick.putDown();
            leftChopstick.putDown();
            eatTimes++;
            if (eatTimes % 10 == 0) {
                System.out.println("Philosopher " + id + " 目前吃了" + eatTimes + "次");
            }
        }

        @Override
        public void run() {
            try {
                while (true) {
                    think();
                    eat();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    static class Chopstick {
        private final Lock lock = new ReentrantLock(true);
        private final Condition condition = lock.newCondition();
        private boolean taken = false;

        public void pickUp() throws InterruptedException {
            lock.lock();
            try {
                while (taken) {
                    condition.await();
                }
                taken = true;
            } finally {
                lock.unlock();
            }
        }

        public void putDown() {
            lock.lock();
            try {
                taken = false;
                condition.signal();
            } finally {
                lock.unlock();
            }
        }
    }
}

可以看到,筷子类Chopstick加上了状态taken,用于判定目前筷子是否被某个哲学家拥有。 当第一个哲学家拥有某一只筷子的时候,taken为true;锁释放。当第二个哲学家拿起这只筷子时,还是会获得相同的锁,但会因为taken为true而进入condition.await()等待,此时也会释放锁,让其他哲学家能够获取这只筷子。 当筷子被放下时,调用signal()方法,此时之前await()函数的线程会被唤醒,执行其后序逻辑。

如果不使用公平锁,那么输出里你可能会看到有两个哲学家很晚才吃10次。如果使用公平锁,则5个哲学家几乎是同步吃到10次。

注意到在初始化哲学家时,奇数号哲学家的筷子是左右反过来拿的。这是因为在后续的获取筷子的逻辑中,我们总是先拿左手边的筷子,再拿右手边的筷子。如果不这样让一部分哲学家左右相反,那么会出现5个哲学家同时拿起左手边的筷子,然后等待右手边的筷子,造成死锁。(当然我们也可以通过随机数来让哲学家选择先左后右,还是先右后左。)

Windows XP虚拟机中文版无需激活下载

2022年10月2日 18:51

在这里直接下载我最后完成的虚拟机镜像

虚拟机镜像下载:

链接:https://pan.baidu.com/s/1yfY0SjDrtOeuTiEWf7YizA?pwd=374l

提取码:374l

我本地的VirtualBox 版本:6.1

我可以先聊聊怀旧吗

也许对于老外来说,Win95是他们的青春,所以他们做了React95这样的UI库,或者大家一起努力写了个操作系统serenity。但是对于咱们国内的用户而言,当家用PC走进千家万户时,映入眼帘的是——Windows XP。

一位中国台湾同胞sh1zuku编写开发了一个网页版的Windows XP模拟器:项目链接在线演示,还有另一位来自越南的老哥khang-nd写了Windows 7的网页模拟器在线演示

我自然没有能力和时间去编写类似系统的开发,但是有一个想法从我的脑中浮现:那么我就装一个Windows XP虚拟机到电脑上就好了。

你可能会选择的做法

  1. 安装VirtualBox;
  2. 下载镜像:从MSDN Itellyou中找到Windows XP的镜像,下载;
  3. 在VirtualBox中安装镜像;
  4. 启动镜像,安装系统

那么你会发现,安装的Windows XP只有30天试用。在二十一世纪初期,盗版泛滥,微软采取了非常强硬的措施限制盗版。即使你安装时输入了正确的序列号,微软也会要求你使用电话等方式进行激活。

但是我们是来怀旧的啊!

正确的做法

不能再使用这种正版的镜像了,需要使用别人处理好的,不需要激活的镜像。

这里我找到了github上的一位老哥提供的链接github WinXPImage,但是他的镜像是位于谷歌云盘上的。我把它下载之后放进百度网盘里了:

链接:https://pan.baidu.com/s/1ypKeaZixJXnbqAo4ZT0YLQ?pwd=30dd 提取码:30dd

在VirtualBox安装此镜像后,你将获得一个无限期使用的,英文版的Windows XP。

但你不一定能启动起来,你需要在导入OVA文件后,在OVA文件的目录下,执行以下命令:

vbox-img geometry --filename Windows_XP_Professional-disk1.vdi --format VDI --cylinders 5874 --heads 255 --sectors 56

来自于github issue streeg 老哥给出的解决方案。

如何汉化Windows XP?

如果只是用英文版Windows XP,那么就达不到我们“怀旧”的目的了。 Windows XP英文版默认没有中文语言包。 我翻遍互联网,终于在一个犄角旮旯里找到了语言包mui_win_xp_pro_n_cd1.iso。

我把它下载之后放进百度网盘里了:

链接:https://pan.baidu.com/s/18mW9OCRejMDoEpUcih-zlA?pwd=peu4 提取码:peu4

然后,

  1. 设置VirtualBox里Windows XP虚拟机和物理机共享文件夹,具体方法
  2. 将mui_win_xp_pro_n_cd1.iso通过共享文件夹从物理机传进虚拟机中
  3. 安装,具体方法

最后重启虚拟机即可。

上图!

Java TreeSet的一些用法和特性

2022年6月18日 17:10

就是一些例子啦。

先看一个例子(kotlin实现)

import java.util.TreeSet

/**
 * 定义一个用于测试TreeSet集合的结构
 * 用TreeSet进行排名
 * id: 玩家id
 * score: 玩家得分
 */
data class PlayerScore(var id: Int, var score: Int): Comparable<PlayerScore> {
    override fun compareTo(other: PlayerScore): Int {
        return if (score > other.score) {
            1
        } else if (score < other.score) {
            -1
        } else {
            0
        }
    }

}


fun main() {
    //创建一个TreeSet
    val treeSet: TreeSet<PlayerScore> = TreeSet()
    //创建3个PlayerScore,其中对id为101的对象存一个引用
    //将3个PlayerScore装入set中
    val id101Obj = PlayerScore(101,100)
    //按不同的顺序加入TreeSet
    treeSet.add(PlayerScore(102,200))
    treeSet.add(id101Obj)
    treeSet.add(PlayerScore(103,300))

    println("按不同的顺序加入TreeSet,按score从小到大输出:")
    showTreeSet(treeSet)

    println("再加入重复id,但分数不同的对象,按score从小到大输出:")
    treeSet.add(PlayerScore(101,500))
    showTreeSet(treeSet)

    println("直接修改id为101的对象的分数为400,按score从小到大输出:")
    id101Obj.score = 400
    showTreeSet(treeSet)

}

private fun showTreeSet(treeSet: TreeSet<PlayerScore>) {
    treeSet.forEachIndexed { index, s ->
        println("排名:${index+1} 分数:${s.score} id:${s.id}")
    }
    println()
}

运行结果:

按不同的顺序加入TreeSet,按score从小到大输出:
排名:1 分数:100 id:101
排名:2 分数:200 id:102
排名:3 分数:300 id:103

再加入重复id,但分数不同的对象,按score从小到大输出:
排名:1 分数:100 id:101
排名:2 分数:200 id:102
排名:3 分数:300 id:103
排名:4 分数:500 id:101

直接修改id为101的对象的分数为400,按score从小到大输出:
排名:1 分数:400 id:101
排名:2 分数:200 id:102
排名:3 分数:300 id:103
排名:4 分数:500 id:101

分析

首先,PlayerScore继承Comparable接口,实现了从小到大排序。在最开始只有3条数据时,按照不同的顺序插入TreeSet,可以正常的完成排序。

之后玩家101的分数发生改变,需要重新给玩家进行排名,我们分别通过两种方式进行操作:

  1. 通过id101Obj的引用来对这个对象的分数进行修改;
  2. 通过再往TreeSet中加入一个id为101,分数不同于之前的分数的对象。

结果: 第一种方式:玩家的积分被修改了,但是排名依旧为第1; 第二种方式:虽然积分正常排序,但是有两个id为101的对象。

所以如果我们的玩家积分发生改变的时候,要如何才能正常排序呢?

可以对某个对象先从TreeSet中删除,再往TreeSet中插入。这是因为TreeSet的底层是红黑树,在删除和插入的时候,会对树结构进行调整。 代码如下:

    //创建一个TreeSet
    val treeSet: TreeSet<PlayerScore> = TreeSet()
    //创建3个PlayerScore,其中对id为101的对象存一个引用
    //将3个PlayerScore装入set中
    val id101Obj = PlayerScore(101,100)
    //按不同的顺序加入TreeSet
    treeSet.add(PlayerScore(102,200))
    treeSet.add(id101Obj)
    treeSet.add(PlayerScore(103,300))

    println("按不同的顺序加入TreeSet,按score从小到大输出:")
    showTreeSet(treeSet)

    treeSet.remove(id101Obj)
    id101Obj.score = 400
    treeSet.add(id101Obj)
    println("先将id101Obj从TreeSet中删除,修改值,然后再插入TreeSet中,按score从小到大输出:")
    showTreeSet(treeSet)

结果如下:

按不同的顺序加入TreeSet,按score从小到大输出:
排名:1 分数:100 id:101
排名:2 分数:200 id:102
排名:3 分数:300 id:103

先将id101Obj从TreeSet中删除,修改值,然后再插入TreeSet中,按score从小到大输出:
排名:1 分数:200 id:102
排名:2 分数:300 id:103
排名:3 分数:400 id:101

更进一步

先删后加的方式是可以完成排序,但是TreeSet本身是线程不安全的。如果有多个线程都要更新玩家积分和排名,那么大概率会存在问题。 TreeSet虽然可以边插边排序,但是它只允许一次插入,插入之后积分的修改并不会修改排序。

对于排行榜上的排名经常变动的情况,用TreeSet是不太合理的。

另一种排行榜实现

本来选用TreeSet,是因为TreeSet拥有“边插入边排序”的特点,但在排行榜需要更新时并不方便。 那么换一种思路,如果只用变长的ArrayList来存储玩家积分对象,同样是在插入/更新/删除时进行排序?

先插入/更新/删除,对ArrayList内的数据进行修改,然后进行排序。

传统软件服务器与游戏服务器架构区别

2022年4月16日 18:15
项目 智能客服 爬虫 SLG游戏
语言 java python kotlin
模型 异步事件驱动 可能没什么模型可言 actor模型
传输协议 http http tcp + netty
传输结构 json json protobuf
数据库 oracle,redis mongodb mysql,redis
数据库框架 mybatis python库(类似jdbc) hibernate
缓存架构 管理员登录状态用redis 可能只缓存了页码 所有游戏用的数据全部缓存,每隔一段时间写到数据库中
配置信息 只有application.properties config.ini zookeeper,启动前需要将策划表信息刷到zk
并发 无并发(所以根本没用到锁) python多线程同时爬多个网站 无并发actor模型保证一次只做一件事
热更 直接重启 继承某个类的某个方法,替换掉线上的类

架构一览

智能客服服务端整体架构

智能客服服务端整体架构

SLG游戏整体架构

SLG游戏整体架构

典型案例

智能客服聊天

graph LR
A[客户端]-->|http聊天消息|B[智能客服系统]-->|将聊天信息放入mq|C[智能问答模型]-->|问答信息记录|D[Oracle数据库]
  

SLG游戏士兵出征

graph LR
A[客户端]-->|tcp发送的protobuf|B[Gate的Netty解包]-->|actor内部消息转发|C[WorldActor校验外城沙盘各种状态]-->|actor内部消息转发|D[playerActor内城校验和获取buff]
  

其中worldActor和playerActor都通过读取内存的方式读取到需要的数据,并不直接读取mysql数据库。 内存里的数据每隔一段时间(30秒左右)将变化的数据通过hibernate的方法更新到mysql中。

配置信息读取

对于智能客服来说,由于并发量业务量很少(因为投放的平台比较少,且晚上凌晨基本没什么人使用,且重启速度很快),有一些需求上的配置数值(比如发送几次“转人工”才进入转人工通道)是直接硬写在业务里的;也有一些配置信息是其他系统的请求地址url,会配在application.properties里。没有使用excel统一存储各个功能模块的配置信息,也确实不需要。

但对于游戏来说,数值这块的配置至关重要,与玩法,奖励,游戏内生态息息相关。因此在游戏服务器里,所有的数值几乎都要从配置表中读取。目前Excel应该是处理数据能力最强的软件了,策划可以通过Excel非常快速的把数值配好。 启动服务器的时候要读取配置表到内存(广义上的)里。但在有了zookeeper这样的工具出现之后,配置表数据可以放在zk中,需要的时候再取出来。

游戏服务器优势

传输结构为protobuf

服务器接收和发送的数据都是ProtoBuf转成的二进制,从而实现客户端服务器端统一的传输结构。protobuf自己有编码解码二进制的功能,从而实现消息数据的大小达到最小,编码解码是由netty执行的,使用上只需要将protobuf生成的对象传给netty即可。这边用的protobuf是v2版本的,有required,optional和repeated三种关键字,分别对传输message里的属性作出了约束。

Akka Actor架构

Actor架构实现单线程的业务逻辑,开发过程中不需要对一些公用的属性上锁;通过内部消息的通讯,让数据处理有序进行。Akka cluster集群有动态负载均衡的功能,可以进行平行扩展,也就是说,如果一台服务器扛不住并发,只要再配置一台或几台新服务器,加入集群就可以完成负载均衡。

配置表信息Zookeeper存储

通过将Excel数据表信息读取到java代码中,再转成二进制放入zk里,随时读取,从而完成了配置表的热更新。

数据在服务器启动后,缓存在内存中,间隔一定时间刷入库中

游戏服务器不直接操作数据库,而是操作内存中的数据。内存中的数据每隔一段时间,将数据变化更新到数据库中。如此一来,并发IO特别大的时候,也只是对内存中的数据进行修改,不会直接操作数据库。

两个系统中存在的问题

智能客服

抽取报表时直接用Oracle关联查询,效率太低,且没有读写分离

智能客服的报表是实时的,是通过一个特别长的,用union拼接的sql写的。所以查询速度非常慢。在线上环境,选定一个超过14天的时间区间,勾选多个报表评估项,查询时间超过30秒。也就是说sql执行了近30秒。当时一直说要优化优化,但是最后也没想出什么办法。

后来我也接触了一些报表,一般是将生的,未处理过的数据通过消息队列发给另一个日志收集库,可能会叫它数据仓库。然后另外有一个系统从数据仓库里抽取并统计数据。 另一种是抽取报表时只查询今天以前的数据,然后每天结束时24点之后执行某一个定时任务,统计更新报表数据。

当然了这些举措都导致了不能实时展示最新报表数据了,这也是一种取舍。

聊天记录日志设计不合理

聊天记录表每一行记录都包含了发送人和聊天会话的各种信息,存储了过多的状态(因为存在聊天记录评价这种特殊消息,所以结构发生了很大变化)。 有可能是最初只考虑了文字,后面加入了图片(支持html又有html注入风险),后面又出了“猜你想问”气泡,但猜你想问本身不计入聊天记录,又需要特殊处理。

由于没有安排重构,导致开发到后期,前后端代码都非常混乱。

SLG游戏服务器

热更问题

项目越庞大,遇到的问题就会越多。问题越多,修复更新到线上的速度当然要越快越好。得益于java的继承功能,我们可以通过继承某个装有业务逻辑的类,重新重写某个之前有问题的函数,修正为正确的业务逻辑,以完成线上玩家无感知的热更新。但是这并不能完全覆盖每一个线上问题。除了业务逻辑类之外,还有数据处理存储类,而数据处理存储类是不能热更的,因为它们是与定时落库逻辑强关联的类。有的业务逻辑被开发人员写在数据处理存储类中,就不能热更修改了。 还有一些属性被加上了private标识符,导致没办法直接取出来,只能用反射拿。

节点之间通信

Akka Actor是单线程,但不意味着整个系统都是单线程的。为了提高效率,我们根据业务逻辑,按照功能模块,分出了维护不同数据的Actor,比如网关Gate,外城沙盘World,内城玩家本身Home,联盟Alliance,全服排行榜Rank….但是这么一来,会有一些本应该由多个节点共享的数据,不得不需要用服务器内部消息来回同步。

比如说有一个运营活动,是关于某个人打沙盘上的某个怪,然后给自己和联盟增加积分。那么这时候,World,Home,Alliance三个节点都需要各自保存一份活动的开启时间和关闭时间。当然还有一种办法,是在zookeeper上分配一个json对象,存储当前的活动信息。但是这么一来,如果所有比较复杂的活动都使用这种方式,到最后zookeeper会存储过多的不常用的数据。

其他系统

智能客服边缘系统:知识库系统

本来的目的是将智能客服的语料信息单独抽出作为一个独立的子系统,但后来由于语料信息与智能客服本体关联太紧,导致没有办法抽出来。

为了实现模糊查询,使用了es作为存储数据库。 其中存在一个问题,就是关系型数据库与非关系型数据库的矛盾之处。 大概是用关系型数据库可以方便管理数据,减少数据冗余,但失去了es模糊查询倒排索引的能力。 但直接用菲关系型数据库的话,会让系统中其他数据库的表无法直接关联,被迫需要用服务的方式来获取资源,增加系统开销 如果在关系型的oracle和非关系型的es各存一份,则会出现数据不同步的问题,被迫需要定时任务去同步两个库,甚至还需要从两个库中作出鉴别,类似于git,svn等做版本管理。

游戏服务器:运维GM

  • 发送补丁到线上服务器,替换线上服务器的某个类的某个方法的实现,从而完成玩家无感知的热更新
  • 发送groovy脚本到线上服务器,在线上服务器上执行一段通过groovy编写的业务逻辑

使用Python实现简单UDP Ping

2021年11月13日 15:31

套接字编程作业2:UDP ping 程序

在本实验中,您将学习使用Python进行UDP套接字编程的基础知识。您将学习如何使用UDP套接字发送和接收数据报,以及如何设置适当的套接字超时。在实验中,您将熟悉Ping应用程序及其在计算统计信息(如丢包率)中的作用。

您首先需要研究一个用Python编写的简单的ping服务器程序,并实现对应的客户端程序。这些程序提供的功能类似于现代操作系统中可用的标准ping程序功能。然而,我们的程序使用更简单的UDP协议,而不是标准互联网控制消息协议(ICMP)来进行通信。 ping协议允许客户端机器发送一个数据包到远程机器,并使远程机器将数据包返回到客户(称为回显)的操作。另外,ping协议允许主机计算它到其他机器的往返时间。

以下是Ping服务器程序的完整代码。你的任务是写出Ping客户端程序。

服务器代码

以下代码完整实现了一个ping服务器。您需要在运行客户端程序之前编译并运行此代码。而且您不需要修改此代码。 在这个服务器代码中,30%的客户端的数据包会被模拟丢失。你应该仔细研究这个代码,它将帮助你编写ping客户端。

# UDPPingerServer.py 
# We will need the following module to generate randomized lost packets import random 
from socket import * 
import random

# Create a UDP socket  
# Notice the use of SOCK_DGRAM for UDP packets 
serverSocket = socket(AF_INET, SOCK_DGRAM) 
# Assign IP address and port number to socket 
serverSocket.bind(('', 12000)) 

while True:     
	# Generate random number in the range of 0 to 10 
	rand = random.randint(0, 10)     
	# Receive the client packet along with the address it is coming from  
	message, address = serverSocket.recvfrom(1024) 
	# Capitalize the message from the client     
	message = message.upper() 
	# If rand is less is than 4, we consider the packet lost and do not respond     
	if rand < 4:         
		continue     
	# Otherwise, the server responds         
	serverSocket.sendto(message, address)

服务器程序在一个无限循环中监听到来的UDP数据包。当数据包到达时,如果生成一个随机整数大于或等于4,则服务器将数字转为大写并将其发送回客户端。

数据包丢失

UDP为应用程序提供了不可靠的传输服务。消息可能因为路由器队列溢出,硬件错误或其他原因,而在网络中丢失。但由于在内网中很丢包甚至不丢包,所以在本实验室的服务器程序添加人为损失来模拟网络丢包的影响。服务器创建一个随机整数,由它确定传入的数据包是否丢失。

客户端代码

您需要实现以下客户端程序。

客户端向服务器发送10次ping。因为UDP是不可靠的协议,所以从客户端发送到服务器的数据包可能在网络中丢失。因此,客户端不能无限期地等待ping消息的回复。客户等待服务器回答的时间至多为一秒,如果在一秒内没有收到回复,您的客户端程序应该假定数据包在网络传输期间丢失。您需要查找Python文档,以了解如何在数据报套接字上设置超时值。

具体来说,您的客户端程序应该

  1. 使用UDP发送ping消息(注意:不同于TCP,您不需要首先建立连接,因为UDP是无连接协议。)
  2. 从服务器输出响应消息
  3. 如果从服务器受到响应,则计算并输出每个数据包的往返时延(RTT)(以秒为单位),
  4. 否则输出“请求超时”

在开发过程中,您应该先在计算机上运行UDPPingerServer.py,并通过向localhost(或127.0.0.1)发送数据包来测试客户端。调试完成代码后,您应该能看到ping服务器和ping客户端在不同机器上通过网络进行通信。

消息格式

本实验中的ping消息格式使用最简单的方式。客户端消息只有一行,由以下格式的ASCII字符组成:

Ping sequence_number time

其中sequence_number从1开始,一直到10,共10条消息,而time则是客户端发送消息时的时间。

需要上交的内容

您需要上交完整的客户端代码和屏幕截图,以验证您的ping程序是否按需求运行。

可选练习

  1. 目前,程序计算每个数据包的往返时间(RTT),并单独打印出来。请按照标准ping程序的模式修改。您需要在客户端每次ping后显示最小,最大和平均RTT。另外,还需计算丢包率(百分比)。
  2. UDP Ping的另一个类似的应用是UDP Heartbeat。心跳可用于检查应用程序是否已启动并运行,并报告单向丢包。客户端在UDP数据包中将一个序列号和当前时间戳发送给正在监听客户端心跳的服务器。服务器收到数据包后,计算时差,报告丢包(若发生)。如果心跳数据包在指定的一段时间内丢失,我们可以假设客户端应用程序已经停止。实现UDP Heartbeat(客户端和服务器端)。您需要修改给定的UDPPingerServer.py和您自己的UDP ping客户端。

客户端源码:

import time
from socket import *

host = '127.0.0.1'
port = 12000
clientSocket = socket(AF_INET, SOCK_DGRAM)
clientSocket.connect((host, port))
clientSocket.settimeout(1)

num = 10
sended = 0
received = 0
lost = 0
maxRTT = 0.0
minRTT = 0.0
sum = 0.0
for i in range(num):
    sendTime = time.time()
    sendMsg = ('Ping %d %s' % (i + 1, sendTime)).encode()
    try:
        clientSocket.sendto(sendMsg, (host, port))
        sended = sended + 1
        recvMsg, addr = clientSocket.recvfrom(1024)
        received = received + 1
        rtt = time.time() - sendTime
        print('Sequence %d: Reply from %s    RTT = %.3fs' % (i + 1, host, rtt))
        sum += rtt
        if i == 0:
            maxRTT = rtt
            minRTT = rtt
        else:
            if rtt < minRTT:
                minRTT = rtt
            if rtt > maxRTT:
                maxRTT = rtt
    except Exception as e:
        lost = lost + 1
        print('Sequence %d: Request timed out' % (i + 1))
clientSocket.close()
print('host: %s ping statistic:' % host)
print('\t package: sended = %d, received = %d, lost = %d (%d%% lost rate)' %(sended, received, lost, int(1.0 * lost / sended * 100)))
if received > 0:
    print('rtt statistic: ')
    print('\t min = %.3fs, max = %.3fs, avg = %.3fs' % (minRTT, maxRTT, 1.0 * sum / received))

在这段源码中,我模仿Windows的ping程序,成功实现了类似的效果。

关于可选练习的第二个问题

我认为服务端这边需要在读取客户端发的消息中,提取客户端的发送时间,并将其与服务器当前时间相减而得到RTT时延,以此来判定是否超时。

另外,心跳检测应规定每X秒都由客户端发送一条简短的消息到服务端,服务端存储一个客户端状态列表,更新最后状态更新时间。

同时服务端应该有一个tick机制,即每隔Y秒都检查一遍客户端状态列表,如果上次状态更新时间与当前时间超过了设置的阈值,则判定该客户端已经离线。

学到的东西:

  1. TCP和UDP用的都是AF_INET,但是TCP用的是SOCK_STREAM,而UDP用的是SOCK_DGRAM
  2. 用sendto()函数的话就不需要在前面调用connect()方法,而用send()之前需要connect()
  3. 用recvfrom()函数可以获得接收的消息和消息来源信息,而recv()只能获取消息
  4. 用settimeout(1)可以设置超时时间,但超时会直接抛出异常;如果要继续下去,则需要用try excerpt接取异常信息处理。

Kotlin手动实现一个最简单的哈希表

2021年10月16日 17:43

参考的是《数据结构(C语言版)》上256页左右的哈希表的介绍,用了最简单的直接寻址法 + 链地址法。

用的是Kotlin。

package main.kotlin

/**
 * 手动实现简单的hash表
 * 简单的数组 +链表 (无红黑树)
 * 要求哈希函数可配置(被自我否决,太复杂了啦),这次就先做比较简单的 直接定址法 + 链地址法
 *
 * @Date 2021-10-16.
 * @author Johnathan Lin
 */

data class Node(
  val key: Int, //key
  var value: Int, //value
  var next: Node? //如果hash值重复了,则用头插法放进去
)

fun main() {
  // hash表,这次可为空
  val size = 100
  val hashArr: Array<Node?> = Array(size) { null }

  //插入 假设插入key 8 value 24
  println("插入key 8 value 24")
  set(hashArr, size, 8, 24) { k, s -> k % s }
  println("插入key 108 value 32")
  set(hashArr, size, 108, 32) { k, s -> k % s }
  var v = get(hashArr, size, 108) { k, s -> k % s }
  println("读取key为108: $v")
  println("删除key 108")
  remove(hashArr, size, 108) { k, s -> k % s }
  v = get(hashArr, size, 108) { k, s -> k % s }
  println("读取key为108: $v")
  v = get(hashArr, size, 8) { k, s -> k % s }
  println("读取key为8: $v")

}

/**
 * @param hashFunc 哈希函数 param1:key param2:size
 */
fun get(hashArr: Array<Node?>, size: Int, key: Int, hashFunc: (Int, Int) -> Int): Node? {
  val pos = hashFunc.invoke(key, size)
  val queueHead = hashArr[pos]
  if (queueHead == null) {
    return null
  } else {
    if (queueHead.key == key) {
      return queueHead
    }
    var p = queueHead
    while(p?.next != null) {
      if (p.next?.key == key) {
        return p.next
      }
      p = p.next
    }
  }
  return null
}

fun set(hashArr: Array<Node?>, size: Int, key: Int, value: Int, hashFunc: (Int, Int) -> (Int)) {
  //get 不到的时候才会set
  val node = get(hashArr, size, key, hashFunc)
  if (node != null) {
    node.value = value
  } else {
    val pos = hashFunc.invoke(key, size)
    val newNode = Node(key, value, null)
    if (hashArr[pos] == null) {
      hashArr[pos] = newNode
    } else {
      val next = hashArr[pos]?.next
      if (next == null) {
        hashArr[pos]?.next = newNode
      } else {
        newNode.next = next
        hashArr[pos]?.next = newNode
      }
    }
  }

}

fun remove(hashArr: Array<Node?>, size: Int, key: Int, hashFunc: (Int, Int) -> (Int)) {
  val pos = hashFunc.invoke(key, size)
  val queueHead = hashArr[pos]
  if (queueHead == null) {
    return
  } else {
    if (queueHead.key == key) {
      if (queueHead.next != null) { //好像jdk7有一个hashMap的bug?
        hashArr[pos] = queueHead.next
        return
      } else {
        hashArr[pos] = null
      }
    } else {
      var p = queueHead
      while(p?.next != null) {
        if (p.next?.key == key) {
          if (p.next?.next != null) {
            p.next = p.next?.next
          } else {
            p.next = null
          }
          return
        }
        p = p.next
      }
    }
  }
}

然后是输出:

插入key 8 value 24
插入key 108 value 32
读取key为108: Node(key=108, value=32, next=null)
删除key 108
读取key为108: null
读取key为8: Node(key=8, value=24, next=null)

Process finished with exit code 0

搭建Spark实战环境(3台linux虚拟机集群)(一)样板机的搭建

2020年5月23日 17:42

系统及软件配置

系统配置

内存:16g 2400 cpu:i5 9400F

软件配置

  • Windows 10 1903版本
  • VMware workstation 15.10
  • CentOS centos-release-7-7.1908.0.el7.centos.x86_64
  • Java jdk-8u241-linux-x64.tar.gz
  • Scala scala-2.11.8.tgz

值得一提的是,win10 1903版本与VMware 15.10之前的版本不兼容,会出现卡死(虚拟机繁忙)的问题,该问题在15.10版本解决。 资源来源于网络: VMware 15.10 链接:https://pan.baidu.com/s/1bpF3M1V3qPydQgGl-hUC1g 提取码:sb8t CentOS 7.7 链接:https://pan.baidu.com/s/1xFOlCvNrmXXwciVNOL2Pew 提取码:wxdx Java jdk-8u241-linux-x64.tar.gz 链接:https://pan.baidu.com/s/1E1TyObvyO6iQUW44lTCqDQ 提取码:6xw9 Scala scala-2.11.8.tgz 链接:https://pan.baidu.com/s/1TQB-wnffHh0i2aqzzfSi6A 提取码:iowh

安装前的准备

查看Windows下的网络配置

以Windows 10 1903为例(windows下控制台输入“winver”可以看自己windows版本),在控制面板->网络和Internet->网络连接中,可以看到本机上的网络配置。本次搭建这个集群,我们需要虚拟机和物理机(也就是windows)共用一个网络,所以需要使用桥接模式。 本人电脑联网时使用的是这个WLAN 2,它使用的网卡是Realtek 8821AE …. 的,记住这个名字。

配置VMware 虚拟网络编辑器

使用管理员权限打开VMware,菜单栏的编辑->虚拟网络适配器:将桥接模式的“桥接到”指向刚才记下的Realtek 8821AE,点击确定保存配置。 同时还要记住我WLAN 2这张网卡的网络配置信息:(windows cmd输入ipconfig查看)

集群及网络配置

根据刚才WLAN 2网络配置信息可知,我的虚拟机的网卡只能配置为192.168.3.***。 你得根据自己网卡上的网络配置去修改虚拟机网卡配置。

序号 IP地址 机器名 运行进程 核数/内存 用户名
1 192.168.3.30 master NN/SNN/DN/RM
Master/Worker
1核/3G spark
2 192.168.3.31 slave1 DN/NM/Worker 1核/2G spark
3 192.168.3.32 slave2 DN/NM/Worker 1核/2G spark
用到的目录:
/app,/app/soft,/app/compile,/app/spark,/home/spark

开始安装

搭建集群样板机

在VMware中安装CentOS

新建虚拟机

菜单栏,文件->新建虚拟机 选择刚才下载的CentOS镜像: 一路下一步到这里,点击“自定义硬件”把内存调成3G,网络适配器调成桥接模式,然后完成并启动。 按↑键,选中Install CentOS 7,再按Enter键开始安装。

安装CentOS 7

选择语言简体中文后,进入到这个界面: 点击这个“软件选择”,进入选择界面: 勾选GNOME桌面,然后自定义勾选需要的附加选项,我这里只选了“兼容性程序库”和“系统管理工具”,之后如果有缺少软件的话,之后再补装就好。点击左上角完成。 选择网络和主机名,可以配置网络。

配置网络

打开以太网的连接,然后点击“配置”,切到IPv4设置,输入刚才我定义的第一台虚拟机的ip地址192.168.3.30,子网掩码255.255.255.0,网关和物理机windows一样192.168.3.1(见前文),最后附加DNS服务器填入谷歌提供的8.8.8.8。 然后点一下安装位置,选择自动分区,再回来就可以点“开始安装”了。

设置用户

安装过程中,可以设置root和用户,简单设置吧以免忘记密码。 以下是我设置的root和用户(仅做学习之用,也以防忘记密码):

用户 密码
root root
spark spark
由于密码太过简单,我们需要多按一次左上角的“完成”。
值得一提的是,如果不在这里设置root和用户,在安装结束后设置的话,系统会要求你设置比较复杂的密码。
静静等待其安装完。

重启,然后接受许可证,点完成,使用spark用户登录,再简单配置下,就正式进入系统。

测试网络

点击应用程序->Firefox,然后访问百度,如果成功则网络连接正常。

设置机器名

用root使用vi /etc/sysconfig/network中设置内容如下:

NETWORKING=yes
HOSTNAME=master

至此,CentOS安装完成。

设置Host映射文件

/etc/hosts内保存了网址域名/机器名与其对应的ip地址建立关联的一个“数据库”,我们根据集群规划配置: 以root的身份(先su root然后输入root的密码)在命令行用#vi /etc/hosts打开配置文件,然后按小写字母i,输入以下内容然后按Esc + :wq回车保存:

192.168.3.30 master
192.168.3.31 slave1
192.168.3.32 slave2

设置完毕后,用#ping master看看master是否连通及检测服务器响应速度。

关闭防火墙和SELinux

关闭防火墙和SELinux的原因在于Hadoop和Spark运行过程中需要使用端口进行通信,而这些安全设施会阻拦。关闭方法: 关闭iptables时,使用root登录,在命令行终端使用#service iptables status查看iptables状态,如果显示“iptables: Firewall is not running”则iptables已关闭,如果显示iptables的配置信息,则使用如下命令关闭iptables:

chkconfig iptables off

同样,用root用户在终端使用#vi /etc/SELinux/config打开配置文件,设置SELINUX=disable,注意需要重启才生效。 然而在我们虚拟机上并没有iptables: SELinux设置为disable。

配置运行环境

更新OpenSSL

CentOS自带的OpenSSL存在bug,所以我们使用如下命令更新

yum update openssl

当然了我自己跑了一下并没有看到需要更新,也许centos7修复了。

修改OpenSSH配置

在集群环境中需要SSH进行免密码登录,需要修改OpenSSH配置文件,确认使用RSA算法进行公钥加密并确认私钥存放文件等,需要使用root用户,使用vi /etc/ssh/sshd_config打开配置文件,打开以下三个配置项: 保存修改后使用systemctl restart sshd.service重启服务。

将用户spark加入sudoers中

后面执行中需要使用sudo命令,故将spark加入sudoers文件中,先修改配置文件的权限:chmod u+x /etc/sudoers,再使用vi /etc/sudoers打开文件,在root ALL=(ALL)ALL后加入:

spark ALL=(ALL) ALL

他虽然说是只读,但是你可以用root编辑,然后使用wq! 加感叹号保存。

创建运行环境所需要的目录结构

mkdir /app
chown -R spark:spark /app
mkdir /app/soft
mkdir /app/compile
mkdir /app/spark
mkdir -p /home/spark/work
chown -R spark:spark /home/spark/work

安装Java和Spark环境

安装和配置JDK

安装一下vmware tools,然后把jdk的包拖进去,放到/home/spark/work中。(这个找一找centos虚拟机安装vmware tools教程吧) 解压该文件并移动到/app/soft中

cd /home/spark/work
tar -zxf jdk-8u241-linux-x64.tar.gz
mv jdk1.8.0_241 /app/soft
ll /app/soft

然后以root执行vi /etc/profile,设置JDK相关配置如下:

export JAVA_HOME=/app/soft/jdk1.8.0_241
export PATH=$JAVA_HOME/bin:$PATH
export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar

保存后回到终端,输入source /etc/profile更新全局变量,然后输入java和javac看看是否安装成功。

安装Scala

把scala安装包放入/home/spark/work中。解压该文件并移动到/app/soft中。

cd /home/spark/work
tar -zxf scala-2.11.8.tgz
mv scala-2.11.8 /app/soft
ll /app/soft

然后以root执行vi /etc/profile,设置JDK相关配置如下:

export SCALA_HOME=/app/soft/scala-2.11.8
export PATH=${SCALA_HOME}/bin:$PATH

保存后回到终端,输入source /etc/profile更新全局变量,然后输入scala -version看看是否安装成功。

参考文献

MongoDB常用查询语句

2020年2月9日 16:36

增删改查部分

MongoDB单条件查询

db.xxx.find({'city.value':'深圳市'})

文档部分json结构为:

{
    ...
    "city": {
        "chinese": "城市",
        "value": "深圳市"
    }
 
}

使用key.key的形式,得到对应的value。

MongoDB与查询

db.xxx.find({$and:[{'city.value':'深圳市'},{'status': 'finished'}]})

MongoDB的Group By和Having

db.xxx.aggregate(
    {
        $match: {
            'city.value':'马鞍山市'
        }
    },
    {
        $group : {
            _id : "$service",
            num_tutorial : {
                $sum : 1
            }
        }
    },
    {
        $match:{
            num_tutorial:{
                $gt:1
            }
        }
    }
)

$group指定对哪个属性分组,用num_tutorial来判断数量是否满足某个条件。 $gt:greater than

删除文档

db.xxx.remove({'city.value':'伊犁哈萨克自治州'})

删除重复数据

删除某个属性重复的数据,只保留第一条

db.xxx.aggregate(
    {
        $match:{
            'city.value':xxx
        }
    },
    {
        $group : {
            _id : "$service", 
            num_tutorial : {
                $sum : 1
            },
            dups:{
                $addToSet:'$_id'
            }
        }
    },
    {
        $match:{
            num_tutorial:{
                $gt:1
            }
        }
    }).forEach(doc2=>{
        doc2.dups.shift();
        db.bszn.remove({_id:{$in:doc2.dups}});
    })

为选中的文档增加属性 为“status”属性赋值为“finished”。

db.xxx.update({}, {$set:{'status':'finished'}},{multi: true})

为选中的文档删除属性 删除“status”属性。

db.xxx.update({}, {$unset:{'status':''}},{multi: true})

根据ObjectId查询

db.xxx.find({'_id': new ObjectId('5d7368fb5a32cfe8d8651781')})

向数组中插入值或对象

db.xxx.update({'_id': new ObjectId('5d7368fb5a32cfe8d8651781')},
{$push:{'某个字段''要插入的值'}})

Mongo导入导出

Mongo导出 – mongodump

mongodump -h 192.168.xx.xxx:xxxxx -u xxxx -p xxx -d xxxxx -c xxx -o W:\xxxxx\dump\someDirectory
  • -h 服务器ip:端口号
  • -u 用户名
  • -p 密码
  • -d 选择的数据库
  • -c 选择的集合(如果没有-c,则将所有集合导出备份)
  • -o 选择导出的目录

注意,如果出现

Failed: error writing data for collection `bigdata_znkf_crawl.fgcx` to disk: error reading collection: 
Failed to parse: { find: "fgcx", skip: 0, snapshot: true, 
$readPreference: { mode: "secondaryPreferred" }, 
$db: "bigdata_znkf_crawl" }. Unrecognized field 'snapshot'.

则在后面加上:

--forceTableScan

Mongo导入 – mongorestore

mongorestore -h 192.168.xx.xxx:xxxxx -u xxx -p xxxx -d xx  -c xxx 
W:\xxxxxx.bson

Mongo数组查询

字符串数组

来源:https://www.jianshu.com/p/cf983a28c2da

使用$all操作: 最后那个文件前面不用 横杠+字母,直接选择一个.bson文件即可导入了。

db.fruitshop.find({"fruits":{"$all":["apple","banana"]}});

对象数组

来源:https://www.jb51.net/article/126911.htm

使用$elemMatch操作:

{ "qList": { $elemMatch: { "qid": 1, "reorderFlag": 0} } }

Mongo树查询(存在parent_id进行连接)

db.crawler_classify.aggregate([
    {$match: {'classify_id' : '94ebb480247d42dcb5c99a2ef19550ab'}},
    {$graphLookup : {from: 'crawler_classify', startWith: "$classify_id" , connectFromField: "classify_id", connectToField: "parent_id", as :"son"}}
    ])

Mongo插入语句

db.crawler_classify.insert({
    'xxxx': 'yyyy',
    'zzzz': 'aaa',
    ...
})

Python的一些用法(可能不定时更新)

2019年12月1日 10:45

strip()、lstrip()、和rstrip()

Python strip() 方法用于移除字符串头尾指定的字符(默认为空格或换行符)或字符序列。

注意:该方法只能删除开头或是结尾的字符,不能删除中间部分的字符。

lstrip()就是从左边匹配然后删除字符,rstrip()从右边匹配然后删除字符。

表面上挺好理解的,但是用起来还是有一些陷阱。

如:

if __name__ == '__main__':
    string = 'abcdefghijkl'
    print(string.lstrip('bac'))
    # 输出 defghijkl

可以看到,虽然左侧开头的’abc’和’bac‘顺序不同,但lstrip()方法依旧将其匹配然后删除了。

所以如果我只是要删除开头的某一部分,比如获取标签内的字符:

if __name__ == '__main__':
    string = '<a href="http://www.windypath.com">abcde</a>'
    print(string.lstrip('<a href="http://www.windypath.com">').rstrip('</a>'))
    # 输出 bcde

就会把标签内容的最左边的a给匹配到了。

那么如何实现只根据字符顺序,匹配前面的字符呢?

用正则表达式:re.sub()

Python 的 re 模块提供了re.sub用于替换字符串中的匹配项。

语法:

re.sub(pattern, repl, string, count=0, flags=0)

参数:

  • pattern : 正则中的模式字符串。
  • repl : 替换的字符串,也可为一个函数。
  • string : 要被查找替换的原始字符串。
  • count : 模式匹配后替换的最大次数,默认 0 表示替换所有的匹配。

使用该方法:

import re
if __name__ == '__main__':
    string = '<a href="http://www.windypath.com">abcde</a>'
    clear_pre = re.sub(r'<a href="http://www.windypath.com">', '', string)
    clear_post = re.sub(r'</a>', '', clear_pre)
    print(clear_post)
    # 输出 abcde

js的indexOf()对应python的index()

对于数组而言:

js的indexOf()返回元素在数组中的下标:

var fruit = ['apple', 'banana', 'orange', 'pear']
console.log(fruit.indexOf('banana'))
// 输出 1

python里也有一个名字很像的index

if __name__ == '__main__':
    fruit = ['apple', 'banana', 'orange', 'pear']
    print(fruit.index('banana'))
    # 输出 1

但对于字符串:

对于js而言,依旧可以使用indexOf()函数。

找得到的情况:

var string = 'I love apple and banana'
console.log(string.indexOf('apple'))
// 输出 7

找不到的情况:

var string = 'I love apple and banana'
console.log(string.indexOf('orange'))
// 输出 -1

找不到时,输出-1。

而python的index在字符串中找不到时,将抛出异常。

if __name__ == '__main__':
    string = 'I love apple and banana'
    print(string.index('orange'))
    # Traceback (most recent call last):
    # File "/****.py", line 12, in <module>
    # print(string.index('orange'))
    # ValueError: substring not found

index()方法在找不到时,并不返回-1。

但find()函数在找不到时可以返回-1.

if __name__ == '__main__':
    string = 'I love apple and banana'
    print(string.find('orange'))
    # 输出 -1
    print(string.find('apple'))
    # 输出 7

python连接Oracle和MongoDB

这个。。待写,下篇文章再见。

2020.2.9已更新:点击这里

文件读取f.truncate()

关于文件的w,a,r和它们的+,在网上资料很多,这里我只想备份一下:

f.truncate()是将文件清空的函数。

只有在w的模式下可以执行,在a下没有效果,r则抛出异常。

if __name__ == '__main__':
    file_name = '测试.txt'
    with open(file_name, 'w') as f:
        f.truncate()

对dict对象的.keys()取出所有键

这是个非常实用的方法。

if __name__ == '__main__':
    obj = {
        'website': 'www.windypath.com',
        'name': '风萧古道',
        'favorite_team': 'Los Angeles Lakers'
    }
    print(obj.keys())
    # dict_keys(['website', 'name', 'favorite_team'])

使用range()和enumerate()对数组遍历

range():传入某个整数,获取从0到该数-1的数组

if __name__ == '__main__':
    sum = 5
    for i in range(sum):
        print(i)
    # 输出
    # 0
    # 1
    # 2
    # 3
    # 4

enumerate():传入某个list,获取每个元素的下标和自身

if __name__ == '__main__':
    fruit_list = ['apple', 'banana', 'orange', 'pear']
    for index, fruit in enumerate(fruit_list):
        print(index, fruit)
    # 输出
    # 0 apple
    # 1 banana
    # 2 orange
    # 3 pear

java正则表达式 - 双反斜杠(\)和Pattern的matches()与find()

2019年11月24日 19:43

参考文献 java正则表达式(find()和 matches()) java正则表达式,求匹配:双反斜杠(\)合法,单反斜杠不合法(\) Java 正则表达式-菜鸟教程 正则表达式-菜鸟教程

Pattern类和Matcher类

在Java中,与正则表达式相关的类有两个:Pattern和Matcher

菜鸟教程已经介绍的很好了。

java.util.regex 包主要包括以下三个类:

  • Pattern 类:pattern 对象是一个正则表达式的编译表示。Pattern 类没有公共构造方法。要创建一个 Pattern 对象,你必须首先调用其公共静态编译方法,它返回一个 Pattern 对象。该方法接受一个正则表达式作为它的第一个参数。
  • Matcher 类:Matcher 对象是对输入字符串进行解释和匹配操作的引擎。与Pattern 类一样,Matcher 也没有公共构造方法。你需要调用 Pattern 对象的 matcher 方法来获得一个 Matcher 对象。
  • PatternSyntaxException:PatternSyntaxException 是一个非强制异常类,它表示一个正则表达式模式中的语法错误。

然后菜鸟教程的第一个代码样例如下:

import java.util.regex.*;
 
class RegexExample1{
   public static void main(String args[]){
      String content = "I am noob " +
        "from runoob.com.";
 
      String pattern = ".*runoob.*";
 
      boolean isMatch = Pattern.matches(pattern, content);
      System.out.println("字符串中是否包含了 'runoob' 子字符串? " + isMatch);
   }
}

但我尝试把他改成自己的正则表达式,如”^I am“来匹配开头的”I am"字符串时,我发现程序一直返回false。

这是为什么呢?

原因是,matches()函数是用于字符串全匹配的。若正则内的字符串与待匹配的字符串存在完全匹配,则返回false。

具体的阳602说的更好一些。

find()和matches()

1.find()方法是部分匹配,是查找输入串中与模式匹配的子串,如果该匹配的串有组还可以使用group()函数。 matches()是全部匹配,是将整个输入串与模式匹配,如果要验证一个输入的数据是否为数字类型或其他类型,一般要用matches()。

2.

Pattern pattern= Pattern.compile(".*?,(.*)");
Matcher matcher = pattern.matcher(result);
if (matcher.find()) {
    return matcher.group(1);
}

3.详解: matches public static boolean matches(String regex, CharSequence input) 编译给定正则表达式并尝试将给定输入与其匹配。 调用此便捷方法的形式 Pattern.matches(regex, input); Pattern.compile(regex).matcher(input).matches() ; 如果要多次使用一种模式,编译一次后重用此模式比每次都调用此方法效率更高。 参数: regex - 要编译的表达式 input - 要匹配的字符序列 抛出: PatternSyntaxException - 如果表达式的语法无效

find public boolean find()尝试查找与该模式匹配的输入序列的下一个子序列。 此方法从匹配器区域的开头开始,如果该方法的前一次调用成功了并且从那时开始匹配器没有被重置,则从以前匹配操作没有匹配的第一个字符开始。 如果匹配成功,则可以通过 start、end 和 group 方法获取更多信息。 matcher.start() 返回匹配到的子字符串在字符串中的索引位置. matcher.end()返回匹配到的子字符串的最后一个字符在字符串中的索引位置. matcher.group()返回匹配到的子字符串 返回: 当且仅当输入序列的子序列匹配此匹配器的模式时才返回 true。

4.部分JAVA正则表达式实例

①字符匹配

Pattern p = Pattern.compile(expression); // 正则表达式
Matcher m = p.matcher(str); // 操作的字符串
boolean b = m.matches(); //返回是否匹配的结果
System.out.println(b);
Pattern p = Pattern.compile(expression); // 正则表达式
Matcher m = p.matcher(str); // 操作的字符串
boolean b = m. lookingAt (); //返回是否匹配的结果
System.out.println(b);
Pattern p = Pattern.compile(expression); // 正则表达式
Matcher m = p.matcher(str); // 操作的字符串
boolean b = m..find (); //返回是否匹配的结果
System.out.println(b);

②分割字符串

Pattern pattern = Pattern.compile(expression); //正则表达式
String[] strs = pattern.split(str); //操作字符串 得到返回的字符串数组

③替换字符串

Pattern p = Pattern.compile(expression); // 正则表达式
Matcher m = p.matcher(text); // 操作的字符串
String s = m.replaceAll(str); //替换后的字符串

④查找替换指定字符串

Pattern p = Pattern.compile(expression); // 正则表达式
Matcher m = p.matcher(text); // 操作的字符串
StringBuffer sb = new StringBuffer();
int i = 0;
while (m.find()) {
    m.appendReplacement(sb, str);
    i++; //字符串出现次数
}
m.appendTail(sb);//从截取点将后面的字符串接上
String s = sb.toString();

⑤查找输出字符串

Pattern p = Pattern.compile(expression); // 正则表达式
Matcher m = p.matcher(text); // 操作的字符串
while (m.find()) {
    matcher.start() ;
    matcher.end();
    matcher.group(1);
}

有趣的双反斜杠(\)

在Java字符串中,存在诸如\n,\r等转义字符,而反斜杠\自己本身也是转义字符,所以在Java字符串中,要输出\,需要写两个\,即\。

而正则表达式也需要匹配转义字符,故正则表达式要匹配一个\的时候,也需要写两个\。

所以,在Java中使用正则表达式,要匹配一个\,需要写四个\。

正则表达式特殊字符(来自菜鸟教程)

特别字符 描述
$ 匹配输入字符串的结尾位置。如果设置了 RegExp 对象的 Multiline 属性,则 $ 也匹配 ‘\n’ 或 ‘\r’。要匹配 $ 字符本身,请使用 $。
( ) 标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。要匹配这些字符,请使用 ( 和 )。
* 匹配前面的子表达式零次或多次。要匹配 * 字符,请使用 *。
+ 匹配前面的子表达式一次或多次。要匹配 + 字符,请使用 +。
. 匹配除换行符 \n 之外的任何单字符。要匹配 . ,请使用 . 。
[ 标记一个中括号表达式的开始。要匹配 [,请使用 [。
? 匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。要匹配 ? 字符,请使用 ?。
\ 将下一个字符标记为或特殊字符、或原义字符、或向后引用、或八进制转义符。例如, ’n’ 匹配字符 ’n’。’\n’ 匹配换行符。序列 ‘\’ 匹配 “",而 ‘(’ 则匹配 “("。
^ 匹配输入字符串的开始位置,除非在方括号表达式中使用,此时它表示不接受该字符集合。要匹配 ^ 字符本身,请使用 ^。
{ 标记限定符表达式的开始。要匹配 {,请使用 {。
| 指明两项之间的一个选择。要匹配

简历

2001年1月1日 08:00

工作技能

编程语言: Golang, Java, Kotlin, Python, Shell, SQL

技术栈: Akka, MongoDB, MySQL, Redis, SpringBoot

英语:大学英语六级(CET-6)

工作经历

字节投资的2年创业公司

类征途MMO Golang服务器开发工程师 上海 类征途MMO是一款多人国战MMORPG网络游戏,目前已在微信小程序测试中。 2023年9月 – 2023年11月

主要负责工作:

  1. 使用 Golang 开发敏感词检测模块,读取100,000条敏感词,构建敏感词检测 Trie 字典树并将其缓存到二进制文件,实现毫秒内完成敏感词匹配,与 Github 其他同类模块(importcjj/sensitive,antlinker/go-dirtyfilter )相比匹配速度快25%;
  2. 快速上手 Mqant 微服务框架,熟悉进程间通信机制,配合使用 MongoDB和 Redis ,开发 MMORPG 战场玩法,实现100名玩家同屏争夺首领尾刀奖励的功能;
  3. 配合策划开发新功能,管理员玩家能够将地图内 NPC 外观替换为指定玩家外观。同时快速熟悉玩家的养成维度,整理任务类型,和策划配合完善玩家养成模块的开发,包括新手任务、主线任务、礼包、活动等。

某上市游戏公司

SLG手游 Java服务器开发执行主程序 上海 SLG手游是一款战争策略SLG网络游戏,目前已在Google Play上架3年。 2020年12月 – 2023年5月

主要负责工作:

  1. 深入参与 SLG 游戏的沙盘玩法开发,包含大地图 AOI 算法和状态同步功能,优化行军及行军线、怪物刷新循环、AI 行为等大功能模块;
  2. 精通 Java 。就职期间基于 Akka Cluster 微服务框架开发服务器,使用 MySQL和 Redis 存储服务器数据。主导了一个大型跨服副本玩法的实现(百名玩家同地图抢怪),完成其中的玩家匹配功能和 Boss 特殊争夺机制(驻扎积攒势力值,满足条件后才可攻击);
  3. 与策划协作进行战斗模块的开发和优化,实现多种新技能机制,简化战斗协议数据结构,实现战报大小缩减10%。协助策划打印线上战报信息,排查战斗 Bug 。完善了内城养成模块(英雄、装备、科技、任务等)及外城沙盘玩法,开发了老玩家回归活动;
  4. 为了提升开发效率,增加测试时间,重构了项目已有的活动框架代码,去除冗余结构。在不影响线上游戏运营的情况下,节约了开发时间,重构后,活动开发时间缩短一半;
  5. 拥有丰富的线上问题排查经验,善于从玩家反馈、后台日志、代码和配置表中找到问题所在。配合运维跟进版本的更新,监控服务器环境和日志,随时解决问题;
  6. 工作态度认真负责,及时排查和修复线上 Bug,7*24小时监听线上客服反馈。于2022年9月起独立负责服务器开发,且负责期间无线上重大 Bug 发生。

某创业板上市公司

云API接口开放平台 Vue + SpringBoot全栈开发 福建厦门 整合集团各个子公司提供的API接口到一个统一的平台中进行管理和使用。 2019年5月 – 2020年11月

负责工作:

  1. 参与开发外部接口的安全传输功能。使用 OAuth 2.0协议标准生成 access_token ,用 Redis 暂存 access_token 并设置过期时间,使用 RSA与 AES 加密算法保证安全传输;
  2. 使用 Vue + SpringBoot 开发 API 接口的管理系统,并将用户数据信息及接口使用情况统计存储在 MySQL中;
  3. 负责版本更新,使用 Nginx 反向代理将其部署到服务器。

教育经历

2015年9月 - 2019年6月 非985 211本科 软件工程 本科 GPA:3.31/5.0 (专业前8%)

MQTT协议中可变长度的具体计算方式(有计算过程解析)

2023年12月26日 16:06

本文介绍MQTT协议中,固定报头中的可变长度部分的计算方式。通过提供一些例子,将其他介绍MQTT协议的文章中没有仔细说明的计算部分进行解释。

参考文章

MQTT 5.0 报文(Packets)入门指南

这篇文章简要介绍了MQTT,但是没有明确提供计算过程。

MQTT简介之三(3) MQTT协议 报文的剩余长度如何计算和编码

这篇文章也介绍了MQTT,且有提供计算过程,但是是用一大段文字进行表述的,理解起来有些困难。

至于New Bing(GPT4.0)和ChatGPT 3.5,他们在做这种特殊规则下的二进制计算时,效果并不好。

如果在读完本文后你对二进制有兴趣,可以阅读这篇文章:CSAPP第二章-信息的表示与处理

如何计算可变长度?

以下是各大文章的相似描述:

MQTT协议的剩余长度字段使用了一种变长编码方案,每个字节的最高位是一个进位标志位,如果为1,表示还有后续的字节;如果为0,表示这是最后一个字节。每个字节的低七位是实际的数据位,用来表示剩余长度的一部分。因此,为了得到剩余长度的完整值,需要循环读取每个字节,并用一个乘法器来计算出总和。

并且,提供了一张表,介绍剩余长度在不同的区间中时,需要使用多少个字节数:

字节数 最小值 最大值
1 0(0x00) 127(0x7F)
2 128(0x80,0x01) 16383(0xFF,0x7F)
3 16384(0x80,0x80,0x01) 2097151(0xFF,0xFF,0x7F)
4 2097152(0x80,0x80,0x80,0x01) 268435455(0xFF,0xFF,0xFF,0x7F)

读取过程分析

首先,我们明确MQTT协议读取数据的过程,是一个字节一个字节依次读取的。即读取完一个字节之后,再处理下一个字节。

那么为了可以动态控制可变长度部分的字节数,MQTT协议约定该部分的字节的第一位来标记“后续是否还有字节”。

明确这一点之后,我们抛开第一位,读取后七位的值,可以得到一个数。

想象一下,如果我们的可变长度res特别长,在2097152~2684354455之间,那么我们需要4个字节进行传输。此时我们将每个字节的第一位剥离,将剩余7位取出:

用变量a表示第一个字节后7位的数,用变量b表示第二个字节后7位的数,cd以此类推。

那么我们会有:res = a * 1 + b * 128 + c * 128 * 128 + d * 128 * 128 * 128

或者咱们换一种写法:res = a * 1280 + b * 1281 + c * 1282 + d * 1283

可以理解为,这是一个“公式”,即可变长度 = 各个字节除1以外的数 * 128字节序号-1 (不会打求和符号,而且也不标准,请以理解为主)

公式验算

以CSDN那篇文章的第四个例子为例,进行“公式”验算:

100000000,

100000000/128 = 781250,不等于0需要进位,

然后 781250/128=6103,不等于0再进一位,

然后 6103/128=47,不等于0再进一位

47/128=0,等于0无需再进位了

所以总体进3位,需要4个字节

第一个字节bit7是1, 第2个字节bit7是1,第3个字节bit7是1.,第4个字节bit7是0.

然后100000000%128=0,就是0x00,所以第一个字节bit0~bit6就是0x00,

然后781250%128=66,所以第2个字节bit0~bit6就是0x42,

然后 6103%128=87,所以第3个字节bit0~bit6就是0x57,

然后 47%128=47,所以第4个字节bit0~bit6就是0x2F,

然后再结合上bit7的话,就是0x80,0xC2,0xD7,0x2F

先抛开中间的计算过程,将它的目标和结果拿出来:目标100000000,结果0x80,0xC2,0xD7,0x2F

换算成二进制是:1000 0000 1100 0010 1101 0111 0010 1111

套用公式,一一计算a, b, c, d:

  • a: 1000 0000 –去除开头1–> 000 0000 –得到值–> 0
  • b: 1100 0010 –去除开头1–> 100 0010 –得到值–> 66
  • c: 1101 0111 –去除开头1–> 101 0111 –得到值–> 87
  • d: 0010 1111 –去除开头0–> 010 1111 –得到值–> 47

然后计算,a * 1 + b * 128 + c * 128 * 128 + d * 128 * 128 * 128 = 0 + 8448 + 1425408 + 98566144 = 100000000

如此一来,验证通过。

其他情况计算

在理解了4个字节的情况之后,我们会发现,前面读到的数字乘的值会比较小,后面读到的数字乘的值会比较大。因此就很容易理解为什么其他文章说0~127的数字只需要一个字节了,因为当可变长度区仅有一个字节时,乘数是1,而一个字节抛开第一位之后剩余7位,不考虑负数的情况下最大能表示的值就是127。

如果超过127,就需要从前往后的第二个字节进行处理,因为第二个字节(除第一位的剩余7位的输)默认要乘以128。

两个字节的存储长度区间是128~16383,如果大于16383,则需要使用三个字节存储,第三个字节(除第一位的剩余7位的输)默认要乘以128*128。

理解了这个之后,就可以通过上面的推算过程,反推为什么100000000要进行多次除以128的操作。

代码实现

在mqant中,官方提供了一种读取过程,将其抄录如下:

	// Read the high
	multiplier := 1
	for {
		count_len++
		pack.length += (int(temp_byte&127) * multiplier)
		if temp_byte>>7 != 0 && count_len < 4 {
			temp_byte, err = r.ReadByte()
			if err != nil {
				return
			}
			multiplier *= 128
		} else {
			break
		}
	}

可以看到,这里定义了变量multiplier,其初始值是1,且读取完一个字节之后,将其自己*=128扩大,满足前述的“公式”。

总结

在TCP完成可靠传输之上,还需要应用层进行拆包解析。通过先读取包长度的方式,可以让后续数据传输时监测是否传输成功。而MQTT使用1~4个字节的方式,灵活配置可变长度,其思路值得学习。

我的自我介绍

2023年4月29日 00:00

欢迎

你好啊,我是本站的站长,我的花名是凌虚。

“凌虚”是动漫《秦时明月》中,张良的佩剑名。凌虚剑身修长秀美,通体晶莹夺目,虽为利器却不见血腥之气,剑身之上有一十八颗碧血丹心,高贵儒雅之风浓重。而其持剑人为张良。取此花名,有暗含自己才智比肩张良之嫌。

本站叫“风萧古道”,英文名是“WindyPath”。

“风萧”来自于“风萧萧兮易水寒”;“古道”来自于“古道西风瘦马”。为了押韵,我把两个词拼在了一起,就是“风萧古道”。为了让外国友人能体会这个词的优雅,我翻遍英语词典,找到了两个单词,“Windy”和“Path”,然后将它们也拼起来,就是“WindyPath”。

我目前是一个游戏服务器开发。之前还做过网页Web的全栈开发。现在已经不摆弄html标签了。 我19年毕业,20年底来到中国上海。我和计算机的其他故事可以到这里查阅。

理想主义

有很长一段时间,我不知道要为何而学习软件开发。如果是为了挣钱,那么我现在也已经在挣钱了。那么在我漫长的软件开发生涯中,我要为了什么而继续前行呢?

For Chinese Software

为了中国软件。

文章

本站文章更新频率不高,但是我经常查阅互联网上其他独立博客站长的文章。有的站长帖子多,但是大多是发牢骚(当微博发),或者是转载(当笔记本/csdn)。这样的博客对于软件开发技术的水平的提升是没有什么效果的。

所以我的博客里,只有一种文章:

技术文章

哪怕是牢骚,也必须是与软件行业相关的牢骚,确保所有来访者能从中了解或者学习一些东西。

其他的日常生活的吐槽和牢骚就放到Twitter里。

联系方式

Email: windypathblog@gmail.com Telegram: @windypath

❌
❌