first commit

This commit is contained in:
张乾
2024-10-16 00:01:16 +08:00
parent ac7d1ed7bc
commit 84ae12296c
322 changed files with 104488 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 DevOps的实施到底是工具先行还是文化先行
你好,我是石雪峰。
当一家企业好不容易接纳了DevOps的思想并下定决心开始实施的时候总会面临这样一个两难的选择工具和文化到底应该哪个先行
的确在DevOps的理论体系之中工具和文化分别占据了半壁江山。在跟别人讨论这个话题的时候我们往往会划分为两个不同的“阵营”争论不休每一方都有自己的道理难以说服彼此。在DevOps的世界中工具和文化哪个先行的问题就好比豆浆应该是甜的还是咸的一样一直没有一个定论。
可是对于很多刚刚接触DevOps的人来说如果不把这个问题弄清楚后续的DevOps实践之路难免会跑偏。所以无论如何这碗豆浆我先干为敬今天我们就先来聊聊这个话题。
DevOps工具
随着DevOps理念的深入人心各种以DevOps命名的工具如雨后春笋般出现在我们身边甚至有很多老牌工具为了顺应DevOps时代的发展主动将产品名称改为DevOps。最具代表性的就是去年9月份微软研发协作平台VSTSVisual Studio Team Services正式更名为Azure DevOps这也进一步地印证DevOps已经成为了各类工具平台建设的核心理念。
在上一讲中我提到高效率和高质量是DevOps的核心价值而工具和自动化就是提升效率最直接的手段让一切都自动化可以说是DevOps的行为准则。
一切软件交付过程中的手动环节都是未来可以尝试进行优化的方向。即便在运维圈里面ITILIT基础架构库)一直是运维赖以生存的基石也并不妨碍自动化的理念逐步深入到ITIL流程之中从而在受控的基础上不断优化流程流转效率。
另外正因为所有人都认可自动化的价值工具平台的引入和建设就成为了DevOps打动人的关键因素之一。
同时现在业界的很多开源工具已经相当成熟以Netflix、Amazon、Etsy等为代表的优秀公司也在不断将内部的工具平台进行对外开放各方面的参考资料和使用案例比比皆是。
无论是单纯使用还是基于这些工具进行二次开发成本都已经没那么高了一个稍微成熟点的小团队可以在很短的时间内完成一款工具的开发。以我之前所在的团队为例从0开始组建到第一款产品落地推广前后不过两个多月的时间而且与业内的同类产品相比较毫不逊色。
不过这也带来一个副作用那就是企业内部的工具平台泛滥很多同质化的工具在完成从0到1的过程后就停滞不前陷入重复的怪圈显然也是一种资源浪费。
当然对于工具决定论的支持者来说这并不是什么大问题因为引入工具就是DevOps的最佳实施路径。
有时候当你问别人“你们公司的DevOps做得怎么样啦”你可能会得到这样的回答“我们的所有团队都已经开始使用Jenkins了。”听起来感觉怪怪的。如果只是使用了最新最强大的DevOps工具就能实现软件交付效率的腾飞那么世界500强的公司早就实现DevOps了。
很多公司引入了完整的敏捷项目管理工具,但是却以传统项目管理的方式来使用这套工具,效率跟以前相比并没有明显的提升。对于自研平台来说,也是同样的道理。如果仅仅是把线下的审批流程搬到线上执行,固然能提升一部分执行效率,但是对于企业期望的质变来说,却是相距甚远。
说到底,工具没法解决人的问题,这样一条看似取巧的路径,却没法解决企业的根本问题。这时候,就需要文化闪亮登场了。
DevOps文化
在谈论DevOps文化之前我先跟你分享一个故事。
上世纪80年代美国加州有一家汽车制造公司叫作NUMMI。当时这家公司隶属于通用公司但是由于劳资关系紧张这家公司一直以来都是通用旗下效益最差的公司。员工整天上班喝酒赌博整个工厂乌烟瘴气旷工率甚至一度达到了20%。通用公司忍无可忍,最后关闭了这家公司。
后来日本丰田公司想在美国联合建厂于是跟通用达成了合资协议。美国联合汽车工会UAW希望新公司可以重新雇佣之前遭到解雇的员工通用公司本来不想接受但是令人惊讶的是丰田公司却同意了。因为他们认为NUMMI工厂之前的情况更多是系统的原因而不是人的原因。
接下来,丰田公司将新招募的员工送到日本进行培训。短短三个月后,整个公司的面貌焕然一新,半年后,一跃成为整个通用集团效益最好的公司。
由此可见,在不同的文化制度下,相同的人发挥出来的生产力也会有天壤之别。
类似的故事并非个例,曾经有一群美国专家到日本参观和学习生产流水线,他们发现了一件有趣的事情。
在美国公司的生产线里面,总有一个人拿着橡胶的锤子在敲打车门,目的是检查车门是否安装完好。但即便如此,车门的质量依然很差。可是,在日本公司的工厂里面,却没有这样的角色。
他们就好奇地问道:“你们如何保障车门没有问题呢?”日方的专家回复说:“我们在设计车门的时候,就已经保证它不会出问题了。”你看,同样是采用流水线技术的两家公司,结果却大不相同。
类比DevOps如果在我们的软件交付过程中始终依靠这个拿锤子的人来保障产品的质量出了问题总是抱怨没有会使用锤子的优秀人才或许这个流程本身就出了问题。
回到文化本身,良好的文化不仅可以让流程和工具发挥更大的作用,更重要的是,它能够诱发人们思考当前的流程和工具哪里是有问题的,从而引出更多有关流程和工具的优化需求,促使流程和工具向更加有力的支持业务发展的方向持续改进。
可是企业内部的DevOps文化本身就是虚无缥缈的事情你很难去量化团队的文化水平进而改变企业的文化。盲目地空谈文化对组织也是一种伤害。因为脱离实践文化就会变成无根之水。当组织迟迟无法看到DevOps带来的实际收益时就会丧失转型的热情和信心。
所以,我们需要先改变行为,再通过行为来改变文化。而改变行为最关键的,就是要建立一种有效的机制。就像我一直强调的那样,机制就是人们愿意做,而且做了有好处的事情。
回想之前提到的某金融公司的案例如果他们的老板只是喊了句口号“我们要在年底完成DevOps试点落地”那么年底即便项目成功本质上也不会有什么改变。相反他们在内部建立了一种机制包括OKR指标的设定、关键指标达成后的激励、成立专项的工作小组、引入外部的咨询顾问以及一套客观的评判标准这一切都保证了团队走在正确的道路上。而承载这套客观标准的就是一套通用的度量平台说到底还是需要将规则内建于工具之中并通过工具来指导实践。
这样一来当团队通过DevOps获得了实实在在的改变那么DevOps所倡导的职责共担、持续改进的文化自然也会生根发芽。
所以你看DevOps中的文化和工具本身就是一体两面我们既不能盲目地奉行工具决定论上来就大干快干地采购和建设工具也不能盲目地空谈文化在内部形成一种脱离实际的风气。
DevOps的3个支柱
对工具和文化的体系化认知可以归纳到DevOps的3个支柱之中即人People、流程Process和平台Platform。3个支柱之间两两组合构成了我们实施DevOps的“正确姿势”只强调其中一个维度的重要性明显是很片面的。
人 + 流程 = 文化
在具体的流程之下,人会形成一套行为准则,而这套行为准则会潜移默化地影响软件交付效率和质量的方方面面。这些行为准则组合到一起,就构成了企业内部的文化。
一种正向的文化可以弥合流程和平台方面的缺失推动二者的持续改进同时可以让相同的流程和平台在不同的人手中产生迥异的效果。就好像《一代宗师》里面的那句经典台词“真正的高手比拼的不是武功而是思想。”而指导DevOps落地发展的思想就是DevOps的文化了。
举个例子在谷歌SRE的实践中研发交付的应用需要自运维一段时间并且要在达到一定的质量指标之后才会交接给SRE进行运维。但是为了避免出现“研发一走运维背锅”的情况他们还建立了“打回”的流程也就是当SRE运维一段时间后如果发现应用稳定性不达标就会重新交还给开发自己负责维护这样一来研发就会主动地保障线上应用的质量。而且在这个过程SRE也会给予技术和平台方面的支持从而形成了责任共担和质量导向的文化。
类似的,有些公司设有线上安全点数的机制,在一定的额度范围内,允许团队出现问题,并且不追究责任。这就可以激励团队更加主动地完成交付活动,不必每一次都战战兢兢,生怕出错。通过流程和行为的改变,团队的文化也在慢慢地改进。
由此看来,虽然我们很难直接改变文化,但是却可以定义期望文化下的行为表现,并通过流程的改进来改变大家的行为,从而让文化得以生根发芽,茁壮成长。
流程 + 平台 = 工具
企业内部流程的标准化,是构成自动化的前提。试想一下,如果没有一套标准的规则,每一项工作都需要人介入进行判断和分析,那么结果势必会受到人的因素的影响,这样的话,又如何做到自动化呢?
而平台的最大意义,就是承载企业内部的标准化流程。当这些标准化流程被固化在平台之中时,所有人都能够按照一套规则沟通,沟通效率显然会大幅提升。
平台上固化的每一种流程,其实都是可以用来解决实际问题的工具。很多人分不清工具和平台的关系,好像只要引入或者开发了一个工具,都可以称之为平台,也正因为这样,企业内部的平台比比皆是。
实际上平台除了有用户量、认可度、老板加持等因素之外还会有3个显著特征。
吸附效应:平台会不断地吸收中小型的工具,逐渐成为一个能力集合体。
规模效应:平台的成本不会随着使用方的扩展而线性增加,能够实现规模化。
积木效应:平台具备基础通用共享能力,能够快速搭建新的业务实现。
简单来说,平台就是搭台子,工具来唱戏。平台提供场所,进行宣传,吸引用户,同时还能提供演出的道具,以及数据方面的分析。观众的喜好各不相同,但是平台将各种戏汇集在一起,就能满足大多数人的需求。如果平台把唱戏的事情做了,难以聚焦“台子”的质量,就离倒闭不远了。同样,如果唱戏的整天琢磨着建平台,那么戏本身的品质就难以不断精进。所以是做平台,还是做工具,无关好坏,只关乎选择。
平台 + 人 = 培训赋能
平台是标准化流程的载体,一方面可以规范和约束员工的行为,另一方面,通过平台赋能,所有人都能以相同的操作,获得相同的结果。这样一来,跨领域之间的交接和专家就被平台所取代,当一件事情不再依赖于个人的时候,等待的浪费就会大大降低,平台就成了组织内部的能力集合体。
但与此同时,当我们定义了期望达到的目标,并提供了平台工具,那么对人的培训就变得至关重要,因为只有这样,才能让工具平台发挥最大的效用。更加重要的是,通过最终的用户使用验证,可以发现大量的可改进空间,进一步推动平台能力的提升,从而带动组织整体的飞轮效应,加速组织的进化。
所以你看文化、工具和培训作为DevOps建设的3个重心折射出来的是对组织流程、平台和人的关注三位一体缺一不可。
最后跟你分享一个关于美国第一资本的例子。他们最初在实施DevOps时采用的是外包方式修改一个很小的问题都需要走复杂的变更流程需要几天的时间。后来他们决定采用“开源为先”的策略并且严格审查原本的商业采购流程。除此之外他们还基于开源工具搭建自己的平台并在公司内部进行跨领域角色的交叉培养交付效率大幅提升实现了从每天迭代一次到每天多次的线上部署。
总结
讲到这里我们今天的专栏内容就到尾声了。在这一讲中我跟你讨论了DevOps中的工具和文化的实际价值以及潜在的问题和挑战最终推导出DevOps的3个支柱也就是人、流程和平台这3个支柱缺一不可。只有通过人、流程和平台的有机结合在文化、工具和人员培训赋能领域共同推进才能实现DevOps的真正落地实施。
思考题
最后给你留一个思考题你们公司的哪些文化是非常吸引你的这些文化对于DevOps的实施又有哪些帮助呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,125 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 DevOps的衡量你是否找到了DevOps的实施路线图
你好我是石雪峰。今天我们来聊聊DevOps的实施路线图。
商业领域有一本特别经典的书,叫作《跨越鸿沟》,这本书中提出了一个“技术采纳生命周期定律”,对高科技行业来说,它的地位堪比摩尔定律。
简单来说这个定律描述了一项新技术从诞生到普及要经历的5个阶段这5个阶段分别对应一类特殊人群即创新者、早期使用者、早期大众、晚期大众和落后者。这个定律表明技术的发展不是线性的需要经历一段蛰伏期才能最终跨越鸿沟为大众所接受成为业界主流。
当然DevOps这项所谓的新技术在企业内部的落地也注定不是一帆风顺的。那么在这种情况下你是否找到了DevOps的实施路线图呢
从2017年第一届DevOpsDays大会中国站举办以来DevOps正式在国内驶入了发展的快车道。从一门鲜为人知的新技术思想到现在在各个行业的蓬勃发展各种思想和实践的激烈碰撞DevOps的理念和价值可谓是深入人心。
这样看来DevOps已经成功地跨越了技术发展的鸿沟从早期使用者阶段进入了早期大众的阶段而这也意味着越来越多的公司开始尝试DevOps。
在2017年底Forrester的一组调查数据显示将近50%的受访公司表示已经引入并正在实施DevOps30%的公司表示有意向和计划来开启这项工作而对DevOps完全不感兴趣的仅占1%。可以说2018年就是企业落地DevOps的元年。
但是就像你要去往一个未知的目的地时需要导航帮你规划路径、实时定位并在出现意外情况时及时提示你是否要重新规划路径一样企业在实施DevOps的过程中其实也面临着相似的问题。企业自身难以清晰定位DevOps的现状客观评估DevOps相关的能力水平识别当前所面临的最大瓶颈以及实施DevOps的阶段性成果预期……
回顾整个IT行业的发展历程新思想和新技术的发展总是同标准化的模型和框架相伴相生的。
我认为,任何技术的成熟,都是以模型和框架的稳定为标志的。因为当技术跨越初期的鸿沟,面对的是广大受众,如果没有一套模型和框架来帮助大众快速跟上节奏,找准方向,是很难大规模推广并健康发展的。
比如软件开发领域的CMMI模型软件能力成熟度模型、运维行业的ITIL模型等在各自的领域都久负盛名甚至一度被各个领域的从业者奉为圭臬和行为准则成为衡量能力高低的标尺。
我曾经在国内某大型通讯设备公司参与过CMMI评级项目。当时就算业务压力再大只要是关于通过评级的事情所有部门都会高优先级支持。由此可见整个公司都非常重视这个认证评级项目。
那么问题来了在DevOps这项新思想和新技术不断走向成熟的过程中是否也有类似的模型和框架能够指导企业内部的DevOps转型落地工作呢
答案是有的而且有很多。只要你去谷歌上搜一下DevOps框架、模型等关键词就能看到非常多的结果。尤其是国外的一些知名公司比如Atlassian、CloudBees、CA等基本上都有一套自己的模型和框架来帮助企业识别当前的DevOps能力水平并加以改进。
我之前参与过工信部旗下的中国信息通讯研究院牵头制定的一套DevOps能力成熟度模型。这套模型覆盖了软件交付的方方面面包括敏捷开发管理、持续交付和技术运营三大部分同时也有与应用架构设计、安全和组织结构对应的内容。
不仅如此对于开发DevOps工具的企业来说系统和工具模型更加偏向于平台能力稍加整理就可以作为平台需求输入到开发团队中。目前已经有不少公司在参考这套模型进行DevOps实践。下图展示了这个模型的整体框架如果你正在企业内部推进DevOps落地的话可以参考一下。
步骤与原则
业界有这么多模型和框架,是不是随便找一个,直接照着做就行了呢?当然不是。
毕竟,每家企业所处的行业现状、竞争压力、市场竞争态势都不尽相同,组织架构、战略目标、研发能力、资源投入等方面也千差万别,很难有一条标准的路径,让大家齐步走。比如,同样是金融企业,让万人规模的大银行和百人规模的城商行同台竞技,本身就有点强人所难。
所以,在实际参考模型和框架的时候,我认为应该尽量遵循以下步骤和原则:
1.识别差距
从“道法术器”的角度来说DevOps的成熟度模型和框架处于“法”这个层面也就是一整套实施DevOps的方法论相当于是一幅战略地图最重要的就是对DevOps实施所涉及到的领域和能力图谱建立全面的认知。
通过和模型、框架进行对标,可以快速识别出企业当前存在的短板和差距,并建立企业当前的能力状态基线,用于对比改进后所取得的效果。
2.锚定目标
数字化转型的核心在于优化软件交付效率。通过对标模型框架,企业需要明确什么是影响软件交付效率进一步提升的最大瓶颈,当前存在的最大痛点是什么,哪些能力的改善有助于企业达成预定的目标……同时,要根据企业的现状,甄别对标的差距结果,识别出哪些是真实有效的,哪些可以通过平台能力快速补齐。
比如对于一家提供CRM软件的公司来说容器化部署虽然在环境管理、部署发布等领域有非常多的优势但并非当前的核心瓶颈和亟需解决的问题那么就不应该纳入近期的改进列表中。
通过现状分析企业可以把有限的资源聚焦在那些高优先级的任务上识别出改进目标和改进后要达到的预期效果。这些效果需要尽量客观和可量化比如缩短50%的环境准备时长。
3.关注能力
模型和框架是能力和实践的集合,也就是道法术器的“术”这个层面,所以在应用模型的过程中,核心的关注点应该在能力本身,而不是单纯地比较数字和结果。
比如亚马逊每天23000次部署的案例经常会被拿来举例子。这个数字的确相当惊人但反过来想想所有企业都需要达到这么高的部署频率吗举个例子一个客户端应用可以在几分钟内构建完成但同样是构建对于大型系统软件来说可能需要几个小时那么到底多长时间才算达标呢
我们不能只关注这些明星企业所达到的成就,而忽略了自身的需求。所以,正确的做法是根据锚定的目标识别所需要的能力,再导入与能力相匹配的实践,不断强化实践,从而使能力本身得到提升。
4.持续改进
模型和框架本身也不是一成不变的也需要像DevOps一样不断迭代更新以适应更高的软件交付需要。另外从今年的DevOps状态报告就可以看出达到精英级别的比例从2018年的7%快速提升到2019年的20%,也就是说,行业整体的能力也在不断提升,这就对企业的软件交付能力提出了更高的要求。
好了以上这些就是我总结的企业应用DevOps能力模型和框架的步骤和原则。DevOps作为一个系统性工程同样需要与之配套的立体化实施方法只有将方法、实践和工具结合起来全方位推进才有可能获得成效。
为了帮助你更好地理解DevOps实施的过程我贴了一幅经典的部署引力图。
可以看出当软件发布的频率从100天1次进化到1天100次的时候分支策略、测试能力、软件架构、发布策略、基础设施能力以及数据库能力都要进行相应的改动。比如分支策略要从长线分支变成基于特性的主干开发模式而架构也要从大的单体应用不断解耦和服务化。在实际应用中企业涉及的领域甚至更多因为这些仅仅是技术层面的问题而组织文化方面也不可或缺。
实践案例
最后,我再跟你分享一个我之前参与改进的一个客户的案例。
刚开始跟这个客户交流的时候,他千头万绪,抓不准重点,甚至由于组织严格划分职责边界,基本上每讲到一块内容,他就要拉相应的人过来聊,在许多人都聊完之后,项目的全貌才被拼凑出来。我相信这并不是个例,很多公司其实都是如此。
于是我们引入了能力成熟度模型并基于模型对企业现有的能力水平进行了一次全盘梳理并初步识别出了100多个问题点和40多个差距项。下面这张图就是汇总的大盘图当然部分数据进行了处理。
接下来针对识别出来的这些差距点我逐项跟企业进行了沟通重点在于锚定一期的改进目标和具体工作事项。在沟通过程中我发现由于企业所处行业的特殊性或者客观条件不具备有些内容并非优先改进事项于是将改进事项缩减为30个并识别出这些改进事项的相互依赖和预期目标。比如这个企业之前初始化一套环境需要2周左右的时间为了加快整体交付能力我们将改进目标定到1周以内完成。
好啦有了改进目标和预期效果之后就要分析哪些关键能力制约了交付效率的提升。还拿刚才那个例子来说核心问题在于环境的初始化过程复杂以及审批流程冗长。其中原有的初始化过程是研发整理一份部署需求文档来说明应用所依赖的环境和版本信息并且这个需求还被整合到一个40多页的文档中。运维团队根据这个文档部署每次都很不顺利因为软件功能迭代所依赖的环境也在不断更新但文档写出来就再也没人维护了。所以很多人说文档即过时就是这个道理。
识别出核心能力在于自动化环境管理之后,团队决定引入基础设施即代码的实践来解决这个问题。关于具体的技术细节,我会在后面的内容中展开,这里你只需要知道,通过将写在文档中的环境配置说明,转变成配置化的信息,并维护在专门的版本控制系统中,从而使得基础环境的初始化可以在分钟级完成。
当然,审批环境的优化属于非技术问题,而是流程和组织方面的问题。当大家认识到这些审批在一定程度上制约了发布频率的提升,就主动改进了现有流程。针对不同的环境进行不同级别的审批,使得单次审批可以在当天完成。
这样优化下来环境准备的时长大大缩短从当初的2周缩短到了2天改进效果非常明显。接下来团队又识别出新的差距锚定新的目标和预期效果并且有针对性地补齐能力建设走上了持续改进的阶段。
由此可以看出DevOps的能力实践和能力框架模型相辅相成能力实践定义了企业落地DevOps的路线图和主要建设顺序能力模型可以指导支撑方法的各类实践的落地建设能力实践时刻跟随企业价值交付的导向而能力模型的积累和沉淀能够让企业游刃有余地面对未来的各种挑战。
至于ITIL和CMMI这些过往的框架体系自身也在跟随DevOps的大潮在持续演进比如以流程合规为代表的ITIL最近推出了第4个版本。我们引用一下ITIL V4的指导原则包括关注价值、关注现状、交互式流程和反馈、协作和可视化、自动化和持续优化、极简原则和关注实践。
看起来是不是有点DevOps的味道呢需要注意的是DevOps不会彻底颠覆ITIL只会在保证合规的前提下尽可能地优化现有流程将流动、反馈和持续学习改进的方法注入ITIL之中从全局视角持续优化企业的价值交付流程。
总结
总结一下今天我给你介绍了新技术和新思想的发展需要面对的鸿沟而能力模型和框架是技术和思想走向成熟的标志对于DevOps而言也是如此。在面对诸多模型和框架的时候企业需要立足自身识别差异锚定目标关注能力并持续改善软件的开发交付效率。DevOps的实施需要立体化的实施框架通过模型、方法、能力和实践的相互作用实现全方位的能力提升。
到此为止我们整体介绍了DevOps的基本概念、核心价值、实施方法和路线图帮助你建立了一套有关DevOps的宏观概念。接下来我们就会开始深入细节尤其是针对每一项核心实践我会介绍其背后的理念、实施步骤以及所依赖的能力模型手把手地帮助你真正落地DevOps。
思考题
最后给你留一道思考题关于CMMI、ITIL和DevOps你觉得它们之间的关系是怎样的呢企业该如何兼顾多套模型框架呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,150 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 价值流分析关于DevOps转型我们应该从何处入手
你好,我是石雪峰。
关于“DevOps如何落地”的问题向来是关注度很高的所以从今天开始我会用16讲的篇幅跟你聊聊这个话题的方方面面。作为“落地实践篇”的第1讲我先跟你聊聊DevOps转型的那些事儿。
相信你一定听说过持续交付吧现在几乎每家实施DevOps的企业都宣称他们已经有了一套持续交付平台或者是正在建设持续交付平台。但是如果你认为只需要做好持续交付平台就够了那就有点OUT了。因为现在国外很多搞持续交付产品的公司都在一门心思地做另外一件事情这就是VSM价值流交付平台。
比如Jenkins的主要维护者CloudBees公司最新推出的DevOptics产品主打VSM功能而经典的持续交付产品GoCD的VSM视图也一直为人所称道。那么这个VSM究竟是个啥玩意儿呢
要说清楚VSM首先就要说清楚什么是价值。简单来说价值就是那些带给企业生存发展的核心资源比如生产力、盈利能力、市场份额、用户满意度等。
VSM是Value Stream Mapping的缩写也就是我们常说的价值流图。它起源于传统制造业的精益思想用于分析和管理一个产品交付给用户所经历的业务流、信息流以及各个阶段的移交过程。
说白了VSM就是要说清楚在需求提出后怎么一步步地加工原材料进行层层的质量检查最终将产品交付给用户的过程。通过观察完整流程中各个环节的流动效率和交付质量识别不合理的、低效率的环节进行优化从而实现整体效率的提升。
这就好比我们在餐厅点了一道菜,这个需求提出后,要经历点单、原材料初加工(洗菜)、原材料细加工(切菜)、制作(炒菜),最终被服务员端到餐桌上的完整过程。但有时候,厨师已经把菜做好摆在窗口的小桌上了,结果负责上菜的服务员正在忙,等他(她)忙完了,才把菜端到我们的餐桌上,结果热腾腾的锅气就这么流失了。
对软件开发来说,也是如此。由于部门职责的划分,每个人关注的都是自己眼前的事情,这使得软件交付过程变得碎片化,以至于没有一个人能说清楚整个软件交付过程的方方面面。
所以通过使用价值流图对软件交付过程进行建模使整个过程可视化从而识别出交付的瓶颈和各个环节之间的依赖关系这恰恰是“DevOps三步工作法”的第一步“流动”所要解决的问题。
我简单介绍下“DevOps三步工作法”。它来源于《DevOps实践指南》可以是说整本书的核心主线。高度抽象的“三步工作法”概括了DevOps的通用实施路径。
第一步:流动。通过工作可视化,限制在制品数量,并注入一系列的工程实践,从而加速从开发到运营的流动过程,实现低风险的发布。
第二步:反馈。通过注入流动各个过程的反馈能力,使缺陷在第一时间被发现,用户和运营数据第一时间展示,从而提升组织的响应能力。
第三步:持续学习和试验。没有任何文化和流程是天生完美的,通过团队激励学习分享,将持续改进注入日常工作,使组织不断进步。
关键要素
你并不需要花大力气去研究生产制造业中的价值流分析到底是怎么玩的你只要了解有关VSM的几个关键要素和核心思想就行了。那么VSM中有哪些关键要素和概念呢有3点是你必须要了解的。
前置时间Lead Time简称LT。前置时间在DevOps中是一项非常重要的指标。具体来说它是指一个需求从提出典型的就是创建一个需求任务的时间点开始一直到最终上线交付给用户为止的时间周期。这部分时间直接体现了软件开发团队的交付速率并且可以用来计算交付吞吐量。DevOps的核心使命之一就是优化这段时长。
增值活动时间和不增值活动时间Value Added Time/Non-Value Added Time简称VAT/NVAT。在精益思想中最重要的就是消除浪费也就是说最大化流程中那些增值活动的时长降低不增值活动的时长。在软件开发行业中典型的不增值活动有很多比如无意义的会议、需求的反复变更、开发的缺陷流向下游带来的返工等。
完成度和准确度(% Complete/Accurate简称%C/A。这个指标用来表明工作的质量也就是有多少工作因为质量不符合要求而被下游打回。这里面蕴含了大量的沟通和返工成本从精益的视角来看也是一种浪费。
在实践中,企业往往将需求作为抓手,来串联打通各个环节,而前置时间是需求管理的自然产物,采集的难度不在于系统本身,而在于各环节的操作是否及时有效。有的团队也在使用需求管理工具,但是前置时长大多只有几秒钟。问题就在于,他们都是习惯了上线以后,一下子把任务状态直接从开始拖到最后,这样就失去了统计的意义。
需要注意的是,关于前置时间,有很多种解释,一般建议采用需求前置时间和开发前置时间两个指标进行衡量,关于这两个指标的定义,你可以简单了解一下。
需求前置时间:从需求提出(创建任务),到完成开发、测试、上线,最终验收通过的时间周期,考查的是团队整体的交付能力,也是用户核心感知的周期。
开发前置时间:从需求开始开发(进入开发中状态),到完成开发、测试、上线,最终验收通过的时间周期,考查的是团队的开发能力和工程能力。
对于增值活动时长我的建议是初期不用过分精细可以优先把等待时长统计出来比如一个需求从准备就绪到进入开发阶段这段时间就是等待期。同前置时间一样很多时候研发的操作习惯也会影响数据的准确性比如有的研发喜欢一次性把所有的需求都放到开发阶段然后再一个个处理掉这就导致很多实际的等待时间难以识别。所以如果完全依靠人的操作来确保流程的准确性就会存在很大的变数。通过流程和平台的结合来驱动流程的自动化流转这才是DevOps的正确姿势。
举个例子,研发开发完成发起提测后,本次关联的需求状态可以自动从“开发中”变成“待测试”状态,而不是让人手动去修改状态,这样就可以避免人为因素的影响。通过代码,流水线和需求平台绑定,从而实现状态的自动流转。
关于完成度和准确度在使用VSM的初期可以暂不处理。实际上我见过一些公司在跑通主流程之后着手建设质量门禁相关的指标比如研发自测通过率这些指标就客观地反映了VSM的完成度和准确度。关于质量门禁在专栏后面我会花一讲的时间来介绍你一定不要错过。
方式
关于VSM的关键要素知道这些就足够了。那么作为企业DevOps转型工作的第一步我们要如何开展一次成功的VSM活动呢一般来说有2种方式。
1.召开一次企业内部价值流程梳理的工作坊或者会议。
这是我比较推荐的一种方式。对于大型企业而言,可以选取改进项目对象中某个核心的业务模块,参加会议的人员需要覆盖软件交付的所有环节,包括工具平台提供方。而且,参会人员要尽量是相对资深的,因为他们对自身所负责的业务和上下游都有比较深刻的理解,比较容易识别出问题背后的根本原因。
不过,这种方式的实施成本比较高。毕竟,这么多关键角色能够在同一时间坐在一起本身就比较困难。另外,面对面沟通的时候,为了给对方保留面子,大家多少都会有所保留,这样就会隐藏很多真实的问题。
所以,一般情况下,像团队内部的敏捷回顾会,或者是版本发布总结会,都是很合适的机会,只需要邀请部分平常不参会的成员就行了。
2.内部人员走访。
如果第1种方式难以开展你可以退而求其次地采用第2种方式。通常来说企业内部的DevOps转型工作都会有牵头人甚至会成立转型小组那么可以由这个小组中的成员对软件交付的各个环节的团队进行走访。这种方式在时间上是比较灵活的但对走访人的要求比较高最好是DevOps领域的专家同时是企业内部的老员工这样可以跟受访人有比较深入坦诚的交流。
无论哪种方式,你都需要识别出几个关键问题,缩小谈话范围,避免漫无目的地东拉西扯,尽量做到有效沟通。比如,可以建立一个问题列表:
在价值交付过程中,你所在团队的主要职责是什么?
你所在团队的上下游团队有哪些?
价值在当前环节的处理方式,时长是怎样的?
有哪些关键系统支持了价值交付工作?
是否存在等待或其他类型的浪费?
工作向下游流转后被打回的比例是多少?
为了方便你更好地理解这些问题,我给你提供一份测试团队的访谈示例。
通过访谈交流我们就可以对整个软件交付过程有一个全面的认识并根据交付中的环节、上下游关系、处理时长、识别出来的等待浪费时长等按照VSM模型图画出当前部门的价值流交付图以及各个阶段的典型工具如下图所示
当然实际交付流程相当复杂涉及到多种角色之间的频繁互动是对DevOps转型团队的一种考验。因为这不仅需要团队对软件开发流程有深刻的认识还要充分了解DevOps的理念和精髓在沟通方面还得是一把好手能够快速地跟陌生人建立起信任关系。
价值
话说回来为什么VSM会是企业DevOps转型的第一步呢实际上它的价值绝不仅限于输出了一幅价值流交付图而已。VSM具有非常丰富的价值包括以下几个方面
1.看见全貌。
如果只关注单点问题我们会很容易陷入局部优化的怪圈。DevOps追求的是价值流动效率最大化也就是说就算单点能力再强单点之间的割裂和浪费对于价值交付效率的影响也是超乎想象的。所以对于流程改进来说第一步也是最重要的一步就是能够看见全貌这样才能从全局视角找到可优化的瓶颈点从而提升整体的交付效率。
另外,对于全局交付的建模,最终也会体现到软件持续交付流水线的建设上,因为流水线反映的就是企业客观的交付流程。这也就很好理解,为啥很多做持续交付流水线的公司,现在都延伸到了价值流交付平台上。因为这两者之间本身就存在一些共性,只不过抽象的级别和展现方式不同罢了。
2.识别问题。
在谈到企业交付效率的时候,我们很容易泛泛而谈,各种感觉满天飞,但感觉既不可度量,也不靠谱,毕竟,它更多地是依赖于个人认知。换句话说,即便交付效率提升了,也不知道是为啥提升的。
而VSM中的几个关键指标也就是前置时长、增值和不增值时长以及完成度和准确度都是可以客观量化改进的指标。当面对这样一幅价值流图的时候我们很容易就能识别出当前最重要的问题和改进事项。
3.促进沟通。
DevOps倡导通过团队成员间的沟通和协作来提升交付效率但客观现实是在很多企业中团队成员基本都是“网友关系”。即便都在一个楼里办公也会因为部门不同坐在不同的地方基本上只靠即时通讯软件和邮件交流。偶尔开会的时候能见上一面但也很少有深入的交流。如果团队之间处于你不认识我、我也不认识你的状况下又怎么有效协作呢
另外很多时候在我们开展VSM梳理的时候团队才第一次真正了解上下游团队的职责、工作方式以及让他们痛苦低效的事情。这时我们通常会设身处地地想“只要我们多做一点点就能大大改善兄弟团队的生存状况了。”实际上这种同理心对打破协作的壁垒很有帮助可以为改善团队内部文化带来非常正面的影响。实际上这也是我推荐你用会议或者工作坊的方式推进VSM的根本原因。
4.驱动度量。
我们都认可数据的力量让数据驱动改进。但是面对这么庞杂的数据体系到底哪些才是真正有价值的呢VSM就可以回答这个问题。
在VSM访谈的时候我们要问一个团队的交付周期、准确率等指标问题如果你发现这个团队支支吾吾只能给出模糊的回答这时你就要注意啦这里本身就大有问题。因为这就表示当前环节的度量指标不够清晰或者指标过于复杂团队不清楚关键的结果指标。
另外,如果数据的提取需要大量时间,比如需要采用人为统计算数的方式,那么这就体现了这个环节的平台建设能力不足,无法自动化地收集和统计数据,甚至有些关键数据还没有沉淀到数据系统中,只能通过人工本地化的方式进行管理。
这些都是DevOps转型的过程中需要解决的问题可以优先处理。可以说VSM是一场团队协作的试炼。收集VSM数据的过程本身就需要平台间的打通和数据共享以及自动化的推进这有助于度量活动的开展。
5. 价值展现。
对于企业而言任何投入都需要有产出。要实现DevOps的转型企业需要投入大量的精力。那么如何让高层领导明白企业交付效率改善所带来的价值呢价值流梳理就是一种很好的方式。因为VSM从价值分析而来到价值优化而去本身就是在回答DevOps对于企业的价值问题。
总结
在这一讲中我给你介绍了DevOps转型的第一步——VSM价值流图包括它的来源、3个关键要素以及在企业中开展VSM的2种方式。最后我介绍了VSM的5大价值分别是看见全貌、识别问题、促进沟通、驱动度量和价值展现。
就像我们常说的DevOps转型是一场没有终点的旅程VSM的梳理也不会是一帆风顺的。因为对于企业价值交付流程的梳理需要随着认知的深入不断地进行迭代和优化。不过好的开始是成功的一半当我们开始梳理VSM的时候我们的着眼点就会慢慢调整到DevOps模式并真正地开启我们的DevOps转型之旅。
思考题
最后给你留一道思考题你认为在公司内部梳理价值流的最大障碍是什么在提取价值流图中的3个关键要素的数据时你遇到过什么挑战吗
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,111 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 转型之路企业实施DevOps的常见路径和问题
你好我是石雪峰。今天我来跟你聊聊企业实施DevOps的常见路径和问题。
由于种种原因我曾直接或者间接地参与过一些企业的DevOps转型过程也跟很多企业的DevOps负责人聊过他们的转型故事。这些企业的转型过程并不是一帆风顺的在最开始引入DevOps的时候他们也面临很多普遍的问题比如企业业务都忙不过来了根本没有时间和精力投入转型工作之中或者是企业内部的系统在经历几代建设之后变得非常庞大以至于谁都不敢轻易改变。
但是即便存在着种种问题我也始终认为DevOps转型之路应该是有迹可循的。很多企业所面临的问题并不是独一无二的甚至可以说很多公司都是这样一步步走过来的。所以在转型之初如果能够参考借鉴一条常见的路径并且对可能遇到的问题事先做好准备企业的转型过程会顺利很多。
两种轨迹
其实对于企业的转型来说DevOps也并没有什么特别之处跟更早之前的敏捷转型一样如果想在企业内部推行一种新的模式无外乎有两种可行的轨迹一种是自底向上一种是自顶向下。
自底向上
在这种模式下企业内部的DevOps引入和实践源自于一个小部门或者小团队他们可能是DevOps的早期倡导者和实践者为了解决自身团队内部以及上下游团队交互过程中的问题开始尝试使用DevOps模式。由于团队比较小而且内部的相关资源调动起来相对简单所以这种模式比较容易在局部获得效果。
当然DevOps的核心在于团队间的协作仅仅一个小团队内部的改进还算不上是DevOps转型。但是就像刚刚提到的那样如果企业太大以至于很难一次性改变的话的确需要一些有识之士来推动这个过程。如果你也身处在这样一个团队之中那么我给你的建议是采用“羽化原则”也就是首先在自己团队内部以及和自己团队所负责的业务范围有强依赖关系的上下游团队之间建立联系一方面不断扩展自己团队的能力范围另一方面逐步模糊上下游团队的边界由点及面地打造DevOps共同体。
当然如果想让DevOps转型的效果最大化你一定要想方设法地让高层知晓局部改进的效果让他们认可这样的尝试最终实现横向扩展在企业内部逐步铺开。
自顶向下
你还记得我在专栏第2讲中提到的那家把DevOps定义为愿景OKR指标的金融企业吗这就是典型的自顶向下模式也就是企业高层基于自己对于行业趋势发展的把握和团队现状的了解以行政命令的方式下达任务目标。在这种模式下公司领导有足够的意愿来推动DevOps转型并投入资源各个团队也有足够清晰的目标。
那么这样是不是就万事大吉了呢其实不然。在企业内部有这样一种说法只要有目标就一定能达成。因为公司领导对于细节的把握很难做到面面俱到团队为了达成上层目标总是能想到一些视角或数据来证明目标已经达成这样的DevOps转型说不定对公司业务和团队而言反而是一种伤害。
举个例子有一次我跟一家公司的DevOps转型负责人聊天。我问道“你们的前置时间是多久”他回答说“一周。”我心想这还挺好的呀。于是就进一步追问“这个前置时间是怎么计算的呢”他回答说“我们计算的是从开发开始到功能测试完成的时间。”我心想这好像有点问题。于是我就又问道“那从业务方提出需求到上线发布的时间呢”他回答“这个啊大概要两个月时间。”你看难怪业务方抱怨不断呢提个需求两个月才能上线。但是如果仅仅看一周的开发时长感觉是不是还不错呢
所以,一套客观有效的度量指标就变得非常重要,关于这个部分,我会用两讲的时间来和你详细聊聊。
说到这儿不知道你发现了没有无论企业的DevOps转型采用哪条轨迹寻求管理层的认可和支持都是一个必选项。如果没有管理层的支持DevOps转型之路将困难重重。因为无论在什么时代变革一直都是一场勇敢者的游戏。对于一家成熟的企业而言无论是组织架构、团队文化还是工程能力、协作精神都是长期沉淀的结果而不是在一朝一夕间建立的。
除此之外转型工作还需要持续的资源投入这些必须借助企业内部相对比较high level的管理层的推动才能最终达成共识并快速落地。如果你所在的公司恰好有这样一位具备前瞻性视角的高层领导那么恭喜你你已经获得了DevOps转型道路上至关重要的资源。
我之前的公司就有这样一位领导他一直非常关心内部研发效率的提升。听说他要投入大量资源加紧进行DevOps能力建设时我兴奋地描绘了一幅美好的图景但当时他说了一句意味深长的话“这个事你一旦做起来就会发现并不容易。”后来在实施DevOps的过程中这句话无数次得到了印证。
通用路径
因此你看管理层的支持只是推动DevOps转型的要素之一在实际操作过程中还需要很多技巧。为了帮助你少走弯路我总结提取了一条通用路径现在分享给你。
第1步寻找合适的试点项目
试点项目是企业内部最初引入DevOps实践并实施改进工作的业务对象。可以说一个合适的项目对于企业积累DevOps实践经验是至关重要的。我认为一个合适的项目应该具备以下几个特征
贴近核心业务。DevOps要以业务价值为导向。对于核心业务管理层的关注度足够高各项业务指标也相对比较完善如果改进效果可以通过核心业务指标来呈现会更有说服力。同时核心业务的资源投入会有长期保障。毕竟你肯定不希望DevOps转型落地项目因为业务调整而半途而废吧。
倾向敏捷业务。敏捷性质的业务需求量和变更都比较频繁更加容易验证DevOps改造所带来的效果。如果一个业务以稳定为主要诉求整体处于维护阶段变更的诉求和意愿都比较低那么这对于DevOps而言就不是一个好的选择。我之前在跟一家军工企业沟通的时候了解到他们每年就固定上线两次那么在这种情况下你说还有没有必要搞DevOps呢
改进意愿优先。如果公司内部的团队心比天高完全瞧不上DevOps觉得自己当前的流程是最完美的那么你再跟他们费力强调DevOps的价值结果很可能事倍功半。相反那些目前绩效一般般的团队都有非常强烈的改进诉求也更加愿意配合转型工作。这时团队的精力就可以聚焦于做事本身而不会浪费在反复拉锯的沟通上。
第2步寻找团队痛点
找到合适的团队,大家一拍即合,接下来就需要识别团队的痛点了。所谓痛点,就是当前最影响团队效率的事情,同时也是改进之后可以产生最大效益的事情。
不知道你有没有读过管理学大师高德拉特的经典图书《目标》,他在这本书中,提出的最重要的理论就是约束理论。关于这个理论,我会在后面的内容中展开介绍,现在你只需要记得“木桶原理”就行了,即最短的木板决定了团队的容量。
至于如何找寻痛点我已经在上一讲详细介绍过了。你不妨在内部试点团队中开展一次价值流分析活动相信你会有很多意外的发现。如果你不记得具体怎么做了可以回到第5讲复习一下。
第3步快速建立初期成功
找到了合适的团队也识别出了一大堆改进事项你是不是感觉前景一片大好准备撸起袖子加油干了呢打住这个时候切记不要把面铺得太广把战线拉得太长这其实是DevOps转型初期最典型的一个陷阱。
首先,转型初期资源投入有限,难以支撑大量任务并行。其次,由于团队成员之间还没有完全建立起信任关系,那些所谓的最佳实践很容易水土不服。如果生搬硬套的话,很可能会导致大量摩擦,从而影响改进效果。最后,管理层的耐心也没有想象中那么多,如果迟迟看不到效果,很容易影响后续资源的投入。
所以最关键的就是识别一个改进点定义一个目标。比如环境申请和准备时间过程那么就可以定义这样一个指标优化50%的环境准备时长。这样一来,团队的目标会更加明确,方便任务的拆解和细化,可以在几周内见到明显的成果。
第4步快速展示和持续改进
取得阶段性的成果之后,要及时向管理层汇报,并且在团队内部进行总结。这样,一方面可以增强管理层和团队的信心,逐步加大资源投入;另一方面,也能够及时发现改进过程中的问题,在团队内部形成持续学习的氛围,激发团队成员的积极性,可以从侧面改善团队的文化。
当然,类似这样的案例在企业内部都极具价值。如果可以快速扩展,那么效果就不仅仅局限于小团队内部,而是会上升到公司层面,影响力就会更加明显了。
以上这四个步骤基本涵盖了企业DevOps转型的通用路径。不过即便完全按照这样的路径进行转型也很难一帆风顺。在这条路径之下也隐藏着一些可以预见的问题最典型的就是DevOps转型的J型曲线这也是在2018年DevOps状态报告中的一个重点发现。
在转型之初,团队需要快速识别出主要问题,并给出解决方案。在这个阶段,整个团队的效能水平比较低,可以通过一些实践引入和工具的落地,快速提升自动化的能力和水平,从而帮助团队获得初期的成功。
但是,随着交付能力的提升,质量能力和技术债务的问题开始显现。比如,由于大量的手工回归测试,团队难以压缩测试周期,从而导致交付周期陷入瓶颈;项目架构的问题带来的技术债务导致集成问题增多,耦合性太强导致改动牵一发而动全身……
这个时候,团队开始面临选择:是继续推进呢?还是停滞不前呢?继续推进意味着团队需要分出额外的精力来加强自动化核心能力的预研和建设,比如优化构建时长、提升自动化测试覆盖率等,这些都需要长期的投入,甚至有可能会导致一段时间内团队交付能力的下降。
与此同时与组织的固有流程和边界问题相关的人为因素也会制约企业效率的进一步提升。如何让团队能够有信心减少评审和审批流程同样依赖于质量保障体系的建设。如果团队迫于业务压力暂缓DevOps改进工作那就意味着DevOps难以真正落地发挥价值很多DevOps项目就是这样“死”掉的。
那么说到这儿你可能会问这些到底应该由哪个团队来负责呢换句话说企业进行DevOps转型是否需要组建一个专职负责的团队呢如果需要的话团队的构成又是怎样的呢
关于这些问题我的建议是在转型初期建立一个专职的转型工作小组还是很有必要的。这个团队主要由DevOps转型关联团队的主要负责人、DevOps专家和外部咨询顾问等牵头组成一般是各自领域的专家或者资深成员相当于DevOps实施的“大脑”主要负责制定DevOps转型项目计划、改进目标识别、技术方案设计和流程改造等。
除了核心团队管理团队和工具团队也很重要。我挂一个转型小组的团队组成示意图供你参考。当然DevOps所倡导的是一专多能跨领域的人才对于企业DevOps的实施同样不可或缺在挑选小组成员的时候这一点你也需要注意下。
总结
今天我给你介绍了企业DevOps转型的常见轨迹分别是自底向上和自顶向下。无论采用哪种轨迹寻求管理层的支持都至关重要。接下来我和你一起梳理了DevOps转型的通用路径你要注意的是任何变革都不会是一帆风顺的企业的DevOps转型也是如此。在经历初期的成功之后我们很容易陷入“J型曲线”之中如果不能突破困局就很容易导致转型半途而废回到起点。最后我们一起探讨了是否需要专职的DevOps转型团队。在企业刚刚开始尝试DevOps的时候这样的团队对于快速上手和建立团队的信心还是很有必要的。
无论如何就像陆游在《冬夜读书示子聿》一诗中写的那样“纸上得来终觉浅绝知此事要躬行。”听过了太多实施DevOps的方法和路径却还是无法真正享受到它的巨大效益差的可能就是先干再说的信心和动力吧。
思考题
你在企业中实施DevOps时遇到过什么问题吗你是怎么解决这个问题的呢你是否走过一些弯路呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,148 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 业务敏捷帮助DevOps快速落地的源动力
你好,我是石雪峰,今天我要跟你分享的主题是业务敏捷,那么,我们先来聊一聊,什么是业务敏捷,为什么需要业务敏捷呢?
先试想这样一个场景你们公司内部成立了专项小组计划用三个月时间验证DevOps落地项目的可行性。当要跟大老板汇报这个事情的时候作为团队的负责人你开始发愁怎么才能将DevOps的价值和业务价值关联起来以表明DevOps对业务价值的拉动和贡献呢
如果朝着这个方向思考,就很容易钻进死胡同。因为,从来没有一种客观的证据表明,软件交付效率的提升,和公司的股价提升有什么对应关系。换句话说,软件交付效率的提升,并不能直接影响业务的价值。
实际上,软件交付团队一直在努力通过各种途径改善交付效率,但如果你的前提是需求都是靠谱的、有效的,那你恐怕就要失望了。因为,实际情况是,业务都是在不断的试错中摸着石头过河,抱着“宁可错杀一千,也不放过一个”的理念,各种天马行空的需求一起上阵,搞得软件交付团队疲于奔命,宝贵的研发资源都消耗在了业务的汪洋大海中。但是,这些业务究竟带来了多少价值,却很少有人能说得清楚。
在企业中推行DevOps的时间越长就越会发现开发、测试和运维团队之间的沟通障碍固然存在但实际上业务部门和IT部门之间的鸿沟有时候会更加严重。试问有多少公司的业务方能够满意IT部门的交付效率又有多少IT团队不会把矛头指向业务方呢说白了就一句话如果业务不够敏捷IT再怎么努力也没用啊所以我觉得很有必要跟你聊一聊有关需求的话题。
回到最开始的那个问题如果DevOps不能直接提升公司的业务价值那么为什么又要推行DevOps呢实际上如果你把DevOps的价值拆开业务价值和交付能力两个部分就很好理解了。
在现在这个多变的时代,没人能够准确地预测需求的价值。所以,交付能力的提升,可以帮助业务以最小的成本进行试错,将新功能快速交付给用户。同时,用户和市场的情况又能够快速地反馈给业务方,从而帮助业务校准方向。而业务的敏捷能力高低,恰恰体现在对功能的设计和需求的把握上,如果不能灵活地调整需求,专注于最有价值的事情,同样会拖累交付能力,导致整体效率的下降。
也就是说,在这样一种快速迭代交付的模式下,业务敏捷和交付能力二者缺一不可。
所以,开发更少的功能,聚焦用户价值,持续快速验证,就成了产品需求管理的核心思想。
开发更少的功能
很多时候,团队面临的最大问题,就是需求太多。但实际上,很多需求一开始就没想好,甚至在设计和开发阶段还在不断变更,这就给交付团队带来了极大的困扰。所以,在把握需求质量的前提下,如何尽可能地减小需求交付批次,采用最小的实现方案,保证高优先级的需求可以快速交付,从而提升上线实验和反馈的频率,就成了最关键的问题。
关于需求分析,比较常见的方法就是影响地图。
影响地图是通过简单的“Why-Who-How-What”分析方法实现业务目标和产品功能之间的映射关系。
Why代表目标它可以是一个核心的业务目标也可以是一个实际的用户需求。
Who代表影响对象也就是通过影响谁来实现这个目标。
How代表影响也就是怎样影响用户以实现我们的目标。
What代表需要交付什么样的功能可以带来期望的影响。
如果你是第一次接触影响地图,可能会听起来有点晕。没关系,我给你举个例子,来帮你理解这套分析方法。
比如一个专栏希望可以在上线3个月内吸引1万名用户那么这个Why也就是最核心的业务目标。为了达成这个目标需要影响的角色包含哪些呢其实就包含了作者、平台提供方、渠道方和最终用户。需要对他们施加哪些影响呢对作者来说需要快速地回答用户的问题提升内容的质量对平台来说需要对专栏进行重点曝光增加营销活动对渠道方来说需要提高推广力度和渠道引流对于用户来说增加分享有礼、免费试读和个人积分等活动。
那么基于以上这些影响方式,转化为最终的实际需求,就形成了一张完整的影响地图,如下图所示:
你可能会问,需求这么多,优先级要怎么安排呢?别急,现在我就给你介绍一下“卡诺模型”。
卡诺模型Kano Model是日本大师授野纪昭博士提出的一套需求分析方法它对理解用户需求对其进行分类和排序方面有着很深刻的洞察。
卡诺模型将产品需求划分为五种类型:
兴奋型指超乎用户想象的需求是可遇不可求的功能。比如用户想要一个更好的功能手机乔布斯带来了iPhone这会给用户带来极大的满足感。
期望型:用户的满意度会随着这类需求数量的增多而线性增长,做得越多,效果越好,但难以有质的突破。比如,一个电商平台最开始是卖书,后面逐步扩展到卖电脑、家居用品等多个类别。用户更多的线性需求被满足,满意度自然也会提升。
必备型:这些是产品必须要有的功能,如果没有的话,会带来非常大的影响。不过有这些功能的话,也没人会夸你做得有多好,比如安全机制和风控机制等。
无差别型:做了跟没做一样,这就是典型的无用功。比如你花了好大力气做了一个需求,但是几乎没有用户使用,这个需求就属于无差别型。
反向型:无中生有类需求,实际上根本不具备使用条件,或者用户压根不这么想。这类需求做出来以后,通常会给用户带来很大的困扰,成为被吐槽的对象。
对于五类需求来说核心要做到3点
优先规划期望型和必备型需求,将其纳入日常的交付迭代中,保持一定的交付节奏;
识别无差别型和反向型需求,这些对于用户来说并没有产生价值。如果团队对需求的分类有争议,可以进一步开展用户调研和分析。
追求兴奋型需求因为它会带来产品的竞争壁垒和差异化。不过对于大公司而言经常会遇到创新者的窘境也就是坚持固有的商业模式而很难真正投入资源创新和自我颠覆。这就要采用精益创业的思想采用MVP最小可行产品的思路进行快速验证并且降低试错成本以抓住新的机遇。
在面对一大堆业务需求的时候,首先要进行识别和分类。当然,最开始时,人人都相信自己的需求是期望型,甚至是兴奋型的,这也可以理解。毕竟,这就好比公司里面所有的缺陷问题等级都是最高级一样,因为只要不提最高级,就会被其他最高级的问题淹没,而长期得不到解决。而解决的方法,就是让数据说话,为需求的价值建立反馈机制,而这里提到的价值,就是用户价值。
聚焦用户价值
“以终为始”这四个字在精益、DevOps等很多改进的话题中经常会出现。说白了就是要“指哪打哪而不是打哪指哪”。产品开发方经常会问“这个功能这么好为什么用户就不用呢”这就是典型的用产品功能视角看问题嘴上喊着“用户是上帝”的口号但实际上自己却用上帝视角来看待具体问题。
如果你所在的公司也在搞敏捷转型,那你应该也听说过用户故事这个概念。需求不是需求,而是故事,这也让很多人不能理解。那么,用户故事是不是换了个马甲的需求呢?
关于这个问题,我曾经特意请教过一位国内的敏捷前辈,他的话让我记忆犹新。他说,从表面上看,用户故事是一种采用故事来描述需求的形式,但实际上是业务敏捷性的重要手段。它改变的不仅仅是需求的书写方式,还是需求达成共识的方式。也就是说,如果所谓的敏捷转型,没有对需求进行拆解,对需求达成共识的方式进行改变,对需求的价值进行明晰,那么可能只是在做迭代开发,而跟敏捷没啥关系。
在以往进行需求讨论的时候,往往有两个极端:一种是一句话需求,典型的“给你一个眼神,你自己体会”的方式,反正我就要做这件事,至于为什么做、怎么做一概不管,你自己看着办;另一种是上来就深入实现细节,讨论表字段怎么设计、模块怎么划分,恨不得撸起袖子就跟研发一起写代码。
每次需求讨论都是一场唇枪舌剑,达成的共识都是以一方妥协为前提的,这样显然不利于团队的和谐发展。更重要的是,始终在功能层面就事论事,而不关注用户视角,这样交付出来的需求很难达到预期。
而用户故事则是以用户的价值为核心,圈定一种角色,表明通过什么样的活动,最终达到什么样的价值。团队在讨论需求的时候,采用一种讲故事的形式,代入到设定的用户场景之中,跟随用户的操作路径,从而达成用户的目标,解决用户的实际问题。这样做的好处在于,经过团队的共同讨论和沟通,产品、研发和测试对需求目标可以达成共识,尤其是对想要带给用户的价值达成共识。
在这个过程中,团队不断探索更好的实现方案和实现路径,并补充关联的用户故事,从而形成完整的待办事项。更重要的是,团队成员逐渐培养了用户和产品思维,不再拘泥于技术实现本身,增强了彼此之间的信任,积极性方面也会有所改善,从而提升整个团队的敏捷性。
用户故事的粒度同样需要进行拆分拆分的原则是针对一类用户角色交付一个完整的用户价值也就是说用户故事不能再继续拆分的粒度。当然在实际工作中拆分的粒度还是以迭代周期为参考在一个迭代周期内交付完成即可一般建议是35天。检验用户故事拆分粒度是否合适可以遵循INVEST原则。
那么什么是INVEST原则呢
Independent独立的减少用户故事之间的依赖可以让用户故事更加灵活地验证和交付而避免大批量交付对于业务敏捷性而言至关重要。
Negotiable可协商的用户故事不应该是滴水不漏、行政命令式的而是要抛出一个场景描述并在需求沟通阶段不断细化完成。
Valuable有价值的用户故事是以用户价值为核心的所以每个故事都是在对用户交付价值所以要站在用户的视角思考问题避免像最近特别火的那句话一样“我不要你觉得我要我觉得。”
Estimatable可评估的用户故事应该可以粗略评估工作量无论是故事点数还是时间都可以。如果是一个预研性质的故事则需要进一步深挖可行性避免不知道为什么做而做。
Small小的用户故事应该是最小的交付颗粒度所以按照敏捷开发方式无论迭代还是看板都需要在一个交付周期内完成。
Testable可测试的也就是验收条件如果没有办法证明需求已经完成也就没有办法进行验收和交付。
持续快速验证
所谓用户价值说起来多少有些虚无缥缈。的确就像我们无法预测未来一样需求的价值难以预测但是需求的价值却可以定义。所以需求价值的定义可以理解为需求价值的度量分为客观指标和主观2个方面。
客观指标:也就是客观数据能够表明的指标,比如对电商行业来说,可以从购买流程角度,识别商品到达率、详情到达率、加入购物车率、完成订单率等等;
主观指标:也就是用户体验、用户满意度、用户推荐率等等,无法直接度量,只能通过侧面数据关联得出。
但是无论是客观指标,还是主观指标,每一个需求在提出的时候,可以在这些指标中选择需求上线后的预期,并定义相关的指标。一方面加强价值导向,让产品交付更有价值的需求,另外一方面,也强调数据导向,尽量客观地展现实际结果。
当然,产品需求是一个复杂的体系,相互之间也会有影响和依赖,怎么从多种指标中识别出关键指标,并跟需求本身进行关联,这就是一门学问了。不过你别担心,我会在度量相关的内容中跟你详细讨论一下。
在很多企业中精益创业的MVP思想已经深入人心了。面对未知的市场环境和用户需求为了快速验证一个想法可以通过一个最小化的产品实现来获取真实的市场反馈并根据反馈数据修正产品目标和需求优先级从而持续迭代产品需求。
这套思想基本上放之四海而皆准,但是在企业中实际应用的时候,也会出现跑偏的情况。比如,在需求提出的时候,产品预定义了一组指标,但是在上线后由于缺乏数据支撑,需求价值的评估变成了纯粹的主观题,比如业务方自主判断需求是达到预期,符合预期还是未达到预期。这样一来,十有八九统计出来的结果都是符合预期及以上。但问题是,这样推导出来的结果对产品方向是否真的有帮助呢?
所以,采用客观有效的反馈机制就成了必选项。从技术层面来说,一个业务需求的背后,一般都会关联一个埋点需求。所谓埋点分析,是网站分析的一种常用的数据采集方法。设计良好的埋点功能,不仅能帮助采集用户操作行为,还能识别完整的上下文操作路径,甚至进行用户画像的聚类和分析,帮助识别不同类型用户的行为习惯。从用户层面来说,众测机制和用户反馈渠道是比较常见的两种方式,核心就是既要听用户怎么说,也要看用户怎么做。
总结
DevOps的关注点要从研发环节继续向上游延伸一直把业务团队包括进来。也就是说IT部门不仅仅是被动的按照业务需求交付功能还要更加快速地提供业务数据反馈辅助业务决策。同时交付能力的提升也进一步降低了业务的试错成本而业务的敏捷性也决定了研发交付的需求价值和交付节奏通过影响地图进行需求分析再通过卡诺模型分析需求属性和优先级通过用户故事和整个团队达成共识通过持续快速验证帮助产品在正确的道路上发展前进。
引入业务的DevOps就成了BizDevOps这也是DevOps发展的一种潮流。最后我帮你梳理下BizDevOps的核心理念
对齐业务和开发目标、指标;
把握安全、合规指标;
及时对齐需求,减少无用开发;
体现DevOps的价值
让开发团队开始接触业务,不单单是执行,调动积极性。
思考题
你所在的企业中对于需求的价值是如何衡量的呢?是否有一套指标体系可以客观地展现需求的价值呢?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,120 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 精益看板(上):精益驱动的敏捷开发方法
你好,我是石雪峰。
提到敏捷开发方法你可能会情不自禁地联想到双周迭代、每日站会、需求拆分等。的确作为一种快速灵活、拥抱变化的研发模式敏捷的价值已经得到了行业的普遍认可。但是即便敏捷宣言已经发表了将近20个年头很多公司依然挣扎在敏捷转型的道路上各种转型失败的案例比比皆是。
我曾经就见过一家公司,一度在大规模推行敏捷。但是,这家公司很多所谓的敏捷教练都是项目经理兼任的,他们的思维和做事习惯还是项目制的方式。即便每天把团队站会开得有模有样,看板摆得到处都是,但从产品的交付结果来看,并没有什么显著的变化。没过多久,由于组织架构的调整,轰轰烈烈的敏捷转型项目就不了了之了。
这家公司虽然表面上采用了业界流行的敏捷实践也引入了敏捷工具但是团队并没有对敏捷的价值达成共识团队领导兼任Scrum Master好好的站会变成了每日工作汇报会。甚至在敏捷项目复盘会上领导还宣称“敏捷就是要干掉变化我们的目标就是保证团队按照计划进行。”这种“貌合神离”的敏捷并不能帮助企业达到灵活响应变化、快速交付价值的预期效果。
作为一种最广泛的敏捷框架Scrum的很多理念和实践都深入人心比如很多时候迭代式开发几乎等同于跟敏捷开发。但是Scrum对于角色的定义并不容易理解在推行Scrum的时候如果涉及到组织变革就会举步维艰。
实际上企业的敏捷转型并没有一条通用的路径所用的方法也没有一定之规。今天我就跟你聊聊另外一种主流的敏捷开发方法——精益看板。与Scrum相比看板方法的渐进式改变过程更加容易被团队接受。我之前所在的团队通过长期实践看板方法不仅使产品交付更加顺畅还提升了团队的整体能力。
那么,这个神奇的精益看板是怎么回事呢?
如果你之前没听说过精益看板,还是很有必要简单了解下它的背景的。其实,“看板”是一个日语词汇,泛指日常生活中随处可见的广告牌。而在生产制造系统中,看板作为一种信号卡,主要用于传递信息。很多人认为看板是丰田公司首创的,其实并非如此,比如在我之前所在的尼康公司的生产制造车间里,看板同样大量存在。
当然,看板之所以能广为人知,还是离不开丰田生产系统。《改变世界的机器》一书首次提到了著名的丰田准时化生产系统,而看板正是其中的核心工具。
简单来说,看板系统是一种拉动式的生产方式。区别于以往的大规模批量生产,看板采用按需生产的方式。也就是说,下游环节会在需要的时候,通过看板通知上游环节需要生产的工件和数量,然后上游再启动生产工作。
说白了,所谓拉动式生产,就是从后端消费者的需求出发,向前推导,需要什么就生产什么,而不是生产出来一大堆没人要的东西,从而达到减低库存、加速半成品流动和灵活响应变化的目的。我你分享一张有关丰田生产方式的图片,它演示了整个丰田生产方式的运作过程,你可以参考一下。
图片来源https://www.toyota-europe.com/world-of-toyota/this-is-toyota/toyota-production-system
软件开发中的看板方法,借鉴了丰田生产系统的精益思想,同样以限制在制品数量、加快价值流动为核心理念。也就是说,如果没有在制品限制的拉动系统,只能说是一个可视化系统,而不是看板系统,这一点非常重要。
比如很多团队都在使用Jira并在Jira中建立了覆盖各个开发阶段的看板围绕它进行协作这就是一个典型的可视化板而非看板。那么为什么对于看板方法而言约束在制品数量如此重要呢
就像刚才提到的,加快价值流动是精益看板的核心。在软件开发中,这个价值可能是一个新功能,也可能是缺陷修复,体验优化。根据利特尔法则,我们知道:平均吞吐率=在制品数量/平均前置时间。其中在制品数量就是当前团队并行处理的工作事项的数量。关于前置时间你应该并不陌生作为衡量DevOps产出效果的核心指标它代表了从需求交付开发开始到上线发布这段时间的长度。
比如1个加油站只有1台加油设备每辆车平均加油时长是5分钟如果有10辆车在等待那么前置时长就是50分钟。
但是这只是在假设队列中的工作都是顺序依次执行的情况下在实际的软件开发过程中。如果一个开发人员同时处理10件事情那么在每一件事情上真正投入的时间绝不是1/10。
还拿刚刚的例子来说如果1台加油设备要给10辆车加油这就意味着给每一辆车加油前后的动作都要重复一遍比如取出加油枪、挪车等。这样一来任务切换的成本会造成极大的资源消耗导致最终加满一辆车的时长远远超过5分钟。
所以,在制品数量会影响前置时间,并行的任务数量越多,前置时间就会越长,也就是交付周期变长,这显然不是理想的状态。
不仅如此,前置时间还会影响交付质量,前置时间增长,则质量下降。这并不难理解。比如,随着工作数量的增多,复杂性也在增加,多任务切换总会导致失误。另外,人的记性没那么可靠。对于一个需求,刚开始跟产品沟通的时候就是最清晰的,但是过了一段时间就有点想不起来是怎么回事了。这个时候,如果按照自己的想法来做,很有可能因为对需求的理解不到位,最终带来大量的返工。
再进一步展开来看的话软件开发工作总是伴随着各种变化和意外。如果交付周期比需求变化周期更长那就意味着紧急任务增多。比如老板发现一个线上缺陷必须高优先级修复类似的紧急任务增多就会导致在制品数量进一步增多。这样一来团队就陷入了一个向下螺旋这对团队的士气和交付预期都会造成非常不好的影响以至于有些团队90%的精力都用来修复问题了,根本没时间交付需求和创新。
更加严重的问题是这个时候业务部门对IT部门的信任度就会直线下降。业务部门往往会想“既然无法预测需求的交付实践那好吧我只能一次性压给你一大堆需求。”这样一来就进一步导致了在制品数量的上升。
可见,一个小小的在制品数量,牵动的是整个研发团队的信心。我把刚刚提到的连环关系整理了一下,如下图所示:
当然针对刚才加油站的问题你可能会说“多加几台加油设备不就完了吗何必依赖同一台机器呢”的确当并行任务过多的时候适当增加人员有助于缓解这个问题但是前置时间的缩短是有上限的。这就好比10个人干一个月的事情给你100个人3天做完这就是软件工程管理的经典图书《人月神话》所讨论的故事了我就不赘述了。这里你只需要知道随着人数的增多人与人之间的沟通成本会呈指数级上升。而且从短期来看由于内部培训、适应环境等因素新人的加入甚至会拖慢原有的交付速度。
了解了精益看板的核心理念,以及约束在制品数量的重要性,也就掌握了看板实践的正确方向。那么,在团队中要如何开始一步步地实施精益看板方法呢?在实施的过程中,又有哪些常见的坑,以及应对措施呢?这正是我要重点跟你分享的问题。我把精益看板的实践方法分为了五个步骤。
第一步:可视化流程;
第二步:定义清晰的规则;
第三步:限制在制品数量;
第四步:管理工作流程;
第五步:建立反馈和持续改进。
今天,我先给你介绍精益看板实践方法的第一步:可视化流程。在下一讲中,我会继续跟你聊聊剩余的四步实践。
第一步:可视化流程
在看板方法中提高价值的流动效率快速交付用户价值是核心原则所以第1步就是要梳理价值交付流程通过对现有流程的建模让流程变得可视化。关于价值流建模的话题在专栏第5讲中我已经介绍过了如果你不记得了别忘记回去复习一下。
其实,在组织内部,无论采用什么研发模式,组织结构是怎样的,价值交付的流程一直都是存在的。所以,在最开始,我们只需要忠实客观地把这个现有流程呈现出来就可以了,而无需对现有流程进行优化和调整。也正因为如此,看板方法的引入初期给组织带来的冲击相对较小,不会因为剧烈变革引起组织的强烈不适甚至是反弹。所以,看板方法是一种相对温和的渐进式改进方法。
接下来,就可以根据价值流定义看板了。看板的设计没有一个标准样式,因为每个组织的价值流都不相同。对于刚刚上手看板方法的团队来说,看板的主要构成元素可以简单概括成“一列一行”。
1.一列。
这是指看板的竖向队列,是按照价值流转的各个主要阶段进行划分的,比如常见的需求、开发、测试、发布等。对识别出来的每一列进一步可以划分成“进行中”和“已完成”两种状态,这也是精益看板拉动式生产的一个显著特征。对于列的划分粒度可以很细,比如开发阶段可以进一步细分成设计、编码、自测、评审、提测等环节,或者就作为一个单独的开发环节存在。划分的标准主要有两点:
是否构成一个独立的环节。比如对于前后端分离的开发来说,前端开发和后端开发就是两个独立的环节,一般由不同的角色负责,这种就比较适合独立阶段。
是否存在状态的流转和移交。看板是驱动上下游协同的信号卡,所以,我们需要重点关注存在上下游交付和评审的环节,这也是提示交付吞吐率和前置时长的关键节点。
除此之外,看板的设计需要定义明确的起点和终点。对于精益看板来说,覆盖端到端的完整价值交付环节是比较理想的状态。但实际上,在刚开始推行看板方法的时候,由于组织架构、团队分工等多种因素,只能在力所能及的局部环节建立看板,比如开发测试环节,这并不是什么大问题,可以在局部优化产出效果之后,再尝试向前或向后延伸。
另外,即便看板可以覆盖端到端的完整流程,各个主要阶段的关注点各不相同,所以,也会采用看板分类分级的方式。对于开发看板来说,起点一般是需求准备就绪,也就是说,需求经过分析评审设计并同研发团队沟通一致准备进入开发的状态,终点可以是提测或者发布状态。流程的起点和终点同样要体现在看板设计中,以表示在局部环节的完整工作流程。
2.一行。
这是指看板横向的泳道。泳道用于需求与需求之间划清界限,尤其在使用物理看板的时候,经常会因为便利贴贴的位置随意等原因导致混乱,而定义泳道就可以很好地解决这个问题。比如,高速公路上都画有不同的行车道,这样车辆就可以在各自的车道内行驶。
当然,泳道的意义不只如此。泳道还可以按照不同维度划分。比如,有的看板设计中会加入紧急通道,用于满足紧急需求的插入。另外,非业务类的技术改进需求,也可以在独立泳道中进行。对于前后端分离的项目来说,一个需求会拆分成前端任务和后端任务,只有当前后端任务都完成之后才能进行验收。这时,就可以把前后端任务放在同一个泳道中,从而体现需求和任务的关联关系,以及任务与任务之间的依赖关系,快速识别当前阻塞交付的瓶颈点。
当然看板的设计没有一定之规。在我们团队的看板中往往还有挂起类需求区域、缺陷区域以及技术攻关类区域等用于管理特定的问题类型。比如对于长期挂起的需求在一定时间之后就可以从看板中移除毕竟如果是几个月都没有进入任务队列的需求可能就不是真正的需求这些可以根据团队的实际情况灵活安排。如果你在使用Jira这样的工具虽然没有区域的概念但是可以通过泳道来实现比如按照史诗任务维度区分泳道然后新建对应区域的史诗任务就可以啦。
总结
今天我给你介绍了敏捷常用的两种框架Scrum和看板。看板来源于丰田生产系统以拉动式生产为最典型的特征。关注价值流动加速价值流动是精益看板的核心限制在制品数量就是核心实践因为在制品数量会直接影响团队的交付周期和产品质量甚至还会影响团队之间的信任导致团队进入向下螺旋。
在团队中实践精益看板,可以分为五个步骤,分别是:可视化流程、定义清晰的规则、限制在制品数量、管理工作流程和建立反馈并持续改进。今天我给你介绍了第一个步骤,也就是可视化流程,通过价值流分析将团队的交付路径可视化,建立起看板的主要结构,那么接下来就是开始应用看板了。下一讲,我会跟你聊聊其余的四个步骤,敬请期待。
思考题
最后,给你留一个思考题:你所在的公司是否也在实践敏捷呢?在敏捷转型的过程中,你遇到的最大问题、踩过的最大的坑是什么呢?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,138 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 精益看板(下):精益驱动的敏捷开发方法
你好我是石雪峰。在上一讲中我给你介绍了两种常见的敏捷框架Scrum和精益看板。我重点提到关注价值流动是精益的核心理念限制在制品数量则是核心实践。此外我还给你介绍了实施精益看板第一步可视化流程。那么今天我会继续介绍剩余的四个步骤。
先提一句如果你比较关心工具使用方面的问题我给你分享一份有关常见的工具配置和使用方面的资料你可以点击网盘下载提取码是mrtd。
好了,现在正式开始今天的内容。
第二步:定义清晰的规则
在完成可视化流程之后,看板的雏形就出来啦。接下来你要做的,就是定义清晰的规则。
可视化的意义不仅在于让人看得见,还在于让人看得懂。工作时间久了,我们很容易产生一种感觉,那就是沟通的成本甚至要大于工作的成本。沟通的最主要目的就是同步和传递信息,如果有一种途径可以提升信息传递的效率,那岂不是很好吗?
而看板恰恰有一个重要的意义,就是状态可视化。团队的所有成员可以通过看板了解当前在进行的任务状态、流程中的瓶颈点、任务与任务之间的依赖关系等信息,从而自发地采取相应的活动,来保证价值交付的顺畅,使整个项目能够有条不紊地交付。
当然,如果想要做到这点,光靠可视化流程还远远不够,你还需要在看板的设计中融入一定的规则。这些规则可以大大地降低团队成员之间的沟通成本,统一团队的沟通语言,形成团队成员之间的默契。看板的规则包含两个方面,一个是可视化规则,另一个是显式化规则,我分别来介绍一下。
1.可视化规则。
在上一讲中我们提到看板中的主要构成元素是“一列一行”。实际上看板中卡片的设计也有讲究主要有3点。
卡片的颜色:用于区分不同的任务类型,比如需求(绿色)、缺陷(红色)和改进事项(蓝色);
卡片的内容用于显示任务的主要信息比如电子看板ID号需求的名称、描述、负责人、预估工作量和停留时长等
卡片的依赖和阻塞状态:用于提起关注,比如在卡片上通过张贴不同的标志,表示当前卡片的健康程度,对于存在依赖和阻塞状态的卡片,需要团队高优先级协调和处理。这样一来,看板就显得主次分明啦。
2.显式化规则。
看板除了要让人看得懂,还要让人会操作,这一点非常重要。尤其是在引入看板的初期,大家对这个新鲜事物还比较陌生,所以,定义清晰的操作规则就显得格外重要了。而且,在团队习惯操作之前,需要反复地强调以加深团队的印象,慢慢培养团队的习惯。当团队习惯了使用看板之后,效率就会大大提升。这些规则包括:
谁来负责整理和移动卡片?
什么时间点进行卡片操作?
卡片的操作步骤是怎样的?(比如,卡片每停留一天需要做一次标记。)
什么时候需要线下沟通?(比如缺陷和阻塞)
哪些标识代表当前最高优先级的任务?
看板卡片的填充规则是怎样的?
谁来保障线下和线上看板的状态一致性?
还是那句话,这些规则在团队内部可能一直都存在,属于心照不宣的那种类型,但是,通过看板将规则显示化,无论是对于规则的明确,新人的快速上手,还是团队内部的持续改进,都有着非常大的好处。
第三步:限制在制品数量
限制在制品数量是看板的核心,也是最难把握的一个环节,主要问题就在于把数量限制为多少比较合适的呢?
要回答这个问题,首先要明确一点:应用看板方法只能暴露团队的现有问题,而不能解决团队的现有问题。
怎么理解这句话呢?这就是说,当在制品数量没有限制的时候,团队的交付时间和交付质量都会受到影响,这背后的原因可能是需求把控不到位,发布频率不够高,自动化程度不足以支撑快速交付,组织间的依赖和系统架构耦合太强……这些都是团队的固有问题,并非是使用看板方法就能统统解决掉的。
但看板方法的好处在于通过降低在制品数量可以将这些潜在的问题逐步暴露出来。比如在极端情况下假设我们将在制品数量设置为1也就是说团队当前只工作在一个需求上按道理来说交付的前置时间会大大缩短。但实际上团队发现由于测试环境不就绪导致无法验收交付或者交付窗口过长错过一个窗口就要再等2周的时间到头来还是不能达到快速交付价值的目标。那么这里的原因就在于测试环境初始化问题和交付频率的问题。这些都是团队固有的问题只不过在没有那么高的交付节奏要求时并没有显现出来而已。
所以,如果你能够摆正心态,正视团队的固有问题,你就会明白,限制在制品数量绝不仅仅是纠结一个数字这么简单的。在我看来,限制在制品数量有两个关键节点:一个是需求流入节点,一个是需求交付节点。
1.需求流入节点。
这里的关键是限制需求的流入。你可能就会说这太不靠谱了面对如狼似虎的业务方研发团队只能做个小绵羊毕竟只要你敢say“no”业务方就直接立刻写邮件抄送老板了。
其实需求的PK是个永恒的话题敢问哪个研发经理没经历过几十、上百次需求PK的腥风血雨呢我之前就因为同项目团队需求PK得过于激烈一度做好了被扫地出门的准备。但是后来我们发现到头来大家还是一根绳子上的蚂蚱在资源有限的前提下一次提100个需求和提10个需求从交付时长来看其实并没有什么区别。所以限制在制品数量只是换了一个方式PK需求从之前业务方提供一大堆需求让研发团队给排期的方式变成了根据需求的优先级限制并行任务数量的方式。
当然,研发团队需要承诺业务方以最快的速度交付最高优先级的需求。如果业务方看到需求的确按照预期的时间上线甚至是提前上线,他们就会慢慢习惯这种做法,团队之间的信任也就一点点建立起来了。
2.需求流出节点。
这里的关键在于加速需求的流出。在一般的看板中,最容易出现堆积的就是待发布的状态列,因为发布活动经常要根据项目的节奏安排,由专门团队在专门的时间窗口进行。如果发现待发布需求大量堆积,这时候就有理由推动下游加快发布节奏,或者以一种更加灵活的方式进行发布。
毕竟DevOps所倡导的是“You build ityou run it”的理念这也是亚马逊公司最为经典的团队理念意思是开发团队自己负责业务的发布每个发布单元都是独立的彼此没有强依赖关系从而实现团队自制。通过建立安全发布的能力将发布变成一件平常的事情这才真正有助于需求价值的快速交付。说白了要想做到业务敏捷就得想发就发做完一个上一个。
至于要将在制品数量限制为多少,我的建议是采用渐进式优化的方式。你可以从团队人数和需求的现状出发,在每个开发人员不过载的前提下,比如并行不超过三件事,根据当前处理中的任务数量进行约定,然后观察各个环节的积压情况,再通过第四步实践进行调整,最终达到一个稳定高效的状态。
第四步:管理工作流程
在专栏第5讲中我提到过精益理论中的增值环节和不增值环节而会议一般都会被归为不增值环节。于是有人就会产生这样一种误解“那是不是所有不增值的环节都要被消除掉以达到最高的流动效率呢
如果这么想的话,那是不是类似项目经理这样的角色也就不需要了呢?毕竟,他们看起来并没有直接参与到软件开发的活动中。显然,这是很片面的想法。实际上,在精益的不增值活动中,还可以进一步划分出必要不增值活动和不必要不增值活动,有些会议虽然不直接增值,但却是非常必要的。所以,我们不能简单地认为精益就等于不开会、不审批。
看板方法同样根植于组织的日常活动之中,所以,同样需要配套的管理流程,来保障看板机制的顺畅运转。在看板方法中,常见的有三种会议,分别是每日站会、队列填充会议和发布规划会议。
1.每日站会。
接触过敏捷的团队应该都非常熟悉每日站会。但是与Scrum方法的“夺命三连问”昨天做了什么今天计划做什么有什么困难或者阻塞相比看板方法的站会则略有不同。因为我们在第二步制定了清晰的规则团队的现状已经清晰可见只需要同步下重点任务就可以了。看板方法更加关注两点
待交付的任务。看板追求价值的快速流动,所以,对于在交付环节阻塞的任务,你要重点关注是什么原因导致的。
紧急、缺陷、阻塞和长期没有更新的任务。这些任务在规则中也有相应的定义如果出现了这些问题团队需要最高优先级进行处理。这里有一个小技巧就是当卡片放置在看板之中时每停留一天卡片的负责人就会手动增加一个小圆点标记通过这个标记的数量就可以看出哪些任务已经停留了太长时间。而对于使用电子看板的团队来说这就更加简单了。比如Jira本身就支持停留时长的显示。当然你也可以自建过滤器按照停留时长排序重点关注Top问题的情况。
每日站会要尽量保持高效,对于一些存在争议的问题,或者是技术细节的讨论,可以放在会后单独进行。同时,会议的组织者也要尽量观察每日站会的执行效果,如果出现停顿或者不顺畅的情况,那就意味着规则方面有优化空间。比如,如果每日站会依赖一名组织者来驱动整个过程,只要这个人不发问,团队就不说话,这就说明规则不够清晰。另外,对于站会中迸发出来的一些灵感或者好点子,可以都记录下来,作为优化事项跟进解决。
2.队列填充会议。
队列填充会议的目标有两点:一个是对任务的优先级进行排序,一个是展示需求开发的状态。一般情况下,队列填充会议需要业务方、技术方和产品项目负责人参与进来,对需求的优先级达成一致,并填充到看板的就绪状态中。
在初期,我建议在每周固定时间举行会议,这样有助于整个团队共享需求交付节奏,了解需求交付状态,帮助业务方和技术方建立良好的合作和信任关系,在会议上也可以针对在制品数量进行讨论和调整。
3.发布规划会议。
发布规划会议以最终交付为目标。一般情况下,项目的交付节奏会影响队列填充的节奏,二者最好保持同步。另外,随着部署和发布的分离,研发团队越来越趋近于持续开发持续部署,而发布由业务方统一规划把控,发布规划会议有助于研发团队和业务方的信息同步,从而实现按节奏部署和按需发布的理想状态。
第五步:建立反馈和持续改进
实际上无论是DevOps还是精益看板任何一套方法框架的终点都是持续改进。因为作为一种新的研发思想和研发方法只有结合业务实际并根据自身的情况持续优化规则、节奏、工具和流程才能更好地为业务服务。关于这部分的内容我会在度量和持续改进中进行详细介绍。你要始终记得没有天然完美的解决方案只有持续优化的解决方案。看板方法的实践是一个循序渐进的过程。为此看板创始人David J Anderson总结了看板方法的成熟度模型用于指导中大型团队实践看板方法如下图所示
图片来源http://leankanban.com/kmm/
这个模型将看板的成熟度划分为7个等级。除此之外它还针对每一级的每一个实践维度给出了具体的能力参考对看板方法的实施有非常强的指导作用可以用于对标现有的能力图谱。
如果你想获取更加详细的信息,可以点击在这一讲的开头我分享给你的链接,作为补充参考。
总结
好啦,回顾一下,在这两讲中,我先给你介绍了看板的背景和起源。看板来源于生产制造行业,是一种常用的生产信号传递方式,同时,看板也是以丰田生产系统为代表的精益生产的核心工具,也就是以拉动为核心的按需生产方式。
接着我跟你探讨了为什么要限制在制品数量以及背后的理念也就是缩短交付前置时长以快速、高质量、可预期的交付方式在业务方和IT部门之间建立起合作信任关系。
除此之外我还给你介绍了精益看板的5个核心实践包括可视化流程定义清晰的规则约束在制品数量管理工作流程和建立反馈持续改进。掌握了这些你就获取了开启精益看板之旅的钥匙。在真正进行实践之后相信你会有更多的收获和感悟。
需要提醒你的是,僵化的实践方法,脱离对人的关注,可以说是影响精益看板在组织内落地的最大障碍。就像《丰田之道》中提到的那样,持续改进和对人的尊重,才是一切改进方法的终极坐标,这一点是我们必须要注意的。
思考题
最后,给你留一个思考题:如果让你现在开始在团队中推行精益看板方法,你觉得有哪些挑战吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,159 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 配置管理最容易被忽视的DevOps工程实践基础
你好我是石雪峰。从今天开始专栏正式进入了工程实践的部分。在DevOps的体系中工程实践所占的比重非常大而且和我们的日常工作息息相关。正因为如此DevOps包含了大量的工程实践很多我们都耳熟能详比如持续集成、自动化测试、自动化部署等等这些基本上是实践DevOps的必选项。
可是还有一些实践常常被人们所忽视但这并不代表它们已经被淘汰或者是不那么重要了。恰恰相反它们同样是DevOps能够发挥价值的根基配置管理Configuration Management就是其中之一。它的理念在软件开发过程中无处不在可以说是整个DevOps工程实践的基础。所以今天我们就来聊一聊配置管理。
说了这么多,那软件配置管理到底是个啥呢?
熟悉运维的同学可能会说不就是类似Ansible、Saltstack的环境配置管理工具吗还有人会说CMDB配置管理数据库也是配置管理吧这些说法都没错。配置管理这个概念在软件开发领域应用得非常普遍几乎可以说无处不在但是刚刚提到的这些概念都是细分领域内的局部定义。
我今天要讲到的配置管理,是一个宏观的概念,是站在软件交付全生命周期的视角,对整个开发过程进行规范管理,控制变更过程,让协作更加顺畅,确保整个交付过程的完整、一致和可追溯。
看到这里,我估计你可能已经晕掉了。的确,配置管理的理论体系非常庞大。但是没关系,你只需要把四个核心理念记在心中就足够了。这四个理念分别是:版本变更标准化,将一切纳入版本控制,全流程可追溯和单一可信数据源。
1. 版本变更标准化
版本控制是配置管理中的一个非常核心的概念而对于软件来说最核心的资产就是源代码。现在很多公司都在使用类似Git、SVN之类的工具管理源代码这些工具其实都是版本控制系统。版本描述了软件交付产物的状态可以说从第一行软件代码写下开始版本就已经存在了。
现代软件开发越来越复杂,往往需要多人协作,所以,如何管理每个开发者的版本,并把它们有效地集成到一起,就成了一个难题。实际上,版本控制系统就是为了解决这个问题的。试想一下,如果没有这么一套系统的话,所有代码都在本地,不要说其他人了,就连自己都会搞不清楚哪个是最新代码。那么,当所有人的代码集成到一起的时候,那该是多么混乱啊!
不仅如此,如果线上发生了严重问题,也找不到对应的历史版本,只能直接把最新的代码发布上去,简直就是灾难。
配置管理中的另一个核心概念是变更。我们对软件做的任何改变都可以称之为一次变更,比如一个需求,一行代码,甚至是一个环境配置。版本来源于变更。对于变更而言,核心就是要记录:谁,在什么时间,做了什么改动,具体改了哪些内容,又是谁批准的。
这样看来,好像也没什么复杂的,因为现代版本控制系统基本都具备记录变更的功能。那么,是不是只要使用了版本控制系统,就做到变更管理了呢?
的确,版本控制系统的出现,大大简化了管理变更的成本,至少是不用人工记录了。但是,从另一方面来看,用好版本控制系统也需要有一套规则和行为规范。
比如版本控制系统需要打通公司的统一认证系统也就是任何人想要访问版本控制系统都需要经过公司统一登录的认证。同时在使用Git的时候你需要正确配置本地信息尤其是用户名和邮箱信息这样才能在提交时生成完整的用户信息。另外系统本身也需要增加相关的校验机制避免由于员工配置错误导致无效信息被提交入库。
改动说明一般就是版本控制系统的提交记录,一个完整的提交记录应该至少包括以下几个方面的内容:
提交概要信息:简明扼要地用一句话说明这个改动实现了哪些功能,修复了哪些问题;
提交详细信息:详细说明改动的细节和改动方式,是否有潜在的风险和遗留问题等;
提交关联需求:是哪次变更导致的这次提交修改,还需要添加上游系统编号以关联提交和原始变更。
这些改动应该遵循一种标准化的格式,并且有相关的格式说明和书写方式,比如有哪些关键字,每一行的长度,变更编号的区隔是使用逗号、空格还是分号等等。如果按照这个标准来书写每次的变更记录,其实成本还是很高的,更不要说使用英文来书写的话,英文的表达方式和内容展现形式又是一个难题。
我跟你分享一个极品的提交注释,你可以参考一下。
switch to Flask-XML-RPC dependency
CR: PBX-2222
The Flask-XML-RPC-Re fork has Python 3 support, but it has a couple
other problems.
test suite does not pass
latest code is not tagged
uncompiled source code is not distributed via PyPI
The Flask-XML-RPC module is essentially dead upstream, but it is
packaged in EPEL 7 and Fedora. This module will get us far enough to-
the
point that we can complete phase one for this project.
When we care about Python 3, we can drop XML-RPC entirely and get the
service consumers to switch to a REST API instead.
(Note, with this change, the Travis CI tests will fail for Python 3.-
The
solution is to drop XML-RPC support.)
这时,肯定有人会问,花这么大力气做这个事情,会不会有点得不偿失呢?从局部来看,的确如此。但是,换个角度想,当其他人看到你的改动,或者是评审你的代码的时候,如果通过提交记录就能清晰地了解你的意图,而不是一脸蒙地把你叫过来,让你再讲一遍,这样节约的时间比当时你书写提交记录的时间要多得多。
所以你看,一套标准化的规则和行为习惯,可以降低协作过程中的沟通成本,一次性把事情做对,这也是标准和规范的重要意义。
当然,如果标准化流程要完全依靠人的自觉性来保障,那就太不靠谱了。毕竟,人总是容易犯错的,会影响到标准的执行效果。所以,当团队内部经过不断磨合,逐步形成一套规范之后,最好还是用自动化的手段保障流程的标准化。
这样做的好处有两点:一方面,可以降低人为因素的影响,如果你不按标准来,就会寸步难行,也减少了人为钻空子的可能性。比如,有时候因为懒,每次提交都写同样一个需求变更号,这样的确满足了标准化的要求,但是却产生了大量无效数据。这时候,你就可以适当增加一些校验机制,比如只允许添加你名下的变更,或者是只允许开放状态的变更号等等。另一方面,在标准化之后,很多重复性的工作就可以自动化完成,标准化的信息也方便计算机分析提取,这样就可以提升流程的流转效率。
可以说标准化是自动化的前提自动化又是DevOps最核心的实践。这样看来说配置管理是DevOps工程实践的基础就一点不为过了吧。
2. 将一切纳入版本控制
如果说,今天这一讲的内容,你只需要记住一句话,那就是将一切纳入版本控制,这是配置管理的金科玉律。你可能会问,需要将什么样的内容纳入版本控制呢?我会毫不犹豫地回答你:“一切都需要!”比如软件源代码、配置文件、测试编译脚本、流水线配置、环境配置、数据库变更等等,你能想到的一切,皆有版本,皆要被纳入管控。
这是因为,软件本身就是一个复杂的集合体,任何变更都可能带来问题,所以,全程版本控制赋予了我们全流程追溯的能力,并且可以快速回退到某个时间点的版本状态,这对于定位和修复问题是非常重要的。
之前我就遇到过一个问题。一个iOS应用发灰度版本的时候一切正常但是正式版本就遇到了无法下载的情况。当时因为临近上线为了查这个问题可以说是全员上阵团队甚至开始互相抱怨研发说代码没有变化所以是运维的问题运维说环境没动过所以是研发的问题。结果到最后才发现这是由于一个工具版本升级某个参数的默认值从“关闭”变成了“打开”导致的。
所以你看,如果对所有内容都纳入版本控制,快速对比两个版本,列出差异点,那么,解决这种问题也就是分分钟的事情,大不了就把所有改动都还原回去。
纳入版本控制的价值不止如此。实际上很多DevOps实践都是基于版本控制来实现的比如环境管理方面推荐采用基础设施即代码的方式管理环境也就是说把用代码化的方式描述复杂的环境配置同时把它纳入版本控制系统中。这样一来任何环境变更都可以像提交代码一样来完成不仅变更的内容一目了然还可以很轻松地实现自动化。把原本复杂的事情简单化每一个人都可以完成环境变更。
这样一来开发和运维之间的鸿沟就被逐渐抹平了DevOps的真谛也是如此。所以现在行业内流行的“什么什么即代码”其背后的核心都是版本控制。
不过这里我需要澄清一下纳入版本控制并不等同于把所有内容都放到Git中管理。有些时候我们很容易把能力和工具混为一谈。Git只是一种流行的版本控制系统而已而这里强调的其实是一种能力工具只是能力的载体。比如Git本身不擅长管理大文件那么可以把这些大文件放到Artifactory或者其他自建平台上进行管理。
对自建系统来说实现版本控制的方式有很多种比如可以针对每次变更插入一组新的数据或者直接复用Git这种比较成熟的工具作为后台。唯一不变的要求就是无论使用什么样的系统和工具都需要把版本控制的能力考虑进去。
另外,在实践将一切纳入版本控制的时候,你可以参考一条小原则。如果你不确定是否需要纳入版本控制,有一个简单的判断方法就是:如果这个产物可以通过其他产物来重现,那么就可以作为制品管理,而无需纳入版本控制。
举个例子,软件包可以通过源代码和工具重新打包生成,那么,代码、工具和打包环境就需要纳入管控,而生成的软件包可以作为制品;软件的测试报告如果可以通过测试管理平台重新自动化生成,那么同样可以将其视为制品,但前提是,测试管理平台可以针对每一个版本重新生成测试报告。
3. 全流程可追溯
对传统行业来说,全流程可追溯的能力从来不是可选项,而是必选项。像航空航天、企业制造、金融行业等,对变更的管控都是非常严谨的,一旦出现问题,就要追溯当时的全部数据,像软件源代码、测试报告、运行环境等等。如果由于缺乏管理,难以提供证据证明基于当时的客观情况已经做了充分的验证,就会面临巨额的罚款和赔偿,这可不是闹着玩的事情。像最近流行的区块链技术,除了发币以外,最典型的场景也是全流程可追溯。所以说,技术可以日新月异,但很多理念都是长久不变的。
对于配置管理来说,除了追溯能力以外,还有一个重要的价值,就是记录关联和依赖关系。怎么理解这句话呢?我先提个问题,在你的公司里面,针对任意一个需求,你们是否能够快速识别出它所关联的代码、版本、测试案例、上线记录、缺陷信息、用户反馈信息和上线监控数据呢?对于任意一个应用,是否可以识别出它所依赖的环境,中间件,上下游存在调用关系的系统、服务和数据呢?
如果你的回答是“yes”那么恭喜你你们公司做得非常好。不过绝大多数公司都是无法做到这一点的。因为这不仅需要系统与系统之间的关联打通、数据联动也涉及到一整套完整的管理机制。
DevOps非常强调价值导向强调团队内部共享目标这个目标其实就是业务目标。但实际情况是业务所关注的维度和开发、测试、运维所关注的维度都各不相同。业务关心的是提出的需求有没有上线而开发关心的是这个需求的代码有没有集成运维关心的是包含这个代码的版本是否上线。所以如果不能把这些信息串联打通就没有真正做到全流程可追溯。
关于这个问题,我给你的建议是把握源头,建立主线。所谓源头,对于软件开发而言,最原始的就是需求,所有的变更都来源于需求。所以,首先要统一管理需求,无论是开发需求、测试需求还是运维需求。
接下来要以需求作为抓手去关联下游环节打通数据这需要系统能力的支持也需要规则的支持。比如每次变更都要强制关联需求编号针对不同的需求等级定义差异化流程这样既可以减少无意义的审批环节给予团队一定的灵活性也达到了全流程管控的目标。这是一个比较漫长的过程但不积跬步无以至千里DevOps也需要一步一个脚印地建设才行。
4. 单一可信数据源
最后,我想单独谈谈单一可信数据源。很多人不理解这是什么东西,我举个例子你就明白了。
有一个网络热词叫作“官宣”,也就是官方宣布的意思。一般情况下,官宣的信息都是板上钉钉的,可信度非常高。可问题是,如果有多个官宣的渠道,信息还都不一样,你怎么知道要相信哪一个呢?这就是单一可信数据源的意义。
试想一下,我们花了很大力气来建设版本控制的能力,但如果数据源本身不可靠,缺乏统一管控,那岂不是白忙一场吗?所以,对于软件开发来说,必须要有统一的管控:
对于代码来说,要有统一的版本控制系统,不能代码满天飞;
对于版本来说,要有统一的渠道,不能让人随便本地打个包就传到线上去了;
对于开发依赖的组件来说,要有统一的源头,不能让来路不明的组件直接集成到系统中。这不仅对于安全管控来说至关重要,对于企业内部的信息一致性也是不可或缺的。
同时单一可信数据源也要能覆盖企业内部元数据的管控。比如企业内部经常出现这种情况同样是应用在A部门的系统中叫作123在B部门的系统中叫作ABC在打通两边平台的时候这就相当于“鸡同鸭讲”完全对不上。再比如信息安全团队维护了一套应用列表但实际上在业务系统中很多应用都已经下线且不再维护了这样一来不仅会造成资源浪费还伴随着非常大的安全风险。
很多时候,类似的这些问题都是因为缺乏统一的顶层规划和设计导致的,这一点,在建立配置管理能力的时候请你格外关注一下。
总结
今天我给你介绍了DevOps工程实践的基础配置管理以及配置管理的四大理念分别是版本变更标准化、将一切纳入版本控制、全流程可追溯和单一可信数据源希望能帮你掌握配置管理的全局概念。
虽然配置管理看起来并不起眼,但是就像那句经典的话一样:“岁月静好,是因为有人替你负重前行。” 对于任何一家企业来说,信息过载都是常态,而配置管理的最大价值正是将信息序列化,对信息进行有效的整理、归类、记录和关联。而软件开发标准和有序,也是协同效率提升的源头,所以,配置管理的重要性再怎么强调都不为过。
思考题
你在企业中遇到过哪些配置管理方面的难题呢?你们的配置管理体系又是如何建立的呢?你遇到过因为缺乏单一可信数据源而导致“鸡同鸭讲”的有趣故事吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 分支策略:让研发高效协作的关键要素
你好,我是石雪峰。今天我们来聊聊分支策略。
在上一讲中,我反复强调过一个理念,那就是将一切纳入版本控制。其实,现代版本控制系统不仅可以记录版本和变更记录,还有一个非常重要的功能,那就是分支管理。
现代软件开发讲究效率和质量,大多依赖于多团队间的协作来实现。对于一些大型软件来说,即便是百人团队规模的协作也没什么奇怪的。如果软件架构没有良好的拆分,很有可能出现几百人在一个代码仓库里面工作的情况。这时,分支管理就成了不可或缺的功能。
一方面,分支可以隔离不同开发人员的改动,给他们提供一个相对独立的空间,让他们能够完成自己的开发任务。另一方面,整个团队也需要根据软件的发布节奏来完成代码提交、审核、集成、测试等工作。
所以如果说多人软件协作项目中有一个灵魂的话我认为这个灵魂就是分支策略。可以说分支策略就是软件协作模式和发布模式的风向标。选择一种符合DevOps开发模式的分支策略对于DevOps的实践落地也会大有帮助。
今天,我会给你拆解一些常见的分支策略,帮你了解这些策略的核心流程、优缺点,以及适用的场景和案例。
主干开发,分支发布
图片来源:-
https://paulhammant.com/2013/12/04/what_is_your_branching_model/
在这种分支策略下,开发团队共享一条主干分支,所有的代码都直接提交到主干分支上,主干分支就相当于是一个代码的全量合集。在软件版本发布之前,会基于主干拉出一条以发布为目的的短分支。
你需要注意一下这句话里的两个关键词:
以发布为目的。这条分支存在的意义不是开发新功能而是对现有功能进行验收并在达到一定的质量标准后对外发布。一般来说新功能不会基于这条分支提交只有一些Bugfix会集成进来。所以对于这种发布分支会有比较严格的权限管控。毕竟谁都不想让那些乱七八糟、未经验证的功能跑到发布分支上来。
短分支。这条发布分支一般不会存在太长时间,只要经过回归验证,满足发布标准后,就可以直接对外发布,这时,这条分支的历史使命也就结束了。除非上线之后发现一些紧急问题需要修复,才会继续在这条分支上修改验证,并将改动同步回主干分支。所以,只要在主干分支和发布分支并行存在的时间段内,所有发布分支上的改动都需要同步回主分支,这也是我们不希望这条分支存在时间过长的原因,因为这会导致重复工作量的线性累计。
对于以版本节奏驱动的软件项目来说,这种分支策略非常常见,比如客户端产品,或者是那种需要在客户终端升级的智能硬件产品,像智能手机、智能电视等。
早在很多年前,乐视刚刚推出超级电视的时候,喊过一个口号叫“周周更新”。要知道,当时智能电视产品的更新频率普遍是几个月一次。
其实,如果你了解分支策略的话,你就会发现,“周周更新”的背后也没什么特别的。当时,我所在的团队恰好负责智能电视产品线的分支策略,采用的就是主干开发、分支发布的模式。其中基于主干的发布分支提前两周拉出,然后在发布分支上进行回归验证,并在第一周发出体验版本给喜欢尝鲜的用户试用。然后,根据用户反馈和后台收集的问题进行进一步修正,并最终发布一个稳定版本。我把当时的分支策略图分享给你,你可以参考一下。
这种模式的优势有三个:
对于研发团队来说,只有一条主线分支,不需要在多条分支间切换。
在发布分支拉出之后,主干分支依然处于可集成状态,研发节奏可以保持在一个相对平稳的状态。
发布分支一般以版本号命名,清晰易懂,线上哪个版本出了问题,就在哪个分支上修复。
不过,这种模式也存在着缺点和挑战:
它对主线分支的质量要求很高。如果主线分支出了问题就会block所有开发团队的工作。对于一个百人团队、每日千次的提交规模来说如果不对提交加以约束这种情况的发生频率就会非常高。
它对团队协作的节奏要求很高。如果主线分支上的功能没有及时合入,但是业务方又坚持要在指定版本上线这个功能,这就会导致发布分支“难产”。甚至有些时候,会被迫允许部分未开发完成的功能在发布分支上继续开发,这会给发布分支的质量和稳定性造成很大的挑战。
在主线和发布分支并存期间,有可能会导致两边提交不同步的情况。比如,发布分支修复了一个线上问题,但是由于没有同步回主线,导致同样的问题在下一个版本中复现。测试出来的问题越多,这种情况出现的概率就越大,更不要说多版本并存的情况了。
这些问题的解决方法包括以下几点:
建立提交的准入门禁,不允许不符合质量标准的代码合入主线。
采用版本火车的方式,加快版本的迭代速度,功能“持票上车”,如果跟不上这个版本就随下个版本上线。另外,可以采用功能开关、热修复等手段,打破版本发布的固定节奏,以一种更加灵活的方式对外发布。
通过自动化手段扫描主线和发布分支的差异建立一种规则。比如Hotfix必须主线和发布分支同时提交或者发布分支上线后由专人反向同步等。
分支开发,主干发布
图片来源https://paulhammant.com/2013/12/04/what_is_your_branching_model/
当开发接到一个任务后会基于主干拉出一条特性开发分支在特性分支上完成功能开发验证之后通过Merge request或者Pull request的方式发起合并请求在评审通过后合入主干并在主干完成功能的回归测试。开源社区流行的GitHub模式其实就是属于这种。
根据特性和团队的实际情况,还可以进一步细分为两种情况:
每条特性分支以特性编号或需求编号命名,在这条分支上,只完成一个功能的开发;
以开发模块为单位,拉出一条长线的特性分支,并在这条分支上进行开发协作。
两者的区别就在于特性分支存活的周期,拉出时间越长,跟主干分支的差异就越大,分支合并回去的冲突也就越大。所以,对于长线模式来说,要么是模块拆分得比较清晰,不会有其他人动这块功能,要么就是保持同主干的频繁同步。随着需求拆分粒度的变小,短分支的方式其实更合适。
这种模式下的优势也有两点:
分支开发相对比较独立,不会因为并行导致互相干扰。同时,特性只有在开发完成并验收通过后才会合入主干,对主干分支的质量起到了保护作用;
随着特性分支的流行,在这种模式下,分支成了特性天然的载体。一个特性所关联的所有代码可以保存在一条特性分支上,这为以特性为粒度进行发布的模式来说提供了一种新的可能性。也就是说,如果你想要发布哪个特性,就可以直接将特性分支合并到发布分支上,这就让某一个特性变得“可上可下”,而不是混在一大堆代码当中,想拆也拆不出来。
关于这种特性分支发布的方法,我给你提供一份参考资料,你可以了解一下。不过,我想提醒你的是,特性发布虽然看起来很好,但是有三个前置条件:第一个是特性拆分得足够小,第二是有强大的测试环境作支撑,可以满足灵活的特性组合验证需求,第三是要有一套自动化的特性管理工具。
当然,分支开发、主干发布的模式也有缺点和挑战:
非常考验团队特性拆分的能力。如果一个特性过大,会导致大量并行开发的分支存在,分支的集成周期拉长,潜在的冲突也会增多。另外,分支长期存在也会造成跟主线差异过大的问题。所以,特性的粒度和分支存活的周期是关键要素。根据经验来看,分支存活的周期一般不要超过一周。
对特性分支的命名规范要求很高。由于大量特性分支的拉出,整个代码仓库会显得非常乱。面对一大堆分支,谁也说不清到底哪个还活着,哪个已经没用了。所以,如果能够跟变更管理系统打通,自动化创建分支就最好了。
特性分支的原子性和完整性,保证一个特性的关联改动需要提交到一条分支上,而不是到处都是。同时,特性分支上的提交也需要尽量清晰,典型的就是原子性提交。
我之前所在的一个团队就是采用的这种分支策略。有一次,我为了分支策略的执行细节跟研发负责人争得面红耳赤,争论的核心点就是:当特性分支合并回主干的时候,到底要不要对特性分支上的代码进行整理?
只要做过开发,你就会知道,很少有人能只用一次提交就把代码写对的,因为总是会有这样那样的问题,导致特性分支上的提交乱七八糟。
在合入主干的时候为了保证代码的原子性其实是有机会对代码提交进行重新编排的Git在这方面可以说非常强大。如果你熟练掌握git rebase命令就可以快速合并分拆提交将每一个提交整理为有意义的原子性的提交再合入主干或者干脆把特性分支上的改动压合成一个提交。当然这样做的代价就是不断重写特性分支的历史给研发团队带来额外的工作量。我跟你分享一些常见的命令。
比如当前特性分支feature1主分支master那么你可以执行以下命令整理提交历史
git checkout feature1 && git fetch origin && git rebase -i origin/master
最常见的操作包括:-
p选择提交-
r更新提交的注释信息-
e编辑提交可以将一个提交拆分成多个-
s压合提交将多个提交合并成一个-
f类似压合提交但是放弃这个提交的注释信息直接使用合并提交的注释信息-
当然在git rebase的交互界面中你也可以调整提交的顺序比如将特性功能和关联的Bugfix整合在一起。
需要提醒你的是,分支策略代表了研发团队的行为准则,每个团队都需要磨合出一套适合自己的模式来。
主干开发,主干发布
图片来源https://paulhammant.com/2013/12/04/what_is_your_branching_model/
今天给你介绍的第三种分支策略是主干开发、主干发布。武学高手修炼到一定境界之后,往往会发现大道至简,分支策略也是如此。所以,第三种分支策略可以简单理解为没有策略。团队只有一条分支,开发人员的代码改动都直接集成到这条主干分支上,同时,软件的发布也基于这条主干分支进行。
对于持续交付而言,最理想的情况就是,每一次提交都能经历一系列的自动化环境并部署到生产环境上面,而这种模式距离这个目标就更近了一点。
可想而知,如果想要做到主干分支在任何时间都处于可发布状态,那么,这就对每一次提交的代码质量要求非常高。
在一些追求工程卓越的公司里你要提交一行代码就必须经历“九九八十一难”因为有一系列的自动化验收手段还有极为严格的代码评审机制来保证你的提交不会把主干分支搞挂掉。当然即便如此问题也是难以避免的那我们该怎么做呢这里我就要给你介绍下Facebook的分支策略演进案例了。
Facebook最早采用的也是主干开发、分支发布的策略每天固定发布两次。但是随着业务发展的压力增大团队对于发布频率有了更高的要求这种分支策略已经无法满足每天多次发布的需求了。于是他们开始着手改变分支策略从主干开发、分支发布的模式演变成了主干开发、主干发布的模式。
为了保证主干分支的质量自动化验收手段是必不可少的因此每一次代码提交都会触发完整的编译构建、单元测试、代码扫描、自动化测试等过程。在代码合入主干后会进行按需发布先是发布到内部环境也就是只有Facebook的员工才能看到这个版本如果发现问题就立刻修复如果没有问题再进一步开放发布给2%的线上生产用户,同时自动化检测线上的反馈数据。直到确认一切正常,才会对所有用户开放。
最后通过分支策略和发布策略的整合注入自动化质量验收和线上数据反馈能力最终将发布频率从固定的每天2次提升到每天多次甚至实现了按需发布的模式。Facebook最新的分支策略如图所示
图片来源https://engineering.fb.com/web/rapid-release-at-massive-scale/
看到这里,你可能会问:“在这三种典型策略中,哪种策略是最好的?我应该如何选择呢?”其实,这个问题也困扰着很多公司。
的确,不同类型、规模、行业的软件项目采用的分支策略可能都不尽相同,同时,发布频率、软件架构、基础设施能力、人员能力水平等因素也在制约着分支策略的应用效果。
所以很难说有一种通用的分支策略可以满足所有场景的需求。但是有些分支策略的原则更加适合于快速迭代发布的场景也就更加适合DevOps的发展趋势。所以我个人比较推荐的是主干开发结合特性分支的模式也就是团队共享一条开发主干特性开发基于主干拉出特性分支快速开发验收并回归主干同时在特性分支和主干分别建立不同的质量门禁和自动化验收能力。
这样做的好处在于,可以加快代码集成频率,特性相对独立清晰,并且主干分支又可以保持一定的质量水平。不过,在执行的过程中,你需要遵守以下原则:
团队共享一条主干分支;
特性分支的存活周期要尽量短最好不要超过3天
每天向主干合并一次代码如果特性分支存在超过1天那么每天都要同步主干代码
谨慎使用功能开关等技术手段,保持代码干净和历史清晰;
并行分支越少越好,如果可能的话,尽量采用主干发布。
关于最后一条你需要注意的是是否需要发布分支主要取决于项目的发布模式。对于按照版本方式发布的项目来说比如App、智能硬件系统以及依赖大量外部系统联调的核心系统可以按照发布固定的节奏拉出发布分支对于发布节奏较快、系统架构拆分后相对独立的应用来说可以直接采用主干发布的模式并结合安全发布策略把控整体的发布质量。
这种分支发布的策略图如下所示:
总结
今天,我给你介绍了三种分支策略,建议你对照我给你分享的分支策略图,好好理解一下。另外, 我还介绍了适合DevOps模式的分支策略以及一些使用原则。还记得我最开始说的吗分支策略就是研发协作和发布模式的风向标分支策略的变化对整个研发团队的习惯和节奏都是一个非常大的调整找到适合当前团队的分支策略才是最重要的。
思考题
你目前所在的团队采用的是哪种分支策略?你觉得当前的分支策略有哪些问题或改进空间吗?你是否经历过分支策略的调整呢?如果有的话,你在这个过程中踩过什么“坑”吗?有没有什么心得呢?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,143 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 持续集成你说的CI和我说的CI是一回事吗
你好我是石雪峰。今天我来跟你聊聊CI。
之前我曾应邀参加某公司的DevOps交流活动他们质量团队的负责人分享了DevOps平台建设方面的经验其中有一大半时间都在讲CI。刚开始还挺好的可是后来我越听越觉得奇怪以至于在交流环节我只想提一个问题“你觉得CI是个啥意思”后来为了不被主办方鄙视话到嘴边我又努力憋回去了。
回来的路上我就一直在思考这个问题。很多时候人们嘴上总是挂着CI但是他们说的CI和我理解的CI好像并不是一回事。比如有时候CI被用来指代负责内部工具平台建设的团队有时候CI类似一种技术实践间接等同于软件的编译和打包有时候CI又成了一种职能和角色指代负责版本的集成和发布的人。可见CI的定义跟DevOps一样每个人的理解都千差万别。
可问题是如果不能理解CI原本的含义怎么发挥CI真正的价值呢以CI的名义打造的平台又怎么能不跑偏并且解决真正的问题呢
所以,今天,就让我们一起重新认识下这个“最熟悉的陌生人”。
CI是Continuous Integration的缩写也就是我们熟悉的持续集成顾名思义这里面有两个关键的问题集成什么东西为什么要持续要回答这两个问题就得从CI诞生的历史说起了。
在20世纪90年代软件开发还是瀑布模式的天下人们发现在很长一段时间里软件是根本无法运行的。因为按照项目计划软件的功能被拆分成各个模块由不同的团队分别开发只有到了开发完成之后的集成阶段软件才会被真正地组装到一起。可是往往几个月开发下来到了集成的时候大量分支合并带来的冲突和功能问题集中爆发团队疲于奔命各种救火甚至有时候发现压根集成不起来。
我最初工作的时候做的就是类似这样的项目。我们负责客户端程序的开发到了集成的时候才发现客户的数据库使用的是Oracle而我们为了省事使用的是微软Office套件中的Access估计现在很多刚工作的年轻工程师都没听说过这个数据库这就导致客户下发的数据没法导入到本地数据库中。结果整整一个元旦假期我们都在加班加点好不容易赶工了一个数据中间层这才把两端集成起来。
所以,软件集成是一件高风险的、不确定的事情,国外甚至有个专门的说法,叫作“集成地狱”。也正因为如此,人们就更倾向于不做集成,这就导致开发末端的集成环节变得更加困难,从而形成了一个恶性循环。
为了解决这个问题CI的思想应运而生。CI本身源于肯特·贝克Kent Beck在1996年提出的极限编程方法ExtremeProgramming简称XP。顾名思义极限编程是一种软件开发方法作为敏捷开发的方法之一目的在于通过缩短开发周期提高发布频率来提升软件质量改善用户需求响应速度。
不知道为什么,每次听到极限编程,我心中都热血沸腾。不管在任何时代,总有那么一群程序员走在时代前沿,代表和传承着极客精神,就像咱们平台的名字极客时间,就代表了不甘于平庸、追求极致的精神,特别好。
扯远了让我们回归正题。极限编程方法中提出的实践现在看来依然相当前沿比如结对编程、软件重构、测试驱动开发、编程规范等这些词我们都耳熟能详但是真正能做到的却是凤毛麟角。其中还有一个特别有意思的实践规范叫作每周40小时工作制也就是一周工作5天每天工作8小时。联想到前些日子在网络上引发激烈争论的“996”就可以看出极限编程方法在国内的发展还是任重而道远啊。
当然,在这么多实践中,持续集成可以说是第一个被广泛接受和认可的。
关于CI的定义我在这里引用一下马丁·福勒Martin Fowler的一篇博客中的内容这也是当前最为业界公认的定义之一
CI是一种软件开发实践团队成员频繁地将他们的工作成果集成到一起通常每人每天至少提交一次这样每天就会有多次集成并且在每次提交后自动触发运行一次包含自动化验证集的构建任务以便尽早地发现集成问题。
CI采用了一种反常规的思路来解决软件集成的困境其核心理念就是越是痛苦的事情就要越频繁地做。很多人不理解为什么举个例子你就明白了。我小时候身体非常不好经常要喝中药第一次喝的时候每喝一口都想吐可是连续喝了一个星期之后我发现中药跟水的味道也没什么区别。这其实是因为人的适应力很强慢慢就习惯了中药的味道。对于软件开发来说也是这个道理。
如果开发周期末端的一次性集成有这么大的风险和不确定性,那不如把集成的频率提高,让每次集成的内容减少,这样即便失败,影响的也仅仅是一次小的集成内容,问题定位和修复都可以更加快速地完成。这样一来,不仅提高了软件的质量,也大大降低了最后阶段的返工所带来的浪费,还提升了软件交付效率。
你可能会说,这个道理我也懂啊,我们的持续集成就是这样的。别急,我们一起来测试一下。
假如你认为自己所在的项目和团队在践行CI那么你可以思考3个问题看看你们是否做到了。
每一次代码提交,是否都会触发一次完整的流水线?
每次流水线是否会触发自动化的测试环节?
如果流水线出现了问题是否能够在10分钟之内修复
我曾在现场做过很多次这个测试如果参与者认为做到了就会举手表示如果没有做到就会把手放下。每次面对一群自信满满的CI“信徒们”三连问的结果总会让人“暗爽”因为最开始几乎所有人都会举手他们坚信自己在实践持续集成。但接下来我每问一个问题就会有一半的人把手放下坚持到最后的人寥寥无几这几个人面对周边人的目光内心也开始怀疑起来如果我再适时地追问两下基本就都放下了。
这么看来CI听起来简单易懂但实施起来并没有那么容易。可以说CI涵盖了三个阶段每个阶段都蕴含了一组思想和实践只有把这些都做到了那才是真正地在实施CI。接下来让我们逐一看下这三个阶段。
第一阶段:每次提交触发完整的流水线
第一个阶段的关键词是快速集成。这是对CI核心理念的最好诠释也就是集成速度做到极致每次变更都会触发CI。
当然这里的变更有可能是代码变更也有可能是配置、环境、数据变更。我之前强调过要将一切都纳入版本控制这样所有的元数据变更都会被版本管理系统捕获并通过事件或者Webhook的方式通知持续集成平台。
对于现代的持续集成平台比如大家常用的Jenkins默认支持多种触发方式比如定时触发、轮询触发或者Webhook触发。那么如果想做到每次提交都触发持续集成的话首先就需要打通版本控制系统和持续集成系统比如GitLab和Jenkins的集成网上已经有很多现成的材料大家照着操作一般都不会有太多问题。但是只要打通两个系统就足够了吗显然没有这么简单。实施提交触发流水线还需要一些前置条件。
1.统一的分支策略。
既然CI的目的是集成那么首先就需要有一条以集成为目的的分支。这条分支可以是研发主线也可以是专门的集成分支一旦这条分支上发生任何变更就会触发相应的CI过程。那么可能有人会问很多时候开发都是在特性分支或者版本分支上进行的难道这些分支上的提交就不要经过CI环节了吗这就引出了第2个前置条件。
2.清晰的集成规则。
对于一个大中型团队来说,每天的提交量是非常惊人的,这就要求持续集成具备足够的吞吐率,能够及时处理这些请求。而对于不同分支来说,持续集成的步骤和要求也不尽相同。不同分支的集成目的不同,相应的环节自然也不相同。
比如对于研发特性分支而言目的主要是快速验证和反馈那么速度就是不可忽视的因素所以这个层面的持续集成主要以验证打包和代码质量为主而对于系统集成分支而言它的目的不仅是验证打包和代码质量还要关注接口和业务层面的正确性所以集成的步骤会更加复杂成本也会随之上升。所以根据分支策略选择合适的集成规则对于CI的有效运转来说非常重要。
3.标准化的资源池。
资源池作为CI的基础设施重要性不言而喻。
首先资源池需要实现环境标准化也就是任何任务在任何节点都具备可运行的能力这个能力就包括了工具、配置等一系列要素。如果CI任务在一个节点可以运行跑到另外一个节点就运行失败那么CI的公信力就会受到影响。
另外,资源池的并发吞吐量应该可以满足集中提交的场景,可以动态按需初始化的资源池就成了最佳选择。当然,同时还要兼顾成本因素,因为大量资源的投入如果没有被有效利用,那么造成的浪费是巨大的。
4.足够快的反馈周期。
越是初级CI对速度的敏感性就越强。一般来讲如果CI环节超过1015分钟还没有反馈结果那么研发人员就会失去耐心所以CI的运行速度是一个需要纳入监控的重要指标。对于不同的系统而言要约定能够容忍的CI最大时长如果超过这个时长同样会导致CI失败。所以这就需要环境、平台、开发团队共同维护。
你看一套基本可用的CI所依赖的条件远不止这些核心还是为了能够在最短的时间内完成集成动作并给出反馈。如果你们公司已经实现了代码提交的CI并且不会有大量失败和排队的情况发生那么恭喜你第一阶段就算通过了。
第二阶段:每次流水线触发自动化测试
第二个阶段的关键词是质量内建。关于质量内建我会在专栏后面的内容中详细介绍。实际上CI的目的是尽早发现问题这些问题既包括构建失败也包括质量不达标比如测试不通过或者代码规约静态扫描等不符合标准。
我见过的很多CI都是“瘸腿”CI因为缺失了自动化测试的能力注入或者自动化测试的能力很差基本无法发现有效问题。这里面有几个重要的关注点我们来看一下。
1.匹配合适的测试活动。
对于不同层级的CI而言同样需要根据集成规则来确定需要注入的质量活动。比如最初级的提交集成就不适合那些运行过于复杂、时间太长的测试活动快速的代码检查和冒烟测试就足以证明这个版本已经达到了最基本的要求。而对于系统层的集成来说质量要求会更高这样一来一些接口测试、UI测试等就可以纳入到CI里面来。
2.树立测试结果的公信度。
自动化测试的目标是帮助研发提前发现问题但是如果因为自动化测试能力自身的缺陷或者环境不稳定等因素造成了CI的大量失败那么这个CI对于研发来说就可有可无了。所以我们要对CI失败进行分类分级重点关注那些异常和误报的情况并进行相应的持续优化和改善。
3.提升测试活动的有效性。
考虑到CI对于速度的敏感性那么如何在最短的时间内运行最有效的测试任务就成了一个关键问题。显然大而全的测试套件是不合时宜的只有在基础功能验证的基础上结合与本次CI的变更点相关的测试任务发现问题的概率才会大大提升。所以根据CI变更自动识别匹配对应的测试任务也是一个挑战。
当你的CI已经集成了自动化验证集并且该验证集可以有效地发现问题那么恭喜你第二阶段也成功了。但这并不是“一锤子买卖”毕竟由于业务需求的不断变化自动化测试要持续更新才能保证始终有效。
第三阶段:出了问题可以在第一时间修复
到现在为止我们已经做到了快速集成和质量内建说实话利用现有的开源工具和框架快速搭建一套CI平台并不困难真正让CI发挥价值的关键还是在于团队面对持续集成的态度以及团队内是否建立了持续集成的文化。
硅谷的很多公司都有一种不成文的规定,那就是员工每天下班前要先确认持续集成是正常的,然后再离开公司,同时,公司也不建议在深夜或者周末上线代码,因为一旦出了问题,很难在第一时间修复,造成的影响难以估计。
其实很多企业并不知道他们花费大量人力、物力建设CI的平均修复时长是多少也缺乏这方面的数据统计。就现状而言有些时候他们可以做到在10分钟内修复而有些时候就需要几个小时原因可能是负责人出去开会了或者是赶上了午休的时间。
当然也有一些企业质疑10分钟这个时间长度因为软件项目的特殊性很有可能每次集成周期就远大于10分钟。如果你也是这样想的那你可能就误解CI的理念和初衷了毕竟我也不相信马丁·福勒能够保证在10分钟内修复问题。在这么短的时间里人为因素其实并不可控所以人不是关键建立机制才是关键。
什么是机制呢机制就是一种约定人们愿意遵守这样的行为并且做了会得到好处。对于CI而言保证集成主线的可用性其实就是团队成员间的一种约定。这不在于谁出的问题谁去修复而在于我们是否能够保证CI的稳定性足够清楚问题的降级路径并且主动关注、分析和推动问题解决。
另外团队要建立清晰的规则比如10分钟内没有修复则自动回滚代码比如当CI“亮红灯”的时候团队不再提交新的代码因为在错误的基础上没有办法验证新的提交这时需要集体放下手中的工作共同恢复CI的状态。
只有团队成员深信CI带给团队的长期好处远大于短期投入并且愿意身体力行地践行CI这个“10分钟”规则才有可能得到保障并落在实处。
总结
在这一讲中我们回顾了CI诞生的历史和CI试图解决的根本问题。同时我们也介绍了CI落地建设的三个阶段和其中的核心理念即快速集成、质量内建和文化建立。
最后我特别想再提一点很多人经常会把工具和实践混为一谈一旦结果没有达到预期就会质疑实践是否靠谱工具是否好用很容易陷入工具决定论的怪圈。实际上CI的核心理念从未有过什么改变但工具却一直在升级换代。工具是实践的载体实践是工具的根基单纯的工具建设仅仅是千里之行的一小步这一点我们必须要明白。
思考题
可以说一个良好的CI体现了整个研发团队方方面面的能力那么你对企业内部实践CI都有哪些问题和心得呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,163 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 自动化测试DevOps的阿克琉斯之踵
你好,我是石雪峰。
在古希腊神话中战神阿克琉斯英勇无比浑身刀枪不入唯独脚后跟是他的致命弱点。在特洛伊战争中他的脚后跟被一箭射中倒地身亡从此阿克琉斯之踵就被用来形容致命的缺陷。我今天要跟你聊的自动化测试就是DevOps的阿克琉斯之踵。
我之前走访过很多公司,我发现,在工程实践领域,比如配置管理、持续集成等,他们实践得还不错,但是却有两大通病,一个是研发度量,另一个就是自动化测试。
没有人会否认自动化测试的价值而且很多公司也都或多或少地在实践自动化测试。但从整体来看自动化测试的实施普遍不成体系大多都在关注单点工具。另外团队对自动化测试的真实效果也存在疑惑。如果不能解决这些问题就很难突破实践DevOps的天花板。
那么,自动化测试究竟要解决什么问题,又适合哪些业务形态和测试场景呢?我们该如何循序渐进地推进建设,并且正确地度量效果以免踩坑呢?这些问题,就是我要在这一讲中跟你分享的重点内容。
自动化测试要解决什么问题?
产品交付速度的提升,给测试工作带来了很大的挑战。一方面,测试时间被不断压缩,以前三天的测试工作要在一天内完成。另一方面,需求的变化也给测试工作的开展带来了很大的不确定性。这背后核心的问题是,业务功能的累加导致测试范围不断扩大,但这跟测试时长的压缩是矛盾的。说白了,就是要测试的内容越来越多,但是测试的时间却越来越短。
全面测试会带来相对更好的质量水平,但是投入的时间和人力成本也是巨大的,而快速迭代交付就意味着要承担一定的风险。那么,究竟是要速度,还是要质量,这是一个很难回答的问题。
所以,要想提升测试效率,自然就会联想到自动化手段。实际上,自动化测试适用于以下几种典型场景:
有大量机械的重复操作,并且会反复执行的场景,比如批量的回归测试;
有明确的设计规范且相对稳定的场景,比如接口测试;
大批量、跨平台的兼容性测试,比如覆盖多种版本和多种机型的测试,几十个机型还可以接受,如果覆盖成百上千个机型,就只能依靠自动化了;
长时间不间断执行的测试,比如压力测试、可用性测试等。
这些典型场景往往都具备几个特征:设计明确、功能稳定、可多次重复、长期大批量执行等,核心就是通过自动化手段来解决测试成本的问题,也就是人的问题。但这并不意味着手工测试就没有价值了。相反,当人从重复性劳动中解放出来后,就可以投入到更有价值的测试活动中,比如探索性测试、易用性测试、用户验收测试等,这些都属于手工测试的范畴。
这听上去还挺合理的,可是,为什么很多公司还是倾向于采用手工测试的方式呢?实际上,并非所有的测试活动都适合自动化,而且,自动化测试建设也面临着一些问题。
投入产出比:很多需求基本上只会上线一次(比如促销活动类需求),那么,实现自动化测试的成本要比手动测试高得多,而且以后也不会再用了,这显然有点得不偿失。
上手门槛:自动化测试依赖代码方式实现,要开发一套配置化的测试框架和平台,对架构设计和编码能力都有很大的要求。但是,测试人员的编码能力一般相对较弱。
维护成本高:无论是测试环境、测试用例还是测试数据,都需要随着需求的变化不断进行调整,否则就很容易因为自动化测试过时,导致执行失败。
测试设备投入高比如移动App的测试需要有大量的手机资源想要覆盖所有的手机型号、操作系统版本本身就不太现实。更何况有限的机器还经常被测试人员拿去做本地调试这就进一步加剧了线上测试没有可用资源的情况。
自动化测试的设计
这么看来,自动化测试并不是一把万能钥匙,我们也不能指望一切测试都实现自动化。只有在合适的领域,自动化测试才能发挥出最大价值。那么,你可能就要问了,面对这么多种测试类型,到底要从哪里启动自动化测试的建设呢?
首先我来给你介绍一下经典的测试三角形。这个模型描述了从单元测试、集成测试到UI测试的渐进式测试过程。越是靠近底层用例的执行速度就越快维护成本也越低。而在最上层的UI层执行速度要比单元测试和接口测试要慢比手工测试要快相应的维护成本要远高于单元测试和接口测试。
图片来源“DevOps Handbook”
这样看来从靠近底层的单元测试入手是一个投入产出相对比较高的选择。但实际上单元测试的执行情况因公司而异有的公司能做到80%的覆盖率,但有的公司却寸步难行。毕竟,单元测试更多是由开发主导的,开发领导的态度就决定了运行的效果。但不可否认的是,单元测试还是非常必要的,尤其是针对核心服务,比如核心交易模块的覆盖率。当然,好的单元测试需要研发投入大量的精力。
对于UI层来说执行速度和维护成本走向了另外一个极端这也并不意味着就没有必要投入UI自动化建设。UI层是唯一能够模拟用户真实操作场景的端到端测试页面上的一个按钮可能触发内部几十个函数调用和单元测试每次只检查一个函数的逻辑不同UI测试更加关注模块集成后的联动逻辑是集成测试最有效的手段。
另外很多测试人员都是从UI开始接触自动化的再加上相对成熟的测试工具和框架实施不依赖于源码也是一种比较容易上手的自动化手段。在实际应用中UI自动化可以帮助我们节省人工测试成本提高功能测试的测试效率。不过它的缺点也是比较明显的随着敏捷迭代的速度越来越快UI控件的频繁变更会导致控件定位不稳定提高了用例脚本的维护成本。
综合考虑投入产出比和上手难度的话,位于中间层的接口测试就成了一种很好的选择。一方面,现代软件架构无论是分层还是服务调用模式,对接口的依赖程度都大大增加。比如典型的前后端分离的开发模式,前后端基本都是在围绕着接口进行开发联调。另一方面,与单元测试相比,接口测试调用的业务逻辑更加完整,并且具备清晰的接口定义,适合采用自动化的方式执行。
正因为如此对于基于Web的应用来说我更推荐椭圆形模型也就是以中间层的API接口测试为主以单元测试和UI测试为辅。你可以参考一下分层自动化测试模型图。
自动化测试的开发
有效的自动化测试离不开工具和平台的支持。以接口测试为例最早都是通过cURL、Postman、JMeter等工具单机执行的。但是一次成功的接口测试除了能够发起服务请求之外还需要前置的测试数据准备和后置的测试结果校验。对于企业的实际业务来说不仅需要单接口的执行还需要相对复杂的多接口而且带有逻辑的执行这就依赖于调用接口的编排能力甚至是内建的Mock服务。
不仅如此,测试数据、用例、脚本的管理,测试过程中数据的收集、度量、分析和展示,以及测试报告的发送等,都是一个成熟的自动化测试框架应该具备的功能。
比如对于UI自动化测试来说最让人头疼的就是UI控件变化后的用例维护成本问题。解决方法就是操作层获取控件和控件本身的定位方法进行解耦这依赖于框架的设计与实现。在实际操作控件时你可以通过自定义名称的方式来调用控件自定义名称在控件相关配置文件中进行定义。在具体操作时可以通过操作层之下的代理层来处理。示例代码如下
public void searchItem(String id) {
getTextBox("SearchBar").clearText();
getTextBox("SearchBar").setText(id);
getButton("Search").click();
}
在代码中搜索条控件被定义为SearchBar通过调用代理层的getTextBox方法得到一个文本输入框类型对象并调用该对象的清除方法。然后在对应的控件配置文件中添加对应的自定义名称和控件的定位方法。
这样一来,即便控件发生改变,对于实际操作层的代码来说,由于采用的是自定义名称,所以你不需要修改逻辑,只要在对应的控件配置文件中,替换控件的定位方法就行了。关于具体的控件配置文件,示例代码如下:
<TextBox comment="首页搜索框" id="SearchBar">
<iOS>
<appium>
<dependMethod methodName="findElementByXPath">
<xpath>
//XCUIElementTypeNavigatorBar[@name="MainPageView"]/XCUIElementTypeOther/...
</xpath>
</dependMethod>
</appium>
</iOS>
</TextBox>
当然为了简化测试人员的编写用例成本你可以在操作层使用Page-Object模式针对页面或模块封装操作方式通过一种符合认知的方式来实现具体的功能操作。这样一来在实际编写用例的时候你就可以非常简单地调用操作层的接口定义。示例代码如下
@TestDriver(driverClass = AppiumDriver.class)
public void TC001() {
String id='10000'
page.main.switchView(3);
page.cart.clearShoppingCart();
page.main.switchView(0);
page.search.searchProduct(id);
page.infolist.selectlist(0);
page.infodetail.clickAddCart();
Assert.assertTrue(page.cart.isProductCartExist(), "商品添加成功")
}
从这些示例中我们可以看出一个良好的自动化测试框架可以显著降低测试人员编写测试用例的门槛以及测试用例的维护成本。对于一个成熟的平台来说平台易用性是非常重要的能力通过DSL方式来声明测试过程可以让测试人员聚焦在测试业务逻辑的设计和构建上大大提升自动化测试的实现效率。
关于自动化测试框架的能力模型我给你分享你一份资料你可以点击网盘获取提取码是gk9w。这个能力模型从测试脚本封装、测试数据解耦、测试流程编排、报告生成等多个方面展示了框架建设的各个阶段应该具备的能力。
自动化测试结果分析
那么,我们该如何衡量自动化测试的结果呢?当前比较常用的方式是覆盖率,不过问题是,测试覆盖率提升就能发现更多的缺陷吗?
一家大型金融公司的单元测试覆盖率达到了80%接口覆盖率更是达到了100%从这个角度来看他们的自动化测试做得相当不错。但是当我问到自动化测试发现的问题数量占到整体问题的比例时他们的回答有点出人意料。在这么高的覆盖率基础上自动化测试发现的问题占比仅仅在5%左右。那么花了这么大力气建设的自动化测试最后仅仅发现了5%的有效问题,这是不是说明自动化测试的投入产出比不高呢?
实际上,说自动化测试是为了发现更多的缺陷,这是一个典型的认知误区。在实际项目中,手工测试发现的缺陷数量要比自动化测试发现的缺陷数量多得多。自动化测试更多是在帮助守住软件质量的底线,尤其是应用在回归测试中,自动化测试可以确保工作正常的已有功能不会因为新功能的引入而带来质量回退。可以这么说,如果自动化测试覆盖率足够高,那么软件质量一定不会差到哪儿去。
在自动化测试领域,除了追求覆盖率一个指标以外,自动化测试的结果分析也值得重点关注一下。如果自动化测试的结果并不准确,甚至带来大量误报的话,这对团队来说反而是一种干扰。关于测试误报,是指由于非开发代码变更导致的自动化测试用例执行失败的情况。业界对于误报率的普遍定义是:
自动化测试误报率=非开发变更引入的问题用例数量/测试失败的用例数量
比如单次自动化测试执行了100个用例其中有20个用例失败这20个失败用例有5个是由于本次功能或代码变更引入的也就是真实的缺陷那么误报率就等于20 - 5/20 = 75%
测试误报率是体现自动化测试稳定性的一个核心指标。对于不同测试类型和产品形态,误报的的原因有很多。比如测试环境的网络不稳定导致的连接超时、测试脚本和测试工具本身的固有缺陷导致的执行失败、测试数据不齐备、测试资源不可用等等。
由于测试误报的客观存在,即便执行了自动化测试并给出了测试结果,但还是需要人工审查判断之后,才能将真正的问题上报缺陷系统。这样一来,在自动化执行末端加入了人工处理,就导致自动化测试难以大规模推行,这也是自动化测试略显“鸡肋”的原因之一。
那么,要如何解决这个问题呢?这就要依赖于自动化测试结果的分析啦。
对自动化测试的问题进行分类。你要弄清楚一次失败是环境问题、网络问题、功能变更,还是系统缺陷?你需要将失败的用例归纳到这些分类之中。当一个类别的问题非常多的时候,你可以考虑进行拆分,比如网络问题,你可以拆分为网络不可达、延迟超时、域名解析错误等等。
增加已有分类的自动识别能力。比如,对于捕获到的常见异常,可以根据异常信息自动上报到对应的错误分类,从而简化人工识别和归类错误的工作量。
提升自动化测试工具和环境的健壮性,对已知问题增加一定的重试机制。
持续积累和丰富错误分类,有针对性地开展改进工作,从而不断提升自动化测试的稳定性。
我跟你分享一幅某公司的自动化测试结果分析示意图。通过统计错误的分类,可以看出错误的占比情况,并且针对常见的误报类型进行有针对性的优化,并建立度量指标来跟踪长期结果,从而保证自动化测试结果的整体可信度。这些工作都需要长期的投入才能看出成效,这也是让自动化测试价值最大化和团队能力提升的必经之路。
总结
总结一下,这一讲我给你介绍了有关自动化测试的四个方面,包括自动化测试要解决的问题和适用场景、实施的路径、框架工具开发的典型思路以及结果分析的要点。希望能够帮你建立起对自动化测试这个“老大难”问题的全面认知,让你在推进自动化测试能力建设的时候有迹可循。
思考题
你所在的企业在进行自动化建设时,有哪些困境和问题,你们是如何解决的呢?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,146 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 内建质量:丰田和亚马逊给我们的启示
你好,我是石雪峰,今天我来跟你聊一个非常重要的话题:内建质量。
我之前给你讲过一个故事,说的是在美国汽车工厂装配流水线的末端,总是有个人在拿着橡胶锤子敲打车门,以检查车门是否安装良好。我还说,如果一个公司要靠“拿锤子的人”来保证质量,这就说明,这个公司的流程本身可能就有问题。
这个观点并不是我凭空捏造出来的而是来自于质量管理大师爱德华·戴明博士经典的质量管理14条原则。其中第3条指出不应该将质量依赖于检验工作因为检验工作既昂贵又不可靠。最重要的是检验工作并不直接提升产品质量只是为了证明质量有缺陷。而正确的做法是将质量内建于整个流程之中并通过有效的控制手段来证明流程自身的有效性。
为什么内建质量如此重要?
在传统的软件开发过程中,检验质量的“锤子”往往都握在测试团队的手中。他们在软件交付的末端,通过一系列的“锤子”来“敲打”软件产品的方方面面,试图找到一些潜在的问题。
这样做的问题是,测试通过尽可能全面的回归测试来验证产品质量符合预期,成本是巨大的,但是效果却不见得有多好。
因为测试只能基于已知的产品设计进行验证,但那些潜在的风险有可能连开发自己都不知道。比如,开发引入了一些第三方的类库,但这些库本身存在缺陷,那么,如果测试没有回归到这个场景,就很有可能出现漏测和生产事故。
另外由于测试存在的意义在于发现更多的缺陷有些团队的考核指标甚至直接关联缺陷提交数量以及缺陷修复数量。那么这里的前提就是假设产品是存在缺陷的。于是测试团队为了发现问题而发现问题在研发后面围追堵截这也造成了开发和测试之间的隔阂和对立这显然不是DevOps所倡导的状态。
那么,解决这个问题的正确“姿势”,就是内建质量啦!
关于内建质量,有个经典的案例就是丰田公司的安灯系统,也叫作安灯拉绳。丰田的汽车生产线上方有一条绳子,如果生产线上的员工发现了质量问题,就可以拉动安灯系统通知管理人员,并停止生产线,以避免带有缺陷的产品不断流向下游。
要知道在生产制造业中生产线恨不得24小时运转因为这样可以最大化地利用时间生产更多的产品。可是现在随随便便一个员工就可以让整条生产线停转丰田公司是怎么想的呢
其实这背后的理念就是“Fail fast”即快速失败。如果工人发现了有缺陷的产品却要经过层层审批才能停止生产线就会有大量带有缺陷的产品流向下游所以停止生产线并不是目的及时发现问题和解决问题才是目的。
当启动安灯系统之后,管理人员、产线质量控制人员等相关人员会立刻聚集到一起解决这个问题,并尽快使生产线重新恢复运转。更重要的是,这些经验会被积累下来,并融入组织的能力之中。
内建质量扭转了看待产品质量的根本视角,也就是说,团队所做的一切不是为了验证产品存在问题,而是为了确保产品没有问题。
几年前我在华为参加转正答辩的时候被问到一个问题“华为的质量观是怎样的”答案是三个字“零缺陷。”我当时并不理解人非圣贤孰能无过产品零缺陷简直就是反常理。但是后来我慢慢明白所谓零缺陷并不是说产品的Bug数量等于零这其实是一种质量观念倡导全员质量管理构建质量文化。每一个人在工作的时候都要力争第一时间发现和解决缺陷。
所以,总结一下,内建质量有两个核心原则:
问题发现得越早,修复成本就越低;
质量是每个人的责任,而不是质量团队的责任。
说了这么多,你应该已经对内建质量有了初步的认识。那么接下来,我来给你介绍下内建质量的实践思路、操作步骤、常见问题以及应对方法。
内建质量的实施思路
既然是内建质量,那么,我们就应该在软件交付的各个环节中注入质量控制的能力。
在需求环节,可以定义清晰的需求准入规则,比如需求的价值衡量指标是否客观、需求的技术可行性是否经过了验证、需求的依赖是否充分评估、需求描述是否清晰、需求拆分是否合理、需求验收条件是否明确等等。
通过前置需求质量控制,可以减少不靠谱的需求流入。在很多公司,“一句话需求”和“老板需求”是非常典型的例子。由于没有进行充分沟通,研发就跟着感觉走,结果交付出来的东西完全不是想要的,这就带来了返工浪费。
在开发阶段,代码评审和持续集成就是一个非常好的内建质量的实践。在代码评审中,要尽量确认编码是否和需求相匹配,业务逻辑是否清晰。另外,通过一系列的自动化检查机制,来验证编码风格、风险、安全漏洞等。
在测试阶段,可以通过各类自动化测试,以及手工探索测试,覆盖安全、性能、可靠性等,来保障产品质量;在部署和发布阶段,可以增加数据库监控、危险操作扫描、线上业务监控等多种手段。
从实践的角度来说,每个环节都可以控制质量,那么,我们要优先加强哪个环节呢?
根据内建质量的第一原则,我们知道,如果可以在代码刚刚提交的时候就发现和修复缺陷,成本和影响都是最低的。如果等到产品上线后,发现了线上质量问题,再回过头来定位和修复问题,并重新发布软件,成本将会呈指数级增长。
所以,研发环节作为整个软件产品的源头,是内建质量的最佳选择。那么,具体要怎么实施呢?
内建质量的实施步骤
第一步:选择适合的检查类型
以持续集成阶段的代码检查为例,除了有单元测试、代码风格检查、代码缺陷和漏洞检查、安全检查等等,还有各种各样的检查工具。但实际上,这些并不是都需要的。至少在刚开始实践的时候,如果一股脑全上,那么研发基本上就不用干活了。
所以选择投入产出比相对比较高的检查类型是一种合理的策略。比如代码风格与缺陷漏洞相比检查缺陷漏洞显然更加重要因为一旦发生代码缺陷和漏洞就会引发线上事故。所以这么看来如果是客户端业务Infer扫描就可以优先实施起来。虽然我们不能忽视编码风格问题但这并不是需要第一时间强制执行的。
第二步:定义指标并达成一致
确定检查类型之后,就要定义具体的质量指标了。质量指标分两个层面,一个是指标项,一个是参考值,我分别来介绍一下。
指标项是针对检查类型所采纳的具体指标,比如单元测试覆盖率这个检查项,可采纳的指标就包括行、指令、类、函数等。那么,我们要以哪个为准呢?这个一般需要同研发负责人达成一致,并兼顾行业的一些典型做法,比如单测行覆盖率就是一个比较好的选择。
另外,很多时候,在既有项目启用检查的时候,都会有大量的技术债。关于技术债,我会在下一讲展开介绍。简单来说,就是欠了一堆债,一时半会儿又还不了,怎么办呢?这个时候,比较合适的做法就是选择动态指标,比如增量代码覆盖率,也就是只关注增量代码的情况,对存量代码暂不做要求。
指标项定义明确之后,就要定义参考值了。这个参考值会直接影响质量门禁是否生效,以及生效后的行为。
我简单介绍下质量门禁。质量门禁就类似一道安全门,通过门禁时进行检查,如果不满足指标,则门禁报警,禁止通过。这就跟交警查酒驾一样,酒精含量如果超过一定的指标,就会触发报警。
参考值的定义是一门艺术。对于不同的项目,甚至是同一个项目的不同模块来说,我们很难用“一刀切”的方式定义数值。我比较推荐的做法是将静态指标和动态指标结合起来使用。
静态指标就是固定值对于漏洞、安全等问题来说采取零容忍的态度只要存在就绝不放过。而动态指标是以考查增量和趋势为主比如基线值是100你就可以将参考值定义成小于等于100也就是不允许增加。你还可以根据不同的问题等级定义不同的参考值比如严格检查致命和阻塞问题其余的不做限制。
最后,对于这个指标,你一定要跟研发团队达成共识,也就是说,团队要能够认可并且执行下去。所以,定义指标的时候要充分采纳对方的建议。
第三步:建立自动化执行和检查能力
无论公司使用的是开源工具还是自研工具,都需要支持自动化执行和检查的能力。根据检查时机的不同,你也可以在提测平台、发布平台上集成质量门禁的功能,并给出检查结果的反馈。
按照快速失败的原则质量门禁的生效节点要尽量靠近指标数据的产生环节。比如如果要检查编码风格最佳的时间点是在研发本地的IDE中进行其次是在版本控制系统中进行并反馈结果而不是到了最后发布的时间点再反馈失败。
现代持续交付流水线平台都具备质量门禁的功能,常见的配置和生效方式有两种:
在持续交付平台上配置规则,也就是不同指标和参考值组合起来,形成一组规则,并将规则关联到具体的执行任务中。这样做的好处是,各个生成指标数据的子系统只需要将数据提供给持续交付平台就行了,至于门禁是否通过,完全依靠持续交付平台进行判断。另外,一般配置规则的都是质量人员,提供这样一个单独的入口,可以简化配置成本。具体的实现逻辑,如图所示:
在各个子系统中配置质量门禁。比如在UI自动化测试平台上配置门禁的指标当持续交付平台调用UI自动化测试的时候直接反馈门禁判断的结果。如果检查不通过则流水线直接失败。
第四步:定义问题处理方式
完成以上三步之后,就已经开始进行自动化检查了,而检查的结果和处理方式,对质量门禁能否真正起到作用非常重要。一般来说,质量门禁都具有强制属性,也就是说,如果没有达到检查指标,就会立即停止并给予反馈。
在实际执行的过程中,质量门禁的结果可能存在多种选项,比如失败、告警、人工确认等。这些都需要在制定规则的时候定义清楚,通过一定的告警值和人工确认方式,可以对质量进行渐进式管控,以达到持续优化的目标。
另外,你需要对所有软件交付团队成员宣导质量规则和门禁标准,并明确通知方式、失败的处理方式等。否则,检查出问题却没人处理,这个门禁就形同虚设了。
第五步:持续优化和改进
无论是检查能力、指标、参考值,还是处理方式,只有在运行起来后才能知道是否有问题。所以,在推行的初期,也应该具备一定程度的灵活性,比如对指标规则的修订、指标级别和参考值的调整等,核心目标不是为了通过质量门禁,而是为了质量提升,这才是最重要的。
内建质量的常见问题
内建质量说起来并不复杂,但想要执行到位却很困难,那么,到底有哪些常见的问题呢?我总结了一些常见问题和处理建议,做成了表格,你可以参考一下。
最后我再给你分享一个亚马逊的故事。2012年安灯系统被引入亚马逊公司一线客服如果收到客户反馈或者观察到商品有潜在的质量和安全风险就可以发出告警邮件并将商品设置为“不可购买”的状态说白了就是强制下架。客服居然可以不经过任何审批直接把商品下架不怕遭到供应商的投诉吗
实际上,这正是亚马逊践行以客户为中心的理念和原则的真实写照,每个人都为最终质量负责,没有例外。当员工得知自己被赋予了这样大的权限时,每个人都会尽自己的力量为质量工作加分。即便偶尔会有错误操作,这也是团队内部难能可贵的学习经验。
在公司中,无论是建立质量门禁的规则,还是开发一套平台系统,其实都不是最困难的事情,难的是,在实际过程中,有多少正常流程走了特殊审批?有多少发布是走的紧急通道?又有多少人会说开启了质量门禁,就会阻碍业务交付?
说到底,还是要问问自己,你愿意付出多少代价,来践行自己的理念和原则,先上再说?我想,能在这一点上达成共识,才是内建质量落地的终极要素吧。
总结
总结一下在这一讲中我通过两个故事给你介绍了内建质量的背景和原则那就是尽早发现问题尽早修复以及每个人都是质量的负责人。另外我还给你介绍了实施内建质量的五个常见步骤。希望你始终记得质量是生产出来的而不是测试出来的。掌握了内建质量你就揭开了DevOps高效率和高质量并存的秘密。
思考题
你所在的企业中是否启用了强制的质量门禁呢?可以分享一些你觉得效果良好的规则吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,161 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 技术债务:那些不可忽视的潜在问题
你好,我是石雪峰,今天我来跟你聊聊技术债务。
如果要问软件开发人员在项目中最不愿意遇到的事情,答案很可能是接手了一个别人开发了一半的系统。而且,系统开发的时间越长,开发人员的抵触情绪也就越大。那么,既然是同一种代码语言,同一种语法规则,至少还是一个能运行的东西,开发人员为什么要发自内心地抵触呢?我猜,很可能是不想看别人写的代码。之所以会这样,看不懂和怕改错是一个非常重要的原因,而这些,其实都是技术债务的结果。
什么是技术债务?
那么,究竟什么是技术债务呢?它是从哪里来的呢?好好地写个代码,咋还欠债了呢?
试想这样一种场景老板拍下来一个紧急需求要求你在3天内开发完成上线。在评估需求和设计的时候你发现要实现这个功能有两种方案
方案1采用分层架构引入消息队列。这样做的好处是结构清晰功能解耦但是需要1周的时间
方案2直接在原有代码的基础上修修补补硬塞进去一块逻辑和页面这样做需要2天时间还有1天时间来测试。
那么,你会选择哪个方案呢?
我想在大多数情况下你可能都会选择方案2因为业务的需求优先级始终是最高的。尤其是当下市场竞争恨不得以秒来计算先发优势非常明显。
而技术债务,就是指团队在开发过程中,为了实现短期目标选择了一种权宜之计,而非更好的解决方案,所要付出的代价。这个代价就是团队后续维护这套代码的额外工作成本,并且只要是债务就会有利息,债务偿还得越晚,代价也就越高。
实际上,带来技术债务的原因有很多,除了压力之下的快速开发之外,还包括不明真相的临时解决方案、新员工技术水平不足,和历史债务累积下来的无奈之举等。总之,代码维护的时间越长,引入的技术债务就会越多,从而使团队背上沉重的负担。
技术债务长什么样?
简单来说,你可以把技术债务理解为不好的代码。但是这里的“不好”,究竟是哪里不好呢?我相信,写过代码的人,或多或少都有过这样的经历:
一份代码里面定义了一堆全局变量,各个角落都在引用;
一个脚本仓库里面,一大堆名字看起来差不多的脚本,内容也都差不多;
一个函数里面修修补补写了上千行;
数据表查询各种神奇的关联;
参数传递纯靠肉眼计算顺序;
因为修改一段代码引发了一系列莫名其妙的问题;
……
那么究竟要如何对代码的技术债务进行分类呢我们可以借用“Sonar Code Quality Testing Essentials”一书中的代码“七宗罪”也就是复杂性、重复代码、代码规范、注释有效性、测试覆盖度、潜在缺陷和系统架构七种典型问题。你可以参考一下这七种类型对应的解释和描述
除了低质量的代码问题之外,还有很多其他类型的技术债务,比如不合理的架构、过时的技术、冷门的技术语言等等。
比如我们公司之前基于Ruby语言开发了一套系统但是与Java、Python等流行语言相比Ruby比较小众所以很难找到合适的工程师也影响了系统的进一步发展。再比如到2020年元旦官方即将停止为Python 2.x分支提供任何支持如果现在你们的新系统还在采用Python 2进行开发那么很快就将面对升级大版本的问题。虽然官方提供了一些减少迁移成本的方案但是从系统稳定性等方面来讲依然有着非常大的潜在工作量。
为什么要重视技术债务?
那么问题来了,为什么要重视技术债务呢?或者说,烂代码会有什么问题呢?
从用户的角度来说技术债务的多少好像并不影响用户的直观体验说白了就是不耽误使用应该有的功能都很正常。那么回到最开始的那个例子既然2天开发的系统和1周开发的系统从使用的角度来说并没有什么区别那是不是就意味着理应选择时间成本更低的方案呢
显然没有这么简单。举个例子一个人出门时衣着得体但是家里却乱成一团找点东西总是要花很长时间这当然不是什么值得骄傲的事情。对于软件来说也是如此。技术债务最直接的影响就是内部代码质量的高低。如果软件内部质量很差会带来3个方面的影响
1.额外的研发成本
对一个架构清晰、代码规范、逻辑有序、注释全面的系统来说新增一个特性可能只需要12天时间。但是同样的需求在一个混乱的代码里面可能要花上1周甚至是更长的时间。因为单是理解原有代码的逻辑、理清调用关系、把所有潜在的坑趟出来就不是件容易的事情。更何况还有大量重复的代码每个地方都要修改一遍一不小心就会出问题。
2.不稳定的产品质量
代码质量越差,修改问题所带来的影响可能就越大,因为你不知道改了一处内容,会在哪个边缘角落引发异常问题。而且,这类代码往往也没有可靠的测试案例,能够保证修改前和修改后的逻辑是正确的。如果新增一个功能,导致了严重的线上问题,这时就要面临是继续修改还是回滚的选择问题。因为如果继续修改,可能会越错越多,就像一个无底洞一样,怎么都填不满。
3.难以维护的产品
正是由于以上这些问题,研发人员在维护这种代码的时候往往是小心加谨慎,生怕出问题。这样一来,研发人员宁愿修修补补,也不愿意改变原有的逻辑,这就会导致代码质量陷入一种不断变坏的向下螺旋,越来越难以维护,问题越积累越多,直到再也没办法维护的那一天,就以重构的名义,推倒重来。其实这压根就不是重构,而是重写。
另外,如果研发团队整天跟这样的项目打交道,团队的学习能力和工作积极性都有可能受到影响。可见,技术债务的积累就像真的债务一样,属于“出来混,迟早要还”的那种,只不过是谁来还的问题而已。
如何量化技术债务?
软件开发不像是银行贷款技术债务看不见摸不着所以我们需要一套计算方法把这种债务量化出来。目前业界比较常用的开源软件就是SonarQube。在SonarQube中技术债是基于SQALE方法计算出来的。关于SQALE全称是Software Quality Assessment based on Lifecycle Expectations这是一种开源算法。当然今天的重点不是讲这个算法你可以在官网查看更多的内容。同时我再跟你分享一篇关于SQALE算法的文章它可以帮你更深入地研究代码质量。
Sonar通过将不同类型的规则按照一套标准的算法进行识别和统计最终汇总成一个时间也就是说要解决扫描出来的这些问题需要花费的时间成本大概是多少从而对代码质量有一种直观的认识。
Sonar提供了一种通用的换算公式。举个例子如下图所示在Sonar的默认规则中数据越界问题被定义为严重级别的问题换算出来的技术债务等于15分钟。这里的15分钟就是根据前面提到的SQALE分析模型计算得出的。当然你也可以在规则配置里面对每一条规则的预计修复时间进行自定义。
计算出来的技术债务会因为开启的规则数量和种类的不同而不同。就像我在上一讲中提到的那样,团队内部对规则达成共识,是非常重要的。因为只有达成了共识,才能在这个基础上进行优化。否则,如果规则库变来变去,技术债务指标也会跟着变化,这样就很难看出团队代码质量的长期走势了。
另外在Sonar中还有一个更加直观的指标来表示代码质量这就是SQALE级别。SQALE的级别为A、B、C、D、E其中A是最高等级意味着代码质量水平最高。级别的算法完全是基于技术债务比例得来的。简单来说就是根据当前代码的行数计算修复技术债务的时间成本和完全重写这个代码的时间成本的比例。在极端情况下一份代码的技术债务修复时长甚至比完全推倒重写还要长这就说明代码已经到了无法维护的境地。所以在具体实践的时候也会格外重视代码的SQALE级别的健康程度。
技术债务比例 = 修复已有技术债务的时间 / 完全重写全部代码的时间
将代码行数引入进来可以更加客观地计算整体质量水平。毕竟一个10万行的代码项目和一个1千行的代码项目比较技术债务本身就没有意义。其实这里体现了一种更加可视化的度量方式。比如现在很多公司在做团队的效能度量时往往会引入一大堆的指标来计算根本看不懂。更加高级的做法是将各种指标汇总成一组算法并根据算法给出相应的评级。
当然,如果你想知道评级的计算方法,也可以层层展开,查看详细的数据。比如,持续集成能力,它是由持续集成频率、持续集成时长、持续集成成功率、问题修复时长等多个指标共同组成的。如果在度量过程中,你发现持续集成的整体评分不高,就可以点击进去查看每个指标的数据和状态,以及详细的执行历史。这种数据关联和下钻的能力对构建数据度量体系而言非常重要。
通过将技术债务可视化,团队会对代码质量有更直观的认识,那么接下来,就要解决这些问题了。
解决方法和原则
我走访过很多公司他们都懂得技术债务的危害不仅把Sonar搭建起来了还定时执行了但问题是没时间。的确很多时候我们没时间做单测没时间做代码评审没时间解决技术债务但是这样一路妥协啥时候是个头儿呢
前几天,我去拜访一家国内最大的券商公司,眼前一亮。这样一家所谓的传统企业,在研项目的技术债务居然是个位数。在跟他们深入交流之后,我发现,公司在这方面下了大力气,高层领导强力管控,质量门禁严格执行,所以才获得了这样的效果。
所以,从来没有一切外部条件都具备的时候,要做的就是先干再说。那么,要想解决技术债务,有哪些步骤呢?
共识:团队内部要对技术债务的危害、解决项目的目标、规则的选择和制定达成一致意见。
可见通过搭建开源的Sonar平台将代码扫描整合进持续交付流水线中定期或者按需执行让技术债务变得可视化和可量化。不仅如此Sonar平台还能针对识别出来的问题给出建议的解决方法这对于团队快速提升编码水平大有帮助。
止损针对核心业务模块对核心指标类型比如vulnerability缺陷的严重和阻塞问题设定基线也就是控制整体数量不再增长。
改善:创建技术优化需求,并在迭代中留出一定的时间修复已有问题,或者采用集中突击的方式搞定大头儿,再持续改进。
在解决技术债务的过程中要遵循4条原则。
让技术债务呈良性下降趋势。一种好的趋势意味着一个好的起点,也是团队共同维护技术债务的一种约定。
优先解决高频修改的问题。技术债务的利息就是引入新功能的额外成本,那么对于高频修改的模块来说,这种成本会快速累积,这也就意味着修复的产出是最大的。至于哪些代码是高频修改的,只要通过分析版本控制系统就可以看出来。
在新项目中启动试点。如果现有的代码过于庞大不可能在短时间内完成修复那么你可以选择控制增长同时在新项目中试点执行一方面磨合规则的有效性另一方面也能试点质量门禁、IDE插件集成等自动化流程。
技术债务无法被消灭,也不要等到太晚。只要还在开发软件项目,技术债务就基本上无法避免,所以不需要一下子把目标定得太高,循序渐进就行了。但同时,技术债务的累积也不是无穷无尽的,等到再也无法维护的时候就太迟了。
在刚开始解决技术债务的时候最大的问题不是参考指标太少而是太多了。所以团队需要花大量时间来Review规则。关于这个问题我给你两条建议第一参考代码质量平台的默认问题级别。一般来说阻塞和严重的问题的优先级比一般问题更高这也是基于代码质量平台长时间的专业积累得出的结论。第二你可以参考业界优秀公司的实践经验比如很多公司都在参考阿里巴巴的Java开发手册京东也有自己的编码规约。最后我总结了一些影响比较大的问题类型建议你优先进行处理。
大量重复代码;
类之间的耦合严重;
方法过于复杂;
条件判断嵌套太多;
缺少必要的异常处理;
多表关联和缺少索引;
代码风险和缺陷;
安全漏洞。
总结
在这一讲中,我给你介绍了什么是技术债。而技术债的成本,就是团队后续开发新功能的额外成本。技术债务有很多形态,典型的就是代码“七宗罪”。除此之外,我还跟你聊了下技术债的影响,以及量化技术债务的方法。最后,我给出了一些解决方法和原则,希望能帮你攻克技术债这个难题。
最近这两年,智能研发的声音不绝于耳,其中关于使用人工智能和大数据技术提升代码质量的方法,是目前的一个热门研究领域。通过技术手段,辅助研发解决技术问题,在未来是一种趋势。如果你在公司中从事的是研发辅助和效率提升类的工作,建议你深入研究下相关的学术文章,这对你的工作会大有裨益。
参考资料:
通过持续监控实现代码克隆的定制化管理
基于代码大数据的软件开发质量追溯体系
代码克隆那点事:开发人员为何克隆?现状如何改变?
思考题
你遇到过印象深刻的烂代码吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,169 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 环境管理:一切皆代码是一种什么样的体验?
你好,我是石雪峰。
网上经常流传着一些有关偏见地图的段子,通俗点说,“偏见地图”就是说网友对世界其他地方的印象,比如很多人认为天津人都会说相声。
如果软件开发中也有偏见地图的话那么对不熟悉运维的人来说提到运维团队可能就觉得是维护环境的那帮人。于是环境就成了软件行业的“头号背锅侠”。比如线上出故障了可以是环境配置错误测试有些功能没测到可以是没有测试环境开发出Bug了也不管三七二十一先甩给环境再说……所以你看好像什么问题都可能跟环境相关。这种没来由的偏见也加剧了开发和运维之间的不信任。
环境管理的挑战
那么为啥环境总是让人这么不放心呢其实这是因为现代企业所面对的业务复杂性很大程度上都可以直观地体现在环境管理的方方面面上。总结起来我认为一共有5点
1.环境种类繁多
首先软件关联的环境种类越来越多比如开发环境、测试环境、UAT用户验收测试环境、预发布环境、灰度环境、生产环境等。光是分清这些环境的名字和作用就不是件容易的事情。
2.环境复杂性上升
现代应用的架构逐渐从单体应用向微服务应用转变。随着服务的拆分,各种缓存、路由、消息、通知等服务缺一不可,任何一个地方配置出错,应用都有可能无法正常运行。这还不包括各种服务之间的依赖和调用关系,这就导致很多企业部署一套完整环境的代价极高,甚至变成了不可能完成的任务。
3.环境一致性难以保证
比如,那句经典的甩锅名言“在我的机器上没问题”说的就是环境不一致的问题。如果无法保证各种环境配置的一致性,那么类似的问题就会无休止地发生。实际上,在很多企业中,生产环境由专门的团队管理维护,管理配置还算受控。但是对于开发环境来说,基本都属于一个黑盒子,毕竟是研发本地的电脑,即便想管也管不到。
4.环境交付速度慢
由于职责分离环境的申请流程一般都比较冗长从提起申请到交付可用的环境往往需要2周甚至更长的时间。
一方面这跟公司内部的流程审批有关。我见过一家企业申请一套环境需要5级审批想象一下于一家扁平化组织的公司从员工到CEO之间的层级可能也没有5级。另一方面环境配置过程依赖手动完成过程繁琐效率也不高大多数情况下环境配置文档都属于过时状态并不会根据应用升级而动态调整这么一来二去几天就过去了。
5.环境变更难以追溯
产品上线以后出现问题,查了半天才发现,原来是某个环境参数的配置导致的。至于这个配置是谁改的,什么时间改的,为什么修改,经过了哪些评审,一概不知,这就给线上环境的稳定性带来了极大的挑战和潜在的风险。要知道,环境配置变更的重要性,一点也不亚于代码变更,通常都需要严格管控。
基础设施即代码
你可能会问有没有一种方法可以用来解决这些问题呢还真有这就是基础设施即代码。可以这么说如果没有采用基础设施即代码的实践DevOps一定走不远。那么到底什么是基础设施即代码呢
基础设施即代码就是用一种描述性的语言通过文本管理环境配置并且自动化完成环境配置的方式。典型的就是以CAPS为代表的自动化环境配置管理工具也就是Chef、Ansible、Puppet和Saltstacks四个开源工具的首字母缩写。
这个概念听起来比较抽象那么所谓基础设施即代码这个描述基础设施的代码长什么样子呢我给你分享一段Ansible的配置示例你可以参考一下。
---
- name: Playbook
hosts: webservers
become: yes
become_user: root
tasks:
- name: ensure apache is at the latest version
yum:
name: httpd
state: latest
- name: ensure apache is running
service:
name: httpd
state: started
无论你是否了解Ansible单就这段代码而言即便你不是专业运维或者工具专家在注释的帮助下你也大概能理解这个环境配置过程。实际上这段代码就做了两件事安装http的软件包并启动相关服务。
为什么基础设施即代码能够解决以上问题呢?
首先,对于同一个应用来说,各种环境的配置过程大同小异,只是在一些配置参数和依赖服务方面有所差别。通过将所有环境的配置过程代码化,每个环境都对应一份配置文件,可以实现公共配置的复用。当环境发生变更时,就不再需要登录机器,而是直接修改环境的配置文件。这样一来,环境配置就成了一份活的文档,再也不会因为更新不及时而失效了。
其次,环境的配置过程,完全可以使用工具自动化批量完成。你只需要引用对应环境的配置文件即可,剩下的事情都交给工具。而且,即便各台机器的初始配置不一样,工具也可以保证环境的最终一致性。由于现代工具普遍支持幂等性原则,即便执行完整的配置过程,工具也会自动检测哪些步骤已经配置过了,然后跳过这个步骤继续后面的操作。这样一来,大批量环境的配置效率就大大提升了。
最后既然环境配置变成了代码自然可以直接纳入版本控制系统中进行管理享受版本控制的福利。任何环境的配置变更都可以通过类似Git命令的方式来实现不仅收敛了环境配置的入口还让所有的环境变更都完全可追溯。
基础设施即代码的实践通过人人可以读懂的代码将原本复杂的技术简单化这样一来即便是团队中不懂运维的角色也能看懂和修改这个过程。这不仅让团队成员有了一种共同的语言还大大减少了不同角色之间的依赖降低了沟通协作成本。这也是基础设施即代码的隐形价值所在特别符合DevOps所倡导的协作原则。
看到这儿你可能会说这不就是一种自动化手段吗好像也没什么特别的呀。回头想想DevOps的初衷就是打破开发和运维的隔阂但究竟要如何打通呢
在大多数公司部署上线的工作都是由专职的运维团队来负责开发团队只要将测试通过的软件包提供给运维团队就行了。所以开发和运维的自然边界就在于软件包交付的环节只有打通开发环节的软件集成验收的CI流水线和运维环节的应用部署CD流水线上线才能真正实现开发运维的一体化。而当版本控制系统遇上基础设施即代码就形成了一种绝妙的组合那就是GitOps。
开发运维打通的GitOps实践
顾名思义GitOps就是基于版本控制系统Git来实现的一套解决方案核心在于基于Git这样一个统一的数据源通过类似代码提交过程中的拉取请求的方式也就是Pull Request来完成应用从开发到运维的交付过程让开发和运维之间的协作可以基于Git来实现。
虽然GitOps最初是基于容器技术和Kubernetes平台来实现的但它的理念并不局限于使用容器技术实际上它的核心在于通过代码化的方式来描述应用部署的环境和部署过程。
在GitOps中每一个环境对应一个环境配置仓库这个仓库中包含了应用部署所需要的一切过程。比如使用Kubernetes的时候就是应用的一组资源描述文件比如部署哪个版本开放哪些端口部署过程是怎样的。
当然你也可以使用Helm工具来统一管理这些资源文件。如果你还不太熟悉Kubernetes可以简单地把它理解为云时代的Linux而Helm就是RPM或者APT这些包管理工具通过应用打包的方式来简化应用的部署过程。
除了基于Kubernetes的应用你也可以使用类似Ansible Playbook的方式。只不过与现成的Helm工具相比使用Ansible时需要自己实现一些部署脚本不过这也不是一件复杂的事情。
你可以看看下面的这段配置文件示例。这些配置文件采用了yml格式它描述了应用部署的主要信息其中镜像名称使用参数形式会有一个独立的文件来统一管理这些变量你可以根据应用的实际版本进行替换以达到部署不同应用的目标。
apiVersion: extensions/v1beta1
kind: Deployment
spec:
replicas: 1
template:
metadata:
labels:
app: demo
spec:
containers:
- name: demo
image: "{{ .Values.image.tag }}"
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
现在,我们来看看这个方案是如何实现的。
首先开发人员提交新的代码改动到Git仓库这会自动触发持续集成流水线对于常见的版本控制系统来说配置钩子就可以实现。当代码经过一系列的构建、测试和检查环节并最终通过持续集成流水线之后就会生成一个新版本的应用并上传到制品库中典型的就是Docker镜像文件或者war包的形式。
以上面的配置为例假如生成了应用的1.0版本镜像接下来会自动针对测试环境的配置仓库创建一个代码合并请求变更的内容就是修改镜像名称的版本号为1.0。这个时候,开发或者测试人员可以通过接受合并的方式,将这段环境变更配置合入主干,并再一次自动化地触发部署流水线,将新版本的应用部署到测试环境中。每次应用的部署采用相同的过程,一般就是将最新版本的应用制品拷贝到服务器并且重启,或者更新容器镜像并触发滚动升级。
这个时候测试环境就部署完成了当然如果使用Kubernetes可以利用命名空间的特性快速创建出一套独立的环境这是使用传统部署的应用所不具备的优势。在测试环境验收通过后可以将代码合并到主分支再一次触发完整的集成流水线环节进行更加全面的测试工作。
当流水线执行成功后,可以自动针对预发布环境的配置仓库创建一个合并请求,当评审通过后,系统自动完成预发布环境的部署。如果职责分离要求预发布环境的部署必须由运维人员来操作,把合并代码的权限只开放给运维人员就行了。当运维人员收到通知后,可以登录版本控制系统,查看本次变更的范围,评估影响,并按照部署节奏完成部署。而这个操作,只需要在界面点击按钮就可以实现了。这样一来,开发和运维团队的协作就不再是一个黑盒子了。大家基于代码提交评审的方式完成应用的交付部署,整个过程中的配置过程和参数信息都是透明共享的。
我跟你分享一幅流程图,希望可以帮你充分地理解这个分层部署的过程。
那么GitOps的好处究竟有哪些呢
首先,就是环境配置的共享和统一管理。原本复杂的环境配置过程通过代码化的方式管理起来,每个人都能看懂。这对于开发自运维来说,大大地简化了部署的复杂度。
另外所有最新的环境配置都以Git仓库中为准每一次的变更和部署过程也同样由版本控制系统进行记录。即便仅仅是环境工具的升级也需要经过以上的完整流程从而实现了环境和工具升级的层层验证。所以这和基础设施即代码的理念可以说有异曲同工之妙。
开发环境的治理实践
关于开发环境的治理,我再给你举一个实际的案例。对于智能硬件产品开发来说,最大的痛点就是各种环境和工具的配置非常复杂,每个新员工入职,配置环境就要花上几天时间。另外,由于工具升级频繁和多平台并行开发的需要,开发经常需要在多种工具之间进行来回切换,管理成本很高。
关于这个问题同样可以采用基础设施即代码的方法生成一个包含全部工具依赖的Docker镜像并分发给开发团队。在开发时仅需要拉起一个容器将代码目录挂载进去就可以生成一个完全标准化的研发环境。当工具版本升级时可以重新制作一个新的镜像开发本地拉取后所有的工具就升级完成了这大大简化了研发环境的维护成本。
其实我们也可以发挥创新能力把多种工具结合起来使用以解决实际问题。比如我们团队之前要同时支持虚拟化设备和容器化两种环境虚拟化可以采用传统的Ansible方式完成环境部署但容器化依赖于镜像的Dockerfile。这就存在一个问题要同时维护两套配置每次升级的时候也要同时修改虚拟化和容器化的配置文件。于是为了简化这个过程就可以把两者的优势结合起来使用单一数据源维护标准环境。
具体来说在Dockerfile中除了基础环境和启动脚本环境配置部分同样采用Ansible的方式完成这样每次在生成一个新的镜像时就可以使用相同的方式完成环境的初始化过程配置示例如下
FROM harbor.devops.com:5000/test:ansible
MAINTAINER XX <[email protected]>
ADD ./docker /docker
WORKDIR /docker
RUN export TMPDIR=/var/tmp && ansible-playbook -v -i playbooks/inventories/docker playbooks/docker_container.yml
开发本地测试的实践
其实我始终认为环境管理是DevOps推行过程中的一个潜在“大坑”。为了提升开发者的效率业界也在探索很多新的实践方向。我在前面也给你介绍过快速失败的理念只有在第一时间反馈失败才能最小化问题修复成本。而对于研发来说由于测试环境的缺失往往要等到代码提交并部署完成之后才能获取反馈这个周期显然是可以优化的。关于如何解决开发本地测试的问题在Jenkins社区也有一些相关的实践。
比如你基于Kubernetes创建了一套最小测试环境按照正常过程来说如果改动一行代码你需要经过代码提交、打包镜像、上传制品、更新服务器镜像等才能开始调试。但如果你使用KSync工具这些过程统统可以省略。KSync可以帮你建立本地工作空间和远端容器目录的关联并自动同步代码。也就是说只要在本地IDE里面修改了一行代码保存之后KSync就可以帮你把本地代码传到线上的容器中对于类似Python这样的解释型语言来说特别省事。
谷歌也开源了一套基于容器开发自动部署工具Skaffold跟KSync类似使用Skaffold命令就可以创建一套Kubernetes环境。当本地修改一行代码之后Skaffold会自动帮你重新生成镜像文件推送远端并部署生效让代码开发变得所见即所得。研发只需要专注于写代码这件事情其余的全部自动化这也是未来DevOps工程实践的一个发展方向。
总结
今天,我给你介绍了企业环境管理的五个难题:种类多,复杂性,一致性,交付速度和变更追溯,并解释了为什么基础设施即代码是解决环境管理问题的最佳实践,还跟你分享了三个基础设施即代码的案例,希望能够帮助你理解这个过程。
如果你不太了解Kubernetes和容器可能会有些内容难以消化。我想跟你说的是无论采用什么技术代码化管理的方式都是未来的发展趋势建议你结合文章中的代码和流程图仔细梳理一下并且尝试使用CAPS工具重新定义环境部署过程将环境配置过程实现代码化。如果有问题可以及时在留言区提问。
思考题
你认为推行开发自运维的最大难点是什么?关于解决这些难点,你有什么建议吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,157 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 部署管理:低风险的部署发布策略
你好,我是石雪峰,今天我来跟你聊聊部署管理。
在DevOps年度状态报告中有四个核心的结果指标其中仅“部署”这一项就占了两个关键指标分别是部署频率和部署失败率。顺便提一下另外两个指标是前置时长和平均故障修复时长。
对DevOps来说部署活动就相当于软件交付最后一公里的最后一百米冲刺。只有通过部署发布软件真正交付到最终用户手中的时候前面走过的路才真正创造了价值。
部署和发布这两个概念,经常会被混用,但严格来说,部署和发布代表两种不同的实践。部署是一组技术实践,表示通过技术手段,将本次开发测试完成的功能实体(比如代码、二进制包、配置文件、数据库等)应用到指定环境的过程,包括开发环境、预发布环境、生产环境等。部署的结果是对服务器进行变更,但是这个变更结果不一定对外可见。
发布也就是Release更偏向一种业务实践也就是将部署完成的功能正式生效对用户可见和提供服务的过程。发布的时机往往同业务需求密切相关。很多时候部署和发布并不是同步进行的比如对于电商业务来说要在0点上线新的活动那么如果部署和发布不分离就意味着要在0点的前1秒完成所有服务器的变更这显然是不现实的。
那么,我想请你思考这样一个问题:所谓的低风险发布,是不是要在发布之前确保本次变更的功能万无一失了,才会真正地执行发布动作呢?
事实上即使没这么说很多公司也都是这样做的。传统软件工程在流程设计的时候也是希望通过层层的质量手段来尽可能全面地验证交付产品的质量。典型的应用就是测试的V模型从单元测试、集成测试、系统测试到用户验收还有各类专项测试其实都是为了在发布之前发现更多的问题以此来保障产品的质量。
那么在DevOps模式下是否也倡导同样的质量思想呢我觉得这是一个有待商榷的问题。
实际上随着发布频率的加速留给测试活动的时间越来越有限了。与此同时现在业务的复杂度也比十年前高了不知道多少个等级。每次发布涉及PC端、移动端还有小程序、H5等多种形态更别提成百上千的终端设备了。要在有限的时间里完成所有的测试活动本来就是件很有挑战的事情。而且各个公司都在衡量测试开发比更是限制了测试人力投入的增长甚至还要不断下降。
你当然可以通过自动化手段来提升测试活动的效率但穷尽测试本来就是个伪命题。那么明明说了DevOps可以又快又好难道是骗人的吗
当然不是。这里的核心就在于DevOps模式下质量思想发生了转变。简单概括就是要在保障一定的质量水平的前提下尽量加快发布节奏并通过低风险发布手段以及线上测试和监控能力尽早地发现问题并以一种最简单的手段来快速恢复。
这里面有几个关键词:一定的质量水平,低风险发布手段,线上测试和监控,以及快速恢复。我分别来给你解释一下。
一定的质量水平
这个“一定”要怎么理解呢?对于不同形态的软件来说,质量标准的高低自然是不相同的。比如,我有一个制造卫星的同学,他们对于软件质量的要求就是要做到几年磨一剑,甚至是不计成本的。但对于互联网这种快速迭代的业务来说,大家都习惯了默认会出问题,所以在圈定测试范围和测试覆盖的基础上,只要完成严重问题的修复即可发布,低级别的问题可以在后续的众测和灰度的环节继续处理。
所以与定义一个发布质量标准相比更重要的随着DevOps的推广扭转团队的质量观念。质量不再是测试团队自身的事情而是整个交付团队的事情。如果出现了线上问题团队要一起来定位和修复并且反思如何避免类似的问题再次发生从失败中学习。
而测试能力的向前、向后延伸,一方面,提供了工具和平台以帮助开发更容易地进行自测;另一方面,加强针对线上监控埋点等类型的测试,可以保证线上问题可以快速暴露,正常获取辅助分析用户行为的数据,这会全面提升整体的发布质量。
低风险的发布手段
既然发布是一件不可回避的高风险事情,那么,为了降低发布活动的风险,就需要有一些手段了。典型的包括以下几种:蓝绿部署,灰度发布和暗部署。
1.蓝绿部署
蓝绿部署就是为应用准备两套一模一样的环境,一套是蓝环境,一套是绿环境,每次只有一套环境提供线上服务。这里的蓝和绿,只是用于区分两套环境的标志而已。在新版本上线时,先将新版本的应用部署到没有提供线上服务的环境中,进行上线前验证,验证通过后就达到了准备就绪的状态。在发布时间点,只要将原本指向线上环境的路由切换成另外一套环境,整个发布过程就完成了。
一般来说,这种方式的实现成本比较高。因为有两套一模一样的环境,只有一套用于真正地提供线上服务。为了减少资源浪费,在实际操作中,另外一套环境可以当作预发布环境使用,用来在上线之前验证新功能。另外,在这种模式下,数据库普遍还是采用同一套实例,通过向下兼容的方式支持多个版本的应用。
图片来源https://www.gocd.org/2017/07/25/blue-green-deployments.html
2.灰度发布
灰度发布,也叫金丝雀发布。与蓝绿部署相比,灰度发布更加灵活,成本也更低,所以,在企业中是一种更为普遍的低风险发布方式。
灰度发布有很多种实现机制,最典型的就是采用一种渐进式的滚动升级来完成整个应用的发布过程。当发布新版本应用时,根据事先设计好的灰度计划,将新应用部署到一定比例的节点上。当用户流量打到这部分节点的时候,就可以使用新的功能了。
值得注意的是要保证同一个用户的行为一致性不能时而看到新功能时而看到老功能。当然解决办法也有很多比如通过用户ID或者cookie的方式来识别用户并划分不同的组来保证。
新版本应用在部分节点验证通过后,再逐步放量,部署更多的节点,依次循环,最终完成所有节点的部署,将所有应用都升级到新版本。分批部署只是实现灰度发布的方法之一,利用配置中心和特性开关,同样可以实现指向性更强的灰度策略。比如,针对不同的用户、地域、设备类型进行灰度。
对于移动端应用来说灰度发布的过程也是必不可少的。我以iOS平台应用为例带你梳理下发布的步骤。首先公司的内部用户可以自行下载安装企业包进行新版本验证和试用。试用OK后再通过官方的Testflight平台对外开启灰度这样只有一部分用户可以收到新版本通知并且在Testflight中安装新版本。灰度指标符合预期后再开启全量用户升级。
现在很多应用都采用了动态下发页面的方法,同样可以使用特性开关,来控制不同用户看到不同的功能。
图片来源https://www.gocd.org/2017/07/25/blue-green-deployments.html
3.暗部署
随着A/B测试的兴起暗部署的方式也逐渐流行起来。所谓暗部署就是在用户不知道的情况下进行线上验证的一种方法。比如后端先行的部署方式把一个包含新功能的接口发布上线这个时候由于没有前端导向这个接口用户并不会真实地调用到这个接口。当用户进行了某些操作后系统会将用户的流量在后台复制一份并打到新部署的接口上以验证接口的返回结果和性能是否符合预期。
比如,对于电商业务场景来说,当用户搜索了一个关键字后,后台有两种算法,会给出两种返回结果,然后可以根据用户的实际操作,来验证哪种算法的命中率更高,从而实现了在线的功能验证。
图片来源https://www.gocd.org/2017/07/25/blue-green-deployments.html
以上这三种低风险发布手段如果应用规模整体不大蓝绿部署是提升系统可用性的最好手段比如各类Hot-standby的解决方案其实就是蓝绿部署的典型应用。而对于大规模系统来说考虑到成本和收益灰度发布显然就成了性价比最高的做法。如果想要跑一些线上的测试收集真实用户反馈那么暗部署是一种不错的选择。
线上测试和监控
那么如何验证多种发布模式是正常的呢核心就在于线上测试和监控了。实际上在DevOps中有一种全新的理念那就是监控就是一种全量的测试。
你可能会问,为什么要在线上进行测试?这岂不是非常不安全的行为吗?如果按照以往的做法,你应该做的就是花费大量精力来建立一个全仿真的预发布环境,尽可能地模拟线上环境的内容,以达到验证功能可用性的目标。但只要做过测试的团队就知道,测试环境永远不能替代生产环境,即便在测试环境做再多的回归,到了生产环境,依旧还是会有各种各样的问题。
关于测试环境和生产环境,有一个特别有趣的比喻:测试环境就像动物园,你能在里面看到各种野生动物,它们都活得都挺好的;生产环境就像大自然,你永远无法想象动物园里的动物回到大自然之后会有什么样的行为,它们面临的就是一个完全未知的世界。产生这种差异的原因有很多,比如环境设备的差异、用户行为和流量的差异、依赖服务的差异等,每一个变量都会影响组合的结果。
那么,既然无法事先模拟发布后会遇到的所有场景,该如何做线上验证呢?比较常见的,有三种手段。
1.采用灰度发布、用户众测等方式,逐步观察用户行为并收集用户数据,以验证新版本的可用性是否符合预期。
这里的主要实践之一就是埋点功能。在互联网产品中,埋点是一种最常用的产品分析和数据采集方法,也是数据驱动决策的主要依据之一。它的价值就在于,根据预先设计的收集和监控数据的方法,采集用户的行为、产品质量、运营数据等多维度的数据。
大型公司一般都实现了自己的埋点SDK根据产品设计需求可以自动化地采集数据并配置采集粒度对于小公司来说像友盟这种第三方统计工具就可以满足绝大多数情况的需求了。
2.用户反馈。
除了自动化的采集数据之外,用户主动的反馈也是获取产品信息的第一手资料。而用户反馈的渠道有很多,公司里面一般都有用户运营和舆情监控系统,用于按照“关键字”等自动爬取各个主流渠道的产品信息。一旦发现负面的反馈,就第一时间进行止损。
3.使用线上流量测试。
这一点在讲暗部署时我也提到过最典型的实践就是流量镜像。除了做线上的A/B测试最常用的就是将线上真实的用户流量复制下来以实时或者离线的方式回放到预发布环境中用于功能测试。
除此之外,流量镜像还有很多高级的玩法。像是根据需求选择性地过滤一些信息,比如使用只读的查询内容来验证搜索接口。另外,还可以按照倍数放大和缩小流量,以达到服务压测的目的。还有,可以自动比对线上服务和预发布服务的返回结果,以验证相同的流量过来时,两个版本之间系统的行为是否一致。另外,流量镜像的数据可以离线保存,这对于一些偶发的、难以复现的用户问题,提供了非常难得的数据积累,可以帮助研发团队进一步分析,以避免此类问题的再次发生。
在工具层面我推荐你使用开源的GoReplay工具。它基于Go语言实现作用于HTTP层不需要对系统进行大量改造并且能很好地支持我刚才提到的功能。
快速恢复
一旦发现新版本发布后不符合预期或者有严重的缺陷最重要的就是尽快控制局面解决故障。平均故障修复时长MTTR是DevOps的四个核心指标之一DevOps的质量信心不仅来源于层层的质量门禁和自动化验证出现问题可以快速定位和修复也是不可忽视的核心能力之一。
平均故障修复时长可以进一步拆解为平均故障检测时长MTTD、平均故障识别诊断时长MTTI以及平均故障修复时长MTTR。在故障发生后根据服务可用性指标SLA对问题进行初步分析定位明确解决方案。在这个领域一款好用的线上诊断工具可以大大地帮助你缓解燃眉之急。比如阿里的开源工具Arthas就可以实时监控堆栈信息、JVM信息调用参数查看返回结果跟踪节点耗时等甚至还能查看内存占用、反编译源码等堪称问题诊断利器。
初步对问题进行分析定位后,你可以有两种选择:向前修复和向后回滚。
向前修复就是快速修改代码并发布一个新版本上线向后回滚就是将系统部署的应用版本回滚到前一个稳定版本。无论选择哪一种考验的都是自动化的部署流水线和自动化的回滚能力这也是团队发布能力的最佳体现。而在DevOps的结果指标中部署前置时长描述的恰恰就是这段时长。当然最佳实践就是自动化的流水线。往往在这个时候你就会希望流水线更快一些更自动化一些。
最后,再提一点,你可能在很多大会上听过“故障自愈”,也就是出现问题系统可以自动修复。这听起来有点神奇,但实际上,故障自愈的第一步,就要做好服务降级和兜底策略。这两个听起来很专业的词是啥意思呢?别着急,我给你举个例子,你就明白了。
我给你截了两张某购物App的图片你可以对比看下有什么不同。
如果你仔细看的话你会发现单这一个页面就有大大小小8个差异。所以服务降级就是指在流量高峰的时候将非主路径上的功能进行临时下线保证业务的可用性。典型的做法就是通过功能开关的方式来手动或自动地屏蔽一些功能。
而兜底策略是指,当极端情况发生时,比如服务不响应、网络连接中断,或者调用服务出现异常的时候,也不会出现崩溃。常见的做法就是缓存和兜底页面,以及前端比较流行的骨架屏等。
总结
在这一讲中我给你介绍了DevOps模式下质量思想的转变那就是要在保障一定的质量水平的前提下尽量加快发布节奏并通过低风险发布手段以及线上测试和监控能力尽早地发现问题并以一种最简单的手段来快速恢复。
质量活动是有成本的,为了保证快速迭代发布,一定程度的问题发生并不是末日,更重要的是通过质量活动向前向后延伸,并在生产环境加强监控和测试。同时,三种典型的低风险发布方式可以满足不同业务场景的需求。当问题发生时,不仅要做到快速识别,快速修复,还要提前通过服务降级、兜底策略等机制保证系统服务的连续性。
思考题
你所在的企业采用了哪些手段来保障部署活动是安全可靠的呢?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,189 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 混沌工程:软件领域的反脆弱
你好,我是石雪峰。
经济学领域有一本特别有名的书,叫作《反脆弱》。它的核心理念就是,在面对普遍存在又不可预估的不确定性时,通过一种行之有效的方法,不仅可以规避重大风险,还能够利用风险获取超出预期的回报。另外,通过积极地试错,控制损失成本,还能不断提升在不确定性事件发生时的收益。
不仅仅要规避风险,还要在风险中受益,这听起来是不是很神奇?其实,在软件工程领域,也有类似的思想和实践,可以帮助我们在面对极其复杂且规模庞大的分布式系统时,有效地应对不可预见的故障,不仅可以从容不迫地应对,还能从中获益,并且通过频繁、大量地实验,识别并解决潜在的风险点,从而提升对于复杂系统的信心。这就是今天我要给你分享的主题:混沌工程。
什么是混沌工程?
混沌工程作为软件领域的一门新兴学科,就和它的名字一样,让很多人感到非常“混沌”。那么,混沌工程究竟是从何而来,又是要解决什么问题呢?
我们先来看看混沌原则网站对混沌工程的定义:
Chaos Engineering is the discipline of experimenting on a distributed system in order to build confidence in the systems capability to withstand turbulent conditions in production.
混沌工程是一门在分布式系统上进行实验的学科,目的是建立人们对于复杂系统在生产环境中抵御突发事件的信心。
简单来说,混沌工程要解决的,就是复杂环境下的分布式系统的反脆弱问题。那么,我们所要面对的“复杂的分布式”的真实世界是怎样的呢?
我给你举个例子。对于一个大型的平台来说每日在线的活动数以万计服务的用户可以达到千万级别。为了满足这种规模的业务量级仅客户端就有300多个组件后端服务更是不计其数。
可以想象,这样一套复杂的系统,任何一个地方出了一点小问题,都有可能带来线上事故。
另外,随着微服务、容器化等技术的兴起,业务驱动自组织团队独立发布的频率越来越高,再加上架构的不断更新演进,可以说,几乎没有人能完整地梳理清楚一套系统的服务间调用关系,这就让复杂系统变成了一个“黑洞”。不管外围如何敲敲打打,都很难窥探到核心问题。
为了让你对复杂的真实系统有更加直观的认识我跟你分享一张Netflix公司在2014年公开的微服务调用关系图你可以参考一下。
图片来源https://www.slideshare.net/BruceWong3/the-case-for-chaos?from_action=save
面对这样复杂的分布式系统,想要通过穷尽全面的测试来保障质量,不出线上问题几乎是不可能的事情。因为测试的假设前提都是为了验证软件的预期行为,而真实世界的问题却从来不按套路出牌,被动遵循已有的经验并不能预防和解决未知的问题。
尤其是,如果系统的可用性是基于某一个服务不会出问题来设计的话,那么,这个服务十有八九会出问题。
比如前不久我们内部的平台就出现了一次宕机原因是依赖的一个基础服务的认证模块出现了异常从而导致存储数据失败。因为平台的所有基础数据都在这个看似万无一失的服务上保存即便监控第一时间发现了这个问题但是除了等待之外我们什么都做不了。结果平台的可用性直接从4个9掉到了3个9。
既然面对复杂的分布式系统我们无法避免异常事件的发生那么有什么更好的办法来应对这种不确定性吗Netflix公司给出了他们的回答而这正是混沌工程诞生的初衷。
区别于以往的方式,混沌工程采取了一种更加积极的方式,换了一个思路主动出击。那就是,尽可能在这些故障和缺陷发生之前,通过一系列的实验,在真实环境中验证系统在故障发生时的表现。根据实验的结果来识别风险问题,并且有针对性地进行系统改造和安全加固,从而提升对于整个系统可用性的信心。
服务可用性实践
看到这儿,你可能就要问了,这不就是日常的系统可用性保障活动吗?我们公司也有类似的实践呀,比如故障演练、服务降级方案、全链路压测等,这些基本都是大促活动到来前必需的备战活动。
的确,这些实践与混沌工程有相似之处,毕竟,混沌工程就是从这些实践中发展起来的,但是,思路又略有不同。
比较正规的公司基本上都会有一套完整的数据备份机制和服务应急响应预案,就是为了当灾难发生时,可以保证系统的可用性和核心数据的安全。
比如,故障演练就是针对以往发生过的问题进行有针对性地模拟演练。通过事先定义好的演练范围,然后人为模拟事故发生,触发应急响应预案,快速地进行故障定位和服务切换,并观察整个过程的耗时和各项数据指标的表现。
故障演练针对的大多是可以预见到的问题比如机器层面的物理机异常关机、断电设备层面的磁盘空间写满、I/O变慢网络层面的网络延迟、DNS解析异常等。这些问题说起来事无巨细但基本上都有一条清晰的路径有明确的触发因素监控事项和解决方法。
另外,在故障演练的过程中,很难覆盖所有的故障类型,只能选择典型的故障进行验证。但是实际问题发生时,往往是多个变量一起出问题,逐个排查下来非常耗时耗力。
很多公司为了模拟线上的真实场景,于是就引入了全链路压测的技术。对于大促密集的电商行业来说,尤为重要。
对于一次完整的压测来说,大致的过程是这样的:
首先,准备压测计划,调试压测脚本和环境,对压测容量和范围进行预估;
然后,为了保证线上流量不受影响完成机房线路切换,确保在压测过程中没有线上真实流量的引入;
接着,根据预定义的压测场景执行压测计划,观察流量峰值并动态调整;
最后在压测完成后再次进行流量切换并汇总压测结果识别压测问题。在压测过程中除了关注QPS指标之外还要关注TP99、CPU使用率、CPU负载、内存、TCP连接数等从而客观地体现出大流量下服务的可用性。
从业务层面来说,面对多变的环境因素,完善的服务降级预案和系统兜底机制也是必不可少的。在业务压力比较大的时候,可以适当地屏蔽一些对用户感知不大的服务,比如推荐、辅助工具、日志打印、状态提示等,保证最核心流程的可用性。另外,适当地引入排队机制也能在一定程度上分散瞬时压力。
好啦,说了这么多服务可用性的方法,是不是把这些都做到位就可以确保万无一失了呢?答案是否定的。这是因为,这些活动都是在打有准备之仗。但实际上,很多问题都是无法预知的。
既然现有的实践并不能帮助我们拓展对不可用性的认知,那么就需要一种有效的实验方法,帮助我们基于各种要素排列组合,从而在问题发生之前,发现这些潜在的风险。
比如Netflix公司著名的“混乱猴子Chaos Monkey就是用来随机关闭生产环境的实例的工具。在生产环境放任一个“猴子”乱搞事情这是疯了吗还真不是。Netflix的“猴子军团”的威力一个比一个巨大甚至可以直接干掉一个云服务可用区。
这背后的原因就是,即便是云服务上,也不能确保它们的服务是永远可靠的,所以,不要把可用性的假设建立在依赖服务不会出问题上。
当然Netflix并没有权限真正关闭云服务上的可用区他们只是模拟了这个过程并由此来促使工程团队建立多区域的可用性系统促进研发团队直面失败的架构设计不断磨练工程师对弹性系统的认知。
引用Netflix的混沌工程师Nora Jones的话来说
混沌工程不是为了制造问题,而是为了揭示问题。
必须要强调的是,在引入混沌工程的实践之前,首先需要确保现有的服务已经具备了弹性模式,并且能够在应急响应预案和自动化工具的支撑下尽早解决可能出现的问题。
如果现有的服务连基本的可恢复性这个条件都不具备的话,那么这种混沌实验是没有意义的。我跟你分享一幅混沌工程的决策图,你可以参考一下:
图片来源https://blog.codecentric.de/en/2018/07/chaos-engineering/
混沌工程的原则
混沌工程不像是以往的工具和实践,作为一门学科,它具有非常丰富的内涵和外沿。你在进入这个领域之前,有必要了解下混沌工程的五大原则:建立稳定状态的假设、真实世界的事件、在生产中试验、持续的自动化实验、最小影响范围。
我们分别来看一下这五条原则要如何进行实践。
1.建立稳定状态的假设
关于系统的稳定状态,就是说,有哪些指标可以证明当前系统是正常的、健康的。实际上,无论是技术指标,还是业务指标,现有的监控系统都已经足够强大了,稍微有一点抖动,都能在第一时间发现这些问题。
比如对于技术指标来说前面在压测部分提到的指标就很有代表性QPS、TP99、CPU使用率等而对于业务指标来说根据公司具体业务的不同会有所不同。
举个例子对于游戏来说在线用户数和平均在线时长就很重要对于电商来说各种到达率、结算完成率以及更加宏观的GMV、用户拉新数等都能表现出业务的健康程度。
与技术指标相比,业务指标更加重要,尤其是对电商这种活动密集型的行业来说,业务指标会受到活动的影响,但基于历史数据分析,总体趋势是比较明显的。
当业务指标发生大量的抖动时(比如瞬时降低提升),就意味着系统出现了异常。比如,几天前微信支付出现问题,从监控来看,支付的成功率就受到了比较明显的影响。
在真实世界中,为了描述一种稳定状态,需要一组指标构成一种模型,而不是单一指标。无论是否采用混沌工程,识别出这类指标的健康状态都是至关重要的。而且,还要围绕它们建立一整套完善的数据采集、监控、预警机制。
我给你提供了一些参考指标,汇总在了下表中。
2.真实世界的事件
真实世界的很多问题都来源于过往踩过的“坑”,即便是特别不起眼的事件,都会带来严重的后果。
比如我印象比较深的一次故障就是服务器在处理并发任务的时候CPU跑满系统直接卡死。通过调查发现在出现问题的时候系统的I/O Wait很高这就说明磁盘发生了I/O瓶颈。经过仔细地分析最终发现是磁盘Raid卡上的电池没电了从而导致磁盘Raid模式的降级。
像这种事情你很难通过监控所有Raid卡的电池容量来规避问题也不可能在每次模拟故障的时候故意换上没电的电池来进行演练。
所以,既然我们无法模拟所有的异常事情,投入产出比最高的就是选择重要指标(比如设备可用性、网络延迟,以及各类服务器问题),进行有针对性地实验。另外,可以结合类似全链路压测等手段,从全局视角测试系统整体运作的可用性,通过和稳定状态的假设指标进行对比,来识别潜在的问题。
3.在生产中实验
跟测试领域的“质量右移理念”一样,混沌工程同样鼓励在靠近生产环境的地方进行实验,甚至直接在生产环境中进行实验。
这是因为,真实世界的问题,只有在生产环境中才会出现。一个小规模的预发布环境更多的是验证系统行为和功能符合产品设计,也就是从功能的角度出发,来验证有没有新增缺陷和质量回退。
但是,系统的行为会根据真实的流量和用户的行为而改变。比如,流量明星的一则消息就可能导致微博的系统崩溃,这是在测试环境很难复现的场景。
但客观来说,在生产环境中进行实验,的确存在风险,这就要求实验范围可控,并且具备随时停止实验的能力。还是最开始的那个原则,如果系统没有为弹性模式做好准备,那么就不要开启生产实验。
还以压测为例,我们可以随机选择部分业务模块,并圈定部分实验节点,然后开启常态化压测。通过定期将线上流量打到被测业务上,观察突发流量下的指标表现,以及是否会引发系统雪崩,断路器是否生效等,往往在没有准备的时候才能发现真实问题。这种手段作为混沌工程的一种实践,已经普遍应用到大型公司的在线系统之中了。
4.持续的自动化实验
自动化是所有重复性活动的最佳解决方案。通过自动化的实验和自动化结果分析,我们可以保证混沌工程的诸多实践可以低成本、自动化地执行。正因为如此,以混沌工程为名的工具越来越多。
比如商业化的混沌工程平台Gremlins就可以支持不可用依赖、网络不可达、突发流量等场景。今年阿里也开源了他们的混沌工具ChaosBlade缩短了构建混沌工程的路径引入了更多的实践场景。另外开源的Resilience4j和Hystrix也都是非常好用的工具。无论是自研还是直接采用都可以帮助你快速上手。
我相信随着越来越多工具的成熟未来混沌工程也会成为CI/CD流水线的一部分被纳入到日常工作中来。
5.最小的影响范围
混沌工程实践的原则就是不要干扰真实用户的使用,所以,在一开始将实验控制在一个较小的范围内,是非常有必要的,这样可以避免由于实验失控带来的更大问题。
比如圈定一小部分用户或者服务范围可以帮助我们客观地评估实验的可行性。假设要实验一个API对错误的处理能力我们可以部署一个新的API实验集群并修改路由导流0.5%的流量用于线上实验。在这个集群中通过故障注入的方式验证API是否能够处理流量带来的错误场景。这有点类似于一个灰度实验环境或者暗部署的方式。
除了可以用于验证新功能做线上的A/B测试同样适用于混沌工程的故障注入。
这五大原则共同勾勒出了混沌工程的全景图,描述系统稳定状态的前提下,将真实世界的事件在生产环境中进行实验,并控制最小影响范围,引入自动化方式持续进行。作为一种全新的工程领域,混沌工程还要走很长的路,才能跨越技术演进的鸿沟。
参考资料:-
Netflix混沌工程成熟度模型-
混沌工程资料集-
Netflix混沌工程手册
总结
在这一讲中我给你介绍了一个应对复杂分布式系统可用性挑战的新学科——混沌工程。实际上混沌工程采用了一种全新的思路在系统中主动注入混沌进行实验以此来发现潜在的真实世界的问题。在服务可用性方面我们一直在努力实践比如故障演练、服务降级、全链路压测已经成为了大型系统的标配。最后我给你介绍了混沌工程的5个实践原则希望可以帮助你建立更加全面的认知。
不可否认,目前国内在混沌工程领域的实践还处于摸索实验阶段,但是随着系统的复杂性越来越高,混沌工程也注定会跨越技术发展的鸿沟,成为解决复杂系统可用性问题的利器。
思考题
关于真实世界中发生的异常事件,你有哪些独特的经历呢?结合混沌工程的实践,你有什么新的思路吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,168 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 正向度量如何建立完整的DevOps度量体系
你好我是石雪峰。到今天为止我用14讲的篇幅给你通盘梳理了DevOps的工程实践基本涵盖了工程实践的方方面面。但是就像那句经典的“不仅要低头看路还要抬头看天”说的一样我们花了这么大的力气投入工程实践的建设结果是不是符合我们的预期呢
所以在工程实践的最后两讲我想跟你聊聊度量和持续改进的话题今天先来看看DevOps的度量体系。
我相信对于每个公司来说度量都是必不可少的实践也是管理层最重视的实践。在实施度量的时候很多人都把管理学大师爱德华·戴明博士的“If you cant measure it, you cant manage it”奉为实践圭臬。
但是,回过头来想想,有多少度量指标是为了度量而度量的?花了好大力气度量出来的数据会有人看吗?度量想要解决的,到底是什么问题呢?
所以,度量不是目的,而是手段,也就是说度量的目标是“做正确的事”,而度量的手段是“正确地做事”。
那么什么才是度量领域正确的事情呢如果想要弄清楚DevOps中的度量长什么样子关键就是要回到DevOps对于软件交付的核心诉求上。
简而言之对于IT交付来说DevOps希望做到的就是持续、快速和高质量的价值交付。价值可以是一个功能特性可以是用户体验的提升也可以是修复阻塞用户的缺陷。
明确了这一点也就明确了DevOps的度量想要达到的目标就是为了证明经过一系列的改进工作与过去相比团队的交付速度更快了交付质量更高了。如果度量的结果不能导向这两个核心目标那么显然就走错了方向也就得不到实际想要的结果了。
如果只有大方向,往往还是不知道具体要怎么做。这个时候,就需要把目标和方向拆解成一系列的度量指标了。那么,怎样定义好的度量指标和不好的度量指标呢?
如何定义指标?
前几天,我被派到某仓库做流水线工人,这个经历让我深刻地理解了工业制造和软件行业的巨大差异。
如果你现在问我,决定工业生产流水线速度的是什么?我可以告诉你,答案就是,流水线本身。因为流水线的传送带的速度是一定的,产线速度也就可以直观地量化出来。
但是,软件开发不像工业制造,开发的过程看不见摸不着,除了工程师真正编写代码的时间,还要包括构思、设计和测试的时间,以及完成各类流程的时间等等。这个过程中可能还存在着各种并行工作的切换和打断,所以,没法用工业流水线的方式来衡量开发人员的效率。
于是,为了达到量化的目的,很多指标就被人为地设计出来了。
比如以准时提测率这个指标为例这个指标采用的是百分制的形式按时提测得100分延期一天得90分延期两天得70分以此类推要是延期五天及以上就只能0分了。这样的指标看起来似乎足够客观公平但是仔细想想延期1天1小时和延期1天23小时似乎也没有太大区别得分的高低并不能反映真实的情况。
在各个公司的度量体系中,类似的人造指标可谓比比皆是。可见,不好的指标总是五花八门,各有各的样子。不过,好的指标大多具备一些典型的特征。
1.明确受众。
指标不能脱离受众而单独存在,在定义指标的同时,要定义它所关联的对象,也就是这个指标是给谁看。
不同的人关注点自然也不一样,即便指标本身看起来没有什么问题,但是如果使用错位了,也很难产生预期的价值。比如,给非技术出身的老板看单元测试的覆盖率,就没有什么太大意义。
2.直指问题。
在NBA中优秀的球员总是自带体系的。所谓体系就是围绕这个球员的核心能力的一整套战术打法可以解决球队的实际问题所以这个球员的表现就成了整支球队的“晴雨表”。
而好的指标也应该是直指问题的,你一看到这个指标,就能意识到问题所在,并自然而然地进行改进,而不是看了跟没看见一样,也不知道具体要做什么。
比如,构建失败率很高,团队就会意识到代码的提交质量存在问题,需要加强事前的验证工作。
3.量化趋势。
按照SMART原则好的指标应该是可以衡量的而且是可以通过客观数据来自证的。
比如,用户满意度这种指标看起来很好,但很难用数据衡量;再比如,项目达成率这个指标,如果只是靠手工填写,那就没啥说服力。
同时,好的度量指标应该能展现趋势。也就是说,经过一段时间的沉淀,指标是变好了,还是变坏了,距离目标是更近了,还是更远了,这些都应该是一目了然的。
4.充满张力。
指标不应该孤立存在,而是应该相互关联构成一个整体。好的指标应该具有一定的张力,向上可以归并到业务结果,向下可以层层分解到具体细节。这样通过不同维度的数据抽取,可以满足不同视角的用户需求。
比如,单纯地度量需求交付个数,就没有太大意义。因为需求的颗粒度会直接影响数量,如果只是把一个需求拆成两个,从而达到需求交付速度加倍的效果,这就失去了度量的意义。
定义指标有哪些原则?
明白了好的度量指标的典型特征接下来我们就来看看定义DevOps度量的五条原则
全局指标优于局部指标:过度的局部优化可能对整体产出并无意义,从而偏离了度量的核心,也就是提升交付速度和交付质量。
综合指标优于单一指标:从单一维度入手会陷入只见树木不见森林的困境,综合指标更加客观。所以,要解决一个问题,就需要一组指标来客观指引。
结果指标优于过程指标:首先要有结果指标,以结果为导向,以过程为途径,一切过程指标都应该归结到结果指标。
团队指标优于个人指标:优先考核团队指标而非个人指标,团队共享指标有助于形成内部合力,减少内部的割裂。
灵活指标优于固化指标:指标的设立是为了有针对性地实施改进,需要考虑业务自身的差异性和改进方向,而非简单粗暴的“一刀切”,并且随着团队能力的上升,指标也需要适当的调整,从而不断挑战团队的能力。
哪些指标最重要?
基于以上的指标特征和指导原则并结合业界大厂的一些实践我给你推荐一套DevOps度量体系。
虽然各个公司的度量指标体系都不尽相同,但是我认为这套体系框架足以满足大多数场景,如下图所示:
1.交付效率
需求前置时间:从需求提出到完成整个研发交付过程,并最终上线发布的时间。对业务方和用户来说,这个时间是最能客观反映团队交付速度的指标。这个指标还可以进一步细分为需求侧,也就是从需求提出、分析、设计、评审到就绪的时长,以及业务侧,也就是研发排期、开发、测试、验收、发布的时长。对于价值流分析来说,这就代表了完整的价值流时长。
开发前置时间:从需求进入排期、研发真正动工的时间点开始,一直到最终上线发布的时长。它体现的是研发团队的交付能力,也就是一个需求进来后,要花多久才能完成整个开发过程。
2.交付能力
发布频率:单位时间内的系统发布次数。原则上发布频率越高,代表交付能力越强。这依赖于架构结构和团队自治、独立发布的能力。每个团队都可以按照自己的节奏安全地发布,而不依赖于关联系统和发布窗口期的约束。
发布前置时间指研发提交一行代码到最终上线发布的时间是团队持续交付工程能力的最直观的考查指标依赖于全流程自动化的流水线能力和自动化测试能力。这也是DevOps状态报告中的核心指标之一。
交付吞吐量:单位时间内交付的需求点数。也就是,单位时间内交付的需求个数乘以需求颗粒度,换算出来的点数,它可以体现出标准需求颗粒度下的团队交付能力。
3.交付质量
线上缺陷密度:单位时间内需求缺陷比例,也就是平均每个需求所产生的缺陷数量,缺陷越多,说明需求交付质量越差。
线上缺陷分布:所有缺陷中的严重致命等级缺陷所占的比例。这个比例的数值越高,说明缺陷等级越严重,体现了质量的整体可控性。
故障修复时长:从有效缺陷提出到修复完成并上线发布的时间。一方面,这个指标考查了故障定位和修复的时间,另外一方面,也考查了发布前置时间,只有更快地完成发布上线过程,才能更快地修复问题。
这三组、八项指标体现了团队的交付效率、交付能力和交付质量从全局视角考查了关键的结果指标可以用于展现团队DevOps改进的效果和价值产出。不过定义指标只能说是DevOps度量的一小步只有让这些指标发挥价值才能说是有意义的度量。
如何开启度量工作?
在企业内部开启度量工作,可以分为四个步骤。
第1步细化指标。
一个完整的指标,除了定义之外,还需要明确指标名、指标描述、指标级别(团队级/组织级)、指标类型、适用场景范围及目标用户、数据采集方式和标准参考值。
以交付指标为例,我汇总了一份细化后的指标内容,你可以参考下表。其实不仅仅是核心结果指标,只要是在度量体系内定义的指标,都需要进行细化。
关于指标的参考值,对于不同的业务形态,参考值也有所不同。比如就单元测试覆盖率而言,无人车的业务和普通的互联网业务的差别可能会非常大。
所以参考值的选定,需要结合业务实际来分析并达成共识。而且,度量指标本身也需要建立定期更新的机制,以适应于整个团队的能力。
第2步收集度量数据
度量指标需要客观数据的支撑,而数据往往都来源于各个不同的平台。所以,在定义指标的时候,你需要评估是否有足够的客观数据来支撑这个指标的衡量。
在采集度量数据的初期,我们面临的最大问题不仅是系统众多、数据口径不一致,还包括数据的准确性。
举个例子,比如开发交付周期这个指标,一般都是计算一个需求从开始开发到线上发布的时间长度。但是,如果开发人员迟迟不把这个需求设置为“已解决”或者“待测试”状态,那么统计出来的开发周期就存在大量的失真,很难反映出客观、真实的情况。
这就需要从流程和平台两个层面入手解决。比如,一方面,从流程层面制定研发操作规范,让每一名研发人员都清楚在什么时间点需要改变需求卡片状态;另一方面,建设平台能力,提供易用性的方式辅助研发,甚至自动流转需求状态。
第3步建立可视化平台。
度量指标毕竟是要给人看的,度量数据也需要有一个地方可以收集和运算,这就依赖于度量可视化平台的建设了。关于如何建设一个支持多维度视图、对接多系统数据,以及灵活可编排的度量平台,我会在工具篇给你分享一个案例,帮助你破解度量平台建设的关键问题。
第4步识别瓶颈并持续改进。
当数据做到了可信和可视化之后,团队面临的问题和瓶颈会自然而然浮现出来。如何通过指标牵引并驱动团队实施改进,这也是下一讲我们要讨论的核心内容。
我给你提供一些常用的度量指标和相关定义你可以点击网盘链接获取提取码是c7F3。需要注意的是指标宜少不宜多宜精不宜烂对于企业的DevOps度量而言这也是最常见的问题定义了一大堆的指标却不知道要拿来做什么。
只有将指标的定义细化,并在团队内部达成共识,仔细甄别数据的完整和有效性,并做到满足不同维度视角的可视化,才具备了驱动团队进行改进的基础,这一点请你一定要记住。
总结
总结一下DevOps度量想要达到的目标就是证明团队经过一系列的改进工作与过去相比交付速度更快了交付质量也更高了。所以交付效率和交付质量是最为核心的两个目标。只有围绕这两个目标建立的度量体系才没有走错方向。
好的指标一般都具备四种特性:明确受众、直指问题、量化趋势和充满张力。结合指标特征和指导原则,以及业界大厂的一些实践,我给你介绍了三组、八项核心结果指标,包括效率指标、能力指标和质量指标。最后,我给你介绍了建立度量体系的四个步骤,希望可以帮助你一步步地搭建持续改进的基石。
度量是把双刃剑,做得不好反而会伤害团队的士气。如果本末倒置,把度量结果跟个人的绩效相绑定,就很容易使度量这个事情变了味道。很多大公司反反复复地在建立度量体系,就是因为前一个体系被人摸透,变成了数字游戏,于是就失去了原有的目的,只能推倒重来。
还是那句话,度量只是一种手段,而非目的。归根结底,度量的真正目的还是团队效率的提升和业务的成功。只有通过度量激起团队自发的改进意愿,提升团队改进的创造性和积极性,才是所谓的“正向度量”,这也是我最想传达给你的理念。
思考题
你所在的企业是否也在建设DevOps的度量体系呢你觉得这些度量指标数据对改进当前的工作是否起到了正面作用呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,151 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 持续改进PDCA体系和持续改进的意义
你好,我是石雪峰。
今天是“工程实践篇”的最后一节课如果你现在问我在这么多的工程实践中什么能力是团队在推行DevOps时最应该具备的我会毫不犹豫地告诉你那就是持续改进。
很多同学在留言区问我“雪峰老师我们公司已经搭建了Gitlab也跟Jenkins实现了打通做到了自动化的编译打包和发布工作。可是接下来我们还有啥可以做的呢我感到很迷茫啊。”
所以这就引申出来一个问题“一个团队做到什么程度才算是达到了DevOps呢
每每遇到这样的问题,我就会回想起,几年前我去国内一家知名公司的杭州总部交流的经历。
当时负责跟我们对接的是这家公司DevOps的主要推动人可以说他见证了这家巨头公司的DevOps转型全过程。在交流时我问了他一个问题他的回答让我印象特别深刻。
我问他“你觉得你们公司是在什么时候实现DevOps转型的呢”他想了想“现在我们公司已经没有专职的测试和专职的运维了基础架构也早就容器化了。这些事情都是业务发展到一定阶段之后自然而然发生的只不过DevOps火起来以后我们才发现原来我们一直在做的就是DevOps。所以很难说在哪个时间点完成了DevOps转型。对我们来说最重要的就是团队具备了一种能力就是始终能够找到新的突破持续追求更好的状态。”
我想这段话应该非常能够代表一个团队实施DevOps转型时期望达到的状态吧。
其实如果你有机会去跟谷歌、Netflix的工程师交流一下你就会发现这些业界DevOps做得特别牛的公司内部都不怎么提DevOps的概念。因为他们早就对DevOps的这些实践习以为常了。很多知名的工具平台都是内部员工自发地为了解决一些问题而开发出来的。
比如像Gerrit这种非常流行的代码在线评审和管理工具最开始就是为了解决谷歌内部缺少一种基于Git并且具备权限管控的代码评审工具的问题才被开发出来的你可以了解下这段历史。
你看,遇到一个钉子,从而造个锤子,和拿着一把锤子,满世界找钉子就是两种截然不同的做法。但很多时候,我们采用的都是后一种做法,手里拿着一堆锤子,却找不到钉子在哪里。
所以如果一定要让我来回答DevOps做到什么程度就算是实现转型落地了那么我的回答是核心就是团队已经具备了持续改进的能力而不只是简简单单地引入了几个工具建立了几个度量指标而已。
说到这儿,你可能会说,这个所谓的持续改进,怎么感觉无处不在呢?似乎很多工程实践的落地方法中,最后一步都是持续改进。那么,持续改进的意义到底是什么呢?为什么一切活动的终极目标都是持续改进呢?
这是因为每家公司面临的问题都不一样从0到1的过程相对比较简单可以对照着工程实践快速地引入工具建立流程补齐能力短板。但是从1到N的过程就需要团队根据业务需要自行识别改进目标了。
还以最开始那个问题为例基于Gitlab和Jenkins搭建了自动化构建和发布的能力之后你觉得还有哪些可行的改进方向呢比如测试是否注入其中了呢是否建立了质量门禁机制呢数据库变更是否实现了自动化呢构建发布的速度是否足够理想构建资源是否存在瓶颈
能想到的方向有很多,但哪个才是现阶段最重要、价值最大化的点,说到底,还是要看业务的需求,没办法泛泛而谈。
谈到持续改进有一个非常著名的方法体系叫作PDCA也称为戴明环。没错你从名称就能看出这套方法体系同样来自于质量管理大师戴明博士。PDCA是四个英文单词的缩写也就是Plan计划、Do实施、Check检查和Action行动
PDCA提供了一套结构化的实施框架任何一项改进类工作都可以划分为这四个实施阶段。通过PDCA循环的不断迭代驱动组织进入一种良性循环不断识别出新的待改进问题。针对这些问题首先要进行根因分析制定具体的实施计划。然后不定期地检查实施的结果和预期目标是否一致。最后要对改进结果进行复盘把做得好的地方保留下来把做得不好的地方纳入下一阶段的循环中继续改进。
这个方法听起来也没什么复杂的,每个人都能够理解,关键在于是否真正地用心在做。
我再给你分享一个真实的例子。
大概两年前我参与到一家中型企业的DevOps转型工作当中。这家企业刚开始接触DevOps时的状态呢我就不细说了反正就是基本啥都没有。代码库使用的是SVN构建打包都在本地完成版本发布要两个月而且经常是多版本并行的节奏光同步代码就需要专人完成。
经过半年多的改造之后,团队内部的整体工具链体系初具规模,版本发布节奏也缩短到了一个月一次,团队对达到的成绩非常满意。
当然,这并不是重点,重点是,我上个月又碰到了这个项目的负责人。她跟我说,他们现在的发布节奏已经实现了两周一次,甚至不定期还有临时版本发布。我很好奇,他们究竟是怎么做到的。
原来最开始导入改进方案的时候我给项目组提到过容器化的思路但是因为当时客观条件不具备就没有继续推进下去。没想到在短短不到一年的时间里他们已经实现了容器化部署自建的PaaS平台也有模有样即便是跟很多大公司相比也毫不逊色。
她说“这段DevOps转型的过程带给我们的不仅仅是一些常见的工程实践和工具平台更重要的是一双总能发现不完美的眼睛和追求极致的态度以及对这类问题的认知方法。这些驱动我们不断地找到新的方法解决新的问题。”
的确,很多工程实践和工具平台,在公司内部其实只是一小步,之后遇到的问题和挑战还会有很多。这时候,我们能够依靠的终极奥义就是持续改进的思想,而构建持续改进的核心,就在于构建一个学习型组织。
那么,究竟要从哪里开始学习呢?在学习和改进的过程中又有哪些比较推荐的做法呢?我总结了四个实践,你可以参考一下。
鼓励正向回溯和总结
从失败中学习是我们从小就懂的道理。一个团队对待故障的态度,很大程度上就反映了他们对于持续改进的态度。系统出现故障是谁都不愿意遇到的事情,但在真实世界中,这是没法避免的。
在很多公司里面,出现故障之后,有几种常见的做法:
把相关方拉到一起,定级定责,也就是确定问题级别和主要的责任方;
轻描淡写地回个改进邮件,但是没有明确的时间节点,即便有,也没人跟踪;
把问题归结为不可复现的偶发事故,最后不了了之。
与这些做法相比,更好的方法是建立一种正向回溯和总结的机制。也就是说,当问题发生之后,事先准备一份详尽的故障分析报告,并拉上相关方一起彻底分析问题的根因,并给出改进任务的具体时间点。
故障回溯并不一定以确定责任为第一要务,更重要的是,要识别系统流程中的潜在问题和漏洞,并通过后续机制来进行保障,比如增加测试用例、增加产品走查事项等等。
其实,大到线上故障,小到日常错误,都值得回溯和总结。
比如,我们每天都会遇到形形色色的编译错误,如果每个人遇到同样的问题,都要爬一次同样的坑,显然是非常低效的。
这就需要有团队来负责收集和总结这些常见的错误,并提取关键错误信息和常见解决方法,形成一个案例库。同时,在构建系统中嵌入一个自动化服务,下次再有人遇到编译错误的时候,就可以自动匹配案例库,并给他推送一个问题分析报告和解决建议,帮助团队成员快速解决问题。
这样随着团队智慧的不断积累越来越多的问题会被识别出来从而实现组织知识共享和研发辅助的能力这在很多大公司里面都是一个重点建设方向。仔细想想这本身就是一个PDCA的过程。
不过,这里要补充一点,团队实施持续改进的过程,不应该是一次大而全的变革,而应该是一系列小而高频的改进动作。因为大的变革往往影响众多,很容易半途而废,而小的改进更加温和,也更加容易成功。为了方便你理解,我跟你分享一张示意图。
预留固定时间进行改进
很多时候团队都处于忙碌的状态时间似乎成了推行DevOps的最大敌人。于是团队就陷入了一种太忙以至于没时间改进的状态中。
如果团队选择在同等时间内去做更多的功能那就说明至少在当前这个阶段业务开发的重要性要高于DevOps建设的重要性。
可问题是业务的需求是没有止境的。有时候我去问一线员工“你觉得有什么地方是DevOps可以帮你的吗”要么大家会说“没什么特别的现在挺好”要么就是一些非常琐碎的点。实际上这只能说明要么是没想过这个事情要么就是不知道还有更好的做法。但是如果不能调动一线员工的积极性持续改进也就无从谈起了。
所以,正确的做法是,在团队的日常迭代中,事先给改进类工作预留一部分时间,或者是在业务相对不那么繁忙的时候(比如大促刚刚结束,团队在调整状态的时候),在改进工作上多花些时间。
这些工作量主要用于解决非功能需求、技术改进类问题,比如修复技术债务、单元测试用例补充、度量识别出来的改进事项等。通过将这部分改进时间固定下来,可以培养团队持续改进的文化。
我比较推荐的做法是在团队的Backlog中新增一类任务专门用于记录和跟踪这类持续改进的内容。在迭代计划会议上对这类问题进行分析并预估工作量保证团队有固定的时间来应对这些问题。
另外很多公司也开始流行举办Hackathon Day黑客马拉松是说在有限的时间里通过编程实现自身的想法和创意在这个过程中充满了积极探索的精神、自由散发的思维和挑战极限的理念通过团队协作与互相激发实现创意到开发的全过程。
我们团队最近也在准备参加今年的黑客马拉松希望通过这个途径寻求合作共建除了解决内部效率提升的“老大难”问题还能提升团队成员的积极性在更大的舞台上展现DevOps的价值一举两得。
在团队内部共享业务指标
很多时候团队成员都像是临时工一样,对于自己所负责的需求和业务的表现一概不知。如果团队成员对一件事情没有归属感,那么又如何激发他们的责任感和自我驱动意识呢?
所以,对于业务的指标和表现,需要尽可能地在团队内部做到透明,让团队成员可以接触真实世界的用户反馈和评价,以及业务的度量信息。
在一个新功能开发完成上线之后,要能实时查看这个需求的上线状态。如果需求分析时已经关联了业务考核指标,那么,同样可以将该业务关联的指标数据进行展示。这样,研发就会知道自己交付的内容有多少问题,用户的真实反馈是怎样的,从而促使团队更多地站在用户的视角思考问题。
除了业务指标DevOps的指标体系也应该对内部公开透明。大家可以查看自己所在团队的表现以及在公司内部的整体水平。
适当的侧向压力,会促使大家更加主动地接受改进工作,并且通过度量数据展示改进的效果,从而形成正向的循环。
激励创造性,并将价值最大化
每个团队中都不乏有创新意愿和思想的员工,他们总是能从墨守成规的规范中找到可以进行优化的点。
比如,之前,我们团队的一个测试人员发现,日常埋点测试费时费力,而且没有数据统计。于是,她就自己利用业余时间开发了一个小工具,用工具来承载这部分工作,效率大幅提升。
如果更多人知道这样的创新,并且在更大范围内使用,不仅可以提升更多人的效率,让团队整体受益,而且还可以减少类似的重复建设,让有想法的员工一起参与工具优化。
比较好的做法是,在团队成员的绩效目标中,增加对团队贡献和技术创新的要求,在团队内部鼓励创新类工作。另外,在团队内部建立对应的选拔和激励机制,为好的想法投入资源,把它们变成可以解决类似问题的工具。
很多公司也开始注意到这种内部知识复用的重要性,所以,无论是代码库开源,还是公共基础组件的市的建设,甚至是公司级的平台治理系统,都可以帮助你快速地复用已有的能力,避免一直重复造轮子。
总结
就像每个工程实践的终点都是持续改进一样,我们专栏的“工程实践篇”同样以持续改进的实践作为收尾。
我始终认为团队是否建立了持续改进的文化是评估团队的DevOps实践效果的重要参考。在这一讲中我给你介绍了PDCA的持续改进方法体系也就是通过计划、实施、检查、行动这四个步骤的持续迭代不断把团队推向更优的状态促使团队进入正向发展的车道。
另外我给你介绍了四个持续改进落地的方法包括在失败中总结和学习建立固定的改进时间在团队内部共享指标、培养团队的责任感以及激发团队的创造力并将价值最大化。这些方法的核心就是想打造一个学习型的组织和文化给DevOps的生根发芽提供丰饶的养分。
从下一讲开始,我们将进入“工具实践篇”,我会给你介绍一些核心工具的设计思想、建设路径,以及一些常见开源工具的使用方法等,敬请期待。
思考题
除了我提到的这四种持续改进的手段,你所在的公司,有什么活动可以促进持续改进文化的建设吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,225 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 开源还是自研企业DevOps平台建设的三个阶段
你好,我是石雪峰,从今天开始,专栏正式进入了“平台工具篇”。
在这个全新的章节,我重点想讲三个方面的内容:
帮助你梳理企业内部DevOps平台的实施路径理清平台建设的主体脉络
给你分享一些核心平台的建设经验,这些经验都来自于生产一线;
给你分析一下DevOps平台的发展方向和热门趋势让你在进行平台建设时能够跟上潮流。
我想跟你说的是没有人天生就是DevOps平台的产品经理但每一个人都能成为DevOps平台的产品经理。
因为DevOps平台的产品与业务方向的产品不同它要解决的就是一线研发交付团队的实际问题。
普通的产品经理没有研发交付的背景,很难理解研发交付的困境,而研发交付团队又缺少产品经理的技能和思路。所以,这个领域的人才少之又少,基本只能靠内部培养,我希望你能通过专栏的学习,摸索出一些产品设计的门道。
好了今天我们就来聊一聊企业DevOps平台建设的话题。
就像我之前提到的那样在企业内部推行DevOps工具不是万能的但是没有工具却是万万不能的。
当企业决定引入DevOps工具的时候无外乎有三种选择直接使用开源工具采购商业工具自己研发工具。
你可能会说如果有能力当然是选自研工具啊自主可控又有核心竞争力。可是在DevOps状态报告中却有一些不同的发现。
那些倾向于使用完全自建工具的企业,效能水平往往不高。所谓的完全自建工具,是指不依赖于开源解决方案,整个工具完全由自己来实现。而那些大部分采用开源工具的企业,效能水平反而不差。
这就有点反常理了。企业花了这么大的时间和精力来建设内部工具,到最后却没有达到预期的效果,究竟是为什么呢?
在我看来,这是因为没有找到企业内部平台建设的正确路径。我们要在正确的时候,做正确的事情,太超前,或者太落后,都是会有问题的。
那么接下来我就跟你聊聊企业DevOps平台建设的三个阶段。
阶段一:从无到有
在这个阶段企业的DevOps平台建设处于刚刚起步的状态在整个交付过程中还有大量的本地操作和重复性的操作。
另外,企业内部一般也没有一个成体系的工具团队,来专门负责平台能力建设。
那么,对于这个阶段,我给你的建议是:引入开源工具和商业工具,快速补齐现有的能力短板。
所谓能力短板,其实就是当前交付工具链体系中缺失的部分,尤其是高频操作,或者是涉及多人协作的部分,比如,需求管理、持续集成等。
无论是开源工具还是商业工具基本都是比较成熟的、拿来即用的这种“即战力”是当前企业最需要的。因为工具的引入解决了从无到有的问题可以直接提升单点效率。这也是在DevOps转型初期团队的效率能够飞速提升的主要原因。
看到这里,你可能会问两个问题:“如何选择工具?”“为什么商业工具也是可选项?”
其实,这也是团队在引入工具的初期,最头疼的两个典型问题,我们一一来看下。
如何选择工具?
现在以DevOps为名的工具太多了。想要在这么多工具中选择一款合适的你要怎么做呢
有的人可能会把相关工具的功能列表拉出来,然后逐项比对,看哪个工具的功能更加强大。其实,我觉得,在从无到有的阶段,不需要这么复杂,核心原则就是选择主流工具。
主流工具就是业内大家用得比较多的,在各种分享文章里面高频出现的,使用经验一搜一大把的那种工具。我给你提供一些工具,你可以参考一下:
需求管理工具Jira
知识管理工具Confluence
版本控制系统GitLab
持续集成工具Jenkins
代码质量工具SonarQube
构建工具Maven/Gradle
制品管理Artifactory/Harbor
配置管理工具Ansible
配置中心Apollo
测试工具RF/Selenium/Appium/Jmeter/TestNG
安全合规工具BlackDuck/Fortify
……
在初期,工具要解决的大多是单点问题,主流工具意味着更好的可扩展性,比如有完整的接口列表,甚至对其他工具已经内置了插件支持。
另外,很多开发实践都是基于主流工具来设计的。业内对于这些工具摸索得也比较深,有很多现成的实践经验,这些都对应了快速补齐能力短板的目标。
我之前见过一家大型金融机构他们也在考虑将代码管理从SVN切换到Git。但是他们选择的Git平台既不是开源的GitLab、Gerrit也不是商业化的主流工具而是一个听都没听过的开源工具。
这个工具的操作流程跟一般工具都不太一样,配套的评审、集成功能也都不够完善。最后,这家机构还是改用主流工具了。
为什么商业工具也是可选项?
随着开源工具的成熟和完善,越来越多的公司,甚至是传统企业,都开始积极拥抱开源,似乎开源就是代表未来的趋势。
那么,是不是只选择开源工具就行了,不用考虑商业工具了呢?我觉得,这种想法也是比较片面的。
商业工具的优势一直都存在,比如,专业性、安全性、扩展性、技术支持力度等。其实,很多开源工具都有商业版本。
比如很多公司即便有开源的Nexus制品管理工具Artifactory也都是标配。因为Artifactory无论是在支持的制品类型、分布式部署、附加制品安全漏洞检查还是在与外部工具的集成等方面都有着明显的优势。
另外像Jira这种需求和缺陷管理工具与Confluence深度集成的话足够满足绝大多数公司的需求。
再举个例子安卓开发最常见的Gradle工具它的商业版本可以直接让你的编译速度提升一个数量级。在最开始时你可能觉得够用就行但是当你开始追求极致效率的时候这些都是核心竞争力。
选择商业工具的理由有很多,不选的理由大多就是一个字:贵。针对这个问题,我要说的是,要分清一笔支出到底是成本,还是投资。
就跟购买黄金一样,虽然也花了钱,但这是一笔投资,未来可以保值和增值,甚至是变现。对于商业工具来说,也是同样的道理。如果一款商业工具可以大幅提升团队效率,最后的产出可能远超最开始的投资。如果我们组建一个团队,仿照商业工具,开发一套自研工具,重复造轮子的成本也可能一点不少。所以,重点就是要看怎么算这笔账。
阶段二:从小到大
经过了第一个阶段,企业交付链路上的工具基本都已经齐全了。团队对于工具的需求开始从够用到好用进行转变。另外,随着业务发展,团队扩大,差异化需求也成了摆在面前的问题。再加上,人和数据都越来越多,工具的重要性与日俱增。
那么,工具的稳定性、可靠性,以及大规模使用的性能问题,也开始凸显出来。
对于这个阶段,我给你的建议是:使用半自建工具和定制商业工具,来解决自己的问题。
所谓半自建工具,大多数情况下,还是基于开源工具的二次开发,或者是对开源工具进行一次封装,在开源工具上面实现需要的业务逻辑和交互界面。
比如基于Jenkins封装一套自己的构建打包平台完全可以利用Jenkins API和插件扩展实现。我附上了一幅架构示意图你可以参考一下。
那么,半自建工具有哪些注意事项呢?虽然各个领域的工具职能千差万别,但从我的经验来看,主要有两点:设计时给扩展留出空间;实现时关注元数据治理。
设计时给扩展留出空间
刚开始建设平台的时候,很容易就事论事,眼前有什么问题,就提供什么功能。这固然是比较务实的态度,但对于平台而言,还是要有顶层设计,给未来留出扩展性。这么说可能比较抽象,我来给你举几个实际的例子,也是我们之前踩过的“坑”。
案例一:
平台的初期设计没有考虑租户的特性,只是为了满足单一业务的使用。当功能比较成熟,想要对外输出的时候,我们发现,要重新在更高的维度插入租户,导致系统需要进行大幅改造,不仅功能页面需要调整,连权限模型都要重新设计。
如果在设计平台之初,就考虑到未来的扩展需求,把单一业务实现为一个平台租户,会不会更好些呢?
案例二:
为了满足快速上线的需要我们对Jenkins进行了简单封装实现了在线打包平台。但是打包页面的参数都“写死”在了页面中。另外每接入一个项目就需要单独实现一个页面。后来面对上百个应用的接入所带来的差异化需求平台只能推倒重来。
如果最开始在设计的时候,就采用接口获取的方式,将参数实现配置化,会不会更好些呢?
除此之外,在技术选型的时候,前后端分离的开发方式、主流的技术栈选型、一些典型的设计模式、相对统一的语言类型,其实都有助于平台空间的后续扩展。
功能可以快速迭代,人员可以快速进入团队,形成战斗力,在设计平台的时候,这些都是需要思考的问题。
当然,顶层规划,不代表过度设计。我只是说,要在可以预见的范围内,预留一些空间,从而规避后期的尴尬。
实现时关注元数据治理
所谓元数据也就是常说的meta-data可以理解为钥匙链这些数据可以串起整个平台的数据结构。比如应用名称、模块名称、安全ID等等。
各个平台在组织数据结构的时候,都需要用到这些元数据,而且一旦使用了,轻易都不好改变。因为,在数据模型里面,这些元数据很有可能已经作为各种主键、外键的约束存在了。
对于单一平台来说,怎么维护这些元数据,都没什么大问题,但是,对于后续平台间的打通而言,这些元数据就成了一种标准语言。如果平台间的语言不通,就需要加入大量的翻译处理过程,这就导致系统性耦合加大,连接变得脆弱。
比如同样是购物车模块在我的平台里面叫购物车而在你的平台里面叫shopping-cart而且还按照平台划分比如shopping-cart-android、shopping-cart-ios甚至还有一些特性维度比如shopping-cart-feature1等等。显然想让两边的数据对齐并不容易。
当然,元数据的治理并不是单一平台能够解决的事情,这同样需要顶层规划。
比如在公司内部建立统一的CMDB在其中统一管理应用信息。或者建立应用创建审批流程通用一个标准化流程来管控应用的生命周期同时管理应用的基础信息。这些都属于技术债务做得越晚还债的成本就越高。
阶段三:从繁到简
到了第三个阶段恭喜你已经在DevOps平台建设方面有了一定的积累在各个垂直领域也积累了成功案例。那么在这个阶段我们要解决的主要问题有3点
平台太多。做一件事情,需要各种切来切去;
平台太复杂。想要实现一个功能,需要对相关人员进行专业培训,他们才能做对;
平台价值说不清。比如,使用平台,能带来多大价值?能给团队和业务带来多大贡献?
对于这个阶段,我给你的建议是:使用整合工具来化繁为简,统一界面,简化操作,有效度量。
整合工具,就是包含了开源工具、半自研工具、商业工具的集合。
你要提供的不再是一个工具,而是一整套的解决方案;不是解决一个问题,而是解决交付过程中方方面面的问题。
企业工具平台治理
如果最开始没有一个顶层规划,到了这个时候,企业内部大大小小的工具平台应该有很多。你需要做的第一步,就是平台化治理工作。
首先,你要识别出来有哪些工具平台,使用情况是怎样的,比如有哪些业务在使用,实现了哪些功能。
如果要把所有工具平台收编起来,这不是一件容易的事情,甚至超出了技术的范畴。尤其是对很多大企业来说,工具平台是很多团队的根基,如果不需要这个平台,就意味着团队的重心也得调整。
所以,我给你的第一条建议是比较温和可行的,那就是,找到软件交付的主路径。用一个平台覆盖这条主路径,从而串联各个单点上的能力,让一些真正好的平台能够脱颖而出。而要做到这个事情,就需要持续交付流水线了。
这些年来,我一直在从事持续交付平台的建设,也总结了很多经验。我会在后面的内容中,跟你好好聊聊,如何设计一个现代的持续交付流水线平台。
流水线平台与一体化平台之间,还是有很大差距的。毕竟,各种工具平台的设计思路、操作路径、界面风格,差别很大。
所以,在实际操作的过程中,我给你的第二条建议就是,区分平台和工具,让平台脱颖而出。
比如,测试环境存在大量的工具,而一整套测试平台,实际上可以满足测试方方面面的需求,也就是说,测试人员只要在这个平台上工作就够了。当企业内部繁杂的工具收敛为几个核心平台之后,对于用户来说,就减少了界面切换的场景,可以通过平台和平台对接完成日常工作。
打造自服务的工具平台
到了这个阶段,自服务就成了平台建设的核心理念。
所谓自服务,就是用户可以自行登录平台实现自己的操作,查看自己关心的数据,获取有效的信息。
而想要实现自服务,简化操作是必经之路。说白了,如果一件事情只要一键就能完成,这才是真正地实现了自服务。
这么说可能有点夸张。但是,打破职能间的壁垒,实现跨职能的赋能,依靠的就是平台的自服务能力。很多时候,当你在埋怨“平台设计得这么简单,为啥还是有人不会用”的时候,其实这只能说明一个问题,就是平台依然不够简单。
之前Jenkins社区就发起过一个项目叫作“5 Click5 Minutes”意思是希望用户只需要5次点击花5分钟时间就能完成一个Jenkins服务的建立。
这个项目的结果就是现在的Jenkins创建导航通过把建立服务的成本降到最低从而帮助更多的用户上手使用。
你看,用户体验是否简单,与技术是否高深无关,重点在于是否能够换位思考。所以,在建设平台的时候,要始终保有一份同理心。
总结
企业内部的平台化建设是个长期问题如果你要问我企业要建设DevOps平台有什么经验总结吗我的回答就是“四化”标准化、自动化、服务化和数据化。实际上这些也是指导平台建设的核心理念。
标准化:一切皆有规则,一切皆有标准;
自动化:干掉一切不必要的手工操作环节,能一键完成的,绝不操作两次;
服务化:面向用户设计,而不是面向专家设计,让每个人都能在没有外界依赖的前提下,完成自己的工作;
数据化:对数据进行收集、汇总、分析和展示,让客观数据呈现出来,让数据指导持续改进。
思考题
最后,关于平台化建设,你有什么私藏的好工具吗?可以分享一下吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 产品设计之道DevOps产品设计的五个层次
你好,我是石雪峰。
在上一讲中我们聊到了企业DevOps平台建设的三个阶段。那么一个平台产品到底做到什么样才算是好的呢不知道你有没有想过这个问题反正做产品的这些年来我一直都在思考这个事儿。直到我听到了梁宁的专栏里面讲到的用户体验的五层要素才发现无论什么产品其实都是为了解决一群特定的人在特定场景的特定问题。
那么回到我们的DevOps产品我们可以借鉴一下梁宁老师的思路来看看DevOps产品设计体验的五个层次战略存在层、能力圈层、资源结构层、角色框架层和感知层。
这么多专有名词一股脑地蹦出来,估计你头都大了吧?没关系,接下来我会逐一解释一下。
第一个层次:战略存在层
在决定开发一个DevOps产品的时候我们首先要回答的根本问题就是这个产品解决了什么样的痛点问题换句话说我们希望用户通过这个产品得到什么显然目标用户和痛点问题的不同会从根本上导致两套DevOps产品之间相距甚远。
举个例子业界很多大公司在内部深耕DevOps平台很多年有非常多很好的实践。但是当他们准备把这些内部平台对外开放提供给C端用户使用的时候会发现存在着严重的水土不服问题。
有些时候内外部产品团队有独立的两套产品对外提供的产品版本甚至比对内的版本要差上几年。这就是用户群体的不同造成的。C端用户相对轻量级需要的功能大多在具体的点上而企业内部因为多年的积累有大量的固有流程、系统、规则需要兼顾。所以整套产品很重甚至是完全封闭的一套体系难以跟用户现有的平台进行打通。
所以,我见过很多产品团队,他们对自己初期的产品定位并非在用户需求本身,而是在同类竞争对手身上。也就是说,他们先从模仿业界做得比较好的同类产品开始,从产品设计、功能模块到用户交互等,一股脑地参考同类产品,美其名曰“至少先赶上业界主流水平再说”。于是乎,团队开足马力在这条路上渐行渐远。
当然,借鉴同类产品的先进经验,这个做法本身并没有什么问题,毕竟,这些经验已经经过市场和用户的检验,至少走偏的风险不大。可问题是,同类产品的经验并不能作为自己产品的战略。
亚马逊的CEO贝佐斯就说过一句特别著名的话“要把战略建立在不变的事物上。”比如如果竞争对手推出了一项新的功能或者他们改变了自己的方向那么我们的战略是否要随之变化继续迎头赶上呢这是一个值得产品团队深思的问题。
以我所在的电商行业为例我们的产品始终在强调用户体验但好的产品设计和用户体验绝不是因为友商做了什么花哨的改变而是始终着眼于那些长久不变的事物之上也就是多、快、好、省。因为不管什么时候用户选择在你的平台购物肯定不会是因为你的产品比其他家的要贵吧同样的道理对于DevOps产品来说也是这样。
那么有没有永远不变的内容可以作为DevOps产品的战略定位呢显然也是有的那就是效率、质量、成本和安全。归根结底产品的任何功能都是要为战略服务的。比如构建加速要解决的就是效率问题而弹性资源池自然更加关注成本方面的问题。在任何时候如果你的产品能在某个点上做到极致那么恭喜你你就找到了自己产品的立身之本。
明确目标用户定义刚性需求服务于典型场景并最终在某一个点上突出重围这就是我们在准备做DevOps产品的时候首先要想清楚的问题。无论是对内产品还是对外产品道理都是一样的。
第二个层次:能力圈层
战略很好,但是不能当饭吃。为了实现战略目标,我们需要做点什么,这就是需要产品化的能力。所谓产品化,就是将一个战略或者想法通过产品分析、设计、实验并最终落地的过程。
很少公司会有魄力一上来就投入百人团队开发DevOps产品大多数情况下都是一两个有志青年搭建起草台班子从一个最简单的功能开始做起。资源的稀缺性决定了我们永远处于喂不饱的状态而在这个时候最重要的就是所有为有所不为。
我们一定要明确,哪些是自己产品的核心竞争力,而哪些是我们的边界和底线,现阶段是不会去触碰的。当我们用这样一个圈子把自己框起来的时候,至少在短期内,目标是可以聚焦的。
当然,随着产品的价值体现,资源会随之而扩充,这个时候,我们就可以调整、扩大自己的能力圈。但说到底,这些能力都是为了实现产品战略而存在的,这一点永远不要忘记。
我还是拿个实际的案例来说明这个问题。之前在企业内部启动持续交付流水线项目的时候我们这个草台班子总共才4个人而我们面对的是千人的协同开发团队。在每个业务领域内部都有很多的产品工具平台在提供服务缺少的就是平台间的打通。
对于企业而言,一套完整覆盖端到端的研发协作平台看起来很美,但是,要做这么一套东西,投入巨大不说,还会同现有的工具平台产生冲突,这样就变成了一个零和游戏。
所谓零和游戏,就是所有玩家资源总和保持固定,只是在游戏过程中,资源的分配方式发生了改变。
就现在的这个例子来说,如果平台潜在用户总量是一定的,有一方向前一步,必定有另外一方向后一步,这显然不是我们这个“小虾米团队”现阶段能做到的。
所以,我们就给自己的产品定义了一个能力圈,它的边界就在于不去替换现有的工具平台,而是只专注于做链路打通的事情。这样一来,既有平台仍然可以单独提供服务,也可以通过标准化的方式提供插件,对接到我们的平台上来,我们的平台就成了它们的另外一套入口,有助于用户规模的扩大。
而对于我们自己来说,这些平台能力的注入,也扩展了我们自己的能力圈外沿,这些既有平台的用户就成了我们的潜在用户群体。这种双赢的模式,后来被证明是行之有效的,平台获得了很大的成功。
在跟很多朋友交流产品思路的时候,我总是把主航道和护城河理论挂在嘴边。所谓主航道,就是产品的核心能力,直接反射了产品战略的具体落地方式。对于流水线产品来说,这个能力来源于对软件交付过程的覆盖,而不论你将来开发任何产品,这条主路径都是无法回避的。那么,产品就有了茁壮成长的环境和土壤。而护城河就是你这个产品的不可替代性,或者是为了替代你的产品需要付出的高额代价。
还是引用流水线产品的例子,我们的护城河一方面来源于用户数据的沉淀,另一方面就在于这些外部能力的接入。你看,随着接入平台的增多,我们自身产品的护城河也越发难以逾越,这就是对于能力圈更加长远的考量了。
第三个层次:资源结构层
为什么做和做什么的问题,我们已经解决了,接下来,我们就要掂量掂量自己在资源方面有哪些优势了。
资源这个事儿吧,就像刚才提到的,永远是稀缺的,但这对于所有人来说都是公平的。所以,对资源的整合和调动能力就成了核心竞争力。当你没有竞争对手的时候,用户选择你的产品并不是什么难事,因为既然解决了一个痛点问题,又没有更好的选择,用一用也无妨。
可现实情况是,无论是企业内部,还是外部,我们都身处在一个充满竞争的环境,最开始能够吸引用户的点,说起来也很可笑,很多时候就在于让用户占了你的资源的便宜,也就是用户认为你的产品有一些资源是他们不具备的。
举个例子在很长一段时间内App的构建和打包都是基于本地的一台电脑来做的这样做的风险不用多说但是大家也没什么更好的选择。尤其是面对iOS这种封闭的生态环境想要实现虚拟化、动态化也不是一句话的事情甚至有可能触犯苹果的规则红线。
这时,如果你的产品申请了一批服务器,并且以标准化的方式部署在了生产机房,那么这些资源就成了产品的核心能力之一。
随着越来越多的用户跑来占便宜,产品对于大规模资源的整合能力就会不断提升,从而进一步压低平均使用成本,这就形成了一个正向循环。
产品蕴含的资源除了这些看得见、摸得着的机器以外,还有很多方面,比如,硬实力方面的,像速度快、机器多、单一领域技术沉淀丰富,又比如,强制性的,像审批入口、安全规则,还有软性的用户习惯,数据积累等等。
对于内部DevOps产品来说还有一项资源是至关重要的那就是领导支持。这一点我们已经在专栏第6讲中深入讨论过了我就不再赘述了。
第四个层次:角色框架层
当用户开始使用你的产品时,不要忘了,他们是来解决问题的,而每一个问题背后都存在一个场景,以及在这个场景中用户的角色。脱离这个场景和角色的设定,单纯讨论问题是没有意义的。
所以,我们总说,要站在用户的角度来看待问题,要在他们当时的场景下,去解决他们的问题,而不是远远地观望着,甚至以上帝视角俯视全局。
举个例子当你和其他部门在为了一个功能设计争得面红耳赤差点就要真人PK的时候你们的领导走进了会议室你猜怎样瞬间气氛就缓和起来似乎刚才什么也没发生过。这难道是因为我们的情绪管理能力很强吗其实不然这主要是因为我们身处的场景发生了变化我们的角色也发生了改变。
再举个产品的例子,当我们在开发流水线产品的时候,为了满足用户不同分支构建任务的需求,我们提供了分支参数的功能。但是,在收集反馈的时候,全都是负面声音,难道这是个“伪需求”吗?
其实不是。通过实际数据,我们可以看到,很多用户已经开始使用这个功能了。这不是得了便宜又卖乖吗?问题就在于,我们没有站在用户当时的角色框架下,来思考这个问题。
因为,分支功能是需要用户手动输入的,但分支名又长又容易出错,每次都要从另外一个系统或者本地复制粘贴。当这个场景出现一次的时候并不是什么大事,但是,如果每个人每天都要做几十次的话,这就是大问题了。其实,解决思路很简单,增加历史信息或者自动关联的功能就可以啦。
所以你看,有时候我们不需要多么伟大的创造和颠覆,基于核心场景的微创新也能起到正向的作用。
说到底,其实就是一句话:不要让你的产品只有专业人士才会使用。
为了兼容灵活性,很多产品都提供了很多配置,但是,对于当时这个场景来说,绝大多数配置,都是没人关心的。产品应该提供抽象能力屏蔽很多细节,而不是暴露很多细节,甚至,好的产品自身就是使用说明书。这一点,在注意力变得格外稀缺的现在,重要性不可忽视。
第五个层次:感知层
现在,我们来看看最后一个层次:感知层,这也是距离用户最近的一个层次。
不可否认这是一个看脸的时代但是产品终究是给人用的而不是给人看的。所以很多人甚至强调对于内部产品来说UI完全不重要家丑不外扬就好了。
可是,换位思考一下,你希望自己每天打交道的是一个设计凌乱、完全没有美感的产品吗?
答案很有可能是否定的。可这对于很多DevOps的产品经理来说是最难的一点。这是因为没有人天生就是DevOps产品经理很多人都是半路出家做开发的做测试的甚至是当老板的。
让不专业的人做专业的事情,结果可想而知,好多产品功能的设计都堪称是“反人类”的。
关于这个层次,我提供两点建议:
多跟前端工程师交流。现在的前端框架已经非常成熟了,基于模板,我们可以快速地搭建出一个平台。而且,模板的框架自身,也蕴含着很多的设计思想。
多学习一些基本的设计原则。你可以参考Element官网上的设计理念章节里面谈到了一致、反馈、效率和可控四个方面每个方面又涉及很多细节。参照着成熟的产品再对照这些基本设计理念你放心你会进步神速的。
总结
今天我们介绍了DevOps产品设计的五个层次包括战略存在层、能力圈层、资源结构层、角色框架层和感知层。其实当用户吐槽你的产品或者产品迟迟没有提升的时候我们可能就要沉下心来对照着这五个层次来看看问题到底出在哪里了。
思考题
你有用到过什么好的DevOps产品吗它们有哪些功能让你眼前一亮不由得为这个产品点赞吗
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,225 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 持续交付平台:现代流水线必备的十大特征(上)
你好,我是石雪峰。
作为DevOps工程实践的集大成者和软件交付的“理想国”持续交付对企业的DevOps落地起到了举足轻重的作用。我接触过的企业全都在建设自己的流水线平台由此可见流水线是持续交付中最核心的实践也是持续交付实践最直接的体现。
那么,如何建设一个现代流水线平台呢?这个平台,应该具备哪些特性呢?
根据我自己在企业内部建设落地流水线平台的经验,以及业界各家公司的平台设计理念,我提取、总结了现代流水线设计的十大特性。
在接下来的两讲中,我会结合平台设计,给你逐一拆解这些特性背后的理念,以及如何把这些理念落地在平台设计中。我把这十个特性汇总在了下面的这张图片里。今天,我先给你介绍下前五个特性。
特性一:打造平台而非能力中心
与其他DevOps平台相比流水线平台有一个非常典型的特征那就是它是唯一一个贯穿软件交付端到端完整流程的平台。正因为这样流水线平台承载了整个软件交付过程方方面面的能力比如持续集成能力、自动化测试能力、部署发布能力甚至是人工审批的能力等。
那么,我们把软件交付过程中所需要的能力都直接做到流水线平台上,是不是就可以了呢?
这个想法是好的,但是在企业中,这并不具备可操作性。因为软件交付的每一个环节都是一项非常专业的工作,比如,仅仅是自动化测试能力这一项做好,就需要一个具备专业技能的团队的长期投入。
而且,把所有能力都做到流水线平台中,会使平台变得非常臃肿。再说了,我们也很难组建一个这么大的团队,来实现这个想法。
另外企业的DevOps平台建设并不是一两天的事情。每家企业内部都有很多固有平台这些平台长期存在已经成为了团队软件交付日常操作的一部分。如果全部推倒重来不仅会打破团队的习惯影响短期效率还会带来重复建设的巨大成本这并不利于流水线平台的快速落地。
那么,既然这条路走不通,流水线平台如何定位才比较合理呢?我认为,正确的做法是,将持续交付流水线平台和垂直业务平台分开,并定义彼此的边界。
所谓的垂直业务平台,就是指单一专业领域的能力平台,比如自动化测试平台、代码质量平台、运维发布平台等等,这些也是软件交付团队日常打交道最频繁的平台。
流水线平台只专注于流程编排、过程可视化,并提供底层可复用的基础能力。比如,像是运行资源池、用户权限管控、任务编排调度流程等等。
垂直业务平台则专注于专业能力的建设、一些核心业务的逻辑处理、局部环节的精细化数据管理等。垂直业务平台可以独立对外服务,也可以以插件的形式,将平台能力提供给流水线平台。
这样一来,我们就可以快速复用现有的能力,做到最小成本的建设。随着能力的不断扩展,流水线平台支持的交付流程也会变得非常灵活。
借用《持续交付2.0》中的一句话来说,流水线平台仅作为任务的调度者、执行者和记录者,并不需要侵入垂直业务平台内部。
这样设计的好处很明显。
从流水线平台的角度来看,通过集成和复用现有的垂直业务能力,可以快速拓展能力图谱,满足不同用户的需求。
从垂直业务平台的角度来看,它们可以持续向技术纵深方向发展,把每一块的能力都做精、做透,这有助于企业积累核心竞争力。另外,流水线可以将更多用户导流到平台中,让垂直业务平台接触更多的用户使用场景。
不仅如此在执行过程中流水线携带了大量的软件开发过程信息比如本次任务包含哪些需求有哪些变更这些信息可以在第一时间通知垂直业务平台。垂直业务平台拿到这些过程信息之后可以通过精准测试等手段大大提升运行效率。这里的核心就是构建一个企业内部DevOps平台的良好生态。
业界很多知名的软件设计都体现了这个思路。比如Jenkins的插件中心、GitHub的Marketplace。它们背后的理念都是基于平台建立一种生态。
我之所以把这个特性放在第一个来介绍,就是因为,这直接决定了流水线平台的定位和后续的设计理念。关于具体怎么设计平台实现能力的快速接入,我会在第八个特性中进行深入介绍。
特性二:可编排和可视化
在现代软件开发中,多种技术栈并存,渐渐成为了一种常态。
举个最简单的例子,对于一个前后端分离的项目来说,前端技术栈和后端技术栈显然是不一样的;对于微服务风格的软件架构来说,每个模块都应该具备持续交付的能力。
所以,传统的标准化软件构建发布路径已经很难满足多样化开发模式的需要了。这样看来,流水线平台作为软件交付的过程载体,流程可编排的能力就变得必不可少了。
所谓的流程可编排能力,就是指用户可以自行定义软件交付过程的每一个步骤,以及各个步骤之间的先后执行顺序。说白了,就是“我的模块我做主,我需要增加哪些交付环节,我自己说了算”。
但是,很多现有的“流水线”平台采用的还是几个“写死”的固定阶段,比如构建、测试、发布,以至于即便有些技术栈不需要进行编译,也不能跳过这个环节。
我之前就见过一家企业,他们把生成版本标签的动作放在了上线检查阶段。我问了之后才知道,这个步骤没有地方可以放了,只能被临时扔在这里。你看,这样一来,整个交付过程看起来的样子和实际的样子可能并不一样,这显然不是可视化所期待的结果。
流程可编排,需要平台前端提供一个可视化的界面,来方便用户定义流水线过程。典型的方式就是,将流水线过程定义为几个阶段,每个阶段按顺序执行。在每个阶段,可以按需添加步骤,这些步骤可以并行执行,也可以串行执行。
前端将编排结果以一种标准化的格式进行保存一般都是以JSON的形式传递给后端处理。后端流程引擎需要对用户编排的内容进行翻译处理并传递给执行器来解释运行即可。
你可以参考一下下面这张流程编排的示意图。在实际运行的过程中,你可以点击每一个步骤,查看对应的运行结果、日志和状态信息。
从表面上看,这主要是在考验平台前端的开发能力,但实际上,编排的前提是系统提供了可编排的对象,这个对象一般称为原子。
所谓原子,就是一个能完成一项具体的独立任务的组件。这些组件要具备一定的通用性,尽量与业务无关。
比如下载代码这个动作,无论是前端项目,还是后端项目,做的事情其实都差不多,核心要实现的就是通过几个参数,完成从版本控制系统拉取代码的动作。那么,这就很适合成为一项原子。
原子的设计是流水线平台的精髓,因为原子体现了平台的通用性、可复用性和独立性。
以我们比较熟悉的Jenkins为例一个原子就是流水线中的一个代码片段。通过封装特性将实现隐藏在函数实现内部对外暴露调用方法。用户只需要知道如何使用不需要关心内部实现。
要想自己实现一个原子其实并不复杂在Jenkins中添加一段Groovy代码就行了。示例代码如下
// sample_atom_entrance.groovy
def Sample_Atom(Map map) {
new SampleAtom(this).callExecution(map)
}
// src/com/sample/atoms/SampleAtom.groovy
class SampleAtom extends AbstractAtom {
SampleAtom(steps) {
super(steps)
}
@Override
def execute() {
// Override execute function from AbstractAtom
useAtom()
}
private def useAtom(){
steps.echo "RUNNING SAMPLE ATOM FUNCTION..."
}
特性三:流水线即代码
这些年来,“什么什么即代码”的理念已经深入人心了。在应用配置领域,有 Configuration As Code在服务器领域有 Infrastructure As Code……流水线的设计与实现同样需要做到 Pipeline As Code也就是流水线即代码。
比如Jenkins 2.0 中引入的 Jenkinsfile 就是一个典型的实现。另外Gitlab中提供的GitlabCI同样是通过一种代码化的方式和描述式的语言来展示流水线的业务逻辑和运行方式。
流水线代码化的好处不言而喻:借助版本控制系统的强大功能,流水线代码和业务代码一样纳入版本控制系统,可以简单追溯每次流水线的变更记录。
在执行流水线的过程中如果流水线配置发生了变化同样需要体现在本次流水线的变更日志里面。甚至是在版本的Release Notes中也增加流水线、环境的变更记录信息。一旦发生异常这些信息会大大提升问题的定位速度。
当然,如果只是想要实现流水线变更追溯,你也可以采用其他方式。比如,将流水线配置存放在后台数据库中,并在每次流水线任务执行时,记录当时数据库中的版本信息。
实际上,流水线即代码的好处远不止于此。因为它大大地简化了流水线的配置成本,和原子一样,是构成现代流水线的另外一个支柱。
我跟你分享一个流水线即代码的示例。在这个例子中,你可以看到,整个软件交付流程,都以一种非常清晰的方式描述出来了。即便你不是流水线的专家,也能看懂和使用。
image: maven:latest
stages:
- build
- test
- run
variables:
MAVEN_CLI_OPTS: "--batch-mode"
GITLAB_BASE_URL: "https://gitlab.com"
DEP_PROJECT_ID: 8873767
build:
stage: build
script:
- mvn $MAVEN_CLI_OPTS compile
test:
stage: test
script:
- mvn $MAVEN_CLI_OPTS test
run:
stage: run
script:
- mvn $MAVEN_CLI_OPTS package
- mvn $MAVEN_CLI_OPTS exec:java -Dexec.mainClass="com.example.app.A
特性四:流水线实例化
作为软件交付流程的建模,流水线跟面向对象语言里面的类和实例非常相似。一个类可以初始化多个对象,每个对象都有自己的内存空间,可以独立存在,流水线也要具备这种能力。
首先,流水线需要支持参数化执行。
通过输入不同的参数,控制流水线的运行结果,甚至是控制流水线的执行过程。
比如,一条流水线应该满足不同分支的构建需要,那么,这就需要将分支作为参数提取出来,在运行时,根据不同的需要,手动或者自动获取。
考虑到这种场景,在平台设计中,你可以事先约定一种参数的格式。这里定义的标准格式,就是以“#”开头,后面加上参数名称。通过在流水线模板中定义这样的参数,一个业务可以快速复用已有的流水线,不需要重新编排,只要修改运行参数即可。
其次,流水线的每一次执行,都可以理解为是一个实例化的过程。
每个实例基于执行时间点的流水线配置,生成一个快照,这个快照不会因为流水线配置的变更而变更。如果想要重新触发这次任务,就需要根据当时的快照运行,从而实现回溯历史的需求。
最后,流水线需要支持并发执行能力。
这就是说,流水线可以触发多次,生成多个运行实例。这考察的不仅是流水线的调度能力、队列能力,还有持久化数据的管理能力。
因为,每次执行都需要有独立的工作空间。为了加速流水线运行,需要在空间中完成静态数据的挂载,比如代码缓存、构建缓存等。有些流水线平台不支持并发,其实就是因为没有解决好这个问题。
特性五:有限支持原则
流水线的设计目标,应该是满足大多数、常见场景下的快速使用,并提供一定程度的定制化可扩展能力,而不是满足所有需求。
在设计流水线功能的时候,我们往往会陷入一个怪圈:我们想要去抽象一个通用的模型,满足所有的业务场景,但是我们会发现,业务总是有这样或者那样的特殊需求。这就像是拿着一张大网下水捞鱼,总是会有漏网之鱼,于是,网做得越来越大。对于平台来说,平台最后会变得非常复杂。
比如拿最常见的安卓应用构建来说目前绝大多数企业都在使用Gradle工具通用命令其实只有两步
gradle clean
gradle assemblerelease / gradle assembledebug
但是在实际的业务场景中应用A用到了Node.js需要安装npm应用B用到了Git-lfs大文件需要先执行安装指令应用C更甚需要根据选项配置执行Patch模式和完整打包模式。
如果试图在一个框架中满足所有人的需求,就会让配置和逻辑变得非常复杂。无论是开发实现,还是用户使用,都会变得难以上手。
以Jenkins原生的Xcode编译步骤为例这个步骤提供了53个参数选项满足了绝大多数场景的需求但是也陷入到了参数的汪洋大海中。
所以,流水线设计要提供有限的可能性,而非穷举所有变量因素。
在设计参数接口的时候我们要遵循“奥卡姆剃刀法则”也就是“如无必要勿增实体”。如果有用户希望给原子增加一个变量参数那么我们首先要想的是这个需求是不是90%的人都会用到的功能。如果不是,就不要轻易放在原子设计中。
你可能会问这样的话用户的差异化诉求该如何满足呢其实这很简单你可以在平台中提供一些通用类原子能力比如执行自定义脚本的能力、调用http接口的能力、用户自定义原子的能力等等。只要能提供这些能力就可以满足用户的差异化需求了。
总结
在这一讲中,我给你介绍了现代流水线设计的前五大特性,分别是打造平台而非能力中心、可编排和可视化、流水线即代码、流水线实例化,以及有限支持原则。在下一讲中,我会继续介绍剩余的五大特性,敬请期待。
思考题
你所在的企业有在使用流水线吗?你觉得,流水线还有什么必不可少的特性吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,205 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 持续交付平台:现代流水线必备的十大特征(下)
你好,我是石雪峰。今天,我来接着跟你聊聊现代流水线必备的十大特性的下半部分,分别是流程可控、动静分离配置化、快速接入、内建质量门禁和数据采集聚合。
特性六:流程可控
在上一讲中,我提到过,流水线是覆盖软件交付端到端完整过程的平台,也就是说,流水线的主要作用是驱动软件交付过程的效率提升和状态可视化。
在线下交流的时候,我发现,不少同学对这个概念的理解都存在着一些误区,他们觉得需要建设一条大而全的流水线,在这条流水线上完成软件交付的所有过程。
其实,流水线是要覆盖端到端的流程,但这并不是说,一定要有一条流水线跑通从代码提交开始到软件发布为止的全流程。实际上,在企业中,往往是多条流水线覆盖不同的环节,比如开发阶段流水线、集成阶段流水线,以及部署阶段流水线。这些流水线一起覆盖了整个软件交付流程。
这就体现了流水线的流程可控性,流水线可以为了满足不同阶段的业务目标而存在,并且每条流水线上实现的功能都不相同。为了达到这个目的,流水线需要支持多种触发方式,比如定时触发、手动触发、事件触发等。其中,事件触发就是实现持续集成的一个非常重要的能力。
以Gitlab为例你可以在代码仓库中添加WebhookWebhook的地址就是触发流水线任务的API这个API可以通过Gitlab的API实现自动注册。
需要注意的是要实现Webhook的自动注册访问Gitlab的账号时必须要有对应代码仓库的Master级别权限否则是无法添加成功的。
当注册完成Webhook代码仓库捕获到对应的事件后比如代码Push动作会自动调用Webhook并且将本次代码提交的基础信息比如分支、提交人等传递给注册地址。
流水线平台接收到接口访问后可以根据规则过滤请求最典型的就是触发分支信息。当满足规则条件后则执行流水线任务并将结果再次通过Gitlab的API写回到代码仓库中。这样一来每次提交历史都会关联一个流水线的执行记录可以用于辅助代码合并评审。
我画了一张流程图,它展示了刚刚我所描述的过程以及调用的接口信息。
除了多种触发方式以外,流水线还需要支持人工审批。这也就是说,每个阶段的流转可以是自动的,上一阶段完成后,就自动执行下一阶段;也可以是手动执行的,必须经过人为确认才能继续执行,这里的人为确认需要配合权限的管控。
其实人工审批的场景在软件交付过程中非常常见。如果是自建流程引擎人工审批就不难实现但是如果你是基于Jenkins来实现这个过程虽然Jenkins提供了input方法来实现人为审批的功能但我还是比较推荐你自己通过扩展代码来实现。比如将每个原子的执行过程抽象为before()、execute() 和 after() 三个阶段可以将人工审批的逻辑写在before()或者after()方法中。
这样一来对于所有原子都可以默认执行基类方法从而获得人工审批的能力。是否开启人工审批可以通过原子配置中的参数实现。你就不需要在每个原子中人工注入input方法了流水线的执行过程会更加清晰。
我给你分享一个抽象原子类的设计实现,如下面的代码所示:
abstract class AbstractAtom extends AtomExecution {
def atomExecution() {
this.beforeAtomExecution()
// 原子预处理步骤,你可以将通用执行逻辑,比如人工审批等写在这里
echo('AtomBefore')
before()
// 原子主体核心逻辑
echo('AtomExecution')
execute()
// 原子后处理步骤,你可以将通用执行逻辑,比如人工审批等写在这里
echo('AtomAfter')
after()
this.afterAtomExecution()
}
}
特性七:动静分离配置化
流水线的灵活性不仅体现在流程可编排、流程可控方面,每一个原子都需要持续迭代功能。那么,如何在不改变代码的情况下,实现原子的动态化配置呢?
这就需要用到动静分离的设计方法了。那么,什么是动静分离呢?
其实,动静分离就是一种配置化的实现方式。这就是指,将需要频繁调整或者用户自定义的内容,保存在一个静态的配置文件中。然后,系统加载时通过读取接口获取配置数据,并动态生成用户可见的交互界面。
你可能觉得有点抽象,我来给你举个例子。你可以看一下下面这张截图。
如果我想对某一个原子扩展一个新的功能提供一个新的用户配置参数传统的做法就是在前端页面中增加一段html代码。这样的话原子功能的每一次变更都需要前端配合调整原子的独立性就不复存在了而是跟页面强耦合在一起。
另外,前端页面加入了这么多业务逻辑,如果哪天需要同时兼容不同的原子版本,那么前端页面也需要保存两套。一两个应用这么玩也就罢了,如果有上百个应用,那简直没法想象。
那么,具体要怎么做呢?最重要的就是定义一套标准的原子数据结构。
比如,在上面这张图的左侧部分,我给你提供了一个参考结构。对于所有的原子来说,它对外暴露的功能都是通过这套标准化的方式来定义的。前端在加载原子的时候,后端提供的接口获取原子的数据结构,并按照约定的参数类型,渲染成不同的控件类型。
不仅如此,控件的属性也可以灵活调整,比如控件的默认值是什么,控件是否属于必填项,是否存在可输入字符限制等等。那么,当你想增加一个新的参数的时候,只需要修改原子配置,而不需要修改前端代码。结构定义和具体实现的分离,可以大幅简化原子升级的灵活性。
无论在原子结构设计,还是前后端交互等领域,定义一个通用的数据结构是设计标准化的系统的最佳实践。
对于流水线平台来说,除了原子,很多地方都会用到配置化的方式。比如,系统报告中体现的字段和图表类型等,就是为了满足用户差异化的需求。而且,将配置纳入版本控制,你也可以快速查询原子配置的变更记录,达到一切变更皆可追溯的目标。
特性八:快速接入
前面我提到过,流水线的很多能力都不是自己提供的,而是来源于垂直业务平台。那么,在建设流水线平台的时候,能否快速地实现外部平台能力的接入,就成了一个必须要解决的问题。
经典的解决方式就是提供一种插件机制来实现平台能力的接入。比如Jenkins平台就是通过这种方式建立了非常强大的插件生态。但是如果每个平台的接入都需要企业内部自己来实现插件的话那么企业对于平台接入的意愿就会大大降低。
实际上,接入成本的高低,直接影响了平台能力的拓展,而流水线平台支持的能力多少,就是平台的核心竞争力。
那么,有没有一种更加轻量化的平台接入方法呢?我给你提供一个解决思路:自动化生成平台关联的原子代码。
在第七个特性中,我们已经将原子的数据结构通过一种标准化的描述式语言定义完成了,那原子的实现代码是否可以也自动化生成呢?实际上,在大多数情况下,外部平台打通有两种类型。
平台方提供一个本地执行的工具也就是类似SonarQube的Scanner的方式通过在本地调用这个工具实现相应的功能。
通过接口调用的方式,实现平台与平台间的交互,调用的实现过程无外乎同步和异步两种模式。
既然平台接入存在一定的共性,那么,我们就可以规划解题方法了。
首先,流水线平台需要定义一套标准的接入方式。以接口调用类型为例,接入平台需要提供一个任务调用接口、一个状态查询接口以及一个结果上报接口。
任务调用接口:用于流水线触发任务,一般由接入平台定义和实现。对于比较成熟的平台来说,这类接口一般都是现成的。接口调用参数可以直接转换成原子的参数,一些平台的配置化信息(比如接口地址、接口协议等),都可以定义在原子的数据结构中。
状态查询接口:用于流水线查询任务的执行状态,获取任务的执行进度。这个接口也是由接入平台定义和实现的,返回的内容一般包括任务状态和执行日志等。
数据上报接口:用于任务将执行结果上报给流水线平台进行保存。这个接口由流水线平台定义,并提供一套标准的数据接口给到接入方。接入方必须按照这个标准接口上报数据,以简化数据上报的过程。
通过将平台接入简化为几个标准步骤,可以大幅简化平台接入的实现成本。按照我们的经验,一套平台的接入基本都可以在几天内完成。
特性九:内建质量门禁
在第14讲中我给你介绍了内建质量的理念以及相关的实施步骤。你还记得内建质量的两大原则吗
问题发现得越早,修复成本就越低;
质量是每个人的责任,而不是质量团队的责任。
毫无疑问,持续交付流水线是内建质量的最好阵地,而具体的展现形式就是质量门禁。通过在持续交付流水线的各个阶段注入质量检查能力,可以让内建质量真正落地。
一般来说流水线平台都应该具备质量门禁的能力我们甚至要把它作为流水线平台的一级能力进行建设。在流水线平台上要完成质量规则制定、门禁数据收集和检查以及门禁结果报告的完整闭环。质量门禁大多数来源于垂直业务平台比如UI自动化测试平台就可以提供自动化测试通过率等指标。只有将用于门禁的数据上报到流水线平台才能够激活检查功能。
那么,质量门禁的功能应该如何设计呢?
从后向前倒推首先是设置门禁检查功能。这个功能也是一种流水线的通用能力所以和人工审核的功能类似也可以放在原子执行的after()步骤中或者独立出来一个步骤就叫作qualityGates()。
每次原子执行时都会走到这个步骤,在步骤中校验当前流水线是否已经开启了门禁检查功能,并且当前原子是否提供了门禁检查能力。如果发现已配置门禁规则,而且当前原子在检查范围内,就等待运行结果返回,提取数据,并触发检查工作。你可以参考下面的示例代码。
def qualityGates() {
// 获取质量门禁配置以及生效状态
boolean isRun = qualityGateAction.fetchQualityGateConfig(host, token, pipelineId, oneScope)
// 激活检查的情况等待结果返回最多等待30分钟
if (isRun) {
syncHandler.doSyncOperation(
30,
'MINUTES',
{
// 等待执行结果返回,质量门禁功能必须同步执行
return httpUtil.doGetToExternalResult(host, externalMap.get(oneScope), token)
})
// 提取返回数据
qualityGateAction.fetchExecutionResult(host, token, externalMap.get(oneScope), buildId)
// 验证质量门禁
qualityGateAction.verify(oneScope)
}
}
解决了如何检查的问题,我们再往前一步,看看质量门禁的规则应该如何定义。
在企业内定义和管理质量规则的一般都是QA团队所以需要给他们提供一个统一入口方便他们进行规则配置和具体数值的调整。
对质量门禁来说,检查的类型可以说是多种多样的。
从比较类型来说,可以比较结果大于、等于、小于、包含、不包含等;
从比较结果来说,可以是失败值、警告值。失败值是指,只要满足这个条件,就直接终止流水线执行。而警告值是说,如果满足这个条件,就给一个警告标记,但是不会终止流水线执行。
这些条件往往需要根据QA团队定义的规则来适配。
质量规则可以由一组子规则共同组成比如单元测试通过率100%、行覆盖率大于50%、严重阻塞代码问题等于0……
所以,你看,想要定义一个灵活的质量门禁,就需要在系统设计方面花点功夫了。在之前的实践中,我们就采用了适配器加策略模式的方式,这样可以满足规则的灵活扩展。
策略模式是23种设计模式中比较常用的一种。如果你之前没有了解过我给你推荐一篇参考文章。如果想要深入学习设计模式极客时间也有相应的专栏或者你也可以购买经典的《设计模式》一书。其实核心就在于面向接口而非面向过程开发通过实现不同的接口类来实现不同的检查策略。
特性十:数据聚合采集
作为软件交付过程的载体,流水线的可视化就体现在可以在流水线上看到每一个环节的执行情况。这是什么意思呢?
在系统没有打通的时候,如果你想看测试的执行结果,就要跑到测试系统上看;如果想看数据库变更的执行状态,就得去数据库管理平台上看。这里的问题就是,没有一个统一的地方可以查看本次发布的所有状态信息,而这也是流水线的可视化要解决的问题。
当平台的能力以原子的形式接入流水线之后,流水线需要有能力获取本次执行相关的结果数据,这也是在平台对接的时候,务必要求子系统实现数据上报接口的原因。至于上报数据的颗粒度,其实并没有一定之规,原则就是满足用户对最基本的结果数据的查看需求。
以单元测试为例,需要收集的数据包括两个方面,一个是单元测试的执行结果,比如一共多少用例,执行多少,成功失败分别多少。另外,即使收集覆盖率信息,至少也要包含各个维度的覆盖率指标。但是,对于具体每个文件的覆盖率情况,这种粒度的数据量比较大,可以通过生成报告的方式来呈现,不用事无巨细地都上报到流水线后台进行保存。
在企业内部没有建立独立的数据度量平台之前,流水线平台承载了全流程数据的展示功能。但是,毕竟流水线的目标是为了展示客观的数据结果,而不是在于对数据进行分析挖掘。所以,当企业开始建设数据度量平台时,流水线也可以作为数据源之一,满足度量平台对于各项工程能力的度量需求。
总结
到此为止我给你完整地介绍了现代流水线必备的十大特性。其实流水线的功能特性远不止这10个。随着云计算和云原生应用的发展云原生流水线也成为了越来越多人讨论的话题。关于这方面的内容我会在后续的课程中给你分享我的一些想法。
可以说一个好的持续交付流水线平台就是企业DevOps能力的巅峰展现。这也难怪越来越多的公司开始在这个领域发力甚至把它作为核心能力对外输出成为企业商业化运作的一份子。深入掌握这10个特性并把它们落实在流水线平台的建设中是企业DevOps平台建设的必经之路。
就像美国著名女演员莉莉·汤姆林Lily Tomlin的那句经典名言所说的那样
The road to success is always under construction.(通往成功的道路,永远在建设之中)
企业迈向持续交付的成功之路也不是一帆风顺的,永无止境的追求是指引我们前进的方向,也希望你能在流水线建设之路上不断思考,不断实践,持续精进。
思考题
你目前在使用的流水线平台有哪些不好用、待改进,或者是“反人类”的设计吗?看完这两讲的内容,你有什么新的想法和改进建议吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,196 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 让数据说话:如何建设企业级数据度量平台?
你好,我是石雪峰。今天我来跟你聊聊数据度量平台。
先说个题外话。在2019年的DevOps World | Jenkins World大会上CloudBees公司重磅发布了他们的全新产品SDM - Software Delivery Management 。在我看来,这注定是一个跨时代的产品。
简单来说SDM想要解决的问题就是将割裂的软件开发流程收敛到一个平台上通过收集软件开发全流程的数据并进行智能分析从而让整个软件交付过程的方方面面对所有人都可视化。
无论这个产品最终是否能够获得成功,它背后的设计理念绝对是非常超前的,因为这是第一次有一个解决方案把业务视角和开发视角连接了起来。
对业务人员来说,他们能够实时看到特性的交付进度;对开发人员来说,他们也能实时看到交付特性的业务指标和用户反馈;对管理人员来说,他们可以纵观整个流程,发现交付过程中的阻塞和效率瓶颈。
这听起来是不是很神奇呢?别急,关于这个产品的更多特性,我会在后续的特别放送中给你带来更多的介绍,敬请期待。
言归正传,我走访过的公司无一例外地都在花大力气建设数据度量平台。这些度量平台虽然看起来长得都不一样,但是他们想要解决的核心问题都是一致的,那就是软件研发过程可视化。
为什么可视化对于软件研发来说这么重要呢?这是因为,可视化可以大幅降低软件开发的协作成本,增加研发过程的透明度,从而大大减少研发过程中的浪费和返工。
举个最简单的例子,每周开会的时间成本一般都比较高,但如果老板能对项目的状态有清晰的了解,何必还要费这么大力气汇报工作呢?
在专栏的第19讲中我给你介绍了DevOps度量体系的相关内容。你还记得好的度量指标一般都具有的典型特征吗这些特征就是明确受众、直指问题、量化趋势、充满张力。
其实,在评价一个度量平台的时候,这些特征同样适用。因为,在数据度量平台上呈现的内容,正是度量指标。这也就是说,将度量指标的数据和详情汇总起来,再根据度量指标的维度,展现出各式各样的视图,从而满足不同用户的需求。
这样一来,整个团队的交付情况,包括交付效率和质量,就可以通过客观数据展示出来,而不再依赖于个人的主观臆测。有了客观的数据做尺子,团队的改进空间也就一目了然了。
听起来是不是特别美好?但实际上,度量平台要想满足这种预期,可不是一件简单的事情。
我认为,在数据度量平台的建设和落地过程中,事前、事中和事后这三个阶段都存在着大量的挑战。接下来,我就从这三个阶段入手,给你聊聊度量平台建设的一些思路。
事前:指标共识
毫无疑问,度量指标是数据度量平台的基础。在建设平台之前,如果指标本身的定义、数据来源、计算方法、统计口径等没有在团队内部达成共识的话,那么,数据度量平台呈现出来的数据也同样是有问题的。
我给你举个例子。需求流转周期这个指标,一般是计算需求卡片在需求的各个状态的停留时长的总和,包括分析、设计、开发、测试、发布等。
其中的测试流转周期计算的是从需求卡片进入待测试状态到测试完成进入待发布状态的时长例如5天。但是在真正支持测试任务的系统中也有一个测试流转周期。这个流转周期计算的是每个测试任务的平均执行时间这样算出来的测试周期可能只有1天。
先不说这两种计算方式谁对谁错,我想表达的是,即便是针对同一个指标,在不同平台、根据不同计算方法得到的结果也大不相同。
如果不能把指标的定义对齐,那么在实施度量的过程中,大家就会不清楚到底哪个数据是正确的,这显然不利于度量工作的推进。
另外,在定义度量指标的时候,一般都会召开指标评审会议。但这个时候,因为拿不出具体的数据,大家光盯着指标定义看,往往也看不出什么问题。等到平台上的数据出来了,才发现有些数据好像不太对。于是,要再针对指标重新梳理定义,而这往往就意味着平台开发的返工和数据重新计算。在平台建设的过程中,数据校准和指标对齐工作花费的时间很有可能比开发平台本身的时间都要多。
“数据本身不会说话,是人们赋予了数据意义”,而“这个意义“就是度量指标。
在定义指标的时候,大家都愿意选择对自己有利的解释,这就导致大家看待数据的视角无法对齐。
所以,在实施度量平台建设之前,最重要的就是细化度量指标的数据源和计算方法,而且一定要细化到可以落地并拿出数据结果的程度。
比如,开发交付周期这个指标一般是指从研发真正动工的时间点开始,一直到最终上线发布为止的时长。但是这个描述还是不够细化,所以,我们团队对这个指标的描述是:从研发在需求管理平台上将一个任务拖拽到开始的开发阶段起,一直到这个任务变成已发布状态为止的时间周期。
这里的任务类型包括特性、缺陷和改进任务三种,不包含史诗任务和技术预研任务类型。我们会对已达到交付状态的任务进行统计,未完成的不在统计范围中。你看,只有描述到这种颗粒度,研发才知道应该如何操作,数据统计才知道要如何获取有效的数据范围。
我建议你在着手启动数据度量平台建设之前,至少要保证这些指标数据可以通过线下、甚至是手工的方式统计出来,并在内部达成共识。
切忌一上来就开始盲目建设!很多时候,我们虽然花了大力气建设平台,最终也建设出来了,但结果却没人关注,核心问题还是出在了指标上。
数据平台作为企业内部的公信平台,数据的准确性至关重要。如果数据出现了偏差,不仅会导致错误的判断,带来错误的结果,还会对平台自身的运营推广造成很大的伤害。
事中:平台建设
随着软件交付活动复杂性的上升,在整个交付过程中用到的工具平台也越来越多。虽然通过持续交付流水线平台实现了交付链路的打通,通过交付流水线来驱动各个环节的工具平台来完成工作,但是,客观来说,企业内部的工具平台依然是割裂的状态,而非完整的一体化平台。
这就带来一个问题:每个平台或多或少都有自己的数据度量能力,甚至也有精细化维度的数据展示,但是这些数据都是存储在各个工具平台自身的数据库中的。
我给你举个例子。Jira是一个业界使用比较普遍的需求管理平台也是一个成熟的商业工具所以对于这类商业化系统都提供了比较完善的API。再加上Jira自带的JQL查询语言可以相对比较简单地查询并获取元数据信息。但是对于一个自研平台来说对外开发的API可能相对简单甚至有的系统都没有对外暴露API。在这种情况下如果想要获取平台数据要么依赖于开发新的API要么就只能通过JDBC直接访问后台数据库的形式来提取数据。
不仅如此,还有些平台的数据是通过消息推送的方式来获取的,无法主动地获取数据,只能通过订阅消息队列广播的方式来获取。
所以,你看,对于不同的元数据平台,数据获取的方式也是千差万别的。
挑战一:大量数据源平台对接
那么,作为一个统一的数据度量平台,面对的第一个挑战就是,如何从这些种类繁多的平台中提取有用的数据,并且保证数据源接入的隔离性,做到灵活接入呢?
我给你的建议还是采用流水线设计的思路,那就是插件化,只不过,这次要实现的插件是数据采集器。你可以看一下这张简单的架构示意图:
采集器是针对每一个对接的数据源平台实现的它的作用就是对每个数据源进行数据建模从而对平台屏蔽各种数据获取方式将采集到的数据进行统一格式化上报和存储。在采集器上面可以设计一个Operation层用来调整采集器的执行频率控制采集数据的范围。
如果数据量比较大你也可以让采集器对接类似Kafka这样的消息队列这些都可以按需实现。这样一来新平台如果想要接入只需要针对这个平台的数据特性实现一个采集器即可平台的整体架构并不需要变化。
你可以看看下面的这段采集器的示例代码:
@Override
public void collect(FeatureCollector collector) {
logBanner(featureSettings.getJiraBaseUrl());
int count = 0;
try {
long projectDataStart = System.currentTimeMillis();
ProjectDataClientImpl projectData = new ProjectDataClientImpl(this.featureSettings,
this.projectRepository, this.featureCollectorRepository, jiraClient);
count = projectData.updateProjectInformation();
log("Project Data", projectDataStart, count);
} catch (Exception e) {
// catch exception here so we don't blow up the collector completely
LOGGER.error("Failed to collect jira information", e);
}
}
挑战二:海量数据存储分析
一般来说常见的数据存储方式无外乎RDMS关系型数据库和NoSQL非关系型数据库两种类型。那么究竟应该如何选择还是要看数据度量平台的数据特征。
第一个典型特征就是数据量大。对于一个大型公司而言,每天的代码提交就有近万笔,单单这部分数据就有几十万、上百万条。
第二个特征就是数据结构不统一。这个其实很好理解,毕竟需求相关的数据字段和代码相关的数据字段基本上没有什么共性,而且字段的数量也会根据指标的调整而调整。
第三个特征就是数据访问频繁。度量平台需要在大规模的数据集中进行随机访问、数据的读取运算等操作,这就要求很好的横向扩展能力。
另外,数据度量平台一般都会保存元数据和加工数据。所谓元数据,就是采集过来的、未加工过的数据,而加工数据则是经过数据清洗和数据处理的数据。
我还是举个代码库的例子来说明一下。元数据就是一条条用户的代码提交记录,而加工数据则是按照分钟维度聚合过的提交信息,包括数量、行数变化等。这些加工过的数据可以很简单地提供给前端进行图表展示。存储加工数据的原因就在于,避免每次实时的大量数据运算,以提升度量平台的性能。
基于这些典型特征和场景,不难看出,非关系型数据库更加适合于大量元数据的保存。
我推荐你使用HBase这是一个适合于非结构化数据存储的数据库天生支持分布式存储系统。而对于加工数据的保存你可以采用关系型数据库MySQL。
当然数据库的选型不止这一种业界还有很多开源、商业工具。比如开源的数据度量平台Hygieia就采用的是MongoDB而商业工具中的Insight也在业内的很多大型公司在大规模使用。
我再给你分享一幅数据度量的架构图。从这张图中你可以看到底层数据都是基于HBase和HDFS来存储的。
挑战三:度量视图的定制化显示
度量平台需要满足不同维度视角的需求所以一般都会提供多个Dashboard比如管理层Dashboard、技术经理Dashboard、个人Dashboard等。但是这种预置的Dashboard很难满足每个人的差异化需求就像“一千个人眼里有一千个哈姆雷特”一样度量平台的视图也应该是千人千面的。
那么如果想要实现度量视图的自定义,比如支持图标位置的拖拽和编辑,自己增加新的组件、并按照自定义视图发送报告等,那就需要在前端页面开发时下点功夫了。好在对于现代前端框架,都有现成的解决方案,你只需要引用对应的组件即可。
我给你推荐两个前端组件,你可以参考一下:
插件一
插件二
这两个组件都可以支持widget的拖拽、缩放、自动对齐、添加、删除等常见操作。这样一来每个人都可以自由地按需定制自己的工作台视图不同角色的人员也可以定制和发送报告而不需要从度量平台提取数据再手动整理到PPT里面了。
以vue-grid-layout为例在使用时你可以将echarts图表放在自定义组件里面同时你也可以自己实现一些方法具体的方法可以参考一下这篇文章。
在了解了刚开始建设度量平台的三个常见挑战之后,你应该已经对度量平台的架构有了一个大体的认识,接下来,我们来看看第三个阶段。
事后:规则落地
以现在的开发效率来说建设一个数据度量平台并不是件困难的事情。实际上建设度量平台只能说是迈出了数据度量的第1步而剩余的99步都依赖于平台的运营推广。
这么说一点也不夸张甚至可以说如果根本没人关心度量平台上的数据那么可能连第1步的意义都要画上个问号。
在开始运营的时候度量平台面临的最大挑战就是数据的准确性这也是最容易被人challenge的地方。
造成数据不准确的原因有很多,比如,度量指标自身的计算方式问题、一些异常数据引入的问题、部门维度归类聚合的问题。但是实际上,往往带来最多问题的还是研发操作不规范。
举个例子,像需求交付周期这种数据强依赖于需求卡片流转的操作是否规范,如果研发上线后一次性把卡片拖拽到上线状态,那么这样算出来的需求交付周期可能只有几秒钟,显然是有问题的。
正确的做法是,根据真实的状态进行流转,比如研发提测关联需求,后台自动将需求卡片流转到待测试状态;测试验收通过后,卡片再次自动流转到测试完成状态等。尽量实现自动化操作,而不是依赖于人的自觉性。
再举个例子,像需求关联的代码行数,如果研发提交的时候并没有对代码和需求建立关联,那么统计出来的数据也会有很大的失真。这些不规范的数据并不会因为后续操作的改变而改变,也很难进行数据的修复和清理,会一直留存在度量系统的数据池中,是抹不掉的印记。
所以,度量平台只有通过项目自上而下的驱动才能起到真正的作用。要对不规范的操作建立规则,对恶意操作的数据进行审计,把度量发现的问题纳入持续改进,对每项指标的走势进行跟踪和定位。
另外,为了让数据可以直指问题,在度量平台中,也需要体现出来当前的数据是好还是坏。
方式和方法有很多比如建立参考值比如对于单测覆盖率制定最低50%的参考值这样在度量图表中就能体现出当前数据和参考值的差距。或者你也可以在每一项可以横向比较的指标旁边体现当前处在大部门的哪个位置是前10%还是后10%?这样的数据都有助于推动改进行为。
说到底度量的目的是持续改进。如果统计了100个指标的数据并都体现在度量平台上却说不出来到底哪个指标给团队带来了改进以及改进是如何实现的那么这种度量平台的价值又在哪里呢
总结
好啦在这一讲中我给你介绍了建设数据度量平台的核心价值也就是让软件交付过程变得可视化。在这一点上业界各大公司的思路都是一致的。也正因为如此数据度量平台是当前企业DevOps平台建设不可或缺的一环。
在平台建设的时候,你需要关注事前、事中和事后三个阶段的事情。
事前就是要对指标的定义达成共识。这里的指标要细化到数据源和详细的计算公式层面,即便没有度量平台,也可以计算出相应的结果;
事中就是平台建设方面面对多数据源平台可以采用采集器插件的方式灵活适配建议使用HBase等非关系型数据库进行数据存储可以利用现有的前端组件来实现可视化界面展示。
事后就是数据的运营和规则落地。只有度量数据能够反映出问题,并驱动团队改进,度量才有意义。
思考题
你在企业中建设和应用度量平台的时候,还遇到过哪些问题呢?你又是如何解决的呢?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,237 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 平台产品研发:三个月完成千人规模的产品要怎么做?
你好,我是石雪峰。
虽然我们之前聊了这么多的平台建设思路,但是,可能很多人都没有机会经历一个平台从构思到开发、再到推广落地的完整过程。
如果要开发一个千人使用的DevOps产品需要多长时间呢你可能会说需要半年甚至是更长的时间我之前也是这么觉得的。
但是2018年在启动流水线平台建设的时候老板“大手一挥”要求在三个月内见到成效我都快惊呆了。
因为,我们要真正地从零开始:原型图都没有一张,代码都没有一行,临时组建的一个草台班子还分散在北京、上海两地,团队成员之前都没怎么打过招呼,这能行吗?
今天,我想给你分享的就是这个真实的故事。我来跟你一起复盘下这次“急行军”的历程,看看我们做对了什么,又做错了什么,有哪些干货是可以拿来就用的,又有哪些“坑”是你一定要努力回避的。
其实作为一个非专业的DevOps产品经理你终将面对这样的挑战但你要相信只要开始去做了就没有什么是不可能的。
项目启动
时间回到一年前当时我所在的这个“草台班子”是个啥情况呢团队组成是这样的两个后台开发在北京一个半前端开发在上海还有一个基础设施工程师和一个流水线开发工程师再加上半个全能打杂的产品经理也就是我满打满算一共6个人。
项目从11月中旬开始构思12月初开启动会当时除了我之外没有任何人清楚我们要做的到底是个什么玩意儿。这该怎么办呢
玩过游戏的同学应该都知道打好开局有多重要所以为了这个Kickoff会议我事先做了大量的准备工作其中就包括0.2版本的产品原型图。与其说是一个原型图,不如说就是一个草稿,简陋得不能再简陋了。
项目的Kickoff会议是项目组成员和未来产品的第一次见面留下一个积极的印象非常重要。所以从第一刻开始我就铆足了精神。
首先,我发出了一封热情洋溢的会议邀请。在会议邀请中,我仔细地陈述了我们为什么要做这件事,为什么是现在,为什么不做不行。
在正式开会的时候,我再一次明确了项目的重要性和紧急性,并给大家演示了第一版的系统原型图(没错,就是简陋到极致的刚刚的这张原型图)。
即便这样,三个月的工期也让大家非常焦虑。为了缓解紧张情绪,证明这个项目的可行性,我还做了两件事:
搭建了一个系统demo几个简单的页面
由于用到了另外一个开源产品的核心技术,于是,我就对这个技术进行了简单演示。
虽然我自己心里对这个计划也相当“打鼓”,但我还是希望告诉大家,这并不是不可能的任务,努力帮助大家树立信心。
在项目启动会上,团队达成了两个非常关键的结论:一个是系统方案选型;另一个是建立协作机制。
首先,由于时间紧任务重,我们决定使用更易于协作的前后端分离的开发模式。后来,事实证明,这是一个非常明智的选择。这不仅大幅提升了开发效率,也大大降低了之后向移动端迁移的成本。在开发移动端产品的时候,后端接口大部分都可以直接拿来使用。
在技术框架方面由于大家对前后端分离的模式达成了共识我们就采用Python+Django+VUE的方式来做。你可能会问为啥不用基于Java的Spring系列呢因为我觉得对于内部系统来说这些典型的框架应付起来基本都绰绰有余关键还是要选你熟悉的、易上手的那个。从这个角度来看Python显然有着得天独厚的优势。即便之前只是写写脚本想要上手Python也不是一件困难的事情。
在项目协作方面,我等会儿会专门提到,由于团队成员分散在北京、上海两地,彼此之间不够熟悉和信任,所以,建立固定的沟通机制就非常重要。
至少,在项目初期,我们每周都要开两次电话会议:
一次是面向全员的。一方面同步项目的最新进展,另一方面,也给大家一些紧迫感,让大家觉得“其他人都在按照计划执行,自己也不能落后”。
另外一次是面向跨地域骨干的。这主要还是为了增进联系,并且对一些核心问题进行二次的进展确认。不拉上全员,也是为了避免过多地浪费项目成员的时间。
最后,项目毕竟还是有一些技术风险的,所以还需要启动预研。我们这个项目的主要风险是在前端交互上。
这是一个从来没人实现过的场景,有大量的用户界面编排操作在里面。所以,我们专门指定了一位同学,让他啥也别想,一门心思地进行技术攻关。
事实证明,但凡能打硬仗的同事,在后来都是非常靠谱且独当一面的,这与年龄无关,哪怕是应届生,也同样如此。
讲到这里,我要先给你总结一下在项目启动阶段要重点关注的几件事情:
明确项目目标,树立团队的信心;
沟通开发模式和技术架构选型,以快速开发和简单上手为导向;
建立沟通渠道,保持高频联系;
识别项目的技术风险,提前开启专项预研。
开发策略
人类社会活动的每一个环节,都需要越来越多的人为了同一个目标推进工作,软件开发也不例外。那么,我们是怎么做的呢?
首先,就是研发环境容器化。
对于接触一个全新技术栈的开发来说,本地搭建一套完整可运行的环境总是绕不过去的坎。即便是对照着文档一步步操作,也总会有遗漏的地方。除此之外,项目依赖的各种中间件,哪怕稍微有一个版本不一致,最后一旦出现问题,就要查很久。
既然如此为什么不一上来就采用标准化的环境呢这就可以发挥容器技术的优势了。主力后台开发同学自己认领了这个任务先在本地完成环境搭建并调试通过接着把环境配置容器化。这样一来新人加入项目后几分钟就能完成一套可以工作的本地开发环境。即便后续要升级环境组件比如Django框架版本也非常简单只要推送一个镜像上去再重启本地环境就可以了。
其次就是选择分支策略。虽然DevOps倡导的是主干开发但是我们还是选择了“三分支”的策略因为我们搭建了三套环境。
测试环境对应dev分支作为开发主线所有新功能在特性分支开发自测通过后再通过MR到dev分支并部署到测试环境进行验收测试一般验收测试由需求提出方负责。
接下来定期每周两次从dev上master分支master分支对应了预发布环境保证跟生产环境的一致性数据也会定期进行同步。只有在预发布环境最终验收通过后才具备上线生产环境的条件。通过将master分支合并到release分支最后完成生产环境部署。这种分支策略的示意图如下
为什么要采用三套环境的“三分支”策略呢?
这里的主要原因就是,团队处于组建初期,磨合不到位,经常会出现前后端配置不一致的情况。更何况,我们这个项目不只有前后端开发,还有核心原子业务开发,以及基础设施维护。任何一方的步调不一致,都会导致出现问题。
另外,内部平台开发往往有个通病,就是没有专职测试。这也能理解,总共才几个人的“草台班子”,哪来的测试资源啊?所以,基本上只能靠研发和产品把关。
但是,毕竟测试也是个专业的工种,这么一来,总会有各种各样的问题。再加上,产品需求本身就没有那么清晰、灵活多变,所以,多一套环境,多一套安全。
但不可否认的是,这种策略并非是最优解,只不过是适应当时场景下的可行方案。当团队磨合到位,而且也比较成熟之后,就可以简化一条分支和一套环境了。不过,前提是,只有快速迭代,快速上线,才能发挥两套环境的优势。
Use what you build to build what you use.(使用你开发的工具来开发你的工具)
这是我们一以贯之的理念。既然是DevOps平台那么团队也要有DevOps的样子所以作为一个全功能团队研发自上线和研发自运维就发挥到了极致。
同时,我们并没有使用公司统一的上线流程,而是自己建立了一个标准化的上线流程并固化在工具里面,团队的每一个人都能完成上线动作。
这样一来,就不会再依赖于某个具体的人员了,这就保持了最大的灵活性。即便赶上大促封网,也不会阻塞正常的开发活动。
开发协作流程
仅仅是做到上面这几点,还不足以让整个团队高效运转起来,因为缺少了最重要的研发协作流程。
作为项目负责人,我花了很大的精力优化研发协作流程,制定研发协作规范。当这一切正常运转起来后,我发现,这些前期的投入都是非常值得的。
在工具层面我们使用了Jira。对于小团队来说Jira的功能就足够优秀了可以满足大多数场景的需求。但是Jira的缺点在于使用和配置门槛稍微有点高。因此团队里面需要有一个熟悉Jira的成员才能把这套方法“玩”下去。
在Jira里面我们采用了精益看板加上迭代的方式基本上两周一个迭代保持开发交付的节奏。这种开发工作流刚好适配我们的分支策略和多环境部署。
需求统一纳入Backlog管理当迭代开始时就拖入待开发状态研发挑选任务启动开发并进入开发中。当开发完成后也就意味着功能已经在测试环境部署。这个时候就可以等待功能验收。只有在验收通过之后才会发布到预发布环境。并经过二次验收后最终上线发布给用户。
开发流程并不复杂,你可以看一下下面这两版流程图。
图片版:
文字版:
定义好开发工作流之后接下来就需要明确原则和规范了。对于一个新组建的团队来说规则是消除分歧和误解的最好手段所以一定要让这些规则足够得清晰易懂。比如在我们内部就有一个“3-2-1”原则
3创建任务三要素
有详细的问题说明和描述
有清晰的验收标准
有具体的经办人和迭代排期
2处理任务两要素
在开发中代码变更要关联Jira任务号
在开发完成后要添加Jira注释说明改动内容和影响范围
1解决任务一要素
问题报告人负责任务验收关闭
当然,团队规则远不止这几条。你要打造自己团队内部的规则,并且反复地强调规则,帮助大家养成习惯。这样一来,你会发现,研发效率提升和自组织团队都会慢慢成为现实。
除此之外,你也不要高估人的主动性,期望每个人都能自觉地按照规则执行。所以,定期和及时的提醒就非常必要。比如,每天增加定时邮件通知,告诉大家有哪些需求需要验收,有哪些可以上线发布,尽量让每个人都明白应该去哪里获取最新的信息。
另外,每次开周会时,都要强调规则的执行情况,甚至每天的站会也要按需沟通。只有保持短促、高频的沟通,才能产生理想的效果。
产品运营策略
关于产品运营策略,“酒香不怕巷子深”的理念已经有些过时了。想要一个产品获得成功,团队不仅要做得好,还要善于运营和宣传,而这又是技术团队的一大软肋。
开发团队大多只知道如何实现功能,却不知道应该怎么做产品运营。往往也正因为如此,团队很难获取用户的真实反馈,甚至开发了很多天才的功能,用户都不知道。产品开发变成了“自嗨”,这肯定不符合产品设计的初衷。
考虑到这些,我们在平台运营的时候,也采取了一些手段。我想提醒你的是,很多事情其实没有没有多难,关键就看有没有想,有没有坚持做。
比如,你可以建立内部用户沟通群,在产品初期尽量选择一些活跃的种子用户来试用。那些特别感兴趣、愿意尝试新事物、不断给你提建议的都是超级用户。这些用户未来都是各个团队中的“星星之火”,在项目初期,你一定要识别出这些用户。
另外每一次上线都发布一个release notes并通过邮件和内部沟通群的方式通知全员一方面可以宣传新功能另一方面也是很重要的一方面就是保持存在感的刷新。你要让用户知道这个产品是在高速迭代的过程中的而且每次都有不一样的新东西总有一样会吸引到他们或者让他们主动提出自己的问题。
在用户群里面注意要及时响应用户的问题。你可以在团队内部建立OnCall机制每周团队成员轮值解决一线用户的问题既可以保证问题的及时收敛也能让远离用户的开发真真切切地听到用户的声音。这样的话在需求规划会和迭代回顾会的时候开发就会更多地主动参与讨论。
以上这些都是比较常规的手段,在我们的产品运营中,还有两个方法特别有效,我也推荐给你。
平台运营就跟打广告是一样的越是在人流最大、关注度最高的地方打广告效果也就越好。每个公司一般都有类似的首页比如公司内部的技术首页、技术论坛、日常办公的OA系统等等这些地方其实都会有宣传的渠道和入口。你要做的就是找到这个入口并联系上负责这个渠道的人员。我们的产品就一度实现了热门站点的霸屏宣传效果非常明显用户量直线上升。
另一个方法有些取巧,但对于技术团队来说,也非常适用,那就是通过技术分享的渠道来宣传产品。
相信每个团队都会有定期的技术分享渠道,或者是技术公众号等,你可以把平台的核心技术点和设计思想提炼出来,拟定一个分享话题,并在内部最大范围的技术分享渠道中进行分享。
很多时候,单纯地宣传一个产品,很多人是“不感冒”的。但是,如果你在讲一些新技术,并结合产品化落地的事情,对技术人员的吸引力就会大很多。所以,换个思路做运营,也是提升产品知名度的好方法。我把我之前总结的产品运营渠道和手段汇总成了一幅脑图,也分享给你。
团队文化建设
最后,我想再跟你简单聊聊团队文化建设的事情。毕竟,无论什么样的工具、流程、目标,最终都是依靠人来完成的。如果忽略对人的关注,就等同于本末倒置,不是一个成熟的团队管理者应该做的事情。我给你分享我的两点感受。
1.让专业的人做专业的事情
很多时候,千万不要小看专业度这个事情。任何一个组织内部的职能都需要专业能力的支撑,这些专业能力都是量变引发的质变。
我举个最简单的例子你还记得我在前面提到的0.2版本的原型草稿吗实际上到了0.3版本,引用前端工程师话来说,“原型做得比系统还漂亮”。这是为什么呢?难道是我这个“半吊子”产品经理突然开窍了吗?
显然不是。其实答案很简单,就是我去找了专业产品经理做外援,让他帮我改了两天的原型图。对于专业的人来说,这些事情再简单不过了。
找专业的人来做这些事情,不仅可以帮助你快速地跨越鸿沟,也能留下很多现成的经验,供你以后使用,这绝对不是一个人埋头苦干可以做得到的。
不仅是产品方面,技术领域也是一样的。我们要勇于承认自己的无知,善于向别人求助,否则到头来,损失的时间和机会都是自己买单,得不偿失。
2.抓大放小,适当地忽略细节
在协作的过程中,团队总会在一些细节上产生冲突。如果任由团队成员在细节上争论不休,久而久之,就会影响团队之间的信任感。这个时候,就需要引导团队将注意力集中在大的方向上,适当地暂缓细节讨论,以保证团队的协作效率。
比如一个业务逻辑是放在前端处理还是放在后端处理结果并没有太大区别说白了就是放在哪儿都行。但是前端同学会坚持认为逻辑处理都应该由后端来解决以降低前端和业务的耦合性这样说也没有错。可是后端同学也会有自己的想法比如针对前端拦截器的处理机制后端到底要不要配合着返回前端要求的返回码而不是直接抛出http原始的返回码呢
类似的这些问题,没有谁对谁错之分,但是真要是纠结起来,也不是一两句话就能说清楚的。
这个时候,就需要有人拍板,选择一条更加符合常规的方式推进,并预留出后续的讨论空间。甚至,为了促进多地合作,自己人这边要适当地牺牲一些,以此来换取合作的顺利推进。这样一来,你会发现,有些不可调和的事情,在项目不断成功、人员不断磨合的过程中,也就不是个事情了。
总结
在这一讲中,关于如何开发产品,可以说,我是把自己在过去几个项目经历中的总结倾囊相授了。
其实就像我在讲“DevOps工程师需要的技能”中提到的那样软实力比如沟通协作、同理心、持续改进等对促进产品快速迭代开发演进有着重大的作用。作为非专业产品经理我也在慢慢地积累自己的产品心经有机会再给你好好聊聊。
你可能还在想,最终千人的目标是否实现了呢?我想说的是,有些时候,真实生活比故事还要精彩。
就在预订目标的倒数第二天平台用户只有997个。当时我跟同事吐槽这个数字他们说要不要拉几个用户进来我说“算了吧随它去吧。“结果你猜怎样在当天周五下班的时候我又去平台上看了一眼不多不少刚好1000个注册用户。当时我的第一感觉就是要相信当我们把自己的全身心和热情都灌注在一个产品的开发过程中时美好的事情会自然而然地发生。
思考题
你对这一讲的哪部分内容印象最深刻呢?你有什么其他有助于产品快速研发落地的观点吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,286 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 巨人的肩膀:那些你不能忽视的开源工具
你好,我是石雪峰。
自研工具平台对公司来说是一件高成本和高投入的事情对于技术人员的要求也非常高。很少有公司能够像BAT一样投入近百人的团队来开发内部系统工具毕竟如果没有这么大规模的团队平台产生的收益也比较有限。
另外,也很少有公司像一些行业头部公司一样,会直接投入大量资金购买成熟的商业化工具或者通过乙方合作的方式联合共建。
这些方法的长期投入都比较大,不太适用于中小型企业。那么,有其他可以低成本、快速见效的解决方案吗?
实际上,现在的开源工具已经非常成熟了,只要稍加熟悉,就能快速地基于开源工具搭建一整套研发交付工具链平台。
几年前,我跟几个朋友利用业余时间就搭建了这样一套开源的端到端流水线解决方案。我依稀记得,这个解决方案架构图是在北京开往上海的高铁上完成的。目前,这个方案在行业内广为流传,成为了很多公司搭建自己内部工具链平台的参考资料。这个系统的架构图如下:
今天我会基于这个解决方案给你介绍一下研发代码提交阶段、集成测试阶段和部署发布阶段的工具使用技巧工具选型以主流开源解决方案为主商业工具为辅涵盖了Jira、GitLab、Jenkins、SonarQube和Kubernetes等希望可以手把手地帮助你快速搭建一套完整的持续交付平台。
对于持续交付工具链体系来说,工具的连通性是核心要素,所以我不会花太多时间介绍工具应该如何搭建,毕竟这类资料有很多,或者,你参考一下官网的搭建文档就可以了。尤其是现在很多工具都提供了容器化的部署方式,进一步简化了自身工具的建设成本。
需求管理 - Jira
在Jira官网上的醒目位置写着一句话敏捷开发工具的第一选择。在我看来Atlassian公司的确有这个底气因为Jira确实足够优秀跟Confluence的组合几乎已经成为了很多企业的标配。这也是为什么我没有选择开源工具Redmine或者其他诸如Teambition等的SaaS化服务。
当然近些年来各大厂商也在积极地对外输出研发工具能力以腾讯的TAPD为代表的敏捷协同开发工具就使用得非常广泛。但是其实产品的思路都大同小异搞定了Jira其他工具基本也就不在话下了。
作为敏捷协同工具Jira新建工程可以选择团队的研发模式是基于Scrum还是看板方法你可以按需选择。在专栏的第8讲和第9讲中我给你介绍了精益看板你完全可以在Jira中定制自己团队的可视化看板。
看板的配置过程并不复杂我把它整理成了文档你可以点击网盘链接获取提取码是mrtd。需要提醒你的一点是别忘了添加WIP在制品约束别让你的精益看板变成了可视化看板。
需求作为一切开发工作的起点是贯穿整个研发工作的重要抓手。对于Jira来说重点是要实现跟版本控制系统和开发者工具的打通。接下来我们分别来看下应该如何实现。
如果你也在使用特性分支开发模式你应该知道一个特性就对应到一个Jira中的任务。通过任务来创建特性分支并且将所有分支上的提交绑定到具体任务上从而建立清晰的特性代码关联。我给你推荐两种实现方式。
第一种方式是基于Jira提供的原生插件比如 Git Integration for Jira。这个插件配置起来非常简单你只需要添加版本控制系统的地址和认证方式即可。然后你就可以在Jira上进行查看提交信息、对比差异、创建分支和MR等操作。但是这个插件属于收费版本你可以免费使用30天到期更新即可。
第二种方式就是使用Jira和GitLab的Webhook进行打通。
首先你要在GitLab项目的“设置 - 集成”中找到Jira选项按下图添加相应配置即可。配置完成之后你只需要在提交注释中添加一个Jira的任务ID就可以实现Jira任务和代码提交的关联这些关联体现在Jira任务的Issue links部分。
另外你也可以实现Jira任务的状态自动流转操作无需手动移动任务卡片。我给你提供一份 配置说明 ,你可以参考一下。
不过如果只是这样的话还不能实现根据Jira任务来自动创建分支所以接下来还要进行Jira的Webhook配置。在Jira的系统管理界面中你可以找到“高级设置 - Webhook”选项添加Webhook后可以绑定各种系统提供的事件比如创建任务、更新任务等这基本可以满足绝大多数场景的需求。
假设我们的系统在创建Jira任务的时候要自动在GitLab中基于主线创建一条分支那么你可以将GitLab提供的创建分支API写在Jira触发的Webhook地址中。参考样例如下
https : //这里替换成你的GitLab服务地址/repository/branches?branch=${issue.key}&ref=master&private_token=[这里替换成你的账号Token]
到这里Jira和GitLab的打通就完成了。我们来总结下已经实现的功能
GitLab每次代码变更状态都会同步到Jira任务中并且实现了Jira任务和代码的自动关联Issue links
可以在MR中增加关键字 Fixes/Resolves/Closes Jira任务号实现Jira的自动状态流转
每次在Jira中创建任务时都会自动创建特性分支。
关于Jira和开发者工具的打通我把操作步骤也分享给你。你可以点击网盘链接获取提取码是kf3t。现在很多工具平台的建设都是以服务开发者为导向的所以距离开发者最近的IDE工具就成了新的效率提升阵地包括云IDE、IDE插件等都是为了方便开发者可以在IDE里面完成所有的日常任务对于管理分支和Jira任务自然也不在话下。
代码管理 - GitLab
这个示例项目中的开发流程是怎样的呢?我们一起来看下。
第1步在需求管理平台创建任务这个任务一般都是可以交付的特性。你还记得吗通过前面的步骤我们已经实现了自动创建特性分支。
第2步开发者在特性分支上进行开发和本地自测在开发完成后再将代码推送到特性分支并触发提交阶段的流水线。这条流水线主要用于快速验证提交代码的基本质量。
第3步当提交阶段流水线通过之后开发者创建合并请求Merge Request申请将特性分支合并到主干代码中。
第4步代码评审者对合并请求进行Review发现问题的话就在合并请求中指出来最终接受合并请求并将特性代码合入主干。
第5步代码合入主干后立即触发集成阶段流水线。这个阶段的检查任务更加丰富测试人员可以手动完成测试环境部署并验证新功能。
第6步特性经历了测试环境、预发布环境并通过部署流水线最终部署到生产环境中。
在专栏的第12讲中我提到过持续集成的理念是通过尽早和及时的代码集成从而建立代码质量的快速反馈环。所以版本控制系统和持续集成系统也需要双向打通。
这里的双向打通是指版本控制系统可以触发持续集成系统,持续集成的结果也需要返回给版本控制系统。
接下来,我们看看具体怎么实现。
代码提交触发持续集成
首先你需要在Jenkins中安装GitLab插件。这个插件提供了很多GitLab环境变量用于获取GitLab的信息比如gitlabSourceBranch这个参数就非常有用它可以提取本次触发的Webhook的分支信息。毕竟这个信息只有GitLab知道。只有同步给Jenkins才能拉取正确的分支代码执行持续集成过程。
当GitLab监听到代码变更的事件后会自动调用这个插件提供的Webhook地址并实现解析Webhook数据和触发Jenkins任务的功能。
其实我们在自研流水线平台的时候也可以参考这个思路通过后台调用GitLab的API完成Webhook的自动注册从而实现对代码变更事件的监听和任务的自动化执行。
当GitLab插件安装完成后你可以在Jenkins任务的Build Triggers中发现一个新的选项勾选这个选项就可以激活GitLab自动触发配置。其中比较重要的两个信息我在下面的图片中用红色方块圈出来了。
上面的链接就是Webhook地址每个Jenkins任务都不相同
下面的是这个Webhook对应的认证Token。
你需要把这两个信息一起添加到GitLab的集成配置中。打开GitLab仓库的“设置-集成”选项可以看到GitLab的Webhook配置页面将Jenkins插件生成的地址和Token信息复制到配置选项中并勾选对应的触发选项。
GitLab默认提供了多种触发选项在下面的截图中只勾选了Push事件也就是只有监听到Git Push动作的时候,才会触发Webhook。当然你可以配置监听的分支信息只针对特性分支执行关联的Jenkins任务。在GitLab中配置完成后可以看到新添加的Webhook信息点击“测试”验证是否可以正常执行如果一切正常则会提示“200-OK”。
持续集成更新代码状态
打开Jenkins的系统管理页面找到GitLab配置添加GitLab服务器的地址和认证方式。注意这里的Credentials要选择GitLab API Token类型对应的Token可以在GitLab的“用户 - 设置 - Access Tokens”中生成。由于Token的特殊性只有在生成的时候可见以后就再也看不到了。所以在生成Token以后你需要妥善地保存这个信息。
-
那么配置完成后要如何更新GitLab的提交状态呢这就需要用到插件提供的更新构建结果命令了。
对于自由风格类型的Jenkins任务你可以添加构建后处理步骤 - Publish build status to GitLab它会自动将排队的任务更新为“Pending”运行的任务更新为“Running”完成的任务根据结果更新为“Success”或者是“Failed”。
对于使用流水线的任务来说官方也提供了相应的示例代码你只需要对照着写在Jenkinsfile里面就可以了。
updateGitlabCommitStatus name: 'build', state: 'success'
这样一来每次提交代码触发的流水线结果也会显示在GitLab的提交状态中可以在查看合并请求时作为参考。有的公司更加直接如果流水线的状态不是成功状态那么就会自动关闭提交的合并请求。其实无论采用哪种方式初衷都是希望开发者在第一时间修复持续集成的问题。
我们再阶段性地总结一下已经实现的功能:
每次GitLab上的代码提交都可以通过Webhook触发对应的Jenkins任务。具体触发哪个任务取决于你将哪个Jenkins任务的地址添加到了GitLab的Webhook配置中
每次Jenkins任务执行完毕后会将执行结果写到GitLab的提交记录中。你可以查看执行状态决定是否接受合并请求。
代码质量 - SonarQube
SonarQube作为一个常见的开源代码质量平台可以用来实现静态代码扫描发现代码中的缺陷和漏洞还提供了比较基础的安全检查能力。除此之外它还能收集单元测试的覆盖率、代码重复率等。
对于刚开始关注代码质量和技术债务的公司来说是一个比较容易上手的选择。关于技术债务在专栏的第15讲中有深入讲解如果你不记得了别忘记回去复习一下。
那么代码质量检查这类频繁执行的例行工作也比较适合自动化完成最佳途径就是集成到流水线中也就是需要跟Jenkins进行打通。我稍微介绍一下执行的逻辑希望可以帮你更好地理解这个配置的过程。
SonarQube平台实际包含两个部分
一个是平台端,用于收集和展示代码质量数据,这也是我们比较常用的功能。
另外一个是客户端也就是SonarQube的Scanner工具。这个工具是在客户端本地执行的也就是跟代码在一个环境中用于真正地执行分析、收集和上报数据。这个工具之所以不是特别引人注意是因为在Jenkins中后台配置了这个工具如果发现节点上没有找到工具它就会自动下载。你可以在Jenkins的全局工具配置中找到它。
了解了代码质量扫描的执行逻辑之后我们就可以知道对于SonarQube和Jenkins的集成只需要单向进行即可。这也就是说只要保证Jenkins的Scanner工具采集到的数据可以正确地上报到SonarQube平台端即可。
这个配置也非常简单你只需要在Jenkins的全局设置中添加SonarQube的平台地址就行了。注意勾选第一个选项保证SonarQube服务器的配置信息可以自动注入流水线的环境变量中。
在执行Jenkins任务的时候同样可以针对自由风格的任务和流水线类型的任务添加不同的上报方式。关于具体的内容你可以参考SonarQube的官方网站这里就不赘述了。
到此为止我们已经实现了GitLab、Jenkins和SonarQube的打通。我给你分享一幅系统关系示意图希望可以帮助你更好地了解系统打通的含义和实现过程。
环境管理 - Kubernetes
最后我们再来看看环境管理部分。作为云原生时代的操作系统Kubernetes已经成为了云时代容器编排的事实标准。对于DevOps工程师来说Kubernetes属于必学必会的技能这个趋势已经非常明显了。
在示例项目中我们同样用到了Kubernetes作为基础环境所有Jenkins任务的环境都通过Kubernetes来动态初始化生成。
这样做的好处非常多。一方面可以实现环境的标准化。所有环境配置都是以代码的形式写在Dockerfile中的实现了环境的统一可控。另一方面环境的资源利用率大大提升不再依托于宿主机自身的环境配置和资源大小你只需要告诉Kubernetes需要多少资源它就会帮助你找到合适的物理节点运行容器。资源的调度和分配统一通过Kubernetes完成这就进一步提升了资源的有效利用率。想要初始化一套完整的环境对于中小系统来说是分分钟就可以完成的事情。关于这一点我会在讲“云原生时代应用的平台建设”时跟你探讨。
那么想要实现动态初始化环境需要打通Jenkins和Kubernetes。好在Jenkins已经提供了官方的Kubernetes插件来完成这个功能。你可以在Jenkins系统配置中添加云 - Kubernetes然后再参考下图进行配置。
需要注意的是必须正确配置Jenkins的地址系统配置 - Jenkins Location否则会导致新建容器无法连接Jenkins。
生成动态节点时需要使用到JNLP协议我推荐你使用Jenkins官方提供的镜像。
JNLP协议的全称是Java Network Launch Protocol是一种通用的远程连接Java应用的协议方式。典型的使用场景就是在构建节点也就是习惯上的Slave节点上发起向Master节点的连接请求将构建节点主动挂载到Jenkins Master上供Master调度使用。区别于使用SSH长连接的方式这种动态连接的协议特别适合于Kubernetes这类的动态节点。镜像配置如下图所示
在配置动态节点的时候,有几个要点你需要特别关注下。
静态目录挂载。由于每次生成一个全新的容器环境,所以就需要将代码缓存(比如.git目录、依赖缓存.m2, .gradle, .npm以及外部工具等静态数据通过volume的方式挂载到容器中以免每次重新下载时影响执行时间。
如果你的Jenkins也是在Kubernetes中运行的注意配置Jenkins的JNLP端口号使用环境变量JENKINS_SLAVE_AGENT_PORT。否则在系统中配置的端口号是不会生效的。
由于每次初始化容器有一定的时间损耗,所以你可以配置一个等待时长。这样一来,在任务运行结束后,环境还会保存一段时间。如果这个时候有新任务运行,就可以直接复用已有的容器环境,而无需重新生成。
如果网络条件不好可以适当地加大创建容器的超时时间默认是100秒。如果在这个时间内无法完成容器创建那么Jenkins就会自动杀掉创建过程并重新尝试。
如果一切顺利动态Kubernetes环境就也可以使用了。这时我们就可以完整地运行一条流水线了。在设计流水线的时候你需要注意的是流水线的分层。具体的流水线步骤我已经写在了系统架构图中。比如提交阶段流水线需要完成拉取代码、编译打包、单元测试和代码质量分析四个步骤对应的代码如下
// pipeline 2.0 - Commit stage - front-end
pipeline {
agent {
// Kubernetes节点的标签
label 'pipeline-slave'
}
environment {
// 镜像仓库地址
HARBOR_HOST= '123.207.154.16'
IMAGE_NAME = "front-end"
REPO = 'front-end'
HOST_CODE_DIR = "/home/jenkins-slave/workspace/${JOB_NAME}"
GROUP = 'weaveworksdemos'
COMMIT = "${currentBuild.id}"
TAG = "${currentBuild.id}"
TEST_ENV_NAME = 'test'
STAGE_ENV_NAME = 'staging'
PROD_ENV_NAME = 'prod'
BUILD_USER = "${BUILD_USER_ID}"
// 需要挂载到容器中的静态数据
COMMON_VOLUME = ' -v /nfs/.m2:/root/.m2 -v /nfs/.sonar:/root/.sonar -v /nfs/.npm:/root/.npm '
}
stages {
stage('Checkout') {
steps {
git branch: 'xxx', credentialsId: '707ff66e-1bac-4918-9cb7-fb9c0c3a0946', url: 'http://1.1.1.1/shixuefeng/front-end.git'
}
}
stage('Prepare Test') {
steps {
sh '''
docker build -t ${IMAGE_NAME} -f test/Dockerfile .
docker run --rm -v ${HOST_CODE_DIR}:/usr/src/app ${IMAGE_NAME} /usr/local/bin/cnpm install
'''
}
}
stage('Code Quality') {
parallel {
stage('Unit Test') {
steps {
sh '''
docker run --rm -v ${HOST_CODE_DIR}:/usr/src/app ${IMAGE_NAME} /usr/local/bin/cnpm test
'''
}
}
stage('Static Scan') {
steps {
sh 'echo "sonar.exclusions=node_modules/**" >> sonar-project.properties'
script {
def scannerHome = tool 'SonarQubeScanner';
withSonarQubeEnv('DevOpsSonar') {
sh "${scannerHome}/bin/sonar-scanner"
updateGitlabCommitStatus name: 'build', state: 'success'
}
}
}
}
}
}
}
}
如果你按照刚刚我所介绍的步骤操作的话,你就会得到这样一张完整的流水线演示效果图:
结合Jenkins自身的人工审批环节可以实现多环境的自动和手动部署构建一个真正的端到端持续交付流水线。
总结
在今天的课程中我通过一个开源流水线的解决方案给你介绍了如何建立一个开源工具为主的持续交付流水线平台。你应该也有感觉对于DevOps来说真正的难点不在于工具本身而在于如何基于整个研发流程将工具串联打通把它们结合在一起发挥出最大的优势。这些理念对于自建平台来说也同样适用你需要在实践中多加尝试才能在应用过程中游刃有余。
思考题
关于这套开源流水线解决方案,你对整体的工具链、配置、设计思路还有什么疑问吗?在实施过程中,你遇到了哪些绕不过去的问题呢?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,203 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 迈向云端:云原生应用时代的平台思考
你好,我是石雪峰。
最近几年相信你一定从各种场合听到过“云原生”这个词。比如云原生应用的12要素、最近大火的现象级技术Docker以及容器编排技术Kubernetes。其中Kubernetes背后的CNCF也就是云原生应用基金会也成了各大企业争相加入的组织。
DevOps似乎也一直跟云技术有着说不清的关系比如容器、微服务、不可变基础设施以及服务网格、声明式API等都是DevOps技术领域中的常客。云原生应用似乎天生就和DevOps是绝配自带高可用、易维护、高扩展、持续交付的光环。
那么所谓的云原生到底是什么意思呢我引用一下来自于CNCF的官方定义
Cloud native computing uses an open source software stack to deploy applications as microservices, packaging each part into its own container, and dynamically orchestrating those containers to optimize resource utilization.-
云原生使用一种开源软件技术栈来部署微服务应用,将每个组件打包到它自己的容器中,并且通过动态编排来优化资源的利用率。
我总结一下这里面的关键字开源软件、微服务应用、容器化部署和动态编排。那么简单来说云原生应用就是将微服务风格的架构应用以容器化的方式部署在云平台上典型的是以Kubernetes为核心的云平台从而受益于云服务所带来的各种好处。
我在专栏中也反复强调过容器技术和Kubernetes是划时代的技术是每一个学习DevOps的工程师的必备技能。就像很多年前要人手一本《鸟哥的Linux私房菜》在学习Linux一样Kubernetes作为云时代的Linux同样值得你投入精力。
今天我并不是要跟你讲Kubernetes我想通过一个项目以及最近两年我的亲身经历给你分享一下云原生究竟会带给DevOps怎样的改变。这个项目就是Jenkins X。
在2018年初我分享过有关Jenkins X的文章在短短几天的时间内阅读量就过万了。这一方面体现了Jenkins在国内的巨大影响力另外一方面也凸显了Jenkins与这个时代的冲突和格格不入。为什么这么说呢因为Jenkins作为一个15年的老系统浑身上下充满了云原生的反模式 ,比如:
Jenkins是一个Java单体应用运行在JVM之上和其他典型的Java应用并没有什么区别
Jenkins使用文件存储以及各种加载模式、资源调度机制等确保了它天生不支持高可用
Jenkins虽然提供了流水线但是流水线依然是执行在主节点上这就意味着随着任务越来越多主节点消耗的资源也就越来越多不仅难以扩展还非常容易被随便一个不靠谱的任务搞挂掉。
举个最简单的例子如果一个任务输出了500MB的日志当你在Jenkins上点击查看全部日志的时候那就保佑自己的服务器能挺过去吧。因为很多时候服务器可能直接就死掉了。当然我非常不建议你在生产环境做这个实验。
那么如果想让Jenkins实现云原生化要怎么做呢有的同学可能会说“把Jenkins放到容器中然后丢给Kubernetes管理不就行了吗”如果你也是这么想的那就说明无论是对Kubernetes还是云原生应用你的理解还不够到位。我来给你列举下如果要把Jenkins改造为一个真正的云原生应用要解决哪些问题
可插拔式的存储典型的像是S3、OSS
外部制品管理
Credentials管理
Configuration管理
测试报告和覆盖率报告管理
日志管理
Jenkins Job
……
你看我还只是列举了其中一部分以云原生应用12要素的标准来说要做的改造还有很多。
以日志为例当前Jenkins的所有日志都是写在Master节点上的如果想改造成云原生应用的方法首先就是要把日志看作一种输出流。输出流不应该由应用管理写在应用运行节点的本地而是应该由专门的日志服务来负责收集、分析、整理和展示。比如ElasticSearch、Fluent或者是AWS的CloudWatch Logs都可以实现这个功能。
那么Jenkins X是怎么解决这个问题的呢
我们来试想一个场景:当开发工程师想要开发一个云原生应用的时候,他需要做什么?
首先他需要有一套可以运行的Kubernetes环境。考虑到各种不可抗力因素这绝对不是一件简单的事情。尤其是在几年前如果有人能够通过二进制的方式完成Kubernetes集群的搭建和部署这一定是一件值得吹牛的事情。好在现在公司里面都有专人负责Kubernetes集群维护各大公有云厂商也都提供了这方面的支持。
现在,我们继续回到工程师的视角。
当他接到一个需求后他首先需要修改代码然后把代码编译打包在本地测试通过。接下来他要将代码提交到版本控制系统手动触发流水线任务并等待执行完毕。如果碰巧这次调整了编译命令他还要修改流水线配置文件。最后经过千辛万苦生成了一个镜像文件并把镜像文件推送到镜像服务器上。这还没完他还需要修改测试环境的Kubernetes资源配置调用kubectl命令完成应用的更新并等待部署完成。如果对于这次修改系统验证出了新的问题那么不好意思刚刚的这些步骤都需要重头来过。
你看虽然云原生应用有这么多好处但是大大提升了开发的复杂度。一个工程师必须要熟悉Kubernetes、流水线、镜像、打包、部署等一系列的环节和新技术新工具才有可能完成一次部署。如果这些操作都依赖于外部门或者其他人那你就且等着吧。这么看来这条路是走不下去的。
在云时代一切皆服务。那么在云原生应用时代DevOps或持续交付理应也是以一种服务的形式存在。就好比你在用电的时候一定不会去考虑电厂是怎么运转的电是怎么送到家里来的你只要负责用就可以了。
那么我们来看看Jenkins X是怎么一步步地把Jenkins“干掉”的。其实我希望你能记得是不是Jenkins X本身并不重要在这个过程中使用到的工具和技术以及它们背后的设计理念才是更重要的。
1.自动化生成依赖的配置文件
对于一个云原生应用来说,除了源代码本身之外,还依赖于哪些配置文件呢?其中就包括:
Dockerfile用于生成Docker镜像
Jenkinsfile应用关联的流水线配置
Helm Chart把应用打包并部署运行在Kubernetes上的资源文件
Skaffold用于在Kubernetes中生成Docker image的工具
考虑到你可能不太熟悉这个Skaffold工具我简单介绍一下。
实际上,如果想在 Kubernetes 环境中生成Docker镜像你会发现一般来说这都依赖于Docker服务也就是Docker daemon。那么常见的做法无外乎Docker-in-Docker和Docker-outside-Docker。
其中Docker-in-Docker就是在基础镜像中提供内建的Docker daemon和镜像生成环境这依赖于官方镜像的支持。而Docker-outside-Docker比较好理解就是将宿主机的Docker daemon挂载到Docker镜像里面。
有三种典型的实现方式第一种是挂载节点的Docker daemon第二种就是使用云平台提供的外部服务比如Google Cloud Builder第三种就是使用无需Docker daemon也能打包的方案比如常见的Kaniko。
而Skaffold想要解决的就是你不需要再关心如何生成镜像、推送镜像和运行镜像它会通通帮你搞定依赖的就是skaffold.yaml文件。
这些文件如果让研发手动生成那会让研发的门槛变得非常高。好在你可以通过Draft工具来自动化这些操作。Draft是微软开源的一个工具它包含两个部分。
源代码分析器。它可以自动扫描你的源代码根据代码特征识别出你所用到的代码类型比如JavaScript、Python等。
build pack。简单来说build pack就是一种语言对应的模板。通过在模板中定义好预设的环境依赖配置文件包括上面提到的Dockerfile、Jenkinsfile等从而实现依赖项的自动生成和创建。当然你也可以定义自己的build pack并作为模板在内部项目中使用。
很多时候模板都是一种特别好的思路它可以大大简化初始配置成本提升环境和服务的标准化程度。对于流水线来说也是如此毕竟不是很多人都是这方面的专家只要能针对90%的场景提供一组或几组最佳实践的模板就足够了。
这样一来,无论是已经存在的代码,还是权限初始化的项目,研发都不需要操心如何实现代码打包、生成镜像,以及部署的过程。这也会大大节省研发的精力。毕竟,就像我刚刚提到的,不是每个人都是容器和构建方面的专家。
2.自动化流水线过程
当应用初始化完成之后流水线应该是开箱即用的状态。也就是说比如项目采用的是特性分支加主干开发分支发布的策略那么build pack中就预置了针对每条分支的流水线配置文件。这些文件定义了每条分支需要经过的检查过程。
那么当研发提交代码的时候对应的流水线就会被自动触发。对于研发来说这一切都是无感知的。只有在必要的时候比如出现了问题系统才会通知研发查看错误信息。这就要求流水线的Jenkinsfile要自动生成版本控制系统和CI/CD系统也需要自动打通。比如Webhook的注册和配置、MR的评审条件、自动过滤的分支信息等等都是需要自动化完成的。
这里所用到的技术主要有三点。
流水线即代码。毕竟,只有代码化的流水线配置才有可能自动化。
流水线的抽象和复用。以典型的Jenkinsfile为例大多数操作应该提取到公共库也就是shared library中而不应该hard code在流水线配置文件里面以提升抽象水平和能力复用。
流水线的条件判断。对于同一条流水线来说,根据不同的条件,可以实现不同的执行路径。
3.自动化多环境部署
对于传统应用来说尤其是对上下游依赖比较复杂的应用来说环境管理是个老大难的问题。Kubernetes的出现大大简化了这个过程。当然前提是云原生应用部署在Kubernetes上时所有依赖都是环境中的资源。
依靠Kubernetes强大的资源管理能力能够动态初始化出来一套环境是一种巨大的进步。
Jenkins X默认就提供了预发环境和生产环境。不仅如此对于每一次的代码提交所产生的PRJenkins X都会自动初始化一个预览环境出来并自动完成应用在预览环境的部署。这样一来每次代码评审的时候都能够打开预览环境查看应用的功能是否就绪。通过借助用户视角来验收这些功能也提升了最终交付的质量。
这里面所用到的技术除了之前我在第16讲中给你介绍过的GitOps主要就是Prow工具。
你可以把Prow看作ChatOps的一种具体实现。实际上它提供的是一种高度扩展的Webhook时间处理能力。比如你可以通过对话的方式输入 /approve 命令Prow接收到这个命令后就会触发对应的Webhook并实现流水线的自动执行以及一系列的后台操作。
4. 使用云原生流水线
在今年年初Jenkins X进行了一次全面的升级开始支持Tekton流水线。Tekton的前身是2018年初创建的KNative项目这是一个面向Kubernetes的Serverless解决方案。但随着这个项目边界的扩大它渐渐地把整个交付流程的编排都纳入了进来于是就成立了Tekton项目用来提供Kubernetes原生的流水线能力。
Tekton提供了最底层的能力Jenkins X提供了上层抽象也就是通过一个yaml文件的形式来描述整个交付过程。我给你分享了一个流水线配置文件的例子
agent:
label: jenkins-maven
container: maven
pipelines:
pullRequest:
build:
steps:
- sh: mvn versions:set -DnewVersion=$PREVIEW_VERSION
- sh: mvn install
release:
setVersion:
steps:
- sh: echo \$(jx-release-version) > VERSION
comment: so we can retrieve the version in later steps
- sh: mvn versions:set -DnewVersion=\$(cat VERSION)
- sh: jx step tag --version \$(cat VERSION)
build:
steps:
- sh: mvn clean deploy
在这个例子中你可以看到流水线过程是通过yaml格式来描述的而不是通过我们之前所熟悉的groovy格式。另外在这个文件中你基本上也看不到Tekton中的资源类型比如Task、TaskRun等。
实际上Jenkins X基于Jenkins原有的流水线语法结构重新定义了一套基于yaml格式的语法。你依然可以使用以前的命令在yaml中完成整个流水线的定义但是在后台Jenkins X会将这个文件转换成Tekton需要使用的CRD资源并触发Kubernetes执行。
说白了用户看起来还是在使用Jenkins但实际上流水线的执行引擎已经从原来的JVM变成了现在Kubernetes。流水线的执行和调度由Kubernetes来完成整个过程中每一步的环境都是动态初始化生成的容器所有的数据都是通过外部存储来保存的。
经过这次升级终于实现了真正意义上的平台云原生化改造。关于这个全新的Jenkins流水线语法定义你可以参考下官方文档。
我再给你分享一幅Serverless Jenkins和Tekton的关系示意图希望可以帮助你更好地理解背后的实现机制。
https://dzone.com/articles/move-toward-next-generation-pipelines
最终我们希望达到的目的就是不再有一个一直存在的Jenkins Master实例等待用户调用而是一种被称为是“Ephemeral Jenkins”的机制也就是一次性的Jenkins只有在需要的时候才会启动一个Master实例用完了就关闭掉从一种静态服务变成了一种转瞬即逝的动态服务也就是看似不在、又无处不在的形式以此来驱动云原生应用的CI/CD之旅。
讲到这里,我们回头再看看最开始的那个场景。对于享受了云原生流水线服务的工程师而言,他所需要关注的就只有把代码写好这一件事情,其他原本需要他操心的事情,都已经通过后台的自动化、模板化实现了。
即便是在本地开发调试你也完全可以利用Kubernetes提供的环境管理能力甚至在IDE里面只要保存代码就能完成从打包、镜像生成、推送、环境初始化和部署的完整过程。我相信这也是云原生工具赋能研发的终极追求。
总结
最近这两年经常有人问我Jenkins是不是过时了类似Argo、Drone等更轻量化的解决方案是否更加适合云原生应用的发展
其实社区的开发者也在问自己这样的问题而答案就是Jenkins X项目。这个项目整合了大量的开源工具和云原生解决方案其中包括
基于Kubernetes的云原生开发体验
自动化的CI/CD流程
多套预置的环境,并能够灵活初始化环境
使用GitOps在多环境之间进行部署晋级
云原生的流水线架构和面向用户的易用配置
可插接自定义的流水线执行引擎
我必须要承认,云原生带给平台的改变是巨大且深刻的。这两年,我一方面惊叹于社区的巨大活力和创新力,另一方面,我也深刻地意识到“未来已来”,这种变更的脚步越来越近。
在云原生时代,我们需要打造的也应该是一个自动化、服务化、高度扩展的平台。这也就是说,用于打造云原生应用的平台自身也应该具备云原生应用的特征,并通过平台最大化地赋能研发工程师,提升他们的生产力水平。
思考题
对于DevOps的落地推行来说建设工具仅仅是完成了第一步那么如何让工具发挥真正的威力并在团队中真正地进行推广落地呢你有哪些建议呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,196 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 向前一步万人规模企业的DevOps实战转型案例
你好,我是石雪峰。
“向前一步”这个名字来源于Facebook的首席运营官谢丽尔·桑德伯格的一本书《向前一步女性工作及领导意志》。她在书中鼓励女性在职场中向前一步勇于面对挑战追求自己的人生目标。
我之所以选择用这个名字作为案例的标题是因为在企业中DevOps转型并不是一件容易的事情我们也需要有勇气向前迈出一小步去承担这个使命。哪怕只是改变了一个小问题也是转型过程中不可忽视的力量源泉。
在专栏最后的案例环节我会用两讲给你介绍下微软这些年的DevOps转型故事以及我在国内企业中的实践总结和经验。
今天我们先从管理实践和文化层面入手来看看这家传统的软件巨头是如何在经历了移动互联网时代的迷失之后在容器云和AI时代再一次独占鳌头的。
微软的DevOps转型并不是一个突然的决定随着云服务的兴起用户需求激增对发布节奏的要求越来越高。这些通过需求的数量就能体现出来。2016年的用户需求数量比过去4年的总量还要多到了2017年需求数量达到了2016年的2倍这就要求团队能够以更快的速度完成交付。
要知道如果你期望的优化水平是提升10%的交付能力那在原有的组织架构、流程规则下做局部优化就有可能实现。但是如果要达到200%的优化效果,就需要做出巨大的改变了。
建立面向交付的特性团队
微软之前的组织架构跟很多公司一样,也是按照职能划分的,比如分为项目管理团队、开发团队、测试团队和运维团队。每个团队都比较封闭,部门墙的问题非常严重,给团队内部的协作效率造成了很大的影响。
为了应对需求交付的压力,微软首先进行了一次组织架构调整,将开发团队和测试团队整合为工程团队。于是,测试的职能在团队中消失了,转而变成了面向开发的开发工程师和面向测试的开发工程师,他们和产品管理团队一起完成项目的敏捷推进。在敏捷的理念中,测试活动应该内嵌于开发环节之中,通过把两个部门整合起来,就完成了测试注入研发的工作。
虽然开发和测试团队融合到了一起,但是交付工作依然依赖于独立的运维团队来完成,这就造成了一个问题:即便开发效率再高,如果运维能力跟不上,那也是没有意义的。
于是,微软开启了第二次组织变革,这一次的核心是构建特性交付团队,并赋予团队自治的能力。
所谓的特性交付团队,就是我们常说的“全功能团队”,实际上,这就是把横向的按照职能划分的组织变成垂直跨职能的组织。这个团队中包含了要完成功能交付的所有角色(比如产品、开发、测试和运维),可以闭环地完成整个交付工作。
在这个过程中,微软引入了一种叫作自组织团队的形式。与传统的管理层自上而下安排组织的方式不同的是,员工可以自由地选择想要加入的特性团队。这种新的自由组队的方式为每个人都提供了学习新知识的机会。
你可能觉得,这么搞的话,组织不是乱掉了?高手都希望跟高手在一起,那剩下的同学怎么办呢?其实,我在国内的一家公司也见过类似的玩法,他们解决这个问题的核心方法就是“传帮带”模式。
比如,一个职能依赖某种特殊技能,但这种技能在团队内部非常稀缺,无论拥有这种特殊技能的这名成员去到哪个小队,剩下的组都会出问题。所以,这家公司就强制采用“老带新”的模式,也就是师傅对新人进行集中培训,给新人快速赋能。而且,这种“师徒关系”会长期存在,如果新人遇到什么问题,都可以请教师傅。当然,对于新人来说,也会有相应的考查机制。这种模式就有助于公司达成内部成员互相学习的目标。
根据数据统计虽然只有不到20%的员工选择了岗位变化但是这种方式却给100%的员工提供了选择的可能性。对于一家官僚政治出名的公司来说,这就可以大大地调动员工的积极性。
实际上,特性交付团队还有几个显著的特征:
拥有团队独立的办公空间,大家都坐在一起,在沟通时基本可以靠“吼”;
一般由1012个团队成员组成
具有明确的工作目标和职责;
为了保证稳定性一旦组队成功未来的1218个月不再改变
自己管控特性向生产环境部署;
团队自治。
无独有偶国内某大型公司在推进DevOps转型的初期也做了类似的事情。为了加速研发和运维的融合它们将一个大的应用运维团队拆分到了各个业务线里面。
不仅如此,研发开始向全栈转型,承接运维工作,而运维自身的工作释放出去后,就要求团队进行能力升级。运维团队需要具备研发能力,来不断地开发和优化运维工具,以降低研发、运维的成本。
这个过程说起来轻松但实际在做的时候就需要非常强的组织执行力甚至还需要高层背书自上而下地贯彻这样的要求。转型的过程对于每个人来说都是很痛苦的但也只有经过这样剧烈的变革才让DevOps转型成为了现实而不仅仅只是说说而已或者只是在几个小部门之间搞来搞去。
我经常说一句话“想在不改变流程的前提下实现企业的DevOps转型是不现实的。”至于团队的组织架构是否要调整还是由交付效率来决定的。
转型初期的引入工具和推广阶段对组织的冲击力没有那么大,但是,当转型到达了“深水区”之后,组织的变革就成了一个非常现实的问题。
根据“康威定律”一个团队交付的系统结构和他们的组织结构是相同的。其实换个角度来说软件交付的流程也是跟当前的组织结构保持一致的。只要有一个独立的测试团队就总会有一个独立的测试阶段。而正是因为这样的一个个阶段才带来了内部协作的部门墙和效率瓶颈而这都是DevOps转型需要考虑的事情。
自组织敏捷团队
回到案例部分,为了促进特性交付团队的自治,微软在敏捷开发计划方面也进行了一定的调整。
首先,按照不同的维度,他们分为四种计划。
迭代维度设定为3周一个迭代
计划维度包含了3个迭代
Season维度6个月包含了两个计划周期
Scenario维度长达18个月的远景图。
其中,管理层负责规划长期目标和全景图,也就是回答“我们要去哪里”的问题;而中短期目标,也就是迭代和计划,由自组织团队自行决定,这回答的就是“我们如何去到那里”的问题。
交付节奏按照迭代来进行每3周的迭代会有一部分价值产出。随着迭代的不断推进再逐步更新、优化计划目标并反馈给长期规划进行互动和调整。也就是说618个月的长期计划并不是一成不变的团队会基于每个迭代和计划的交付增量以及用户的反馈进行调整建立起一种“计划 - 交付 - 学习”的闭环路径,不断地校准产品目标和整体方向,保证长期规划的有效性,从而规避了原本瀑布模式下的在项目初期决定未来开发路径的潜在问题。
毕竟,在这个快速变化的时代,谁也无法保证你的计划是一成不变、永远有效的。
现在,特性交付团队的迭代和计划是由自己来决定了,但是你别忘了,每个成功的项目都需要成百上千人的协作。那么,如何保证团队目标的一致性和互相的配合度呢?
微软引入了三种实践方法,分别是迭代邮件、团队交流、体验评审。
1.迭代邮件
在每个迭代的开始和结束时,团队都会发出迭代计划和状态邮件。在邮件中,除了明确本次迭代的特性完成情况以及下个迭代的交付计划之外,为了帮助其他团队成员更好地了解这个迭代的功能,他们还将这些功能录制成视频附在邮件里。不仅如此,待办事项的列表和看板状态也都以链接的形式被附在了邮件中。
2.团队交流
在每次迭代完成的时候,团队成员都要问自己三个问题:
下一步的待办事项中包含哪些内容?
有哪些技术债务的累积和非功能特性?
有哪些遗留问题?
团队中的每个成员都要亲自完成这项任务,这不仅是为了减少信息传递的损失,更重要的是建立一种仪式感,帮助大家更加理性地安排迭代计划。毕竟,一旦把任务安排好了,就要按时完成。
3.体验评审
在分析需求之初,采用用户故事的形式,站在用户的角度,以场景化的方式来描述用户所处的现状,以及这个特性想要解决的问题。那么,不同团队的成员就可以站在用户的视角来实现这个功能。
特别有意思的是,微软在管理特性的时候,会尽量保持对原始用户需求的关联。他们会在特性旁边附上原始的用户需求。
很多时候,开发要处理的任务都是被产品人员翻译过的用户需求,而并非原始的用户需求,以至于在开发的时候,我们并不知道要解决的核心问题是什么。通过关联原始用户需求,每个人都能在开发、测试、交付的过程中,站在用户的视角来重新审视一下,我们交付的功能到底是不是用户想要的。
这些环节的变化带来了一系列积极的影响。
首先,团队成员的积极性大大提高。因为他们觉得自己是用户体验的首要负责人,他们有责任自己修复并解决用户的实际问题。
其次,团队无需再等待领导的规划。在符合整体项目计划的前提下,他们可以自行制定计划。
最后,计划的更新是由持续学习来驱动的。比如,团队会给用户经常使用的功能添加埋点,观察用户使用的数据情况,定期关注和解决用户反馈信息。
持续的增量交付和不断的反馈建议也是现在保证产品需求有效性的最佳手段。毕竟业务敏捷是DevOps的源头如果业务自己对需求都没有明确的衡量方法那么即便拥有了最强的持续交付能力也是跟“蒙眼狂奔”一样。所以想要推进DevOps敏捷开发实践和需求价值分析都是必不可少的要素。
在微软,为了促进有效反馈,他们的度量体系也很好,非常值得一说。对于微软来说,获取用户的真实行为数据至关重要。他们在建设指标体系的时候,出发点大多落脚在考量哪些指标对业务衡量有直接作用上,而不是衡量团队的产出以及个人的产出。
他们采用的指标包括以下几个方面:
使用维度:用户增长、用户满意度、特性交付情况等;
效率维度:构建时长、自测时长、部署时长等。
在线站点健康度:错误定位时长、用户影响时长、线上问题的遗留时长等。
但是,某些国内流行的指标却并没有被纳入绩效考核,比如完成时长、代码行数、缺陷数量等。
你可能会说,这也没什么特殊的啊,但是,你要知道,微软对于用户的关注不止如此。
我给你举个具体的例子。一般情况下我们在度量系统可用性的时候都是面向系统整体的比如保证整体可用性达到4个9也就是在99.99%的情况下是可用的。但是,微软认为,系统可用性应该更进一步,要以账号的维度来进行度量和统计。
当我们站在系统整体的视角时,很多个人用户的行为就被整体数据掩盖了,也就是我们常说的“被平均”了。但是,如果站在账号的视角,也就是每一个用户的视角来看待这个问题的时候,我们就会发现,用户是真真切切地遇到了一些问题。
比如,某一个账号下服务不可用的情况出现频率比较高,那么,与其等着用户上网吐槽,倒不如提前跟用户取得联系,主动帮助他们解决问题。在联系用户的邮件中,不仅要清楚地描述团队观察到的客观情况,还要提供建议的解决方案。如果用户无法自主完成定位和修复,还可以通过邮件中的联系方式和团队取得联系,寻求进一步的帮助。
微软对用户的关注不仅体现在系统可用性的度量方面,在特性开关方面也是如此。
特性开关是一种比较常见的在运行时控制特性是否对外可见的技术手段。在微软的产品中,也大量地使用到了特性开关的技术,但他们的特性开关可以细化到用户级别,也就是可以将用户添加到或者移出列表中,从而控制每一个用户的可见特性。
这样一来,如果某些新特性影响了特定用户的使用,就可以通过这种方式处理,无需部署,直接将特性下线。这不仅有助于问题的快速解决,还提供了一种更加精细化的实验机制。与灰度发布相比,基于特性的发布也更加灵活。
团队转型从中型团队入手
在转型团队的选择方面,微软的经历带给我们的启示是,尽量避免从大型团队开始入手。
在DevOps转型的过程中常见的思维方式是先把企业内部最核心和最大的团队搞定。只要把最复杂的部分搞定了其他中小团队的需求也就都可以满足了他们会自然而然地跟上转型的节奏。
但是事实上,这些大团队往往都有一些独特的流程以及特殊的需求,对系统工具和流程的定制化程度较高,实现起来也最复杂,甚至对他们来说,转型工作的优先级并不是最高的,总会因为这样那样的需求导致转型工作一拖再拖。这对于转型工作来说,并不是一件好事。
所以微软调整了他们的策略采用了“middle-out”的方法也就是专注于中型团队40到100人。这些团队由于资源不像大团队那样充足对外有充分的需求。而且这种规模的团队可以快速地评估现状收集团队的必要信息而不是猜测他们到底需要什么。
通过持续细小的改进,帮助这样的团队做得更好,内部的传播让更多的团队主动联系他们并寻求帮助,从而建立了一个有效的持续改进循环。
总结
今天我给你介绍了微软DevOps转型的上半部分内容。我们来简单总结一下。
为了满足快速交付的需求,他们打破组织的原有架构,建立了面向特性交付的跨职能组织;
通过团队自治,他们将计划分为短期目标和长期目标,短期目标(包括迭代和计划)都由特性团队来自主决定;
在度量方面,他们更加关注业务指标的表现。而且,无论是在系统可用性方面,还是在特性开关方面,他们都细化到了具体的用户级别,以保证每个用户的使用体验;
在选择转型团队方面,他们主动避开了最复杂的团队,而是从能够把握住的中型团队做起,积累成功经验,然后不断传播。
最后我再提一点自己的想法。这两年来在DevOps领域特性的出镜率越来越高。因为特性是更加符合DevOps快速交付原则的需求颗粒度。所以业界的各大公司在基于特性的需求管理、基于特性的分支策略、基于特性的发布和价值追踪策略等层面都有很多实践和思考。比如今年CloudBees公司发布的SDM产品就是基于特性维度的。
我相信未来的DevOps也会朝着这个方向发展。打造一整套基于特性开发的研发模式是一个值得我们花精力好好思考的点。
思考题
你认为,基于特性维度的开发和交付,有哪些流程、工具、规则是有效的呢?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 向前一步万人规模企业的DevOps实战转型案例
你好我是石雪峰。今天我们接着上一讲的内容继续来聊一聊微软DevOps转型的故事。
经常有人会问企业的DevOps转型应该由哪个团队来负责是否要组建一个全新的DevOps团队呢带着这个问题我们来看看微软是怎么做的。
1ES
微软有一个特殊的团队叫作1ES。1ES是One Engineering System的缩写直译过来就是“一套工程系统”的意思。从这个名字相信你就可以看出来在微软内部有一套统一的工程能力平台来支撑微软内部各种形态产品的研发交付工作。没错这个1ES团队包含了近200名工程师作为组织级的研发效能团队他们的目标就是通过一整套通用的工程能力平台来提升内部的研发交付效率。
1ES团队的工作职责可不仅仅是开发通用工具平台他们还要负责公司的文化转型、最新的工程方法导入试验、研发过程改进、安全合规性检查、内部研发效率咨询以及在工程团队推广最佳实践等等可以说是一个“全功能”的企业研发效能和生产力团队。截至2018年数据显示总共有近10万名用户在1ES提供的平台上协同办公。
但国内的现状是很多企业对于研发效能的关注才刚刚起步。即便有人员负责类似的事情也大多分散在各个业务内部难以形成合力。组建了企业级统一的研发效能团队而且规模能够跟微软的1ES相提并论的企业基本上一只手就可以数得过来就更别提建立一套统一的工程能力平台了。我曾见过一家大型企业他们内部的工具平台有1700多个殊不知这里面有多少的重复建设和资源浪费。
那么你以为微软的1ES团队天生就是这样“一统天下”的吗还真不是这么回事。
事实上1ES团队的历史可以追溯到2014年。当时微软新上任的CEO萨提亚·纳德拉非常重视研发能力建设他致力于通过最好的工具来赋能研发团队。结果微软的每个部门都会根据自己的实际情况采购自己习惯的工具平台这就导致整个公司内部的工具、流程和成熟度差异巨大。差异化的工具和流程进一步增强了不同团队之间的共享和协作内部人员转岗的成本极高因为他们到了新团队以后要从头开始适应一切。
为了解决这个问题1ES团队识别了三大领域工作计划管理、版本控制和构建能力。他们先在企业内部识别哪些团队没有使用公司构建的统一工具然后自顶向下强推。这背后的核心理念就是“Use what we ship to ship what we use”也就是使用他们对外发布的工具来研发团队自己的工具。
不知道你发现没有,这三个领域都是软件交付的主路径,需求和任务管理、版本控制和构建系统无一不是核心系统。当你想要建立一个统一的效能平台的时候,最重要的就是抓住主路径上的核心系统。
关于“如何基于核心系统扩展一整套解决方案”我给你推荐一篇GitHub的博客你可以看看他们是如何思考这个问题的。
在接下来的几年里面1ES团队推动VSTS也就是现在的Azure DevOps成为了微软内部的工具平台标准平台的用户也从最开始的几千个人增长到了后来的10万多人。
正是从2010年开始至今150个迭代的千锤百炼才造就了后来Azure DevOps产品的大放异彩。可以说无论是从设计理念、功能还是用户体验等方面微软的Azure DevOps平台在当今业界都是首屈一指的。
持续交付
持续交付是DevOps转型的核心部分1ES提供的统一工程能力平台让这一切成为了可能。那么微软的持续交付做到了什么程度呢
从2019年3月份的数据来看他们每天部署82,000次、创建28,000个工作项每个月有44万个提交请求、460万次构建和240万次的提交数量。
无论把这些数据的哪一项拿出来,都是非常惊人的,这体现了微软卓越的工程能力水平。
那么微软是如何一步步走到今天的呢我们先来看看DevOps中最重要的、也是“老大难”的测试部分看看微软是如何实现在6分钟内完成6万个测试用例的。
其实早在2014年微软在测试中遇到的问题跟大多数公司没什么两样测试耗时太长、测试频繁失败、主线质量不可靠、迭代周期末端的质量远远达不到发布门槛。
这些问题严重到什么程度呢?我给你列举几个数字,你就明白了。
每天的自动化测试耗时22个小时
全功能自动化测试长达2天
仅有60%的P0级别用例可以执行成功
在过往的8年里面甚至没有一次每日自动化测试是全部通过的。
不仅如此团队成员之间对单元测试存在着巨大的分歧研发不愿意花时间写单元测试团队不认为可以通过单元测试替代功能测试甚至连用不用Mock他们在理念上也存在着冲突。
历史总是惊人的相似。在我之前的公司里面,研发总能找到各种理由苦口婆心地说服你他们不需要写单元测试,或者是,各种环境问题导致单元测试压根没法执行完成,因为引用了大量的外部服务。
微软的解法是,停止这种无意义的争论,为了达成预期目标前进。他们先从能达成共识的部分开始推进,并重新整理了内部的测试模型,如下图所示:
L0级这是没有外部依赖的单元测试。这部分在代码合并请求中执行执行时长小于60ms
L1级这是存在外部依赖的单元测试测试时间一般小于2秒平均400ms左右
L2级是面向接口的功能测试在预发环境执行
L3级也就是在生产环境下执行的线上测试。
在明确了整体策略之后,团队开始对测试活动进行改造。整个改造过程可以划分为四个阶段:
阶段一从L0/L1级测试入手
在这个阶段尽可能地简化L0/L1级测试的执行成本编写高质量的测试用例。
根据我在企业里面推行单测的经验,抛开“到底应不应该写单测”这个事情不说,最大的争议点就是分工的问题。从做事的角度来说,包含几个方面:工具和框架选型、规则整理输出、工具平台开发、数据的度量和可视化建设。
为了加快单测的推行我建议前期工具和框架选型由自身的开发和测试工程师或者有经验的DevOps工程师一起完成并在试点项目跑通。接下来研发完成规则的梳理包括单测的书写规则、工具环境配置规则等等平台方面启动单测相关的能力建设目的就是研发只需要写单测代码具体的执行、数据分析、报告统计都交给平台完成。最后在团队内部进行推广并持续更新迭代规则和工具。在这个阶段尽量不要新增每日测试用例。
阶段二:分析已有的每日测试用例
在这个阶段,重点要识别几个方面的内容:
哪些测试用例已经过时,可以删掉?
哪些测试用例可以转移到L0/L1级完成
哪些测试可以整合进SDK中专项进行比如性能测试
这一步骤的目的就是让每日测试用例集合尽可能地“瘦身”,加快执行速度。毕竟,每次跑几十个小时,一旦失败的话,就没有第二次机会了。
阶段三将每日测试转化为L2级测试
接口测试是一种性价比相对更高的测试类型,所以,推进面向接口的自动化测试建设可以兼顾测试的执行效率和业务的覆盖情况。
在这个阶段我们需要完善接口自动化测试框架提供代码、配置和多接口验证等多种测试类型。除此之外要集中统一的管理系统的API一方面进行API的治理另一方面加强研发和测试基于API的协作把所有的变更版本线上化。一旦研发更新了API定义测试可以在同一个地方更新他们的测试用例和Mock数据从而实现基于API的在线协同工作。
阶段四建设L3级测试
这就是在生产环境的线上测试主要是通过监控机制来诊断系统的健康度。这部分内容我在第17讲中提到过如果你不记得了可以回去复习一下。
随着L0/L1级测试的不断增多这些测试都可以纳入到代码合并请求中自动执行。另外L2级的API接口测试同样可以纳入到流水线中。
通过40多个迭代的持续努力以及考核机制的促进作用整个测试的分布情况发生了明显的反转。
你可以看到每日测试的数量不断减少L0级别的测试不断增多到后来L1/L2级的测试也相对稳定下来。你要知道这40多个迭代可是花了将近3年的时间。如果以后谁再跟你说“3个月就能搞定单测”你可千万别跟他聊天。
持续部署
持续交付的终点是持续部署,那么,微软在部署层面又做了哪些事情呢?
首先,微软不承认半自动化部署这个事情。其实很多时候,部署动作都不是一次性完成的。有些命令或者步骤并没有线上化,或者就是非高频的动作没有做到工具里面,还是需要通过手动复制一段命令的方式来实现。
经常有人会问:“我们的大部分操作都实现了自动化,这算不算做得不错了呢?”我的回答也很简单:“对于一个没有基础或者非专业的人来说,他是否可以完成这项任务?”坦率地说,这有点“抬杠”的性质,但事实上,如果一个平台做完了,结果还是要依赖于指定人去操作,那你就得想想这个事情的意义和未来的价值了。
之前我在做一个项目的时候,就遇到过类似的案例。为了解决配置变更的问题,团队成员实现了一个非常复杂的任务,但是在评审的时候,我们发现,这个任务并不能解决所有问题,到头来还是需要他手动入库操作。手动入库的成本其实还好,但是为了自动而自动,结果得不偿失,这就有点浪费时间和精力了。
那么,要想解决所有人都能部署的需求,要做的就是完全的自动化。把所有的操作都内嵌于流水线之中,并且纳入版本控制,用于记录变更信息。使用同一套工具实现多环境部署,通过配置中心完成不同环境的配置下发。
这样做的好处有很多,一方面,可以在不同的环境中完善部署工具的健壮性,避免由于部署方式或者工具的差异带来的潜在风险。另一方面,与生产环境的部署相比,测试环境的部署心理压力没有那么大。当大家都熟悉测试环境的部署过程之后,对生产环境的部署就是小菜一碟了。
为了实现安全低风险的部署,微软引入了“部署环”的概念,你可以把部署环理解为将部署活动拆分成了几个阶段。每一次生产部署都需要经过五环验证过程,即便是配置变更,也是如此,不存在额外的紧急通道。这五个部署环分别是:
金丝雀(内部用户)
小批量外部用户
大批量外部用户
国际用户
其他所有用户
通过渐进式的部署方式,每一个新的版本都缓慢地经过每一环的验证,并逐步放量,开放给所有用户。其中有几个点值得我们借鉴。
1.通过流水线打通CI/CD
我们可以这样理解CI/CD
CI的目的是生成一个可以用于部署的包。这个包可以是war包、tar包、ear包也可以是镜像这取决于系统的部署方式。
CD的目的是将这个包部署到生产环境并发布给用户。
所以CI和CD的结合点就在于制品库通过流水线调度部署包在制品库中的流转从而完成制品的晋级。我发现很多大厂都是用部署前重新打包的方式人为地将CI和CD的过程割裂开来这并不是一种好的处理方式。
2.持续部署并不意味着全自动
我们都知道持续部署能力是考查一个公司DevOps能力的最好指标比如前面我提到的微软每天能够部署8万多次。那么这是不是说每次变更都要经过自动化过程部署到生产环境呢答案是不一定。
你可以看一下这幅微软开发的全景图其中在CD过程中每一环的部署都需要人工确认来完成这背后的核心理念是控制“爆炸半径”。
既然无法彻底阻止失败,那么是否能够控制影响范围呢?“部署环”的设计理念正是如此,为了做到这一点,适当的人工管控还是很有必要的。
那么,如何确认部署是成功的呢?
微软定义了非常详细的保障在线服务可用性的规则,其中最重要的就是,明确线上服务状态永远处于第一优先级。你可能觉得,本来不就应该是这样的吗?但是,在实际工作中,我们会发现,内部工具团队经常专注于实现新功能,而把线上的报警放在一边。
要想解决这个问题除了明确线上为先的理念之外制定相应的规则也是很重要的。比如微软的值班工程师叫作DRIDesignated Responsible Individual也就是“指定责任人”。微软明确要求每个在岗工程师必须在工作日5分钟内、休息日15分钟内响应问题并把这纳入到了人员和团队的考核之中。另外通过每周、每月的线上服务状态报告以及每次事故的详尽故障分析不断在内部强化线上为先的理念。
总结
在这个案例中我给你介绍了微软在转型过程中的几个重点包括自动化测试能力、统一工程平台和工程团队、分级持续部署、组织变革、团队自治和文化转变等。这些都是在实际的DevOps转型过程中企业所面对的最为头疼的事情。微软的经历是否给你带来了一些启发呢当然想要做好DevOps可绝对不只是做好这几点就够了的。
对于DevOps的转型过程微软的理念是
A journey of a thousand miles begins with a single sprint.
这就是咱们常说的“千里之行始于足下”。DevOps不是一种魔法可以立即见效而是每次变好一点点每个人都在不断地思考“我能为DevOps建设做点什么”。这就像微软的自动化测试转型过程一样你能看到整个趋势在不断变好慢慢变成了现在这样每次提交可以在10分钟左右完成近9万个自动化测试。
微软一直在致力于推广DevOps并且不断把自己的经验通过各种形式分享出来。仅仅从这一点上我们就能看出微软的文化转变、向开放开源的转变。我再跟你分享一些微软DevOps转型的资料你可以参考一下。
资料1. https://docs.microsoft.com/en-us/azure/devops/learn/devops-at-microsoft/
资料2. https://azure.microsoft.com/en-us/solutions/devops/devops-at-microsoft/
你还记得我在第6讲中提到的DevOps转型的J型曲线吗其实无论是DevOps转型还是研发效率建设都是一个长期、琐碎的过程。你要做的就是树立自己的信心做正确的事情并期待美好的事情自然发生。
思考题
通过案例学习DevOps是一种特别好的方法在案例中你不仅能借鉴别人的经验也能学习到系统背后的设计理念。那么你有什么好的案例学习途径吗可以分享一下吗
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,210 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
期中总结 3个典型问题答疑及如何高效学习 (1)
你好我是石雪峰。不知不觉中专栏已经上线快两个月了整体进度过半。我在专栏写作之初就给自己定下了一个小目标认真对待每一条留言。到现在单单是回复留言我就已经写了3万多字了。
其实,对我来说,每一次看留言和回复留言,都是一个不断反思和学习的过程。实际上,很多时候,很多留言和讨论甚至比文章本身都更精彩,也更接地气。
今天是期中总结,我分为两个部分内容来讲:
第1部分我从众多留言中挑选了3个典型问题进一步展开讲解。
第2部分我想跟你说说心里话。两个月的高强度写作也让我从一个讲师的角度重新审视了“如何高效学习”这件事情我把这些想法分享给你希望可以帮助你更好地提升自己。
典型问题
首先,我们来看一些典型问题。
问题一
敏捷开发模式没有花费大量时间去研究业务,这会不会出现因为对业务没有分析透,导致方向偏离,甚至系统开发到一半发现总体业务架构不合理,需要返工的情况呢?
相信你也知道实施DevOps有助于产品快速和高质量的交付那么我想问的是快速和高质量的交付是否就一定意味着业务的成功呢显然没有这么简单。
实际上,影响业务成功的因素有很多,比如,行业趋势、产品竞争力、用户消费习惯、政策法律法规等等。在这众多因素之中,需求质量的高低,或者说,需求是否靠谱,也很重要。
毕竟,如果交付了一大堆没用的需求,不仅没法提升业务,反而还会浪费大量的时间和精力,错过真正有价值的机会。
我们身处在一个飞速变化的时代,企业对于用户想要什么其实并不清楚。很多需求都是人为拍脑袋拍出来的。在提出一个新需求的时候,需求价值到底有多少呢?这不仅很难预测,而且还很难衡量。
所以,产品人员就倾向于采用“广撒网”的方式,提出一大堆需求,来提升命中的几率。毕竟,如果一次猜不对,打不准,那就多打几次呗。
这么看来,采用敏捷开发方式,还是瀑布开发方式,与需求是否靠谱并没有直接关系。即便是采用瀑布模式,依然也有“大力出悲剧”的案例,比如摩托罗拉的铱星计划。
既然无法事先预测需求是否靠谱,那么要解决这个问题,就需要业务团队和交付团队的通力协作了。
从业务侧来说,就是要采取精益创业的思想,通过最小可行产品,将需求进行拆解,通过原型产品降低市场的试错成本。这就引出了我在“业务敏捷”这一讲中提到的影响地图、卡诺模型、用户故事等一系列的手段和方法,核心还是采用持续迭代、小步快跑的方式来获取市场反馈。正因为如此,更加灵活拥抱变化的敏捷开发模式才被广泛地接受。
说完了业务侧,再来看看交付侧。
一个想法被提出来以后,需要经过软件开发交付过程,才能最终交付到用户手中。那么,就要用尽一切手段来缩短这条交付链路的时长。
如果开发的时间成本是一定的那么剩余的部分就是DevOps中的各种工程实践试图要去解决的问题。
比如,通过持续集成来降低软件集成中的解决成本,降低软件缺陷在最后一刻被发现的修复成本;通过自动化测试,降低大量手工回归测试用例执行的成本,降低新功能导致已有功能出现回退的修复成本。软件交付过程中的其他部分也大都如此,这也是每个领域都会有自己的实践集合的原因。
反过来看,功能上线之后,依然需要交付侧提取、汇总和及时反馈业务指标,来证明需求的靠谱程度,从而帮助业务侧更加有序地进行决策。对反映不好的功能及时止损,对反映不错的功能加大投入。
这样一来业务侧的需求拆解、需求分析减小了需求颗粒度提升了需求的靠谱度交付侧的工程实践大大缩短了上线交付周期提升了质量。这就帮助业务在不增加成本的前提下可以验证更多的需求。这个过程的成本越低频率越高企业存活下来的几率和整体竞争力也会越高。这也正是DevOps想要解决的核心真问题。
问题二
公司对于配置管理的关注度不是很高,有没有什么好的落地实践方法,来建设完整的配置管理体系呢?
在专栏的第10讲我从4个核心原则出发介绍了配置管理的相关知识引起了很多同学的共鸣。
的确作为一个长期被忽视但是格外重要的实践配置管理不仅是诸多DevOps工程实践的基础也是工程能力的集大成者。
正因为如此,配置管理体系的建设,并不只是做好配置管理就够了。实际上,这还依赖于其他工程实践的共同实施。
关于配置管理怎么落地,我跟你分享一个案例。
这家公司最早也没有专职的配置管理软件的集成和发布都是由研发团队自行管理的。推动建立配置管理体系的契机源于公司决定加快版本发布节奏从三周一个版本变成两周一个版本。看起来这只是版本发布周期缩短了一周但是就像我在专栏第4讲中演示的部署引力图一样想要达成这个目标需要方方面面的努力其中就包括配置管理。
于是,公司决定引入配置管理岗位。初期,他们重点就做两件事:
重新定义分支策略,从长分支改为了短分支加特性分支的模式;
管理集成权限,从任何时间都能集成代码,到按照版本周期管控集成。
在这个过程中,配置管理同学梳理了代码仓库的目录结构和存储方式,并基于开发流程建立了在线提测平台,从而实现了研发过程的线上化以及权限管理的自动化。
接下来,配置管理与平台和流程相结合,开发过程开始向前、向后延展。
向前:在需求管理阶段,建立需求和代码的关联规范,严格约束代码提交检查,并且将构建工具和环境配置等纳入统一管控,可追溯历史变更;
向后:在部署运维阶段,定义版本发布和上线规则,建立单一可信的发布渠道,可统一查询所有正式发布版本的信息,包括版本关联的需求信息、代码信息、测试信息等。
团队在走上有序开发的正轨之后,就针对发现的问题,逐步加强了平台和自动化能力的建设。
代码提交失控:做集成线上化,测试验收通过之后,自动合并代码;
环境差异大:通过容器化和服务端配置管理工具,实现统一的初始化;
构建速度慢:通过网络改造和增量编译等,提升构建速度。
这样一来,版本发布这件事情,从原本耗时耗力的操作,最终变成了一键式的操作,团队也达成了预期的双周发版的目标。
在这个案例中,配置管理更多是从流程和平台入手,通过规则制定、权限管控、统一信息源,以及版本控制手段,重塑了整个开发协作的交付过程。
所以,在把握原则的基础上,面对诸多实践,想要确定哪些实践可以解决实际问题,最好是要从预期结果进行反推。
如果你不知道该从哪里入手,不妨看看现在的软件交付流程是否是由配置管理来驱动的,是否还有一些数据是失控和混乱的状态,版本的信息是否还无法完整回溯。如果是的话,那么,这些都是大有可为的事情。
总之,任何一家公司想要落地配置管理,都可以先从标准化到自动化,然后再到数据化和服务化。这是一条相对通用的路径,也是实施配置管理的总体指南。
问题三
度量指标要如何跟组织和个人关联?这么多指标,到底该如何跟项目关联起来呢?
我在第19讲中介绍了正向度量的实践引发了一个小高潮。文章发出后有不少同学加我好友并跟我深入沟通和探讨了度量建设的问题。由此可见当前企业的研发度量应该是一个大热门。
但是,度量这个事情吧,你越做就越会发现,这是个无底洞。那么,在最最开始,有没有可以用来指导实践的参考步骤呢?当然是有的。我总结了四个步骤:找抓手、对大数、看差距、分级别。
第1步找抓手。
对于度量体系建设来说,很多公司其实都大同小异。最开始的时候,核心都是需要有一个抓手来梳理整个研发过程。这个抓手,往往就是需求。因为,只有需求是贯穿研发交付过程始终的,没有之一。
当然,你也可以思考一下,除了需求,是否还有其他选项?那么,围绕需求的核心指标,首先是需要提取的内容。如果,连一个需求在交付周期内各个阶段的流转时长都没有,那么,这个度量就是不合格的。
第2步对大数。
对大数,也就是说,当度量系统按照指标定义,提取和运算出来指标数据之后,最重要的就是验证数据的真实有效性,并且让团队认可这个客观数据。
很多时候,如果公司里面没有一套权威指标,各个部门、系统就都会有自己的度量口径。如果是在没有共识的前提下讨论这个事情,基本也没什么意义。所以,说白了,一定要让团队认可这些大数的合理性。
第3步找差距。
抓手有了核心大数也有了大家也都承认这个度量数据的客观有效性了。但是在这个阶段肯定有些地方还是明显不合理。这个时候就需要对这个领域进一步进行拆分。比如测试周期在大的阶段里只是一个数字但实际上这里面包含了N多个过程比如功能测试、产品走查、埋点测试等等。
如果没有把表面问题,细分成各个步骤的实际情况,你就很难说清楚,到底是哪个步骤导致的问题。所以,在达成共识的前提下,识别可改进的内容,这就是一个阶段性的胜利。
第4步分级别。
实际上,不是所有指标都可以关联到个人的。比如,如果要计算个人的需求前置周期,这是不是感觉有点怪呢?同样,应用的上线崩溃率这种指标,也很难关联到一个具体的部门。
所以,我们需要根据不同的视角和维度划分指标。比如,可以划分组织级指标、团队级指标和项目级指标。
划分指标的核心还是由大到小,从指标受众和试图解决的问题出发,进行层层拆解,从而直达问题的根本原因,比如用户操作原因、数据计算原因、自动化平台原因等等。当然,这是一件非常细致的工作。
我们再来回顾下我们刚刚深入剖析了3个DevOps的典型问题。
首先你要非常清楚地知道DevOps在面对未知需求时的解题方法和解题套路那就是业务侧尽量拆解分析靠谱需求交付侧以最快、最低的成本完成交付。它们之间就是一个命运共同体一荣俱荣一损俱损。
配置管理作为DevOps的核心基础实践在实施的过程中并不只局限在单一领域。实际上要从研发流程优化的视角出发驱动标准化、自动化和数据可视化的能力建设。
最后,关于度量指标部分,你要注意的是,向上,要支撑核心指标;向下,要层层分解,展示真实细节。
讲解完这3个典型问题之后接下来进入第2部分这也是我极力要求增加的部分。其实我就是想跟你说说心里话。
如何高效学习?
跟你一样,我也是极客时间的用户,订阅了很多感兴趣的课程。在学习的过程中,我一直在思考,如何在有限的时间内高效学习。直到我自己成为了课程老师,从用户和老师两个角度思考这个问题,有了一些感悟,想要跟你分享一下。
忙,是现在大多数人的真实生活写照。我们每天从早到晚,忙于工作,忙于开会,忙于刷手机……忙得一塌糊涂。
但是,如果要问,过去的一天,自己都在忙什么,要么是大脑一片空白,要么是碎碎念式的流水账。可见,我们每天忙的很多事情,都没有什么价值。
其实,很多事情,都没有我们想象得那么重要。我们常常把目光聚焦于眼前,眼前的事情就变成了整个世界。但是,如果把时间拉长到一周,甚至一年,你会发现,这些事情,做与不做没有什么分别。
正因为时刻处于忙碌的状态,所以,抽出一整段时间学习,就变成了一件奢侈的事情。但我要祝贺你,因为至少你比大多数人有意识,有危机感,愿意拿出零碎的时间,来充实自己。
既然花了这么难得的时间,你肯定希望能有所收获,无论是在知识上,还是能力上,抑或是见识上,至少不白白浪费这段时间。
那么,我想问的是,你真的有收获吗?
史蒂芬·科维曾经说过,大多数人聆听的目的是为了“怼回去”,而不是为了真正的理解。
Most of people listen with the intent to reply, but not with the intent to understand.
这里面的“怼回去”稍微有点夸张,实际上,我发现,当我在交流的时候,脑海里总是不自觉地想象如何回复对方,而不是专心地听对方讲话,感悟他的意图和情绪。
所以你看,听这种学习方式,总是会受到固有思维模式的影响。也就是说,在很多时候,我们往往会把自己置身于一种评论者的身份。
那么,什么是评论者的身份呢?这就是说,站在一种置身事外的立场,以一种审视的角度,来看待每一件事情,并试图找到一些问题。当然,这些问题,都是在已有的认知局限中发现的。
这些反馈,对于知识的生产者而言,其实是一件好事,因为他能够时刻审视自己,反思自己,并从中找到不足之处。
但是,对学习者来说,能不能在学习的过程中,暂时放弃评论者的身份,转而做一个实践者呢?
比如,以极客时间的专栏为例,对于作者提到的内容,你有哪些不同的观点呢?面对同样的问题,你又有哪些更好的手段呢?
其实,每一个作者之所以能成为作者,都有他的独到之处。那么,能够让他的思想为你所用,让他的知识与你互补,让你自己成为交流的赢家,这才是对得起时间的更好选择。
最后,以极客时间的专栏为例,我认为:
60分的体验就是可以看完所有的文稿而不是仅仅听完课程音频
70分的体验就是可以仔细学习文稿中的附加资源比如代码、流程图以及补充的学习信息等。这些都是精选的内容可以帮助你在15分钟之外扩充自己的知识面
80分的体验就是可以积极参与到专栏的留言和讨论中甚至可以就自己的问题跟作者深入交流建立连接
90分的体验就是可以结合工作中的实际场景给出自己的思考和答案并积累出自己的一整套知识体系并且可以反向输出给其他人
100分的体验就是持续改进。我想能够具备这种思想可能要比100分本身更重要。
那么,你想做到多少分的体验呢?你可以自己想一想。
好了,接下来,我们即将进入“工具实践篇”,希望你可以继续保持学习的热情,坚持下去,期待美好的事情自然发生。
思考题
对于前面已经更新的内容,你还有什么疑惑点吗?或者说,你在实践的过程中,有什么问题吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,210 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
期中总结 3个典型问题答疑及如何高效学习
你好我是石雪峰。不知不觉中专栏已经上线快两个月了整体进度过半。我在专栏写作之初就给自己定下了一个小目标认真对待每一条留言。到现在单单是回复留言我就已经写了3万多字了。
其实,对我来说,每一次看留言和回复留言,都是一个不断反思和学习的过程。实际上,很多时候,很多留言和讨论甚至比文章本身都更精彩,也更接地气。
今天是期中总结,我分为两个部分内容来讲:
第1部分我从众多留言中挑选了3个典型问题进一步展开讲解。
第2部分我想跟你说说心里话。两个月的高强度写作也让我从一个讲师的角度重新审视了“如何高效学习”这件事情我把这些想法分享给你希望可以帮助你更好地提升自己。
典型问题
首先,我们来看一些典型问题。
问题一
敏捷开发模式没有花费大量时间去研究业务,这会不会出现因为对业务没有分析透,导致方向偏离,甚至系统开发到一半发现总体业务架构不合理,需要返工的情况呢?
相信你也知道实施DevOps有助于产品快速和高质量的交付那么我想问的是快速和高质量的交付是否就一定意味着业务的成功呢显然没有这么简单。
实际上,影响业务成功的因素有很多,比如,行业趋势、产品竞争力、用户消费习惯、政策法律法规等等。在这众多因素之中,需求质量的高低,或者说,需求是否靠谱,也很重要。
毕竟,如果交付了一大堆没用的需求,不仅没法提升业务,反而还会浪费大量的时间和精力,错过真正有价值的机会。
我们身处在一个飞速变化的时代,企业对于用户想要什么其实并不清楚。很多需求都是人为拍脑袋拍出来的。在提出一个新需求的时候,需求价值到底有多少呢?这不仅很难预测,而且还很难衡量。
所以,产品人员就倾向于采用“广撒网”的方式,提出一大堆需求,来提升命中的几率。毕竟,如果一次猜不对,打不准,那就多打几次呗。
这么看来,采用敏捷开发方式,还是瀑布开发方式,与需求是否靠谱并没有直接关系。即便是采用瀑布模式,依然也有“大力出悲剧”的案例,比如摩托罗拉的铱星计划。
既然无法事先预测需求是否靠谱,那么要解决这个问题,就需要业务团队和交付团队的通力协作了。
从业务侧来说,就是要采取精益创业的思想,通过最小可行产品,将需求进行拆解,通过原型产品降低市场的试错成本。这就引出了我在“业务敏捷”这一讲中提到的影响地图、卡诺模型、用户故事等一系列的手段和方法,核心还是采用持续迭代、小步快跑的方式来获取市场反馈。正因为如此,更加灵活拥抱变化的敏捷开发模式才被广泛地接受。
说完了业务侧,再来看看交付侧。
一个想法被提出来以后,需要经过软件开发交付过程,才能最终交付到用户手中。那么,就要用尽一切手段来缩短这条交付链路的时长。
如果开发的时间成本是一定的那么剩余的部分就是DevOps中的各种工程实践试图要去解决的问题。
比如,通过持续集成来降低软件集成中的解决成本,降低软件缺陷在最后一刻被发现的修复成本;通过自动化测试,降低大量手工回归测试用例执行的成本,降低新功能导致已有功能出现回退的修复成本。软件交付过程中的其他部分也大都如此,这也是每个领域都会有自己的实践集合的原因。
反过来看,功能上线之后,依然需要交付侧提取、汇总和及时反馈业务指标,来证明需求的靠谱程度,从而帮助业务侧更加有序地进行决策。对反映不好的功能及时止损,对反映不错的功能加大投入。
这样一来业务侧的需求拆解、需求分析减小了需求颗粒度提升了需求的靠谱度交付侧的工程实践大大缩短了上线交付周期提升了质量。这就帮助业务在不增加成本的前提下可以验证更多的需求。这个过程的成本越低频率越高企业存活下来的几率和整体竞争力也会越高。这也正是DevOps想要解决的核心真问题。
问题二
公司对于配置管理的关注度不是很高,有没有什么好的落地实践方法,来建设完整的配置管理体系呢?
在专栏的第10讲我从4个核心原则出发介绍了配置管理的相关知识引起了很多同学的共鸣。
的确作为一个长期被忽视但是格外重要的实践配置管理不仅是诸多DevOps工程实践的基础也是工程能力的集大成者。
正因为如此,配置管理体系的建设,并不只是做好配置管理就够了。实际上,这还依赖于其他工程实践的共同实施。
关于配置管理怎么落地,我跟你分享一个案例。
这家公司最早也没有专职的配置管理软件的集成和发布都是由研发团队自行管理的。推动建立配置管理体系的契机源于公司决定加快版本发布节奏从三周一个版本变成两周一个版本。看起来这只是版本发布周期缩短了一周但是就像我在专栏第4讲中演示的部署引力图一样想要达成这个目标需要方方面面的努力其中就包括配置管理。
于是,公司决定引入配置管理岗位。初期,他们重点就做两件事:
重新定义分支策略,从长分支改为了短分支加特性分支的模式;
管理集成权限,从任何时间都能集成代码,到按照版本周期管控集成。
在这个过程中,配置管理同学梳理了代码仓库的目录结构和存储方式,并基于开发流程建立了在线提测平台,从而实现了研发过程的线上化以及权限管理的自动化。
接下来,配置管理与平台和流程相结合,开发过程开始向前、向后延展。
向前:在需求管理阶段,建立需求和代码的关联规范,严格约束代码提交检查,并且将构建工具和环境配置等纳入统一管控,可追溯历史变更;
向后:在部署运维阶段,定义版本发布和上线规则,建立单一可信的发布渠道,可统一查询所有正式发布版本的信息,包括版本关联的需求信息、代码信息、测试信息等。
团队在走上有序开发的正轨之后,就针对发现的问题,逐步加强了平台和自动化能力的建设。
代码提交失控:做集成线上化,测试验收通过之后,自动合并代码;
环境差异大:通过容器化和服务端配置管理工具,实现统一的初始化;
构建速度慢:通过网络改造和增量编译等,提升构建速度。
这样一来,版本发布这件事情,从原本耗时耗力的操作,最终变成了一键式的操作,团队也达成了预期的双周发版的目标。
在这个案例中,配置管理更多是从流程和平台入手,通过规则制定、权限管控、统一信息源,以及版本控制手段,重塑了整个开发协作的交付过程。
所以,在把握原则的基础上,面对诸多实践,想要确定哪些实践可以解决实际问题,最好是要从预期结果进行反推。
如果你不知道该从哪里入手,不妨看看现在的软件交付流程是否是由配置管理来驱动的,是否还有一些数据是失控和混乱的状态,版本的信息是否还无法完整回溯。如果是的话,那么,这些都是大有可为的事情。
总之,任何一家公司想要落地配置管理,都可以先从标准化到自动化,然后再到数据化和服务化。这是一条相对通用的路径,也是实施配置管理的总体指南。
问题三
度量指标要如何跟组织和个人关联?这么多指标,到底该如何跟项目关联起来呢?
我在第19讲中介绍了正向度量的实践引发了一个小高潮。文章发出后有不少同学加我好友并跟我深入沟通和探讨了度量建设的问题。由此可见当前企业的研发度量应该是一个大热门。
但是,度量这个事情吧,你越做就越会发现,这是个无底洞。那么,在最最开始,有没有可以用来指导实践的参考步骤呢?当然是有的。我总结了四个步骤:找抓手、对大数、看差距、分级别。
第1步找抓手。
对于度量体系建设来说,很多公司其实都大同小异。最开始的时候,核心都是需要有一个抓手来梳理整个研发过程。这个抓手,往往就是需求。因为,只有需求是贯穿研发交付过程始终的,没有之一。
当然,你也可以思考一下,除了需求,是否还有其他选项?那么,围绕需求的核心指标,首先是需要提取的内容。如果,连一个需求在交付周期内各个阶段的流转时长都没有,那么,这个度量就是不合格的。
第2步对大数。
对大数,也就是说,当度量系统按照指标定义,提取和运算出来指标数据之后,最重要的就是验证数据的真实有效性,并且让团队认可这个客观数据。
很多时候,如果公司里面没有一套权威指标,各个部门、系统就都会有自己的度量口径。如果是在没有共识的前提下讨论这个事情,基本也没什么意义。所以,说白了,一定要让团队认可这些大数的合理性。
第3步找差距。
抓手有了核心大数也有了大家也都承认这个度量数据的客观有效性了。但是在这个阶段肯定有些地方还是明显不合理。这个时候就需要对这个领域进一步进行拆分。比如测试周期在大的阶段里只是一个数字但实际上这里面包含了N多个过程比如功能测试、产品走查、埋点测试等等。
如果没有把表面问题,细分成各个步骤的实际情况,你就很难说清楚,到底是哪个步骤导致的问题。所以,在达成共识的前提下,识别可改进的内容,这就是一个阶段性的胜利。
第4步分级别。
实际上,不是所有指标都可以关联到个人的。比如,如果要计算个人的需求前置周期,这是不是感觉有点怪呢?同样,应用的上线崩溃率这种指标,也很难关联到一个具体的部门。
所以,我们需要根据不同的视角和维度划分指标。比如,可以划分组织级指标、团队级指标和项目级指标。
划分指标的核心还是由大到小,从指标受众和试图解决的问题出发,进行层层拆解,从而直达问题的根本原因,比如用户操作原因、数据计算原因、自动化平台原因等等。当然,这是一件非常细致的工作。
我们再来回顾下我们刚刚深入剖析了3个DevOps的典型问题。
首先你要非常清楚地知道DevOps在面对未知需求时的解题方法和解题套路那就是业务侧尽量拆解分析靠谱需求交付侧以最快、最低的成本完成交付。它们之间就是一个命运共同体一荣俱荣一损俱损。
配置管理作为DevOps的核心基础实践在实施的过程中并不只局限在单一领域。实际上要从研发流程优化的视角出发驱动标准化、自动化和数据可视化的能力建设。
最后,关于度量指标部分,你要注意的是,向上,要支撑核心指标;向下,要层层分解,展示真实细节。
讲解完这3个典型问题之后接下来进入第2部分这也是我极力要求增加的部分。其实我就是想跟你说说心里话。
如何高效学习?
跟你一样,我也是极客时间的用户,订阅了很多感兴趣的课程。在学习的过程中,我一直在思考,如何在有限的时间内高效学习。直到我自己成为了课程老师,从用户和老师两个角度思考这个问题,有了一些感悟,想要跟你分享一下。
忙,是现在大多数人的真实生活写照。我们每天从早到晚,忙于工作,忙于开会,忙于刷手机……忙得一塌糊涂。
但是,如果要问,过去的一天,自己都在忙什么,要么是大脑一片空白,要么是碎碎念式的流水账。可见,我们每天忙的很多事情,都没有什么价值。
其实,很多事情,都没有我们想象得那么重要。我们常常把目光聚焦于眼前,眼前的事情就变成了整个世界。但是,如果把时间拉长到一周,甚至一年,你会发现,这些事情,做与不做没有什么分别。
正因为时刻处于忙碌的状态,所以,抽出一整段时间学习,就变成了一件奢侈的事情。但我要祝贺你,因为至少你比大多数人有意识,有危机感,愿意拿出零碎的时间,来充实自己。
既然花了这么难得的时间,你肯定希望能有所收获,无论是在知识上,还是能力上,抑或是见识上,至少不白白浪费这段时间。
那么,我想问的是,你真的有收获吗?
史蒂芬·科维曾经说过,大多数人聆听的目的是为了“怼回去”,而不是为了真正的理解。
Most of people listen with the intent to reply, but not with the intent to understand.
这里面的“怼回去”稍微有点夸张,实际上,我发现,当我在交流的时候,脑海里总是不自觉地想象如何回复对方,而不是专心地听对方讲话,感悟他的意图和情绪。
所以你看,听这种学习方式,总是会受到固有思维模式的影响。也就是说,在很多时候,我们往往会把自己置身于一种评论者的身份。
那么,什么是评论者的身份呢?这就是说,站在一种置身事外的立场,以一种审视的角度,来看待每一件事情,并试图找到一些问题。当然,这些问题,都是在已有的认知局限中发现的。
这些反馈,对于知识的生产者而言,其实是一件好事,因为他能够时刻审视自己,反思自己,并从中找到不足之处。
但是,对学习者来说,能不能在学习的过程中,暂时放弃评论者的身份,转而做一个实践者呢?
比如,以极客时间的专栏为例,对于作者提到的内容,你有哪些不同的观点呢?面对同样的问题,你又有哪些更好的手段呢?
其实,每一个作者之所以能成为作者,都有他的独到之处。那么,能够让他的思想为你所用,让他的知识与你互补,让你自己成为交流的赢家,这才是对得起时间的更好选择。
最后,以极客时间的专栏为例,我认为:
60分的体验就是可以看完所有的文稿而不是仅仅听完课程音频
70分的体验就是可以仔细学习文稿中的附加资源比如代码、流程图以及补充的学习信息等。这些都是精选的内容可以帮助你在15分钟之外扩充自己的知识面
80分的体验就是可以积极参与到专栏的留言和讨论中甚至可以就自己的问题跟作者深入交流建立连接
90分的体验就是可以结合工作中的实际场景给出自己的思考和答案并积累出自己的一整套知识体系并且可以反向输出给其他人
100分的体验就是持续改进。我想能够具备这种思想可能要比100分本身更重要。
那么,你想做到多少分的体验呢?你可以自己想一想。
好了,接下来,我们即将进入“工具实践篇”,希望你可以继续保持学习的热情,坚持下去,期待美好的事情自然发生。
思考题
对于前面已经更新的内容,你还有什么疑惑点吗?或者说,你在实践的过程中,有什么问题吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,165 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
期末总结 在云时代,如何选择一款合适的流水线工具?
你好,我是石雪峰。今天是期末总结,我们来聊一聊,在云时代,如何选择一款合适的流水线工具。
在过去的几年里,我一直专注于软件持续交付的工程实践领域。我发现,越来越多的公司(无论规模大小)开始重视软件持续交付能力的建设了,基本上每家公司都有自己的流水线平台。
以前提到CI/CD工具基本上就默认是Jenkins也没什么其他太好的选项。但是最近两年随着云容器技术的快速发展在CI/CD流水线领域新工具和解决方案出现了爆发式的增长。比如不甘寂寞的GitLab CI、轻量级的容器化解决方案Drone。最近一段时间GitHub的Actions也火了一把。可见作为软件交付主路径上的核心工具流水线是每一家企业都不愿意错过的领域。
对于行业发展来说这当然是好事情。老牌工具Jenkins自己都开始反省“在云容器时代是不是过于保守十几年的老架构是否已经难以支撑云时代的快速发展了”于是他们就另辟蹊径孵化出了Jenkins X项目。
但是,对于用户来说,选择工具时就很为难:“这些工具看起来大同小异,要解决的也是类似的问题,到底应该选择哪个呢?”
今天我就来给你梳理一下流行的CI/CD工具并给你提供一些选择建议。我挑选了5个工具分为3组介绍分别是Jenkins系的Jenkins和Jenkins X、版本控制系统系的GitLab CI和GitHub Actions以及新兴的、正在快速普及的云原生解决方案Drone。我会从5个方面入手对它们进行对比和介绍包括工具的易用性、流水线设计、插件生态、扩展性配置以及适用场景。
Jenkins/Jenkins X
关于Jenkins我想已经不需要做太多介绍了。在过去的15年里面Jenkins一直都在为无数的软件开发者默默服务。从一组数字中我们就能看出来它的影响力官方能统计到的集群数有26万多个、插件将近1700个、执行的任务数超过3000万次这还不包括大量公司自建、本地电脑运行的节点信息。另外一年两次的Jenkins全球大会往往能够吸引上千人参与这对于国外的技术大会来说已经是超大规模的盛会了。
当然Jenkins的优缺点也很明显。
优点:普及率高,搞过开发的基本应该都接触过;插件生态成熟且丰富,可以适用于任何场景。
缺点软件架构和UI设计风格有些过时配置操作比较复杂插件的安全性、通用性方面也存在很多问题最重要的是在云容器领域多少有些格格不入。
我重点说说Jenkins X。很多人都不清楚Jenkins和Jenkins X是什么关系这就好比刚开始我们很难说清楚Java和JavaScript的关系一样。实际上JavaScript除了名字上带有“Java”字眼蹭了个热度之外本质上它们之间并没有什么关系。而对于Jenkins和Jenkins X来说虽然并不能说二者一点关系没有但其实它们面对的场景和要解决的问题是不同的。所以并不能说Jenkins X就是下一代Jenkins或者是Jenkins迟早会迁移到Jenkins X上面。
Jenkins X最开始的确是作为Jenkins的子项目存在的但是发展到现在它已经有了独立的品牌和Logo并且和Jenkins一起作为CDF持续交付基金会的初始项目。Jenkins X想要解决的核心问题是Kubernetes上的原生CI/CD解决方案。所以Jenkins X和Kubernetes是强绑定的关系它致力于通过一系列的自动化工具和最佳实践来降低云原生环境下的研发配置和使用CI/CD的成本并尽可能地做成开箱即用的状态。
而Jenkins更像一个百宝箱你可以通过插件扩展来解决各种各样的问题并没有一定之规。
我给你举个例子来形象地对比一下Jenkins和Jenkins X这两个项目。
Jenkins就好比你在开车你知道目的地但是走哪条路开多快中间要不要休息一下什么时候加油这些都是你自己来决定的。当然灵活性带来的就是多变性你并不知道是不是下一秒就封路了或者是汽车突然坏了。
而Jenkins X更像是一辆高速列车你只要上对了车列车会把你安全、快速地送往目的地而你并不需要关心这个车是怎么设计的时速应该是多少甚至你在哪里能够下车它都规定好了。
Jenkins X项目中内建了大量的开源工具和解决方案可以说是开源工具的理想国和试验田核心目的就是为了简单、快速、开箱即用。比如对Tekton的集成就被视为对Jenkins自身的颠覆因为这彻底改变了Jenkins流水线调度机制。因为在Jenkins X看来Jenkins只不过是Jenkins X中的一个应用是一个黑盒子编排通过Tekton来实现换句话说即便你想用其他应用来取代Jenkins也不是不可能的。
值得注意的是Jenkins X中有很多约束比如你必须使用GitOps的方案来完成应用的晋级和部署没有其他的选择。如果你没有使用Helm管理应用也不想使用GitOps那就现阶段来说Jenkins X对你就不是一个可选项。
我们来总结一下Jenkins X项目
工具的易用性采用了开箱即用的设计提供大量的模板来降低新应用上手CI/CD的成本。虽然安装复杂但是目前已经提供了JX Boot工具通过初始化向导帮你完成环境搭建。而且随着云服务商的引入环境方面应该都是可以默认提供的就像你不需要操心如何搭建Kubernetes一样因为会有人以服务的形式把Jenkins X提供出来。
流水线设计Tekton取代了Jenkins成为了流水线的默认引擎作为Kubernetes的原生解决方案这也是未来的发展趋势。在编排方面它采用了yaml方式继承了原有Jenkinsfile的语法特征并对Tekton的资源进行隐藏和抽象通过描述式的语言以代码化的方式实现可以说是当前的通用解决方案。不过它目前并没有提供可视化的编排界面。
插件生态继承了Jenkins丰富的插件生态以及庞大的开发者社区。
扩展性配置采用容器化的解决方案对于Tekton来说更是如此。每个步骤都在容器中完成可扩展性非常强。
适用场景我认为Jenkins X项目现在还处于快速开发的阶段适用于原型产品验证。对于那些没有固有模式想要沿用Jenkins X的设计流程的项目来说可以尝试使用。不过由于云服务商的接入度不足目前应该还存在很多挑战你可以保持学习和跟进。毕竟这个项目中的很多工具和设计思路都是非常有价值的。
GitLab CI/GitHub Actions
除了Jenkins国内使用比较多的应该当属GitLab CI了。前些年也有过社区的讨论到底应该使用GitLab CI还是Jenkins很显然这样的讨论并不能达成共识毕竟“萝卜白菜各有所爱”。而GitHub Actions的推出也是看中了流水线编排领域的“蛋糕”。曾经GitHub和TravisCI是珠联璧合可以说是“开源双碧”。GitHub也一再强调自己只想把代码托管服务做到极致其他领域都交给合作伙伴完成。但是今天的Package功能和Actions功能都体现出了GitHub自建生态的野心。
其实,这两个产品有很多相似之处,因为它们都是依托于一个成熟的代码托管平台衍生出来的原生流水线功能。
对于软件开发而言,最重要的无疑就是源代码。之前,我有个同事就说过,只要掌握了源代码,你就可以为所欲为了。比如,基于代码拓展代码评审工具、内建各类静态动态代码检查功能、增加包管理和依赖管理工具等,这些是代码编译之前和编译之后的必备功能。增加内建的持续集成功能,也有助于在代码评审的时候做到机器辅助。
当这些功能都集成到代码托管系统中时你就会发现它不再是一个简单的版本控制系统了而是一整套DevOps平台。它们的设计理念是一个平台解决所有DevOps的工具问题。这一点在GitLab的路线图规划中也体现得淋漓尽致GitLab对主流工具都进行了对比并提供了一个工具的全景图。可以说在行业对标方面GitLab是做到极致了。你可以参考一下下面这张全景图和他们自己写的对比文章。
图片来源https://about.gitlab.com/devops-tools/
回到流水线方面GitLab CI和GitHub Actions都和版本控制系统进行了深度集成。我们还是从五个方面来整体看一下。
1.工具的易用性
易于上手由于是内建功能GitLab CI/GitHub Actions使用起来都非常简单你并不需要单独构建和维护一个独立的CI服务器来实现这个功能。
原生体验由于是原生功能所以无论是在流水线状态展示方面还是在代码评审流程的集成方面它们都做到了原生化的体验显示的信息和丰富程度是外部独立的CI工具所无法比拟的。
一体化协同平台工具链繁多、集成配置复杂、信息分散都是DevOps工具方面的痛点问题。而一体化的研发协同平台的价值就在于能够集中解决这些问题。开发者不需要在各种工具系统中跳来跳去可以在一个地方解决所有问题在一个地方看到所有有用的数据。
在线文档GitLab的文档和示例都非常丰富GitHub就相对薄弱一些不过两者的文档基本都够用。
2.流水线设计
流水线描述GitLab CI和GitHub Actions都采用了yaml形式的流水线过程描述文件二者的语法规则虽然不同但基本上大同小异。但相对来说GitHub的语法规则更加符合当前Kubernetes的资源描述风格。关于这两个产品的语法风格你可以看下这两份资料GitHub ActionsGitLab CI
流水线编辑两个产品都支持在线编辑流水线文件GitHub在这方面更加人性化一些。当你打开Actions的时候系统会给你推荐一些模板你可以直接选择生成Actions配置。如果想自己编辑Actions文件的话系统的右侧也提供了很多示例代码片段让你可以通过简单的复制、粘贴完成这项工作。另外GitHub新版本提供了在线的可视化编辑器毕竟GitHub Actions是全新设计的集合了各方面的优势。
3.插件生态
GitLab生态作为一个开源软件GitLab的优势也恰恰在于开源官方对于社区PR和feature的响应也是非常及时的。但是由于GitLab是基于Ruby语言、Rails框架开发的这个语言就成了比较大的瓶颈毕竟熟练掌握Ruby语言的国内开发者相对还是比较少的所以GitLab的插件生态并没有做起来。
GitHub生态GitHub有建设Marketplace的长期经验再加上开源贡献者众多所以在短短一年左右的时间里他们已经积累了1700多个Actions组件可以帮助你快速地搭建自己的流水线。从扩展性和生态丰富性方面来说GitHub更胜一筹。
使用成本必须要强调的是GitHub是商业软件虽然对待开源项目采用免费策略但是如果企业级使用的话成本也是必须要考虑的因素之一而自建GitLab如果采用社区版本就没有这么多限制了这也是优势之一。
4.扩展性配置
它们都支持多种环境类型。GitLab很早就提供了对容器和Kubernetes的支持GitHub在这方面自然也不会落后官方提供了Linux、Windows和Mac环境的支持你也可以自建节点并注册到GitHub中。不过必须强调一点GitHub如果是非企业版本的话是不支持私有化部署的这也就意味着如果你想把企业内部的资源注册到GitHub上那么就意味着这些资源必须对外可见。
5.适用场景
由于国内GitLab自建服务的普及如果你对CI的功能要求没有那么高那么GitLab CI就足够了。但是在功能广度方面由于缺少庞大的插件生态很多功能还是更多地依赖于你自己实现所以如果软件交付流程非常复杂依赖于多种环境GitLab CI就不是那么适用了。
而GitHub在企业中的使用场景就更加有限了一方面是成本问题另一方面SaaS化服务依赖于内部开放性。所以如果是开源项目或者创业项目不希望自己维护一套很重的研发基础设施那么我建议你考虑使用GitHub的方案。
在最新发布的2019年Forrester的趋势报告中GitLab和Jenkisn都入选了云原生CI工具的榜单并且处于行业领先地位你可以看一下报告的图片。虽然图中没有写明Jenkins但是其背后的CloudBees公司以及目前在云原生项目Jenkins X中有深度合作的Google公司都处于领先地位由此可以看出各大公司都已经开始在云原生领域布局了。
Drone
这也是一个近来冉冉升起的CI工具领域的新星。在咱们专栏的留言中有很多同学提到过这个工具可见好工具是会自己说话的。
Drone主打的就是云原生CI整体设计非常轻量级即便没有什么经验一两天也能快速上手搭建。在我看来Jenkins X虽然也是主打云原生但由于引入了大量组件和流程约束整体还是略显笨重一些。相反Drone的实现非常优雅无论是流水线的语法还是环境的扩展性方面都让人不由得赞叹。
作为一个开源软件Drone使用Go语言实现。在我看来Go就是为云原生而存在的无论是Docker、Kubernetes还是我参与的Jenkins X项目都是通过Go语言来实现的。所以这个项目对于内部开发团队快速提升Go语言的DevOps平台建设能力也是一个很好的参考学习案例。
对于Drone平台我目前也在学习和探索阶段我从下面这几个方面谈谈我个人的看法。
1.工具的易用性
Drone的搭建非常简单你可以采用自建服务的形式也可以使用SaaS服务。UI风格设计体现了恰到好处的理念整体非常清爽同时也能跟其他工具如GitHub进行集成。
2.流水线设计
作为云原生的解决方案流水线同样采用yaml形式、具备描述式表达和流水线即代码的功能。虽然没有过于复杂的语法但是Drone的流水线语法风格是我个人最喜欢的它的结构非常清晰。
3.插件生态
Drone也提供了插件机制而且官方还提供了对主流版本控制系统和云服务商的集成支持。虽然数量远远比不上Jenkins生态但是你能想到的基本都有了。比如常见的Artifactory、SonarQube、Ansible等工具甚至还包含了对微信、钉钉这类国内流行的通讯软件的集成。由于它的开放特性未来它也会提供更多的插件。
4.扩展性配置
对于Drone来说最大的特征就是容器优先。上面提到的这些工具虽然都支持容器但是并没有把容器作为默认支持的第一选项。而在Drone中容器则是标配这也是典型的云原生CI工具的特征一切都在容器中运行。也正因为如此非容器化开发部署的项目如果采用Drone就不太合适了。另外除了容器方式之外Drone也支持本地执行这为一些特殊的场景提供了可能性比如绑定设备的自动化测试等
5.适用场景
我认为Drone在云原生CI/CD方面的设计代表了未来的趋势。对于基于容器开发交付的产品来说如果你在寻找一个对应的云原生解决方案那么我推荐你用Drone。它也比较适合于中小型团队、初创公司想要快速受益于CI/CD又不想投入太多精力的场景。同时作为一款Go语言开发的开源软件随着业务扩展你大可以自建插件满足差异化的需求。
总结
最后,为了方便你理解和进行对比学习,我把这五个云原生流水线工具的特征汇总了图片里。
到此为止,这几款主流的流水线工具,我就介绍完了。在文章的最后,我还想再补充两点:
工具并非决定性的因素不要轻易陷入“工具决定论”的思想之中就好比真正的编程高手可能都不需要IDE选择好的工具并不代表就有好的结果。
工具是“存在即合理”的它们都有各自擅长的领域没有绝对意义上的最好只有最适合的场景。另外即便是同一个工具在不同的人手中发挥的作用也不一样选择自己最熟悉的工具一般都不会有错。比如你要问我选择什么工具的话我肯定推荐Jenkins。但这并不是因为Jenkins完美无缺而仅仅是因为我用得顺手而已。
思考题
对于Drone这款工具在生产环境的应用你有哪些实际的经验又踩过哪些“坑”呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,135 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送成为DevOps工程师的必备技能
你好我是石雪峰今天到了“特别放送”环节。有很多留言问道“DevOps专家这个岗位需要的技能和技术栈有哪些成长路径是怎样的呢
我相信这应该是很多刚开始接触DevOps的同学最关心的问题。毕竟从实用的角度出发每个人都希望能够尽快上手实践。所以今天我来跟你聊聊我认为的DevOps工程师的必备技能以及学习路径。不过在此之前我们要先了解DevOps工程师的岗位职责。
全球最大职业社交网站LinkedIn领英2018年发布的一份报告显示当今全球最热门的招聘职位分别是DevOps工程师、企业客户经理和前端开发工程师。其中排名第一的就是DevOps工程师。
无独有偶2019年全球最大知识共享平台Stack Overflow的开发者调查报告显示在薪资排行榜上DevOps工程师排名第三仅次于技术经理和SRE网站可靠性工程师。而在去年的调查报告中DevOps工程师的收入甚至排名第二。
无论是人才市场需求还是收入薪资水平这种种迹象都表明DevOps工程师已经成为了当今最炙手可热的岗位收入也攀升至IT行业的金字塔顶端。难怪有越来越多的人开始接触和学习DevOps。
但是DevOps这样一个刚刚诞生10年的“新兴事物”并不像一门专业技术那样有一条相对清晰的学习路径以及经典的学习资料比如你要学习Java就可以从《Java编程思想》看起。
除此之外DevOps似乎又跟软件工程的方方面面有着说不清的关系。我跟你分享一幅DevOps技能发展路线图根据这幅路线图你要从编程语言入手理解操作系统原理、系统性能、网络安全、基础设施即代码、CI/CD、运维监控和云技术等等。
图片来源https://roadmap.sh/devops
怎么样是不是看到这么一堆名词就瞬间头大了吧如果要把这些所有的技术全部精通那至少得是CTO级别的岗位。对普通人来说这并不太现实。毕竟啥都懂点儿但是啥都不精通本身就是IT从业者在职业发展道路上的大忌。
如果要说清楚这个岗位核心就是要回答3个问题
DevOps工程师在公司内承担的主要职责是什么
为了更好地承担这种职责,需要哪些核心技能?尤其是从我接触过的这些公司来看,有哪些技能是当前最为紧俏的呢?
学习和掌握这些技能,是否存在一条可参考的路径呢?
接下来,我们就重点聊一聊这些内容。
DevOps工程师的岗位职责
关于DevOps工程师这个岗位一直以来都存在着很大的争议。很多人认为DevOps应该是一种文化或者实践而不应该成为一个全新的职位或者部门因为这样会增加公司内部的协作壁垒。
其实我倒觉得没有必要纠结于这个Title因为很多时候DevOps跟公司内部已有的角色存在着重叠。比如开发变成了DevOps开发运维变成了DevOps运维。另外在不同的公司里面类似角色的岗位名称也大不相同。比如在DevOps状态报告中DevOps就和SRE被归为一类进行统计。而在公司中实际负责推行DevOps的部门至少我见过的就有工程效能团队、运维团队、配管团队甚至还有项目管理团队。可见不同公司对于DevOps工程师的职责定义也同样存在着差异。
但不管怎样我觉得谈到DevOps工程师职责的时候除了本职工作的内容以外至少还应该额外关注3个方面
1.工具平台开发
关于工具平台开发争议应该是最小的而且这也是很多公司推行DevOps的起点。因为工具是自动化的载体而自动化可以说是DevOps的灵魂。随着公司规模越来越大研发内部的协作成本也随之水涨船高那么工具平台的能力水平就决定了公司交付能力的上限。
但问题是,因为种种原因,很多公司只有大大小小的分散工具,并没有一套完整的研发协同工作平台,这本身就制约了协作效率的提升。你可以想象一下,研发每天要在大大小小的系统里面“跳来跳去”,很多功能甚至还是重复的,这显然是很浪费时间的。
比如你明明已经在代码托管平台上做了代码评审结果提测平台上面还有个必填项是“你是否做过了评审”是不是很让人抓狂呢这背后的主要原因就是缺乏顶层设计或者压根就没有专人或者团队负责这个事情。这样一来团队各自为战发现一个痛点就开发一个工具发现一个场景就引入一个系统再加上考核指标偏爱从0到1的创造性工作也难怪每个高T升级都要有自己的系统加持了。但如果任由这种趋势发展下去内部的重复建设就难以避免了。
所以对于DevOps工程师而言除了要关注原有的工具重构、新功能的开发之外更要聚焦于整个软件交付流程将现有的工具全面打通以实现可控的全流程自动化。也就是说不仅仅要追求点状的工具还要包括整条线上的工具链从而形成覆盖软件交付完整流程的工具体系。
另外工具平台同样是标准化流程的载体同时也是DevOps实践的载体所以在设计实现时需要考虑这些实践的支持。举个例子在配置管理领域将一切纳入版本控制是不二法则。那么在建设工具平台的时候就需要始终有这样的意识比如记录流水线的每一次配置变更的版本并且能够支持快速的对比回溯。
2.流程实践落地
其次,无论是工具平台的推广落地,还是结合平台的流程改进,都需要有人来做。毕竟,即便是完全相同的工具,在不同人的手里,发挥的作用也千差万别,把好好的敏捷管理工具用成了瀑布模式的人也不是少数。而针对流程本身的优化,也是提升协作效率的有效手段。
比如在有的公司里,单元测试需要手动执行,那么当工具平台具备自动化执行的能力,并且能够输出相应的报告时,这部分的操作流程就应该线上化完成。再比如,以往申请环境需要走严格的线上审批流程,当环境实现自动化管理之后,这些流程都可以变为自服务,通过工具平台进行跨领域角色的交叉赋能,从而实现流程优化的目标。
另外我接触过的一些公司倾向于在不改变流程的前提下推动DevOps落地。坦率地说这种想法是不现实的。如果流程上没有约束开发和测试共同为结果负责那开发为什么要跟测试共同承担责任呢出了问题又怎么可能不扯皮呢因此如果你在公司内部负责流程改进遇到问题就应该多问几个为什么找到问题的本源然后将流程和工具相结合双管齐下地进行改进。
所以理念和实践的宣导内部员工的培训持续探索和发现流程的潜在优化点这些也都是DevOps工程师要考虑的事情。
3.技术预研试点
最后,各种新技术新工具层出不穷,哪些适用于公司现有的业务,哪些是个大坑呢?如果适合的话,要如何结合公司的实际情况,评估潜在的工具和解决方案,而不是盲目地跟随业界最佳实践呢?类似技术债务的识别和偿还这种重要不紧急的事情,到底什么时候做合适呢?
另外,如果公司决定开始推行单元测试,那么,选用什么样的框架,制定什么样的标准,选择什么样的指标,如何循序渐进地推进呢?这些同样非常考验团队的功底。如果步子一下子跨得太大了,到最后就可能成为形式主义了。
你可能会觉得我就是一个小开发、小运维怎么能推动这么大的事情呢但实际上DevOps从来都不是某一个人或者某一个角色的职责而是整个研发交付团队所共享的职责。在你力所能及的范围内比如在你所在的部门内部开展DevOps的理念宣导和技术培训鼓动领导参加行业的大会在和上下游团队协作的时候向前一步这些都是DevOps所倡导的自服务团队应该具备的能力。
DevOps工程师的主要技能
说完了DevOps工程师主要负责的事情接下来我们就来看看DevOps工程师所要具备的能力。我从实用的角度出发总结了DevOps工程师的核心能力模型。
其中能力模型分为两个方面专业能力和通用能力。专业能力也就是常说的硬实力是IT从业人员身上的特有能力比如软件工程师会写代码就跟导演会拍电影司机会开车一样。而通用能力更加接近于软实力这些能力并不局限于某一个岗位或者职业是所有人都应该努力培养的能力。很多时候当硬实力到达天花板之后软实力的差异将决定一个人未来的高度这一点非常重要。
软实力
我们今天先从软实力说起。在讲具体的软实力之前,我先跟你分享一个小故事。
我在国外听过这样一种说法在企业中印度裔的工程师往往比华裔工程师的岗位职级要高。为什么会这样呢我曾经做过一个跨中美印三地的工程团队的负责人我发现每次我跟印度工程师交代一个事情他们总能又快又好地做出一个特别清晰漂亮的PPT。我特意问过他们是怎么做到的。原来他们在上学时受过这方面的训练还专门练习过表达、演讲等技能可见事出必有因软实力对个人的发展至关重要。
那么作为一名DevOps工程师需要具备什么软实力呢
1.沟通能力
DevOps倡导的核心理念就是沟通和协作所以难怪沟通能力会排在软实力的第一名。
在推动DevOps落地的过程中你需要同时具备向上沟通、向下沟通和横向沟通的能力。提炼DevOps实施框架和落地价值寻求领导层的支持需要向上沟通打破组织间的边界建立跨团队的协同需要横向沟通引导团队快速完善平台工具能力表明工作的意义和价值提升大家的主动性需要向下沟通。所以你看其实每天的工作中都充满了大量的沟通。
需要注意的是,沟通能力不仅限于语言能力,很多时候,开发运维的沟通是基于代码完成的。所以,良好的注释风格、清晰结构化的描述方式……这些细节往往也能提升沟通的效率。
比如有一种很DevOps的方式就是ChatOps是以GitHub的Hubot为代表的对话式运维慢慢扩展为人机交互的一种形式。通过建立一种通用的沟通语言打破开发和运维之间的隔阂。
2.同理心
DevOps希望团队可以共享目标共担责任但是实际上哪个团队不想更加自动化、更加高效地工作呢所以DevOps工程师要能够站在对方的角度来看问题设身处地地想想他们的困难是什么我能做些什么来帮助他们。这种同理心也是弥合团队分歧建立良好的协作文化所必需的能力。
除此之外,培养团队以用户为中心的思想,也是很好的方式。这里的用户,不是外部用户,而是在交付流程中存在交付关系的上下游部门。在交付一个版本的时候,要尽力做到最好,而不是不管三七二十一,先丢过去再说。
我还是要再强调一下,同理心只有在流程和机制的保证之下才能生根发芽。
3.学习能力
DevOps工程师需要了解的东西真得很多因此能够在有限的时间里快速学习新的技能并且有意愿主动地改进提升也是一种能力。
在DevOps工程师的眼里从来没有“完美”二字。比如完美的流程、完美的技术实现、完美的软件架构等。他们似乎天生就有一种能力那就是能发现问题并时刻想着可以做到更好。但实际上如果没有日积月累的思考没有外部优秀实践的学习没有开放的沟通和交流是没有办法知道原来还有一种更好的工作方式的。引用质量管理大师戴明博士的一句话
Dont just do the same things better find better things to do.
很多时候我们都在等待一个完美的时机比方说你打算学习一个新的知识点但要等到工作都完成了没人来打扰有大段的时间投入才开始学习。但实际上哪来这么多准备就绪的时候呢真正的学习者都是在没有条件来创造条件的过程中学习的。所以如果想开始学习DevOps我信奉的原则只有一个那就是先干再说。
总结
今天我给你介绍了DevOps工程师的前景可以说现在是这个岗位的黄金时期。我还给你介绍了DevOps工程师的主要职责包括工具平台开发流程实践落地和技术预研试点这些都是在完成本职工作的基础上需要额外考虑的。在个人技能要求方面我重点提到了3项软实力希望你始终记得软实力不等于玩虚的这对未来个人的发展高度至关重要。
在下一讲中我会跟你分享DevOps工程师必备的硬技能以及成长路径敬请期待。
思考题
你所在的公司是否有DevOps工程师的岗位呢他们的职责要求是怎样的呢你觉得还有哪些软实力是DevOps工程师所必备的呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,195 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送学习DevOps不得不了解的经典资料
你好,我是石雪峰。
今天又到了特别放送的环节在学习交流DevOps的过程中经常有人会问这样的问题
我想学习DevOps可以推荐一些好的书和资源吗
DevOps相关的最新行业案例我可以在哪里获取呢
你是怎么知道这么多有趣的故事的呢?
这些问题的“出镜率”特别高所以我今天专门来跟你聊聊有关DevOps学习资料的事情。
你应该也有感觉在这个信息爆炸的时代如果想要了解一个新的事物相关的信息不是太少而是太多了。像DevOps这种热门话题相关的资料网上一搜就一大把。各种新书也像“采用了DevOps实践”一样发布频率越来越快。信息一多我们就很容易焦虑这么多资料什么时候才能看完啊
更何况,如果单单只是臻选有用的资料,就要花费大量的时间,按照精益的理论来说,这也是不增值的活动呀。在这个时间稀缺的时代,想要花大段的时间投入到一件事情上,找到一个靠谱和有价值的信息,就成了很多人开始学习的第一步,
所以为了让你在专栏之余可以更加有效地持续学习我特意整理了一份我认为DevOps从业人员需要了解和关注的资料你可以参考一下。
需要强调的是,有针对性地精读一本好书的一部分内容,要比泛泛地读好几本书要更有收获一些,也就是“贵精不贵多”,先定下一个小目标,然后沉下心来反复地学习实践,这个道理在大多数领域都是适用的。
一份报告
如果说DevOps领域有行业公认的权威资料的话DevOps状态报告自然是不二之选。
从2014年开始这份报告每年发布一次主要编写方也经历了好几次变迁从最开始的Puppet实验室、IT Revolution到DORADevOps Research & Assessment的加入再到去年DORA和Puppet分家两边各自推出了自己的DevOps状态报告。
但从影响力来说我更推荐DORA的这份报告从去年开始这份报告正式改名为加速度DevOps状态报告。
提到DORA你可能不太熟悉但是如果说到DORA的两位核心创始人Nicole博士和Jez Humble相信你一定有所耳闻他们也是我今天推荐的一些书的作者。
有意思的是去年DORA宣布加入谷歌其主要成员也被谷歌云收编比如Jez Humble目前就是谷歌云的技术布道师。
回到报告本身我在2017年就开始进行报告的本地化工作。从近两年来看报告的体量在持续扩大比如今年的报告洋洋洒洒有80页内容而且是全英文的。
那么,关于这份报告,重点是要看什么呢?纵观过去几年的报告模式,我给你画个重点:核心是看趋势、看模型和看实践。
首先看趋势。
每年的报告都会有一些核心发现这些发现代表了DevOps行业的发展趋势。比如今年的报告就指出云计算能力的使用依然是高效能组织和中低效能组织的分水岭所以如果公司还在纠结是否要上云不妨从DevOps的角度思考一下使用云计算能力带给交付能力的提升可以有多明显。
另外公司内的DevOps组织比例也从2014年的14%提升到了今年的27%。由此可见越来越多的公司在拥抱DevOps至少从组织层面可以看到越来越多带有DevOps职责或者是以DevOps命名的团队出现。这对于公司内部职责的划分和团队架构演进具有一定的指导意义。
当然不得不提的还有衡量DevOps实施效果的4个核心指标也就是变更前置时间、部署频率、变更失败率和故障修复时长。
从2014年的第一份报告开始每年的报告都在对比这4个核心指标在不同效能团队之间的变化和差异。实际上就我观察国内很多公司的DevOps度量体系都深受这些指标的影响或多或少都有它们的影子。
可以说这4个指标已经成为了衡量DevOps效果的事实标准甚至有人直接把指标拿给老板看“你看高效能组织比低效能组织的故障恢复时长要快2000倍由此可以证明DevOps是势在必行的。”
我个人觉得,没有必要纠结于数字本身,这东西吧,看看就好,更多的还是要透过数据看趋势。
比如,去年的指标数据就显示,在交付能力方面,不同组织间的差距在缩小,相应的质量维度的指标差异却在拉大。这就说明,通过初期的自动化能力建设,团队可以快速地提升交付水平。但是,由于缺少质量能力的配套,很容易产生更多的问题,这就带来一个警示,在快速提升交付能力的同时,质量建设也不能落在后面。
关于报告,其次是看模型。
我在第4讲中提到过一个观点任何技术的走向成熟都是以模型和框架的稳定为标志的。因为当技术跨越初期的鸿沟在面对广大的受众时如果没有一套模型和框架来帮助大众快速跟上节奏找准方向是难以大规模推广和健康发展的。
在软件开发领域是这样,在其他行业也是如此,要不然,为啥会有那么多国标存在呢?所以说,模型和框架的建立是从无序到有序的分水岭。
在今年的状态报告中,研发效能模型进一步细化为软件交付运维模型和生产力模型。今天我不会深入解析模型本身,但我会在专栏后面的内容中结合实际案例进行详细解释,从而帮助你更好地理解。
但是从过往的报告可以看出每一年关于模型的进化是整个报告的核心内容报告也在不断覆盖新的领域试图更加全面地揭示影响软件开发效能的核心要素。在实践DevOps的时候你可以参考这个能力模型识别当前的瓶颈点在遇到拿捏不准的决策时也可以参考模型中要素的影响关系。
比如,公司内部经常会争论是否需要更加严格的审批流程,希望借助严格的审批流程,促使软件交付更加有序和可靠。很多系统和需求在提出的时候,都是以这种思想为指导的。我一直对这种流程的有效性抱有怀疑,加入更多的领导审批环节,除了出问题的时候大家一起“背锅”之外,并没有带来什么增值活动。
在今年的模型中,这种观点得到了印证。重流程管控不利于软件交付效能的提升,轻流程管控也不会影响软件交付质量,关键要看公司是否选择一种“更好”的做法来实现管控的目的。
最后,我们要重点关注实践。
在实施DevOps的时候经常会有这样的困扰道理都懂却仍然做不好DevOps。所以DevOps落地的核心无外乎实践和文化而实践又是看得见摸得着的这一点当然值得关注。在状态报告中有很大篇幅都在介绍实践部分这些实践都是在大多数公司实施总结出来的并且得到了实际的验证具有很强的参考性。
比如今年的报告重点介绍了技术债务、灾难恢复测试和变更管理流程这几个方面的实践这些都是企业实施DevOps时的必经之路。
比如灾难恢复测试,很多公司都有非常详尽的文档,但是如果找他们要操作记录,他们却又很难拿出来。
我之前就见过一家国内Top的公司说是在做关键数据的备份但实际去看才发现这个备份任务已经很长时间处于失败状态了。
如果有定期的灾难恢复测试,类似的这种问题是一定可以发现的。而往往在灾难发生的时候,才能体现一家公司的工程能力水平。
比如Netflix正是因为混沌工程才没有受到AWS云服务down机的影响这和日常的演练是密不可分的。
从2014年至今的DevOps状态报告的中英文版本我已经收集并整理好了你可以点击网盘链接获取提取码是mgl1。
几本好书
讲完了报告,接下来,我再给你推荐几本好书。
1.《持续交付》&《持续交付2.0》
谈到DevOps里面的工程实践持续交付可以说是软件工程实践的终极目标。对于在企业内部推进DevOps工程能力建设的人来说这两本书可以说是案头必备常看常新。
对我自己来说因为2011年机缘巧合地拿到了第一版第一次印刷的《持续交付》这本书我的职业生涯彻底改变了。因为我第一次发现原来软件交付领域有这么多门道。帮助组织提升交付效率这个事情真是大有可为。
《持续交付》围绕着软件交付的原则给出了一系列的思想、方法和实践核心在于以一种可持续的方式安全快速地把你的变更特性、配置、缺陷、试验交付到生产环境上让用户使用。你可以参考一下软件交付的8大原则。
为软件交付创建一个可重复且可靠的过程
将几乎所有事情自动化
将一切纳入版本控制
频繁地做痛苦的事情
内建质量
DONE意味着已发布
交付过程是每个成员的责任
持续改进
很多人都有《持续交付》这本书,但我敢打赌,真正能沉下心来把这本书看透的人并不多,因为这本书里面通篇都是文字,而且有些难懂,如果没有相关的实践背景,基本上就跟看天书差不多了。
所以,通读《持续交付》并不是一个好的选择,我建议你尽量带着问题有选择性地去读。
到了《持续交付2.0》乔梁老师创新性地将精益创业的思想和《持续交付》结合起来更加强调IT和业务间的快速闭环也更加适应当今DevOps的发展潮流。
另外,乔梁老师的文笔更加流畅,读起来更加轻松,他会结合案例进行说明,对于实际操作的指导性也更强。毫无疑问,他是国内软件工程领域的集大成者。
如果你对软件开发流程的工程实践不太了解,你可以读一读这两本书。
当然,对于开发、测试、运维人员这些软件交付过程中必不可少的角色来说,也可以用来拓展知识领域。
2.《精益创业》&《Scrum精髓》&《精益产品开发》&《精益开发与看板方法》
关于管理实践和精益方面我给你推荐4本书。
《精益创业》提出的MVP最小可行产品思想已经被很多的企业奉为圭臬。它的核心是只有经过真实市场和用户的验证想法才是真正有效的产品需要在不断的验证和反馈过程中持续学习持续迭代而不是试图一步到位耗尽所有资源从而失去了回旋的余地。
《Scrum精髓》适合于使用Scrum框架的敏捷团队学习和实践以避免Scrum实施过程中形似而不神似的问题。同时这也是立志成为Scrum Master的同学的红宝书。
《精益产品开发》是何勉老师在2017年出版的一本基于精益思想和精益看板方法的著作。在精益软件开发领域这本书和李智桦老师的《精益看板方法》都是看一本就够了的好书。
这几本书比较适合想要了解敏捷或者是在实际工作中践行敏捷开发方法的同学阅读。另外精益思想可以说是DevOps的理论源泉很多的文化导向以及持续改进类工作都跟精益思想有密切的关系。
3.《DevOps实践指南》&《Accelerate加速》
如果你想了解DevOps的全貌以及核心理论体系和实践《DevOps实践指南》和《Accelerate加速》就是最好的选择了。这两本书的作者都是DevOps行业内的领军人物作为Thought Leader他们引领的DevOps的体系在不断向前演进。
其中《DevOps实践指南》也就是俗称的Handbook重点介绍了DevOps实践的三步工作法还包含了大量DevOps实施过程中的参考案例。而《Accelerate加速》的作者就是DevOps状态报告的作者。他在这本书中揭示了状态报告背后的科学方法并提出了DevOps能力成长模型以帮助你全面提升软件交付能力。
4.《凤凰项目》&《人月神话》&《目标》
最后,我想再推荐三本小说,这也是我读过的非常耐看的几本书了。
其中《凤凰项目》提出的DevOps三步工作法和《DevOps实践指南》一脉相承《人月神话》是IT行业非常经典的图书畅销40余年《目标》则是约束理论的提出者高德拉特的经典著作他所提出的改进五步法构成了现代持续改进的基础。
大会,网站和博客
当然报告和书只是DevOps资源中的一小部分还有很多信息来源于大会、网站和博客我挑选了一些优质资源分享给你。
DEOS DevOps国际峰会以案例总结著称
DevOpsDays大名鼎鼎的DevOpsDays社区
TheNewStack :综合性网站,盛产高质量的电子书;
DevOps.com :综合性网站;
DZone 综合性网站,盛产高质量的电子书;
Azure DevOps综合性网站盛产高质量的电子书
Martin Fowler Martin Fowler的博客
CloudBees Devops Jenkins背后的公司的博客。
在这些资源中,有一些值得你重点关注一下。
比如Gene Kim发起的DOESDevOps企业峰会就是获取实践案例的绝佳场地而DZone和NewStack经常会推出免费的电子书和报告也值得订阅Martin Fowler的博客每一篇内容都是精品对于很多技术细节可以说是起到了正本清源的作用值得好好品味。
说了这么多,最后我还想再花一点点时间,跟你聊聊学习这个事情。我跟你分享一幅美国学者爱德加·戴尔提出的学习金字塔模型图,这个模型也是目前比较有参考性的模型之一。
图片来源https://www.businessdirect.bt.com/
在这个模型中,学习的方式分为两种,一种是主动学习,一种是被动学习。其实,无论是读书,看视频,还是听专栏,都属于是被动式的学习,最终收获的知识可能只有输入信息的一半儿,这还是在记性比较好的情况下。大多数时候,看得越多,忘得越多,这并不是一种特别有效的学习方式。
实际上对于DevOps这种理念实践、技术文化、硬技能、软实力交织在一起的内容来说主动学习的方式是不可或缺的比如案例讨论线下交流在实践中学习等。
所以希望你能多思考多总结结合工作中的实际问题摸索着给出答案并积极分享跟大家讨论。只有主动思考才能消化吸收最终总结沉淀出一套自己的DevOps体系认知。
总结讨论
好了今天我跟你聊了DevOps的学习资料包括状态报告、书籍和大会、网站、博客。不过对于DevOps来说这些也仅仅是点到为止。
我想请你来聊一聊你自己在学习和实践DevOps的过程中有没有私藏的干货和渠道呢如果有的话希望你可以分享出来我们共建一个DevOps相关的资源库并在GitHub上进行开源维护从而帮助更多人了解和学习DevOps。
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,128 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送成为DevOps工程师的必备技能
你好我是石雪峰。在上一讲我介绍了DevOps工程师的具体职责以及DevOps工程师必备的3项软实力分别是沟通能力、同理心和学习能力。有了这些认知之后我们今天来看看“重头戏”DevOps工程师必备的硬实力以及学习路径。
DevOps工程师必备的硬实力
所谓硬实力,说白了就是指一个人的技术能力。软实力通常是“只可意会不可言传”的,但技术本身就具体多了,重要的是,技术水平的高低相对来说也更好衡量。在公司里面,技术人员要想获得晋升,重点就是依靠技术能力。
IT行业覆盖的技术领域非常广而且近些年的新技术也是层出不穷的从入门到精通任何一门技术都需要大量时间和精力的投入。那么在面对这么多技术的时候究竟要选择从哪个开始入手真是一个难题。对于希望成为DevOps工程师甚至是DevOps专家的你来说究竟有哪些必须掌握的核心技术呢
1.代码能力
现在这个时代代码能力可以说是最重要的硬实力了。IT行业自然不用说像运维有运维开发测试也有测试开发就连产品经理都要懂代码不然可能都没办法跟开发同学顺畅交流。
对于工具平台自身的建设而言,代码能力自然是重中之重。这不仅仅在于通过写代码来实现工具平台本身,还在于你能了解开发的完整过程。这些平台的用户每天跟代码打交道的时间可能比跟人打交道的时间还多,如果你不能理解他们的日常工作方式,那么你做出来的工具平台,又怎么能真正解决团队的问题呢?
这里提到的代码能力包含两个方面,分别是脚本语言能力和高级语言编程能力。
脚本语言能力。这对于运维工程师来说自然是驾轻就熟各种VIM、Emacs手到擒来Shell和Python也是轻车熟路。而对于开发人员来说难点不在于语法本身而在于对关联操作系统和命令的理解上。毕竟脚本语言是一种快速的自动化手段追求的是高效开发简单易用。
高级语言编程能力。你需要至少掌握一门高级语言无论是Java、Python还是Ruby和PHP。其实语言只是工具你不用过度纠结于选择哪门语言要求只有一个就是你能用它来解决实际问题比如能够支持你实现面向移动端或者Web端的工具平台开发。为了写出好代码而不仅仅是写出能用的代码你也需要对于一些常见的开发框架和开发模式有所了解。这是一个相对漫长的过程绝对不是什么“21天精通XX语言”就够了。因为看得懂和写得好完全是两码事。
好的代码是需要不断打磨和推敲的。与其说写好代码是一门技术不如说是一种信仰。我们团队的内部沟通群名叫作“WBC团队”“WBC”也就是“Write Better Code”的缩写这其实也是我们团队对自己的一种激励。在日常的开发过程中我们会不断发现和总结更好的实现方式在内部分享互相学习从而持续提升代码能力。我截取了一部分我们最近优化流水线脚本的经验总结你可以参考一下。其实每个人都能总结出自己的代码心经。
2.自动化能力
在自动化方面你首先需要对CI/CD也就是持续集成和持续交付建立起比较全面的认知。因为CI/CD可以说是DevOps工程领域的核心实践目前大部分公司都在集中建设软件的持续交付能力尤其是以流水线为代表的持续交付平台很多时候就同DevOps平台划上了等号。
接下来为了实现全流程的自动化你需要能够熟练使用CI/CD各个关键节点上的典型工具并且了解它们的设计思路。
一方面目前很多公司都在拥抱开源参与开源开源工具自身的成熟度也非常高并且逐渐取代商业工具成为了主流方案。通过直接使用开源工具或者基于开源工具进行二次开发也是自动化领域投入产出比最高的方式。所以像版本控制工具Git、代码托管平台Gitlab、CI工具Jenkins、代码扫描工具Sonar、自动化配置管理Ansible、容器领域的Docker、K8S等等这些高频使用的工具都是你优先学习的目标。
另一方面无论是开源工具还是自研工具工具与工具之间的链路打通也是自动化的重要因素。所以在理解开源工具的实现方式的基础上就要能做到进可攻退可守。无论是封装还是自研有了工具的加持CI/CD也会更加游刃有余。
关于DevOps的工具图谱我跟你分享一个信通院的DevOps能力成熟度模型版本供你参考。值得注意的是工具不在多而在精。其实工具的设计思路和理念有共通之处只要精通单个节点上的工具就可以做到以点带面。
3.IT基础能力
我始终认为运维是个特别值得尊敬的工种也是DevOps诞生的原点。如果你不是运维出身那你要重点掌握运维的基础概念最起码要了解Linux操作系统方面的基础知识包括一些常用的系统命令使用以及网络基础和路由协议等。毕竟对于开发者来说他们通常习惯基于IDE集成开发环境图形界面工作。比如如果问一个iOS开发同学怎么通过命令行的方式进行构建调试或者如何用代码的方式实现工程的自动化配置他可能就答不上来了。
另外,随着基础设施即代码的技术不断成熟,你还要能看懂环境的配置信息,应用自动化构建、运行和部署的方式等,甚至可以自行修改环境和应用配置,这样才能实现所谓的开发自运维。虽然在大多数公司,运维的专业能力一般都会通过运维平台对外提供服务,但对于基础概念,还是需要既知其然,也知其所以然。
4.容器云能力
云计算对于软件开发和部署所带来的变化是革命性的。未来企业上云或者基于云平台的软件开发会慢慢成为主流。而容器技术又天生适合DevOpsKubernetes可以说是云时代的Linux基于它所建立的一整套生态环境为应用云化带来了极大的便利。
所以无论是容器技术的代表Docker还是实际上的容器编排标准Kubernetes你也同样需要熟悉和掌握。尤其是在云时代基于容器技术的应用开发和部署方式都是DevOps工程师必须了解的。
5.业务和流程能力
在任何时候DevOps的目标都是服务于业务目标DevOps本身也从来不是墨守成规的方式而是代表了一种变革的力量。所以加强对业务的理解有助于识别出DevOps改进的重点方向而流程化的思维建设有助于突破单点放眼全局。
很多时候,企业需要的不仅仅是一个工具,而是工具所关联的一整套解决方案,其中最重要的就是业务流程。
对于DevOps工程师来说要有能力发现当前流程中的瓶颈点并且知道一个更加优化的流程应该是怎样的这一点也是制约工程师进一步拓展能力的瓶颈之一。
举个例子对于开发DevOps平台工具来说你可能认为最合适承担的团队就是开发团队因为他们的代码能力最强。但是实际上DevOps平台的设计很多时候都是由最熟悉企业内部研发流程的团队来主导的。正因为DevOps工程师的工作应该同业务紧密联系更加关注于全局交付视角所以很多时候配置管理、质量管理、项目管理和技术运维团队更多地在承担相近的角色。毕竟只有方向正确所做的一切才是加法。
学习路径
那么要想成为DevOps工程师是否有一条普适性的学习路径呢实际上这个问题就跟我们要在公司推行DevOps是否存在一条通用的改进路径一样并不是一个容易回答的问题。
从前面的能力模型可以看出DevOps工程师特别符合现在这个时代的要求他具备多重复合能力是典型的全栈工程师或者“梳子型”人才。因为只有这样才能充分弥合不同角色之间的认知鸿沟堪称团队内部的万金油。
基于过往在公司内部推行DevOps的经验以及当前行业的发展趋势我有几条建议送给你
1.集中强化代码能力
未来的世界是软件驱动的世界。我们以前总说的必备能力,比如外语、开车等,未来都可以被软件所取代。而编程能力即将成为下一个必备能力,甚至连国务院发布的《新一代人工智能发展规划》中都提到,要在中小学普及推广编程教育。
而写可以用的代码,和写好的代码之间,距离绝不只是一点点而已。你可能会说,以后都用人工智能来编程了,可问题是人工智能从何而来?又是谁来训练和标注人工智能的呢?所以,越是基础的能力,越不会过时,比如数学、核心的编程思想、数据结构,以及基于代码构建对世界的认知和建模能力。
所以如果你现在只是刚开始接触代码我建议你给自己定一个目标专门强化自己的代码能力至少花1年时间从新手变成熟手这对于你未来在IT行业的发展至关重要。
跟你分享一个小技巧。你可以基于成熟的开源软件来边学习边应用比如像Adminset这种轻量级的自动化运维平台已经可以解决大多数中小公司的问题了。其实代码能力不仅仅是掌握语法和框架更重要的是基于场景整体设计数据和业务流程并通过代码实现出来。毕竟只有结合实际的应用场景进行学习才是最有效率的。
2.培养跨职能领域核心能力
相信经过几年的工作,你已经具备了当前岗位所需要的基本能力,这是你当前赖以为生的根本。那么在这些能力的基础上,逐步发展跨领域跨界的能力,尤其是那些核心能力,就成了投入产出比最高的事情。
举个例子,如果你是软件开发工程师,那么恭喜你,你已经走在了代码的道路上,接下来,运维能力就是你要尝试攻克的下一个目标。而在这些目标中,比如操作系统、自动化部署以及云能力,就是你要最优先发展的跨界能力,因为它们是运维的核心,也是了解运维最好的出发点。反过来说,如果你从事的是运维行业,那么除了常用的脚本以外,核心代码能力就是你的目标。
其实,我们每天的工作其实都离不开跨界,比如,运维每天部署的应用,为什么要部署这么多实例?每个实例之间的调用关系又是怎样的?多问几个为什么,往往就有新的收获。
不仅如此,在接触跨领域的时候,除了基础核心技能,那些最常见的工具,你也要花时间来了解。现在网上的资料足够多,快速入门应该并不困难。
3.DevOps核心理念和业务思维
如果你不理解DevOps到底是什么那何谈成为DevOps工程师呢因此像DevOps中的核心理念比如精益敏捷、持续交付以及很多实践你都要有所了解。当然如果你订阅了这个专栏我将带你走过前面的这段路你可以快速地进入下一阶段在实战中练习。
DevOps在公司的落地是大势所趋也许你所在的团队也会参与其中那么除了做好自己的本职工作外你也可以多参与多思考看看推进的过程是怎样的涉及到的角色又在做些什么项目的整体进展和计划是什么。在实战中练习和补齐短板对于积累经验来说是不可或缺的。很多时候不是没有学习的机会只是我们自己不想看到罢了。
另外,可能你现在距离业务还比较远,那么你可以尝试了解一些大的业务目标,多跟你所在团队的上下游进行沟通,看看他们现在的关注点在什么地方。既然业务的目标需要整个团队紧密协作才能完成,那么每个团队都是其中的一份子,所以他们身上也同样体现了业务的目标。
4.潜移默化的软实力建设
类似沟通能力、同理心、自驱力、学习能力、主动性等,无论从事任何职业,都是你身上的闪光点。很多天生或者从小养成的习惯,需要长时间潜移默化的训练才能有效果。
很多时候IT从业人员给人的印象都是不善表达再加上东方文化的影响本身就比较含蓄这对很多沟通和表达来说都是潜在的障碍。这个时候就要尽量把握已有的机会比如多参加团队内部的读书分享、公司内部的讲师培训报名等。即便刚开始分享的内容还不足你脑中的1%但至少也是一个好的起点。我的建议就是6个字勤练习多总结。就像DevOps一样持续改进和持续反馈培养自己的自信心。
总结
总结一下我在这两讲给你介绍了DevOps工程师要重点关注的3大职责分别是工具平台开发、流程实践落地和技术预研试点。另外我还基于实用角度提炼了8大核心能力模型分为3条软实力和5条实力并给出了4条提升DevOps核心能力的建议。为了方便你复习和理解我画了一张脑图把这两讲内容进行了汇总你可以参考一下。
最后我想强调的是就像DevOps没有明确的定义一样DevOps工程师的技能也没有明确的限定所以你要时刻保持好奇心持续学习总结出自己的能力体系并在实践积累经验这样才能在激烈的竞争中占得先机。
思考题
针对我们这两讲的内容,你觉得自己需要提升哪方面的能力呢?你有哪些快速提升能力的小窍门吗?
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,160 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送关于DevOps组织和文化的那些趣事儿
你好我是石雪峰今天又到了特别放送环节。写到这儿专栏已经接近尾声了我想再跟你聊聊DevOps的组织和文化。
DevOps文化好像是一个矛盾结合体一方面文化这种东西似乎只可意会不可言传另一方面文化对DevOps实践的重要性又是毋庸置疑的。
在各种行业大会上,关于文化的议题总是屈指可数。原因也很简单,关于文化,一般都说不明白,即便能说明白,也改变不了什么。因为文化的改变可不是像引入一个工具那么简单,很多时候都需要思想上的转变。
谈到DevOps文化我想到去年我和几个朋友一起组织《DevOps实践指南》的拆书帮活动。这个活动就是通过连续几周的线上分享我们帮助大家总结提炼书中的核心知识。
在分享的过程中有这样一件事我印象特别深刻。事情的起源是原书的第14章中有这样一段描述
团队在客户面前没有任何需要隐藏的,对自己也同样如此。与其把影响线上系统的问题视为一种秘密,不如尽可能地将它透明化,主动将内部的问题广而告之给外部用户。
某大型公司的IT负责人刚好负责分享这个章节他表示为了尊重原文他保留了这段描述但是在国内的环境下这并不现实。即便是他自己一个坚定的DevOps实践者也很难做到这种程度。因为如果把公司内部的问题通通开放给客户那估计转天就可以收拾东西回家了。
也正因为公司一般不会在第一时间对外公布故障,所以也难怪,这些事情基本都是通过“云头条”这类公众号第一时间公布出来的。
但是,似乎大家的记忆力也都不太好,很多时候,这些事情过去了也就不了了之了,除了听说“谁又背锅了,谁又被牵连了“之类的流言蜚语之外,也没有什么特别之处。
这也可以理解,毕竟家丑不可外扬,内部吐吐槽也就罢了,如果凡事都到外面去宣传,那公司岂不是形象全无?更有甚者,还会影响用户对公司的信心。你想,如果天天就你问题最多,那谁还敢用你的服务呢?
我们都知道DevOps文化的几个关键词协作、分享共担、无指责文化、在错误中学习……这些道理大家都懂但真正遇到问题、需要平衡不同部门利益的时候是否还能以这些文化为准则来指导行为模式就是另外一码事了。
说白了如果想看团队是否具备DevOps文化与嘴上说说相比更重要的是看怎么做。所以今天我给你分享几个故事看看在面对同样的问题时其他公司是怎么做的并思考一下为什么这样是一种更好的做法
GitLab删库的故事
时间回到2017年1月31日全球最大的代码托管协作平台之一的GitLab出现了一次长达18小时的停机事故原因居然是一个IT工程师把生产数据库的数据给清空了。
由于遇到了爬虫攻击主备数据库之间的同步延迟已经超过了WAL的记录上限导致数据同步无法完成。当时遇到这种问题的操作就是移除所有备份数据库上的数据记录然后全量触发一次新的同步。但是由于数据库配置并发数和连接数等一系列的配置问题导致数据库的数据备份一直失败。
这个时候时间已经来到了标准国际时间的晚上11点半。由于时差的关系对于身在荷兰的工程师来说这时已经是深夜1点半了。当值工程师认为有可能是之前失败的同步遗留的数据导致的数据库备份失败所以决定再一次手动清空备份服务器的数据。
但是也许是由于疏忽他并没有意识到当时他操作的是生产数据库。几秒钟后当他回过神来取消操作的时候一切都已经来不及了。最终的结果是总共有超过300G的线上数据丢失直接导致了服务进入恢复模式。
按道理说这种事情虽然难以接受但其实并不少见。更加严重的是当GitLab尝试恢复数据的时候才发现他们所谓的“精心设计”的多重备份机制竟然都无法拯救被删除的数据。
最夸张的是,直到这会儿,他们才发现,由于升级后工具版本不匹配,数据库的定时备份一直处于失败状态。他们原以为邮件会告警这个问题,但巧合再一次出现,针对自动任务的报警也没有生效。
事已至此,要么是隐藏事实,然后给外界一个不疼不痒的解释,要么就是把问题完全公开,甚至是具体到每一个细节,你会选择怎么处理呢?
GitLab公司的选择是后者。他们第一时间将系统离线并将事件的所有细节和分析过程记录在一个公开的谷歌文档中。不仅如此他们还在世界上最大的视频网站YouTube上对恢复过程进行全程直播。
考虑到有些用户不看YouTube他们还在Twitter上同步更新问题状态硬生生地将一场事故变成了一个热门话题。当时同时在线观看直播的用户超过5000人甚至一度冲到了热门榜的第二位。
除此之外在几天后公司的CEO亲自给出了一篇长达4000字的问题回溯记录包含问题发生的背景、时间线、核心原因分析针对每一种备份机制的说明以及将近20条后续改进事项由此获取了用户的信任和认可。可以说在这一点上他们真的做到了透明、公开和坦诚相待并且做到了极致。
问题回溯的资料: https://about.gitlab.com/2017/02/10/postmortem-of-database-outage-of-january-31/
至于那位倒霉的工程师的结果,估计你也听说了,对他的惩罚就是强迫他看了几十分钟的《彩虹猫》动画。说实话,这个动画有点无聊。但是,如果这种事情发生在咱们身边,估计直接就被开掉了。我知道你肯定好奇这个《彩虹猫》到底是个啥动画片,我也特别无聊地找来看了下,如下所示:
从此以后GitLab的开放越发“变本加厉”。现在你可以在任何时间去查看服务的实时状态包括每一次过往的事故分析。同时名叫“GitLab状态”的Twitter账号实时更新当前的问题目标就是在任何用户发现问题之前尽量主动地将问题暴露出来至今已经发布了将近6000条问题。
同时你还可以查看GitLab服务的详细监控视图和监控数据包括GitLab的运维标准手册、备份脚本。这些通通都是对外开放的。只要你想用你就可以直接拿来使用如果你觉得哪里不靠谱也可以直接提交改动给他们。我提取了一些截图和地址你可以参考一下。
1.GitLab状态Twitterhttps://twitter.com/gitlabstatus
2.GitLab状态网页https://status.gitlab.com/
3.GitLab内部监控大屏https://dashboards.gitlab.com/
这并不是GitLab公司发疯了实际上开放已经成为了主流公司的标配。比如在GitHub上你同样可以看到类似的信息。
故事讲到这儿,就可以告一段落了。面对事故的态度,很大程度上体现了公司的文化。
首先,就是在错误中学习。
GitLab的分析报告不仅是对问题本身的描述很大程度上也是希望把他们的经验尤其是修复过程中的经验分享出来通过错误来积累经验改善现有的流程和工具从而彻底地避免类似问题的出现。
每个人、每个公司都会犯错,对错误的态度和重视程度,决定了成长的高度。所以,假如说我要去一家公司面试,面试官问我有没有问题,那我非常关心的一定是他们公司对错误的态度,以及具体的实际行动。
另外,就是建立信任和及时反馈,公开透明是关键。这不仅是对外部用户而言的,对内部协作的部门和组织来说,也是这样。因为只有充分的透明,才能赢得对方的信任,很多事情才有得聊,否则,建立协作、责任共担的文化,就成了一句空谈。
在开始建立DevOps文化的时候你首先要明白上下游所需要的信息是否能够自主简单、随时地获取到如果不能的话这就是一个很好的潜在改进事项。
Etsy三只袖子毛衣的故事
Etsy是美国的一家手工艺电商平台从2015年上市以来它的市值一度接近80亿美元。当然除了快速增长的市值以外最为人称道的就是它们的DevOps能力而它们的案例也大量出现在了《DevOps实践指南》一书中。
那么,为什么这个名不见经传的公司能够做到这种程度呢?实际上,通过一件小事,我们就能看出来原因。
你可能不知道的是一家在线电子商务公司每日浏览频率最高的单体页面不是首页也不是具体哪个商品的页面而是网站的不可用页面也就是我们习惯说的502页面。有些公司甚至为了提升502页面的用户体验利用好这部分流量在502页面做了很多文章比如把502页面作为一个产品推广的阵地等。
当Etsy的网站不可用的时候你看到的是一个小姑娘在织毛衣的画面而这个毛衣竟然有三只袖子。
实际上“三只袖子的毛衣”代表了Etsy对于错误的态度。我们都知道一件毛衣应该只有两只袖子这是常识。如果有人真的织出来第三只袖子我们的第一反应就是觉得这很可笑这只是个人的问题却很少去想他为什么会做这种反常识的事情背后的根因是什么。
但是Etsy公司却不是这样的。在每年的年终总结大会上公司都会颁发各种奖项其中一个奖项的奖品就是“三只袖子的毛衣”获奖者是公司年度引入最大问题的个人。
这是因为,在他们看来,犯错误并不是什么大不了的事情。错误本身并不是个人的问题,而是公司系统和制度的问题,正因为有了这样的错误,才给了公司改进和成长的空间。从某种意义上说,这也是一种贡献。
当然,除了制造噱头之外,通过这种行为,其实公司想表达的是它们对文化的偏好,也就是要建立一种心理安全、快速变化、及时反馈、鼓励创新的文化,由此来激发整个团队的士气和战斗力。
无独有偶2019年的DevOps状态报告也特别指出心理安全的文化氛围有助于团队生产力的提升。更重要的是状态报告还把它作为一条重要能力放入了DevOps能力模型之中。
因为只有当员工感受到心理安全时才会把注意力集中在解决问题和快速完成工作上而不是花费大量的时间用于互相攻击和部门政治。在跨部门寻求合作的时候才会思考如何让组织的价值最大化而不是想“谁过来动了我的奶酪我要如何制造更高的门槛来保护自己的利益”。对于DevOps这种注重协作的研发模式来说这一点真的太重要了。
Netflix招聘成年人的故事
美国硅谷聚集了世界上大多数精英的IT公司但是精英中的精英就是FAANG也就是Facebook、Apple、Amazon、Netflix和Google这五家公司的首字母简称这五家公司基本引领了硅谷技术的风向标。大多数人对其中的4家公司都非常熟悉但是对Netflix却知之甚少。那么这家公司凭啥能跻身为精英中的精英呢
如果我告诉你在Netflix每个工程师不仅拿着数一数二的薪水还可以自己决定什么时候休假爱休多长时间就休多长时间而且报销不需要经过审批填多少就报多少。另外即便只加入公司一天就离职了公司给予的补偿也足够他们活上一年半载。
看到这里,你是不是觉得这家公司的老板疯了呢?
这个叫作里德·哈斯廷斯的人还真没疯。我所说的这一切背后的原因都被记录在了《奈飞文化手册》一书中这也是号称硅谷最重要的文件的作者在离开Netflix之后写的一本阐述Netflix文化的书。
Netflix认为与其建立种种流程来约束员工不如砍掉所有不必要的流程给员工一个自由发挥自我价值的空间。因为把所有员工都视为一个成年人是他们的行为准则。作为一个成年人你应该能够为自己的行为负责同时为公司的发展负责由此做出最好的选择并付出最大的努力。
正是这种开放的氛围使得Netflix至今开源了171个项目和插件。其中像混沌工程的鼻祖混乱猴子Chaos Monkey、断路器工具Hystrix、服务注册工具Eureka、部署工具Spinnaker 都是DevOps领域最为著名的开源工具。
开源为先的共享精神正在成为越来越多的公司重视开源的动力之一。让真正优秀的人做有价值的事情而不是让他们整日为复杂的流程、公司的内部政治和无意义的工作所影响他们才能发挥最大的价值。对DevOps来说也是如此。
总结
说到这儿三个故事已经讲完了。我们来总结一下在DevOps文化中最为知名的几点内容
建立免责的文化,并在错误中学习;
通过对外开放透明,建立信任,促进协作;
打造心理安全的氛围,鼓励创新;
开源为先的共享精神。
改变企业文化绝不是一个人、一句话的事情管理层的认同和导向非常重要。但是我们并不能期望每家公司都能成为FAANG一样的硅谷巨头。所以从我做起从力所能及的范围做起别觉得文化跟自己没有关系这才是最重要的。
最后,希望你看完这一讲以后,可以重新审视一下,团队内部是否建立了正向的错误回溯机制?是否鼓励内部分享和创新?是否和上下游之间做到了开放和协作为先?是否在身体力行地减少重复建设?
思考题
你对今天的哪些内容印象最深刻呢你又有哪些跟DevOps文化相关的故事可以拿来分享呢
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,107 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送Jenkins产品经理是如何设计产品的
你好,我是石雪峰。这是一期临时增加的特别放送。
前两天我去葡萄牙里斯本参加了2019年的DevOps World | Jenkins World大会。这是一年一度的社区聚会参会人会围绕Jenkins和DevOps展开为期3天的密集交流信息量非常大。很多新技术、行业趋势、产品设计思路都在大会上涌现了出来我觉得非常有价值也很有必要整理出来分享给你。
2019年是Jenkins诞生15周年对于任何一个软件来说15年都不是一个短暂的时间。在这个时间点社区也在展望过去15年来的Jenkins发展历程并憧憬下一个15年Jenkins的变化。
可以说从DevOps产品的角度来说Jenkins本身就是一个非常出色的典型案例。
最开始这是一个由于Jenkins创始人KK无法忍受同事天天导致编译失败而开发的一个人项目。到今天这个项目已经有将近900名或全职、或兼职的贡献者26万多个Master节点超过3000万个任务了。这些数字还仅仅是官方可以统计到的部分如果再加上企业内网、个人电脑上的实例那就更加不计其数了。
今年我印象最深刻的是Jenkins创始人KK并没有在主会场上讲太多的产品细节、设计思路、发展方向等而是仅仅用了10多分钟回顾了自己的心路历程。在演讲的最后他将舞台交给了一位Jenkins产品经理。这位产品经理是何方神圣呢为什么是一位产品经理来讲这些内容呢这激起了我极大的好奇心。
一直以来KK都被视为Jenkins的头号产品经理。的确技术专家兼产品经理是比较普遍的一个现象。这是因为与普通面向用户的产品相比DevOps产品有几个非常鲜明的特征。
技术背景要求高。因为DevOps产品要解决的很多问题都是一线的技术问题
面向的用户是开发人员。这就意味着,如果你不了解开发的真实工作方式,就很难设计出开发友好的产品;
专业工具繁多。产品引用到的开源组件和工具都是专业领域的内容比如Jenkins就是一个典型的持续集成系统如果你不了解Jenkins又怎么设计Jenkins呢
在几天的会议过程中针对DevOps产品经理面对的这些挑战我专门跟这位神奇的Jenkins产品经理进行了沟通。他就是Jeremy Hartley一个来自荷兰的大哥。
我先给你介绍下社区的运作方式。以Jenkins这个产品为例它背后的主要贡献者都来自于CloudBees公司。虽然这些人都属于同一个公司但实际上他们大多各自分散在家办公一年到头也见不了几次面。
比如产品经理Jeremy在荷兰创始人KK在加州基础设施的负责人Oliver在比利时K8S的插件维护者在西班牙。因此每年的FOSDEM年初的欧洲最大的开源软件大会以及年末的Jenkins World大会就成了这些世界各地的开发者汇聚到一起的难得机会。
言归正传与产品经理的积极外向、滔滔不绝的一般形象不同Jeremy可以说是一个异类。他从始至终都给人一种温文尔雅的感觉甚至在公开演讲的时候他的语气也非常平和没有太多的情绪表达只是把他和他的产品的故事娓娓道来。
Jeremy早先在一家互联网在线视频公司干了10年。他半开玩笑地说即便干了10年也不如跟腾讯合作一个项目来得出名。后来他加入XebiaLabs。这是一家专门做DevOps平台产品的公司在国内可能不是特别出名但如果提到DevOps工具元素周期图相信你肯定听说过这就是这家公司迭代更新的。
图片来源https://xebialabs.com/periodic-table-of-devops-tools/
在今年的4月份他加入了CloudBees成为了主管开源和商业版本Jenkins的高级产品经理。在跟他交流的过程中我对产品经理这部分内容的印象非常深刻。我梳理了一些要点分享给你。如果你已经是DevOps产品经理或者是立志要成为DevOps产品经理的话你一定要认真看一下。
一、自我颠覆
什么叫自我颠覆呢我给你举个例子。比如Jenkins的用户UI项目Blue Ocean很多人应该都知道目前这个项目的主要开发已经停止了。社区仍然会修复缺陷和安全漏洞也会接受开发者共享的PR但是不会再投入专职工程师进行开发工作了新需求也都处于无限暂停的状态。
实际上不仅仅是Blue Ocean去年Jenkins大会上星光闪耀的项目比如Five super power、Jolt in Jenkins、Evergreen等项目也都因为方向调整和人员变动而处于半终止、暂缓开发的状态。那么为什么在短短一年的时间内会有这么大的颠覆性变化呢我把这个问题抛给了Jeremy。
他的观点是,这些项目并非没有意义,但是确实没有达到项目原本的预期。对于产品经理来说,管理预期是一项非常重要的能力。当需求走到产品经理的时候,做哪个、不做哪个经常是个问题。团队往往会进行协商,挑选出来最有希望的项目,但这并不代表这些项目注定会成功。相反,很多想法只有做了才知道是不是靠谱,用户是不是买单。如果使用场景有限,又没有很好的增长性,及时叫停反而是一种好的选择。
Blue Ocean项目诞生之初可以说是让人眼前一亮充满期待甚至一度和Jenkins流水线一起被视为2.0版本的最大功能。但是几年之后,由于产品性能、插件扩展支持等种种原因,真正在企业中大规模使用的机会并不多。正因为项目没有达到预期,产品团队就决定停止这个项目。
但是与此同时全新的Jenkins用户界面项目已经被提到了日程表中。这个全新的用户界面大量借鉴了Blue Ocean的设计思路并最终通过一套用户界面取代了现有的Blue Ocean。我想正是这种不断的自我颠覆才让一个15年的软件始终保持着活力和创新力。
二、化繁为简
对于Jenkins这样的产品来说很多插件都是开发者提供的但是开发者往往倾向于追求功能的全面性这从很多插件的设计中就能看出来。
开发者不加筛选地把所有功能都罗列在用户面前,自然是得心应手。但是,对普通用户来说,当他第一眼看到这个复杂产品的时候,他的使用意愿就会大打折扣。
另外面对这么多的插件从表面上看用户好像有很多选择但是有些插件的名字长得差不多你并不知道哪个能用。或者有些插件适用于当前的Jenkins版本但是一旦Jenkins升级它们就无法正常使用了。但是用户在升级之前并不知道是否适配往往是在升级完成之后才会发现问题只能再进行版本回滚。类似这些插件使用中的问题都给用户带来了很大的使用障碍。
在探讨这个问题的时候Jeremy也认为系统过于复杂有悖于产品设计的初衷但是作为一个公开的平台他们并不能约束开发者的行为所以就需要一种方法来平衡功能的全面性和功能的易用性。
比如在重新考虑Jenkins插件生态的时候一方面产品团队会针对全新的业务场景提供官方的插件支持。举个例子在云原生开发场景下通过和云服务商深度合作提供更多的官方插件来满足典型的云服务商的使用场景。无论是对亚马逊的AWS、微软的Azure还是未来国内的主流云服务商他们都会通过这种方式来进行合作。无论你使用的开源产品还是商业产品都能通过这个项目来获得收益。
另一方面产品团队也会进一步对现有的1600多款插件进行分类并将其中的一部分插件纳入CloudBees的保障项目之下。这就意味着将由CloudBees公司来保证这类插件的兼容性和可用性。对于专业用户来说他们依然可以按照自己的方式自由地选择和开发插件而对于普通用户来说官方推荐的插件集合就足够了。
不仅仅是插件,产品的易用性体现在产品设计的方方面面。凡是阻塞用户使用的问题,都是需要优先解决的。
比如对于一个10多年的产品来说历史积累的文档数量巨大很多时候用户都无法找到真正有用的信息。所以Jenkins产品团队启动了一个文档治理的项目会重新梳理所有文档并把它们迁移到GitHub平台上。另外他们还会结合新的产品功能整理出最佳实践。比如对于流水线使用来说官方也总结了很多最佳实践供入门者参考你可以结合前面两讲的内容一起学习。
要始终记得,不要让你的产品只有专家才会使用。将复杂的问题简单化,是产品经理不论何时都要思考的问题。
三、退后一步
DevOps的产品经理大多是技术人员出身因此会特别容易一上来就深入细节甚至是代码实现的细节。
Jeremy同样也是程序员出身他做过很长一段时间的前端开发。当我问他“一个好的产品应该如何平衡用户视角和实现视角”的时候他给我的回答是要尽量退后一步来看问题。
退后一步,就是说不要把关注点只聚焦在问题表面,而是要尽量站在旁边,以第三方的视角来全面审视问题。
他举了个Jenkins的流水线即代码的例子。在实际使用的时候流水线文件中经常会有大量的代码有时候流水线代码甚至会有上千行。代码越多系统的不稳定因素就越多测试起来也越麻烦。同时按照现有的运行机制来说很多代码都是运行在master节点上的这就给集群的master节点带来了很大压力。
要想解决这个问题从实现的角度出发就是提供一种标准化、结构化的语法格式也就是声明式流水线语法以此来降低流水线的编写难度减少流水线代码量并且让这个代码结构更加清晰。但是这些优化依然不能解决集群master节点压力过大的问题这就相当于问题只看了一部分。
退后一步来看,这就需要一种全新的视角,来提升流水线整体的隔离性。所以,产品团队目前就在设计一种新的流水线组件 building block也就是构建块。
所谓构建块是指一整块的代码片段而不是一条条独立的指令。这些构建块结合到一起就可以满足一个具体场景的问题。比如Maven打包构建的场景构建块可以帮你解决环境、工具、构建命令等一系列问题。这些构建块以代码形式在子节点上运行既降低了流水线的编写难度也缓解了master节点上的压力。对用户来说使用构建块也更为简单可以直接把它放在自定义的步骤中执行。
对于产品经理来说,找到方案、解决问题自然是职责所在,但与此同时,他们往往需要同时保有两种思维,即用户思维和实现思维。能够在这两种思维之间自由切换,是产品经理走向成熟的标志。
总结
说到这儿,我来回答一下最开始的那个问题,也就是“为什么是产品经理来分享产品的规划呢?”这是因为,无论要开发一个多大还是多小的产品,都需要有这样一拨人来退后一步,找到用户的真实问题,化繁为简,实现这个功能,并不断颠覆自己,持续打磨和改进。这对于任何一个想要解决更多人问题的产品来说,都是至关重要的。
思考讨论
关于这次Jenkins World大会你还有什么希望进一步了解的内容吗欢迎你积极提问我会知无不言。
如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 持续改进,成就非凡!
不知道你是否看过或者听说过《中国好声音》这个节目?在这个节目中,导师总会发出“灵魂拷问”:“你的梦想是什么?”
和很多“80后”的男孩子一样我最初的梦想就是当一名飞行员翱翔天空。但是随着视力越来越差身体越长越高我才发现并非所有的梦想都能实现。好在我还留了一手因为我还有另外一个梦想那就是当一名老师。现在我的这个梦想已经在极客时间上实现了。
为什么想当老师呢?说真的,我也不记得当初是怎么想的了,可能是因为在中小学生眼中,老师这个形象都是霸气侧漏的。但随着年龄的增大,我越发觉得,当老师这个事情真的没有那么容易。你应该也听说过“教学相长”这个词,但你有没有想过,“教”为什么在“学”的前面,是“教学相长”,而不是“学教相长”呢?
或许,只有当你的身份从一名学生变为一位老师的时候,你才能真的想明白这个问题。实际上,很多时候,教的人可能比学的人收获要大得多。为什么这么说呢?
任何一门课程,任何一个知识点,你在学的时候可以不懂,大不了就当没听过,等到真正用到的时候,临时再学也是可以的。但是,作为老师,你不仅要懂,还要逻辑清晰、思维缜密,甚至要尽可能地用有趣的方式把别人教会,这可就没那么简单了。
不过,任何一个知识领域都是博大精深的,你不可能对每一个细节都了如指掌,这就会逼着你不断学习、不断思考、不断精进。我想,这就是输出式学习之所以高效的奥秘所在。
对于专栏写作来说这个道理也同样适用。几个月的持续输出无论是对精力、体力还是家庭和谐力都是一场漫长的试炼。在专栏完结的时间点我看到的不仅仅是20万字的内容更多的是自己身上的不足而这些都是我成长道路上的灯塔指引我面向未来持续精进。
在最后,我想给你分享我在专栏写作中的三个心得,希望这些心得可以帮助你在未来的学习道路上披荆斩棘,无往而不利。
当你跨越技术领域的门槛之后,知识的体系化程度就成了决定你未来发展高度的一个重要因素。只有建立了自己的知识体系,并不断地吸收外界精华,你才能让这些知识和经验在身体内不断循环、沉淀,并最终成为你的一部分。这也是写作专栏几个月以来,我想给你分享的第一个心得:建立自己的知识体系,持续进行输出式学习。
对于一篇专栏的写作来说,你知道什么时间点最可怕吗?那就是当你打开一个空白的文档,却不知道第一个字应该写什么的时候。这跟我们平时的工作是很相似的,你知道这件事要做到什么程度,可就是不知道该如何开始。脑子里思绪万千,身体的疲劳有时还在同你作对,当你在不断地自我怀疑的时候,时间却悄悄地跑掉了,而你终究还是得自己面对这个问题。这该如何是好呢?
有句经典的话大概是这么说的“一件事情当你不想去做的时候理由可以有一百个但是当你决定做的时候理由只有一个那就是做。”很多事情并没有你想象的那么困难。我们不是科学家也不是要解决人类的未解之谜我们面对的都是身边的问题。我们之所以觉得这些事情很难缺少的往往不是能力、经验和学识而是“先干再说”的勇气和信心。因为只要开始做了你就已经成功一半了。对于DevOps这种改进类工作来说更是如此你要先想尽一切办法完成它有机会再追求完美这可比一开始就全盘规划要实际得多。这也是我想分享给你的第二个心得完成比完美更重要很多事情可以先干再说。
我们家也有一句特别经典的话,那就是有日子就快。这句话的意思就是,对于一件事情,你只要确定了里程碑,时间就会带领你快速地抵达那个终点。比如,对于一个项目的推进来说,事先看见全貌和里程碑节点就是至关重要的。在专栏的写作过程中,我认为最最重要的一份素材,就是编辑同学帮我整理的《专栏发布排期计划》,里面注明了我每星期、每天需要完成的任务。
虽然计划就是为了被打破而存在的它永远也赶不上变化尤其是在软件的世界里Delay似乎是一件不可避免的事情但是这个计划存在的目的是帮你守住一件事情的下限。既然最差也就如此了多做一点就多一点成功那你又何必纠结和焦虑呢所以在推进项目的时候尤其是在依赖多人协作的时候一个清晰的项目计划至关重要。这恰恰是我想分享给你的第三个心得让计划帮你守住底线让行动为成功添砖加瓦。
我想,此时此刻还在坚持看下去、听下去的你内心里一定有一团火焰,激励着自己有朝一日可以脱颖而出。因此,在最后的最后,我特别想给你分享一些我个人的职业生涯发展的经验,这也是帮助我从一个默默无闻的小兵成长为极客时间作者的秘密。
1.找到自己适合的领域
要知道并非所有人都适合所有领域。有的人天生就是编程高手有的人天生就爱与人沟通与其在你不擅长的领域死磕不如找到自己擅长的领域并不断深耕。与此同时要以这个领域为起点不断向外扩展营造自己的“护城河”体系提升自己的专业素养。这些是你将来安身立命的本事你一定要让自己有几个拿得出手的核心技能。如果你现在还答不上来你擅长的领域和核心技能是什么那么2020年请继续努力。
2.打造自己的专属标签
当你掌握了一门核心手艺之后,你可以在这个圈子里不断地总结和分享,建立起别人对你的初始认知。只要你用心,你就会发现,这种分享的机会其实有很多,如果你苦于没有途径,欢迎你来找我。
不过,这还并不足以让你脱颖而出,最多也只能达到平均水准。这时候,你需要的就是等待一个机会,比如一门新技术、一种新思想、一个新工具,什么都可以。然后,你要快速地抓住这个机会,让自己站在第一线,去分享,去实践,去布道,让它成为你的专属标签。
3.不断积累成功,打造自己的良好口碑
你要知道,有一种能力,叫作“让别人相信你”的能力。企业在为某个职位寻找合适的人选时,为什么选你而不选别人呢?除了你自身过硬的技术素养之外,你能不能让别人相信你的能力,是你能否突破天花板的重要因素。那这种能力从何而来呢?我认为,这是来自于过往点滴的积累,最终由量变产生的质变。所以,请你善待每一个机会,善待每一个人。在企业中,要么提升自己的执行力,要么提升自己的创新力,要么让自己能够快速地整合资源,只有这样,你才能具备成功的资本。
4.保持责任心、进取心和事业心
不管做什么事情最重要的就是责任心要把自己该做的事情做好做正确的事情而不仅仅是KPI要求的事情。另外你要保持进取心并且对新事物、新技术保持长久的好奇和开放的心态而不是故步自封局限在自己的一亩三分地上。如果可能的话要把自己的工作视为一个事业你要保持着“每一行代码都是你的名片每一个产品都是你的代言人”的信念和团队一起努力共同成长只要还有一丝改进空间就不要轻言放弃。
正如这篇文章的标题所说的,只有持续改进,才能成就非凡,也必将不枉此生。
希望这个专栏能够带给你一些灵感和新知不管怎样感谢你陪我一起走过2019年的夏天、秋天和冬天。
最后,我给你准备了一份调研问卷,欢迎你点击下面的图片,去填写问卷,给我和专栏提供一些宝贵的建议,期待你的反馈。
P.S. 最最后,感谢我的夫人在这半年里给予我的无私“支持和理解”,如果不是她,这个专栏早就写完了。

View File

@@ -0,0 +1,110 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 深入掌握 Dubbo 原理与实现,提升你的职场竞争力
你好,我是杨四正,接下来一段时间我们会一起来探究 Dubbo。
我曾在电商、新零售、短视频、直播等领域的多家互联网企业任职,期间我在业务线没日没夜地“搬过砖”,在基础组件部门“造过轮子”,也在架构部门搞过架构设计,目前依旧在从事基础架构的相关工作,主要负责公司的 Framework、RPC 框架、数据库中间件等方向的开发和运维工作。我深入研究过多个开源中间件,平时喜欢以文会友,分享源码分析的经验和心得。
为什么要学习 Dubbo
我们在谈论任何一项技术的时候,都需要强调它所适用的业务场景,因为: 技术之所以有价值,就是因为它解决了一些业务场景难题。
一家公司由小做大,业务会不断发展,随之而来的是 DAU、订单量、数据量的不断增长用来支撑业务的系统复杂度也会不断提高模块之间的依赖关系也会日益复杂。这时候我们一般会从单体架构进入集群架构如下图所示在集群架构中通过负载均衡技术将流量尽可能均摊到集群中的每台机器上以此克服单台机器硬件资源的限制做到横向扩展。
单体架构 VS 集群架构
之后,又由于业务系统本身的实现较为复杂、扩展性较差、性能也有上限,代码和功能的复用能力较弱,我们会将一个巨型业务系统拆分成多个微服务,根据不同服务对资源的不同要求,选择更合理的硬件资源。例如,有些流量较小的服务只需要几台机器构成的集群即可,而核心业务则需要成百上千的机器来支持,这样就可以最大化系统资源的利用率。
另外一个好处是,可以在服务维度进行重用,在需要某个服务的时候,直接接入即可,从而提高开发效率。拆分成独立的服务之后(如下图所示),整个服务可以最大化地实现重用,也可以更加灵活地扩展。
微服务架构图
但是在微服务架构落地的过程中,我们需要解决的问题有很多,如:
服务之间如何高性能地通信?
服务调用如何做到负载均衡、FailOver、限流
如何有效地划清服务边界?
如何进行服务治理?
……
Apache Dubbo是一款高性能、轻量级的开源 Java RPC 框架,它提供了三大核心能力:
面向接口的远程方法调用;
可靠、智能的容错和负载均衡;
服务自动注册和发现能力。
简单地说, Dubbo 是一个分布式服务框架,致力于提供高性能、透明化的 RPC 远程服务调用方案以及服务治理方案,以帮助我们解决微服务架构落地时的问题。
Dubbo 是由阿里开源,后来加入了 Apache 基金会,目前已经从孵化器毕业,成为 Apache 的顶级项目。Apache Dubbo 目前已经有接近 32.8 K 的 Star、21.4 K 的 Fork其热度可见一斑 很多互联网大厂(如阿里、滴滴、去哪儿网等)都是直接使用 Dubbo 作为其 RPC 框架,也有些大厂会基于 Dubbo 进行二次开发实现自己的 RPC 框架 ,如当当网的 DubboX。
作为一名 Java 工程师,深入掌握 Dubbo 的原理和实现已经是大势所趋,并且成为你职场竞争力的关键项。拉勾网显示,研发工程师、架构师等高薪岗位,都要求你熟悉并曾经深入使用某种 RPC 框架,一线大厂更是要求你至少深入了解一款 RPC 框架的原理和核心实现。
(职位信息来源:拉勾网)
而 Dubbo 就是首选。Dubbo 和 Spring Cloud 是目前主流的微服务框架,阿里、京东、小米、携程、去哪儿网等互联网公司的基础设施早已落成,并且后续的很多项目还是以 Dubbo 为主。Dubbo 重启之后,已经开始规划 3.0 版本,相信后面还会有更加惊艳的表现。
另外RPC 框架的核心原理和设计都是相通的,阅读过 Dubbo 源码之后,你再去了解其他 RPC 框架的代码,就是一件非常简单的事情了。
阅读 Dubbo 源码的痛点
学习和掌握一项技能的时候,一般都是按照“是什么”“怎么用”“为什么”(原理)逐层深入的:
同样,你可以通过阅读官方文档或是几篇介绍性的文章,迅速了解 Dubbo 是什么;接下来,再去上手,用 Dubbo 写几个项目,从而更加全面地熟悉 Dubbo 的使用方式和特性,成为一名“熟练工”,但这也是很多开发者所处的阶段。而“有技术追求”的开发者,一般不会满足于每天只是写写业务代码,而是会开始研究 Dubbo 的源码实现以及底层原理,这就对应了上图中的核心层:“原理”。
而开始阅读源码时,不少开发者会提前去网上查找资料,或者直接埋头钻研源码,并因为这样的学习路径而普遍面临一些痛点问题:
网络资料不少,但大多是复制 Dubbo 官方文档,甚至干脆就是粘贴了一堆 Dubbo 源码过来,没有任何自己的个人实践和经验分享,学习花费精力不说,收获却不大。
相关资料讲述的 Dubbo 版本比较陈旧,没有跟上最新的设计和优化,有时候还会误导你。或者切入点很小,只针对 Dubbo 的一个流程进行介绍,看完之后,你只知道这一条调用分支上的相关内容,代码一旦运行到其他地方,还是一脸懵。
若抛开参考资料,自己直接去阅读 Dubbo 源码,你本身又需要具备一定的技术功底,而且要对整个开源项目有比较高的熟练度,这样你才能够循着它的核心逻辑去快速掌握它。而对于一个相对陌生的开源项目来说,这可能就是一个非常痛苦的过程了,并且最致命的是,由于对整个架构的“视野”受限,你很可能会迷失在代码迷宫中,最后虽然也花了很大力气去阅读和 Debug 源码,却在关上 IDEA 之后依然“雾里看花”。
课程设置
我曾经分享过各种开源项目的源码分析资料,并且收到大家的一致好评,所以我决定和拉勾教育合作,开设一个系列课程,根据自己丰富的开源项目分析经验来带你一起阅读 Dubbo 源码,希望帮你做到融会贯通,并在实践中能够举一反三。
具体来说,在这个课程中我会:
从基础知识开始,通过丰富的 Demo 演示,手把手带你分析 Dubbo 涉及的核心知识点。之后再带你使用这些核心技术,通过编写一个简易版本的 RPC 框架串联所有知识点。
带你自底向上剖析 Dubbo 的源码,深入理解 Dubbo 的工作原理及核心实现,让你不再停留在简单使用 Dubbo 的阶段做到知其然也知其所以然。例如Provider 是如何将服务发布到注册中心的、Consumer 是如何从注册中心订阅服务的,等等问题都可以在这里找到解答。
点名 Dubbo 源码中的设计模式,让你了解设计模式的优秀实践方式,帮助你从“纸上谈兵”变成“用兵如神”,这样在你进行架构设计以及代码编写的时候,就可以真正使用这些设计模式,让你的代码扩展性更强、可维护性更好。
带你领略 Dubbo 2.7.5 版本之后的最新优化和设计,让你紧跟时代潮流,更好地反馈到工作实践中。
本课程的每一个知识点都是你深入理解 Dubbo 的进步阶梯,整个分析 Dubbo 实现的过程,就是一步步到达山顶,成为高手的过程。你也可以通过目录,快速了解这个课程的知识体系结构。
讲师寄语
最后,我想和你说的是: 沉迷于代码,但不要只沉迷于代码。
阅读源码的目的是提升自身的技术能力,而提升技术能力的目的是更好地支持业务。阅读源码不是终点,你还需要结合实际业务,更好地体会开源项目的设计理念,并将这种设计应用到实践中。
让我们开启一次紧张刺激的 Dubbo 探秘之旅!我也希望你能在留言区与我分享你的 Dubbo 学习情况,分享你的成长心得和学习痛点,学习不是单向的输出,而是一次交流反馈的过程!加油。
为便于你更好地学习,我将整个 Dubbo 的源码(带注释的)放到 GitHub 上了你可以按需查看https://github.com/xxxlxy2008/dubbo。

View File

@@ -0,0 +1,396 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 Dubbo 源码环境搭建:千里之行,始于足下
好的开始是成功的一半,阅读源码也是一样。 很多同学在下定决心阅读一个开源框架之后,就一头扎进去,迷失在代码“迷宫”中。此时,有同学意识到,需要一边 Debug 一边看;然后又有一批同学在搭建源码环境的时候兜兜转转,走上了放弃之路;最后剩下为数不多的同学,搭建完了源码环境,却又不知道如何模拟请求让源码执行到自己想要 Debug 的地方。
以上这些痛点问题你是不是很熟悉?是不是也曾遇到过?没关系,本课时我就来手把手带领你搭建 Dubbo 源码环境。
在开始搭建源码环境之前,我们会先整体过一下 Dubbo 的架构,这可以帮助你了解 Dubbo 的基本功能以及核心角色。
之后我们再动手搭建 Dubbo 源码环境,构建一个 Demo 示例可运行的最简环境。
完成源码环境搭建之后,我们还会深入介绍 Dubbo 源码中各个核心模块的功能,这会为后续分析各个模块的实现做铺垫。
最后,我们再详细分析下 Dubbo 源码自带的三个 Demo 示例,简单回顾一下 Dubbo 的基本用法,这三个示例也将是我们后续 Debug 源码的入口。
Dubbo 架构简介
为便于你更好理解和学习,在开始搭建 Dubbo 源码环境之前,我们先来简单介绍一下 Dubbo 架构中的核心角色,帮助你简单回顾一下 Dubbo 的架构,也帮助不熟悉 Dubbo 的小伙伴快速了解 Dubbo。下图展示了 Dubbo 核心架构:
Dubbo 核心架构图
Registry注册中心。 负责服务地址的注册与查找,服务的 Provider 和 Consumer 只在启动时与注册中心交互。注册中心通过长连接感知 Provider 的存在,在 Provider 出现宕机的时候,注册中心会立即推送相关事件通知 Consumer。
Provider服务提供者。 在它启动的时候,会向 Registry 进行注册操作,将自己服务的地址和相关配置信息封装成 URL 添加到 ZooKeeper 中。
Consumer服务消费者。 在它启动的时候,会向 Registry 进行订阅操作。订阅操作会从 ZooKeeper 中获取 Provider 注册的 URL并在 ZooKeeper 中添加相应的监听器。获取到 Provider URL 之后Consumer 会根据负载均衡算法从多个 Provider 中选择一个 Provider 并与其建立连接,最后发起对 Provider 的 RPC 调用。 如果 Provider URL 发生变更Consumer 将会通过之前订阅过程中在注册中心添加的监听器,获取到最新的 Provider URL 信息,进行相应的调整,比如断开与宕机 Provider 的连接,并与新的 Provider 建立连接。Consumer 与 Provider 建立的是长连接,且 Consumer 会缓存 Provider 信息,所以一旦连接建立,即使注册中心宕机,也不会影响已运行的 Provider 和 Consumer。
Monitor监控中心。 用于统计服务的调用次数和调用时间。Provider 和 Consumer 在运行过程中,会在内存中统计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。监控中心在上面的架构图中并不是必要角色,监控中心宕机不会影响 Provider、Consumer 以及 Registry 的功能,只会丢失监控数据而已。
搭建Dubbo源码环境
当然要搭建Dubbo 源码环境,你首先需要下载源码。这里你可以直接从官方仓库 https://github.com/apache/dubboFork 到自己的仓库,直接执行下面的命令去下载代码:
git clone [email protected]:xxxxxxxx/dubbo.git
然后切换分支,因为目前最新的是 Dubbo 2.7.7 版本,所以这里我们就用这个新版本:
git checkout -b dubbo-2.7.7 dubbo-2.7.7
接下来,执行 mvn 命令进行编译:
mvn clean install -Dmaven.test.skip=true
最后,执行下面的命令转换成 IDEA 项目:
mvn idea:idea // 要是执行报错,就执行这个 mvn idea:workspace
然后,在 IDEA 中导入源码,因为这个导入过程中会下载所需的依赖包,所以会耗费点时间。
Dubbo源码核心模块
在 IDEA 成功导入 Dubbo 源码之后,你看到的项目结构如下图所示:
下面我们就来简单介绍一下这些核心模块的功能,至于详细分析,在后面的课时中我们还会继续讲解。
dubbo-common 模块: Dubbo 的一个公共模块,其中有很多工具类以及公共逻辑,例如课程后面紧接着要介绍的 Dubbo SPI 实现、时间轮实现、动态编译器等。
dubbo-remoting 模块: Dubbo 的远程通信模块,其中的子模块依赖各种开源组件实现远程通信。在 dubbo-remoting-api 子模块中定义该模块的抽象概念在其他子模块中依赖其他开源组件进行实现例如dubbo-remoting-netty4 子模块依赖 Netty 4 实现远程通信dubbo-remoting-zookeeper 通过 Apache Curator 实现与 ZooKeeper 集群的交互。
dubbo-rpc 模块: Dubbo 中对远程调用协议进行抽象的模块,其中抽象了各种协议,依赖于 dubbo-remoting 模块的远程调用功能。dubbo-rpc-api 子模块是核心抽象其他子模块是针对具体协议的实现例如dubbo-rpc-dubbo 子模块是对 Dubbo 协议的实现,依赖了 dubbo-remoting-netty4 等 dubbo-remoting 子模块。 dubbo-rpc 模块的实现中只包含一对一的调用,不关心集群的相关内容。
dubbo-cluster 模块: Dubbo 中负责管理集群的模块,提供了负载均衡、容错、路由等一系列集群相关的功能,最终的目的是将多个 Provider 伪装为一个 Provider这样 Consumer 就可以像调用一个 Provider 那样调用 Provider 集群了。
dubbo-registry 模块: Dubbo 中负责与多种开源注册中心进行交互的模块,提供注册中心的能力。其中, dubbo-registry-api 子模块是顶层抽象其他子模块是针对具体开源注册中心组件的具体实现例如dubbo-registry-zookeeper 子模块是 Dubbo 接入 ZooKeeper 的具体实现。
dubbo-monitor 模块: Dubbo 的监控模块,主要用于统计服务调用次数、调用时间以及实现调用链跟踪的服务。
dubbo-config 模块: Dubbo 对外暴露的配置都是由该模块进行解析的。例如dubbo-config-api 子模块负责处理 API 方式使用时的相关配置dubbo-config-spring 子模块负责处理与 Spring 集成使用时的相关配置方式。有了 dubbo-config 模块,用户只需要了解 Dubbo 配置的规则即可,无须了解 Dubbo 内部的细节。
dubbo-metadata 模块: Dubbo 的元数据模块本课程后续会详细介绍元数据的内容。dubbo-metadata 模块的实现套路也是有一个 api 子模块进行抽象,然后其他子模块进行具体实现。
dubbo-configcenter 模块: Dubbo 的动态配置模块,主要负责外部化配置以及服务治理规则的存储与通知,提供了多个子模块用来接入多种开源的服务发现组件。
Dubbo 源码中的 Demo 示例
在 Dubbo 源码中我们可以看到一个 dubbo-demo 模块,共包括三个非常基础 的 Dubbo 示例项目,分别是: 使用 XML 配置的 Demo 示例、使用注解配置的 Demo 示例 以及 直接使用 API 的 Demo 示例 。下面我们将从这三个示例的角度,简单介绍 Dubbo 的基本使用。同时,这三个项目也将作为后续 Debug Dubbo 源码的入口,我们会根据需要在其之上进行修改 。不过在这儿之前,你需要先启动 ZooKeeper 作为注册中心,然后编写一个业务接口作为 Provider 和 Consumer 的公约。
启动 ZooKeeper
在前面 Dubbo 的架构图中,你可以看到 Provider 的地址以及配置信息是通过注册中心传递给 Consumer 的。 Dubbo 支持的注册中心尽管有很多, 但在生产环境中, 基本都是用 ZooKeeper 作为注册中心 。因此,在调试 Dubbo 源码时,自然需要在本地启动 ZooKeeper。
那怎么去启动 ZooKeeper 呢?
首先,你得下载 zookeeper-3.4.14.tar.gz 包(下载地址: https://archive.apache.org/dist/zookeeper/zookeeper-3.4.14/)。下载完成之后执行如下命令解压缩:
tar -zxf zookeeper-3.4.14.tar.gz
解压完成之后,进入 zookeeper-3.4.14 目录,复制 conf/zoo_sample.cfg 文件并重命名为 conf/zoo.cfg之后执行如下命令就可以启动 ZooKeeper了。
>./bin/zkServer.sh start
# 下面为输出内容
ZooKeeper JMX enabled by default
Using config: /Users/xxx/zookeeper-3.4.14/bin/../conf/zoo.cfg # 配置文件
Starting zookeeper ... STARTED # 启动成功
业务接口
在使用 Dubbo 之前,你还需要一个业务接口,这个业务接口可以认为是 Dubbo Provider 和 Dubbo Consumer 的公约,反映出很多信息:
Provider ,如何提供服务、提供的服务名称是什么、需要接收什么参数、需要返回什么响应;
Consumer ,如何使用服务、使用的服务名称是什么、需要传入什么参数、会得到什么响应。
dubbo-demo-interface 模块就是定义业务接口的地方,如下图所示:
其中DemoService 接口中定义了两个方法:
public interface DemoService {
String sayHello(String name); // 同步调用
// 异步调用
default CompletableFuture<String> sayHelloAsync(String name) {
return CompletableFuture.completedFuture(sayHello(name));
}
}
Demo 1基于 XML 配置
在 dubbo-demo 模块下的 dubbo-demo-xml 模块,提供了基于 Spring XML 的 Provider 和 Consumer。
我们先来看 dubbo-demo-xml-provider 模块,其结构如下图所示:
在其 pom.xml 中除了一堆 dubbo 的依赖之外,还有依赖了 DemoService 这个公共接口:
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-demo-interface</artifactId>
<version>${project.parent.version}</version>
</dependency>
DemoServiceImpl 实现了 DemoService 接口sayHello() 方法直接返回一个字符串sayHelloAsync() 方法返回一个 CompletableFuture 对象。
在 dubbo-provider.xml 配置文件中,会将 DemoServiceImpl 配置成一个 Spring Bean并作为 DemoService 服务暴露出去:
<!-- 配置为 Spring Bean -->
<bean id="demoService" class="org.apache.dubbo.demo.provider.DemoServiceImpl"/>
<!-- 作为 Dubbo 服务暴露出去 -->
<dubbo:service interface="org.apache.dubbo.demo.DemoService" ref="demoService"/>
还有就是指定注册中心地址(就是前面 ZooKeeper 的地址),这样 Dubbo 才能把暴露的 DemoService 服务注册到 ZooKeeper 中:
<!-- Zookeeper 地址 -->
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
最后,在 Application 中写个 main() 方法,指定 Spring 配置文件并启动 ClassPathXmlApplicationContext 即可。
接下来再看 dubbo-demo-xml-consumer 模块,结构如下图所示:
在 pom.xml 中同样依赖了 dubbo-demo-interface 这个公共模块。
在 dubbo-consumer.xml 配置文件中,会指定注册中心地址(就是前面 ZooKeeper 的地址),这样 Dubbo 才能从 ZooKeeper 中拉取到 Provider 暴露的服务列表信息:
<!-- Zookeeper地址 -->
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
还会使用 dubbo:reference 引入 DemoService 服务,后面可以作为 Spring Bean 使用:
<!--引入DemoService服务并配置成Spring Bean-->
<dubbo:reference id="demoService" check="false"
interface="org.apache.dubbo.demo.DemoService"/>
最后,在 Application 中写个 main() 方法,指定 Spring 配置文件并启动 ClassPathXmlApplicationContext 之后,就可以远程调用 Provider 端的 DemoService 的 sayHello() 方法了。
Demo 2基于注解配置
dubbo-demo-annotation 模块是基于 Spring 注解配置的示例,无非就是将 XML 的那些配置信息转移到了注解上。
我们先来看 dubbo-demo-annotation-provider 这个示例模块:
public class Application {
public static void main(String[] args) throws Exception {
// 使用AnnotationConfigApplicationContext初始化Spring容器
// 从ProviderConfiguration这个类的注解上拿相关配置信息
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(
ProviderConfiguration.class);
context.start();
System.in.read();
}
@Configuration // 配置类
// @EnableDubbo注解指定包下的Bean都会被扫描并做Dubbo服务暴露出去
@EnableDubbo(scanBasePackages = "org.apache.dubbo.demo.provider")
// @PropertySource注解指定了其他配置信息
@PropertySource("classpath:/spring/dubbo-provider.properties")
static class ProviderConfiguration {
@Bean
public RegistryConfig registryConfig() {
RegistryConfig registryConfig = new RegistryConfig();
registryConfig.setAddress("zookeeper://127.0.0.1:2181");
return registryConfig;
}
}
}
这里,同样会有一个 DemoServiceImpl 实现了 DemoService 接口,并且在 org.apache.dubbo.demo.provider 目录下,能被扫描到,暴露成 Dubbo 服务。
接着再来看 dubbo-demo-annotation-consumer 模块,其中 Application 中也是通过 AnnotationConfigApplicationContext 初始化 Spring 容器,也会扫描指定目录下的 Bean会扫到 DemoServiceComponent 这个 Bean其中就通过 @Reference 注解注入 Dubbo 服务相关的 Bean
@Component("demoServiceComponent")
public class DemoServiceComponent implements DemoService {
@Reference // 注入Dubbo服务
private DemoService demoService;
@Override
public String sayHello(String name) {
return demoService.sayHello(name);
}
// 其他方法
}
Demo 3基于 API 配置
在有的场景中,不能依赖于 Spring 框架,只能使用 API 来构建 Dubbo Provider 和 Consumer比较典型的一种场景就是在写 SDK 的时候。
先来看 dubbo-demo-api-provider 模块,其中 Application.main() 方法是入口:
// 创建一个ServiceConfig的实例泛型参数是业务接口实现类
// 即DemoServiceImpl
ServiceConfig<DemoServiceImpl> service = new ServiceConfig<>();
// 指定业务接口
service.setInterface(DemoService.class);
// 指定业务接口的实现由该对象来处理Consumer的请求
service.setRef(new DemoServiceImpl());
// 获取DubboBootstrap实例这是个单例的对象
DubboBootstrap bootstrap = DubboBootstrap.getInstance();
//生成一个 ApplicationConfig 的实例、指定ZK地址以及ServiceConfig实例
bootstrap.application(new ApplicationConfig("dubbo-demo-api-provider"))
.registry(new RegistryConfig("zookeeper://127.0.0.1:2181"))
.service(service)
.start()
.await();
这里,同样会有一个 DemoServiceImpl 实现了 DemoService 接口,并且在 org.apache.dubbo.demo.provider 目录下,能被扫描到,暴露成 Dubbo 服务。
再来看 dubbo-demo-api-consumer 模块,其中 Application 中包含一个普通的 main() 方法入口:
// 创建ReferenceConfig,其中指定了引用的接口DemoService
ReferenceConfig<DemoService> reference = new ReferenceConfig<>();
reference.setInterface(DemoService.class);
reference.setGeneric("true");
// 创建DubboBootstrap指定ApplicationConfig以及RegistryConfig
DubboBootstrap bootstrap = DubboBootstrap.getInstance();
bootstrap.application(new ApplicationConfig("dubbo-demo-api-consumer"))
.registry(new RegistryConfig("zookeeper://127.0.0.1:2181"))
.reference(reference)
.start();
// 获取DemoService实例并调用其方法
DemoService demoService = ReferenceConfigCache.getCache()
.get(reference);
String message = demoService.sayHello("dubbo");
System.out.println(message);
总结
在本课时,我们首先介绍了 Dubbo 的核心架构以及各核心组件的功能,接下来又搭建了 Dubbo 源码环境,并详细介绍了 Dubbo 核心模块的功能,为后续分析 Dubbo 源码打下了基础。最后我们还深入分析了 Dubbo 源码中自带的三个 Demo 示例,现在你就可以以这三个 Demo 示例为入口 Debug Dubbo 源码了。
在后面的课时中我们将解决几个问题Dubbo 是如何与 ZooKeeper 等注册中心进行交互的Provider 与 Consumer 之间是如何交互的为什么我们在编写业务代码的时候感受不到任何网络交互Dubbo Provider 发布到注册中心的数据是什么Consumer 为何能正确识别?两者的统一契约是什么?这个契约是如何做到可扩展的?这个契约还会用在 Dubbo 的哪些地方?这些问题你也可以提前思考一下,在后面的课程中我会一一为你解答。

View File

@@ -0,0 +1,222 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 Dubbo 的配置总线:抓住 URL就理解了半个 Dubbo
你好,我是杨四正,今天我和你分享的主题是 Dubbo 的配置总线:抓住 URL就理解了半个 Dubbo 。
在互联网领域,每个信息资源都有统一的且在网上唯一的地址,该地址就叫 URLUniform Resource Locator统一资源定位符它是互联网的统一资源定位标志也就是指网络地址。
URL 本质上就是一个特殊格式的字符串。一个标准的 URL 格式可以包含如下的几个部分:
protocol://username:password@host:port/path?key=value&key=value
protocolURL 的协议。我们常见的就是 HTTP 协议和 HTTPS 协议,当然,还有其他协议,如 FTP 协议、SMTP 协议等。
username/password用户名/密码。 HTTP Basic Authentication 中多会使用在 URL 的协议之后直接携带用户名和密码的方式。
host/port主机/端口。在实践中一般会使用域名,而不是使用具体的 host 和 port。
path请求的路径。
parameters参数键值对。一般在 GET 请求中会将参数放到 URL 中POST 请求会将参数放到请求体中。
URL 是整个 Dubbo 中非常基础,也是非常核心的一个组件,阅读源码的过程中你会发现很多方法都是以 URL 作为参数的,在方法内部解析传入的 URL 得到有用的参数,所以有人将 URL 称为Dubbo 的配置总线。
例如,在下一课时介绍的 Dubbo SPI 核心实现中,你会看到 URL 参与了扩展实现的确定;在本课程后续介绍注册中心实现的时候,你还会看到 Provider 将自身的信息封装成 URL 注册到 ZooKeeper 中,从而暴露自己的服务, Consumer 也是通过 URL 来确定自己订阅了哪些 Provider 的。
由此可见URL 之于 Dubbo 是非常重要的,所以说“抓住 URL就理解了半个 Dubbo”。那本文我们就来介绍 URL 在 Dubbo 中的应用,以及 URL 作为 Dubbo 统一契约的重要性,最后我们再通过示例说明 URL 在 Dubbo 中的具体应用。
Dubbo 中的 URL
Dubbo 中任意的一个实现都可以抽象为一个 URLDubbo 使用 URL 来统一描述了所有对象和配置信息,并贯穿在整个 Dubbo 框架之中。这里我们来看 Dubbo 中一个典型 URL 的示例,如下:
dubbo://172.17.32.91:20880/org.apache.dubbo.demo.DemoService?anyhost=true&application=dubbo-demo-api-provider&dubbo=2.0.2&interface=org.apache.dubbo.demo.DemoService&methods=sayHello,sayHelloAsync&pid=32508&release=&side=provider&timestamp=1593253404714dubbo://172.17.32.91:20880/org.apache.dubbo.demo.DemoService?anyhost=true&application=dubbo-demo-api-provider&dubbo=2.0.2&interface=org.apache.dubbo.demo.DemoService&methods=sayHello,sayHelloAsync&pid=32508&release=&side=provider&timestamp=1593253404714
这个 Demo Provider 注册到 ZooKeeper 上的 URL 信息,简单解析一下这个 URL 的各个部分:
protocoldubbo 协议。
username/password没有用户名和密码。
host/port172.17.32.91:20880。
pathorg.apache.dubbo.demo.DemoService。
parameters参数键值对这里是问号后面的参数。
下面是 URL 的构造方法,你可以看到其核心字段与前文分析的 URL 基本一致:
public URL(String protocol,
String username,
String password,
String host,
int port,
String path,
Map<String, String> parameters,
Map<String, Map<String, String>> methodParameters) {
if (StringUtils.isEmpty(username)
&& StringUtils.isNotEmpty(password)) {
throw new IllegalArgumentException("Invalid url");
}
this.protocol = protocol;
this.username = username;
this.password = password;
this.host = host;
this.port = Math.max(port, 0);
this.address = getAddress(this.host, this.port);
while (path != null && path.startsWith("/")) {
path = path.substring(1);
}
this.path = path;
if (parameters == null) {
parameters = new HashMap<>();
} else {
parameters = new HashMap<>(parameters);
}
this.parameters = Collections.unmodifiableMap(parameters);
this.methodParameters = Collections.unmodifiableMap(methodParameters);
}
另外,在 dubbo-common 包中还提供了 URL 的辅助类:
URLBuilder 辅助构造 URL
URLStrParser 将字符串解析成 URL 对象。
契约的力量
对于 Dubbo 中的 URL很多人称之为“配置总线”也有人称之为“统一配置模型”。虽然说法不同但都是在表达一个意思URL 在 Dubbo 中被当作是“公共的契约”。一个 URL 可以包含非常多的扩展点参数URL 作为上下文信息贯穿整个扩展点设计体系。
其实,一个优秀的开源产品都有一套灵活清晰的扩展契约,不仅是第三方可以按照这个契约进行扩展,其自身的内核也可以按照这个契约进行搭建。如果没有一个公共的契约,只是针对每个接口或方法进行约定,就会导致不同的接口甚至同一接口中的不同方法,以不同的参数类型进行传参,一会儿传递 Map一会儿传递字符串而且字符串的格式也不确定需要你自己进行解析这就多了一层没有明确表现出来的隐含的约定。
所以说,在 Dubbo 中使用 URL 的好处多多,增加了便捷性:
使用 URL 这种公共契约进行上下文信息传递,最重要的就是代码更加易读、易懂,不用花大量时间去揣测传递数据的格式和含义,进而形成一个统一的规范,使得代码易写、易读。
使用 URL 作为方法的入参(相当于一个 Key/Value 都是 String 的 Map),它所表达的含义比单个参数更丰富,当代码需要扩展的时候,可以将新的参数以 Key/Value 的形式追加到 URL 之中,而不需要改变入参或是返回值的结构。
使用 URL 这种“公共的契约”可以简化沟通,人与人之间的沟通消耗是非常大的,信息传递的效率非常低,使用统一的契约、术语、词汇范围,可以省去很多沟通成本,尽可能地提高沟通效率。
Dubbo 中的 URL 示例
了解了 URL 的结构以及 Dubbo 使用 URL 的原因之后,我们再来看 Dubbo 中的三个真实示例,进一步感受 URL 的重要性。
1. URL 在 SPI 中的应用
Dubbo SPI 中有一个依赖 URL 的重要场景——适配器方法,是被 @Adaptive 注解标注的, URL 一个很重要的作用就是与 @Adaptive 注解一起选择合适的扩展实现类。
例如,在 dubbo-registry-api 模块中我们可以看到 RegistryFactory 这个接口,其中的 getRegistry() 方法上有 @Adaptive({“protocol”}) 注解说明这是一个适配器方法Dubbo 在运行时会为其动态生成相应的 “$Adaptive” 类型,如下所示:
public class RegistryFactory$Adaptive
implements RegistryFactory {
public Registry getRegistry(org.apache.dubbo.common.URL arg0) {
if (arg0 == null) throw new IllegalArgumentException("...");
org.apache.dubbo.common.URL url = arg0;
// 尝试获取URL的Protocol如果Protocol为空则使用默认值"dubbo"
String extName = (url.getProtocol() == null ? "dubbo" :
url.getProtocol());
if (extName == null)
throw new IllegalStateException("...");
// 根据扩展名选择相应的扩展实现Dubbo SPI的核心原理在下一课时深入分析
RegistryFactory extension = (RegistryFactory) ExtensionLoader
.getExtensionLoader(RegistryFactory.class)
.getExtension(extName);
return extension.getRegistry(arg0);
}
}
我们会看到,在生成的 RegistryFactory$Adaptive 类中会自动实现 getRegistry() 方法,其中会根据 URL 的 Protocol 确定扩展名称,从而确定使用的具体扩展实现类。我们可以找到 RegistryProtocol 这个类,并在其 getRegistry() 方法中打一个断点, Debug 启动上一课时介绍的任意一个 Demo 示例中的 Provider得到如下图所示的内容
这里传入的 registryUrl 值为:
zookeeper://127.0.0.1:2181/org.apache.dubbo...
那么在 RegistryFactory$Adaptive 中得到的扩展名称为 zookeeper此次使用的 Registry 扩展实现类就是 ZookeeperRegistryFactory。至于 Dubbo SPI 的完整内容,我们将在下一课时详细介绍,这里就不再展开了。
2. URL 在服务暴露中的应用
我们再来看另一个与 URL 相关的示例。上一课时我们在介绍 Dubbo 的简化架构时提到Provider 在启动时,会将自身暴露的服务注册到 ZooKeeper 上,具体是注册哪些信息到 ZooKeeper 上呢?我们来看 ZookeeperRegistry.doRegister() 方法,在其中打个断点,然后 Debug 启动 Provider会得到下图
传入的 URL 中包含了 Provider 的地址172.18.112.15:20880、暴露的接口org.apache.dubbo.demo.DemoService等信息 toUrlPath() 方法会根据传入的 URL 参数确定在 ZooKeeper 上创建的节点路径,还会通过 URL 中的 dynamic 参数值确定创建的 ZNode 是临时节点还是持久节点。
3. URL 在服务订阅中的应用
Consumer 启动后会向注册中心进行订阅操作,并监听自己关注的 Provider。那 Consumer 是如何告诉注册中心自己关注哪些 Provider 呢?
我们来看 ZookeeperRegistry 这个实现类,它是由上面的 ZookeeperRegistryFactory 工厂类创建的 Registry 接口实现,其中的 doSubscribe() 方法是订阅操作的核心实现,在第 175 行打一个断点,并 Debug 启动 Demo 中 Consumer会得到下图所示的内容
我们看到传入的 URL 参数如下:
consumer://...?application=dubbo-demo-api-consumer&category=providers,configurators,routers&interface=org.apache.dubbo.demo.DemoService...
其中 Protocol 为 consumer ,表示是 Consumer 的订阅协议,其中的 category 参数表示要订阅的分类,这里要订阅 providers、configurators 以及 routers 三个分类interface 参数表示订阅哪个服务接口,这里要订阅的是暴露 org.apache.dubbo.demo.DemoService 实现的 Provider。
通过 URL 中的上述参数ZookeeperRegistry 会在 toCategoriesPath() 方法中将其整理成一个 ZooKeeper 路径,然后调用 zkClient 在其上添加监听。
通过上述示例,相信你已经感觉到 URL 在 Dubbo 体系中称为“总线”或是“契约”的原因了,在后面的源码分析中,我们还将看到更多关于 URL 的实现。
总结
在本课时,我们重点介绍了 Dubbo 对 URL 的封装以及相关的工具类,然后说明了统一契约的好处,当然也是 Dubbo 使用 URL 作为统一配置总线的好处,最后我们还介绍了 Dubbo SPI、Provider 注册、Consumer 订阅等场景中与 URL 相关的实现,这些都可以帮助你更好地感受 URL 在其中发挥的作用。
这里你可以想一下,在其他框架或是实际工作中,有没有类似 Dubbo URL 这种统一的契约?欢迎你在留言区分享你的想法。

View File

@@ -0,0 +1,353 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 Dubbo SPI 精析,接口实现两极反转(上)
Dubbo 为了更好地达到 OCP 原则(即“对扩展开放,对修改封闭”的原则),采用了“微内核+插件”的架构。那什么是微内核架构呢微内核架构也被称为插件化架构Plug-in Architecture这是一种面向功能进行拆分的可扩展性架构。内核功能是比较稳定的只负责管理插件的生命周期不会因为系统功能的扩展而不断进行修改。功能上的扩展全部封装到插件之中插件模块是独立存在的模块包含特定的功能能拓展内核系统的功能。
微内核架构中,内核通常采用 Factory、IoC、OSGi 等方式管理插件生命周期Dubbo 最终决定采用 SPI 机制来加载插件Dubbo SPI 参考 JDK 原生的 SPI 机制,进行了性能优化以及功能增强。因此,在讲解 Dubbo SPI 之前,我们有必要先来介绍一下 JDK SPI 的工作原理。
JDK SPI
SPIService Provider Interface主要是被框架开发人员使用的一种技术。例如使用 Java 语言访问数据库时我们会使用到 java.sql.Driver 接口,不同数据库产品底层的协议不同,提供的 java.sql.Driver 实现也不同,在开发 java.sql.Driver 接口时,开发人员并不清楚用户最终会使用哪个数据库,在这种情况下就可以使用 Java SPI 机制在实际运行过程中,为 java.sql.Driver 接口寻找具体的实现。
1. JDK SPI 机制
当服务的提供者提供了一种接口的实现之后,需要在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,此文件记录了该 jar 包提供的服务接口的具体实现类。当某个应用引入了该 jar 包且需要使用该服务时JDK SPI 机制就可以通过查找这个 jar 包的 META-INF/services/ 中的配置文件来获得具体的实现类名,进行实现类的加载和实例化,最终使用该实现类完成业务功能。
下面我们通过一个简单的示例演示下 JDK SPI 的基本使用方式:
.png]
首先我们需要创建一个 Log 接口,来模拟日志打印的功能:
public interface Log {
void log(String info);
}
接下来提供两个实现—— Logback 和 Log4j分别代表两个不同日志框架的实现如下所示
public class Logback implements Log {
@Override
public void log(String info) {
System.out.println("Logback:" + info);
}
}
public class Log4j implements Log {
@Override
public void log(String info) {
System.out.println("Log4j:" + info);
}
}
在项目的 resources/META-INF/services 目录下添加一个名为 com.xxx.Log 的文件,这是 JDK SPI 需要读取的配置文件,具体内容如下:
com.xxx.impl.Log4j
com.xxx.impl.Logback
最后创建 main() 方法,其中会加载上述配置文件,创建全部 Log 接口实现的实例,并执行其 log() 方法,如下所示:
public class Main {
public static void main(String[] args) {
ServiceLoader<Log> serviceLoader =
ServiceLoader.load(Log.class);
Iterator<Log> iterator = serviceLoader.iterator();
while (iterator.hasNext()) {
Log log = iterator.next();
log.log("JDK SPI");
}
}
}
// 输出如下:
// Log4j:JDK SPI
// Logback:JDK SPI
2. JDK SPI 源码分析
通过上述示例,我们可以看到 JDK SPI 的入口方法是 ServiceLoader.load() 方法,接下来我们就对其具体实现进行深入分析。
在 ServiceLoader.load() 方法中,首先会尝试获取当前使用的 ClassLoader获取当前线程绑定的 ClassLoader查找失败后使用 SystemClassLoader然后调用 reload() 方法,调用关系如下图所示:
在 reload() 方法中,首先会清理 providers 缓存LinkedHashMap 类型的集合),该缓存用来记录 ServiceLoader 创建的实现对象,其中 Key 为实现类的完整类名Value 为实现类的对象。之后创建 LazyIterator 迭代器,用于读取 SPI 配置文件并实例化实现类对象。
ServiceLoader.reload() 方法的具体实现,如下所示:
// 缓存,用来缓存 ServiceLoader创建的实现对象
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
public void reload() {
providers.clear(); // 清空缓存
lookupIterator = new LazyIterator(service, loader); // 迭代器
}
在前面的示例中main() 方法中使用的迭代器底层就是调用了 ServiceLoader.LazyIterator 实现的。Iterator 接口有两个关键方法hasNext() 方法和 next() 方法。这里的 LazyIterator 中的next() 方法最终调用的是其 nextService() 方法hasNext() 方法最终调用的是 hasNextService() 方法,调用关系如下图所示:
首先来看 LazyIterator.hasNextService() 方法,该方法主要负责查找 META-INF/services 目录下的 SPI 配置文件,并进行遍历,大致实现如下所示:
private static final String PREFIX = "META-INF/services/";
Enumeration<URL> configs = null;
Iterator<String> pending = null;
String nextName = null;
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
// PREFIX前缀与服务接口的名称拼接起来就是META-INF目录下定义的SPI配
// 置文件(即示例中的META-INF/services/com.xxx.Log)
String fullName = PREFIX + service.getName();
// 加载配置文件
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
}
// 按行SPI遍历配置文件的内容
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
// 解析配置文件
pending = parse(service, configs.nextElement());
}
nextName = pending.next(); // 更新 nextName字段
return true;
}
在 hasNextService() 方法中完成 SPI 配置文件的解析之后,再来看 LazyIterator.nextService() 方法,该方法负责实例化 hasNextService() 方法读取到的实现类,其中会将实例化的对象放到 providers 集合中缓存起来,核心实现如下所示:
private S nextService() {
String cn = nextName;
nextName = null;
// 加载 nextName字段指定的类
Class<?> c = Class.forName(cn, false, loader);
if (!service.isAssignableFrom(c)) { // 检测类型
fail(service, "Provider " + cn + " not a subtype");
}
S p = service.cast(c.newInstance()); // 创建实现类的对象
providers.put(cn, p); // 将实现类名称以及相应实例对象添加到缓存
return p;
}
以上就是在 main() 方法中使用的迭代器的底层实现。最后,我们再来看一下 main() 方法中使用ServiceLoader.iterator() 方法拿到的迭代器是如何实现的,这个迭代器是依赖 LazyIterator 实现的一个匿名内部类,核心实现如下:
public Iterator<S> iterator() {
return new Iterator<S>() {
// knownProviders用来迭代providers缓存
Iterator<Map.Entry<String,S>> knownProviders
= providers.entrySet().iterator();
public boolean hasNext() {
// 先走查询缓存缓存查询失败再通过LazyIterator加载
if (knownProviders.hasNext())
return true;
return lookupIterator.hasNext();
}
public S next() {
// 先走查询缓存,缓存查询失败,再通过 LazyIterator加载
if (knownProviders.hasNext())
return knownProviders.next().getValue();
return lookupIterator.next();
}
// 省略remove()方法
};
}
3. JDK SPI 在 JDBC 中的应用
了解了 JDK SPI 实现的原理之后,我们再来看实践中 JDBC 是如何使用 JDK SPI 机制加载不同数据库厂商的实现类。
JDK 中只定义了一个 java.sql.Driver 接口,具体的实现是由不同数据库厂商来提供的。这里我们就以 MySQL 提供的 JDBC 实现包为例进行分析。
在 mysql-connector-java-*.jar 包中的 META-INF/services 目录下,有一个 java.sql.Driver 文件中只有一行内容,如下所示:
com.mysql.cj.jdbc.Driver
在使用 mysql-connector-java-*.jar 包连接 MySQL 数据库的时候,我们会用到如下语句创建数据库连接:
String url = "jdbc:xxx://xxx:xxx/xxx";
Connection conn = DriverManager.getConnection(url, username, pwd);
DriverManager 是 JDK 提供的数据库驱动管理器,其中的代码片段,如下所示:
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
在调用 getConnection() 方法的时候DriverManager 类会被 Java 虚拟机加载、解析并触发 static 代码块的执行;在 loadInitialDrivers() 方法中通过 JDK SPI 扫描 Classpath 下 java.sql.Driver 接口实现类并实例化,核心实现如下所示:
private static void loadInitialDrivers() {
String drivers = System.getProperty("jdbc.drivers")
// 使用 JDK SPI机制加载所有 java.sql.Driver实现类
ServiceLoader<Driver> loadedDrivers =
ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
while(driversIterator.hasNext()) {
driversIterator.next();
}
String[] driversList = drivers.split(":");
for (String aDriver : driversList) { // 初始化Driver实现类
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
}
}
在 MySQL 提供的 com.mysql.cj.jdbc.Driver 实现类中,同样有一段 static 静态代码块,这段代码会创建一个 com.mysql.cj.jdbc.Driver 对象并注册到 DriverManager.registeredDrivers 集合中CopyOnWriteArrayList 类型),如下所示:
static {
java.sql.DriverManager.registerDriver(new Driver());
}
在 getConnection() 方法中DriverManager 从该 registeredDrivers 集合中获取对应的 Driver 对象创建 Connection核心实现如下所示
private static Connection getConnection(String url, java.util.Properties info, Class<?> caller) throws SQLException {
// 省略 try/catch代码块以及权限处理逻辑
for(DriverInfo aDriver : registeredDrivers) {
Connection con = aDriver.driver.connect(url, info);
return con;
}
}
总结
本文我们通过一个示例入手,介绍了 JDK 提供的 SPI 机制的基本使用,然后深入分析了 JDK SPI 的核心原理和底层实现,对其源码进行了深入剖析,最后我们以 MySQL 提供的 JDBC 实现为例,分析了 JDK SPI 在实践中的使用方式。
JDK SPI 机制虽然简单易用,但是也存在一些小瑕疵,你可以先思考一下,在下一课时剖析 Dubbo SPI 机制的时候,我会为你解答该问题。

View File

@@ -0,0 +1,659 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 Dubbo SPI 精析,接口实现两极反转(下)
在上一课时,我们一起学习了 JDK SPI 的基础使用以及核心原理,不过 Dubbo 并没有直接使用 JDK SPI 机制,而是借鉴其思想,实现了自身的一套 SPI 机制,这就是本课时将重点介绍的内容。
Dubbo SPI
在开始介绍 Dubbo SPI 实现之前,我们先来统一下面两个概念。
扩展点:通过 SPI 机制查找并加载实现的接口(又称“扩展接口”)。前文示例中介绍的 Log 接口、com.mysql.cj.jdbc.Driver 接口,都是扩展点。
扩展点实现:实现了扩展接口的实现类。
通过前面的分析可以发现JDK SPI 在查找扩展实现类的过程中,需要遍历 SPI 配置文件中定义的所有实现类,该过程中会将这些实现类全部实例化。如果 SPI 配置文件中定义了多个实现类而我们只需要使用其中一个实现类时就会生成不必要的对象。例如org.apache.dubbo.rpc.Protocol 接口有 InjvmProtocol、DubboProtocol、RmiProtocol、HttpProtocol、HessianProtocol、ThriftProtocol 等多个实现,如果使用 JDK SPI就会加载全部实现类导致资源的浪费。
Dubbo SPI 不仅解决了上述资源浪费的问题,还对 SPI 配置文件扩展和修改。
首先Dubbo 按照 SPI 配置文件的用途,将其分成了三类目录。
META-INF/services/ 目录:该目录下的 SPI 配置文件用来兼容 JDK SPI 。
META-INF/dubbo/ 目录:该目录用于存放用户自定义 SPI 配置文件。
META-INF/dubbo/internal/ 目录:该目录用于存放 Dubbo 内部使用的 SPI 配置文件。
然后Dubbo 将 SPI 配置文件改成了 KV 格式,例如:
dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol
其中 key 被称为扩展名(也就是 ExtensionName当我们在为一个接口查找具体实现类时可以指定扩展名来选择相应的扩展实现。例如这里指定扩展名为 dubboDubbo SPI 就知道我们要使用org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,只实例化这一个扩展实现即可,无须实例化 SPI 配置文件中的其他扩展实现类。
使用 KV 格式的 SPI 配置文件的另一个好处是:让我们更容易定位到问题。假设我们使用的一个扩展实现类所在的 jar 包没有引入到项目中,那么 Dubbo SPI 在抛出异常的时候,会携带该扩展名信息,而不是简单地提示扩展实现类无法加载。这些更加准确的异常信息降低了排查问题的难度,提高了排查问题的效率。
下面我们正式进入 Dubbo SPI 核心实现的介绍。
1. @SPI 注解
Dubbo 中某个接口被 @SPI注解修饰时,就表示该接口是扩展接口,前文示例中的 org.apache.dubbo.rpc.Protocol 接口就是一个扩展接口:
@SPI 注解的 value 值指定了默认的扩展名称,例如,在通过 Dubbo SPI 加载 Protocol 接口实现时,如果没有明确指定扩展名,则默认会将 @SPI 注解的 value 值作为扩展名,即加载 dubbo 这个扩展名对应的 org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,相关的 SPI 配置文件在 dubbo-rpc-dubbo 模块中,如下图所示:
那 ExtensionLoader 是如何处理 @SPI 注解的呢?
ExtensionLoader 位于 dubbo-common 模块中的 extension 包中,功能类似于 JDK SPI 中的 java.util.ServiceLoader。Dubbo SPI 的核心逻辑几乎都封装在 ExtensionLoader 之中(其中就包括 @SPI 注解的处理逻辑),其使用方式如下所示:
Protocol protocol = ExtensionLoader
.getExtensionLoader(Protocol.class).getExtension("dubbo");
这里首先来了解一下 ExtensionLoader 中三个核心的静态字段。
strategiesLoadingStrategy[]类型): LoadingStrategy 接口有三个实现(通过 JDK SPI 方式加载的),如下图所示,分别对应前面介绍的三个 Dubbo SPI 配置文件所在的目录,且都继承了 Prioritized 这个优先级接口,默认优先级是
DubboInternalLoadingStrategy > DubboLoadingStrategy > ServicesLoadingStrateg
EXTENSION_LOADERSConcurrentMap类型
Dubbo 中一个扩展接口对应一个 ExtensionLoader 实例,该集合缓存了全部 ExtensionLoader 实例,其中的 Key 为扩展接口Value 为加载其扩展实现的 ExtensionLoader 实例。
EXTENSION_INSTANCESConcurrentMap, Object>类型该集合缓存了扩展实现类与其实例对象的映射关系。在前文示例中Key 为 ClassValue 为 DubboProtocol 对象。
下面我们再来关注一下 ExtensionLoader 的实例字段。
typeClass<?>类型):当前 ExtensionLoader 实例负责加载扩展接口。
cachedDefaultNameString类型记录了 type 这个扩展接口上 @SPI 注解的 value 值,也就是默认扩展名。
cachedNamesConcurrentMap, String>类型):缓存了该 ExtensionLoader 加载的扩展实现类与扩展名之间的映射关系。
cachedClassesHolder>>类型):缓存了该 ExtensionLoader 加载的扩展名与扩展实现类之间的映射关系。cachedNames 集合的反向关系缓存。
cachedInstancesConcurrentMap>类型):缓存了该 ExtensionLoader 加载的扩展名与扩展实现对象之间的映射关系。
ExtensionLoader.getExtensionLoader() 方法会根据扩展接口从 EXTENSION_LOADERS 缓存中查找相应的 ExtensionLoader 实例,核心实现如下:
public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
ExtensionLoader<T> loader =
(ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
if (loader == null) {
EXTENSION_LOADERS.putIfAbsent(type,
new ExtensionLoader<T>(type));
loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
}
return loader;
}
得到接口对应的 ExtensionLoader 对象之后会调用其 getExtension() 方法,根据传入的扩展名称从 cachedInstances 缓存中查找扩展实现的实例,最终将其实例化后返回:
public T getExtension(String name) {
// getOrCreateHolder()方法中封装了查找cachedInstances缓存的逻辑
Holder<Object> holder = getOrCreateHolder(name);
Object instance = holder.get();
if (instance == null) { // double-check防止并发问题
synchronized (holder) {
instance = holder.get();
if (instance == null) {
// 根据扩展名从SPI配置文件中查找对应的扩展实现类
instance = createExtension(name);
holder.set(instance);
}
}
}
return (T) instance;
}
在 createExtension() 方法中完成了 SPI 配置文件的查找以及相应扩展实现类的实例化,同时还实现了自动装配以及自动 Wrapper 包装等功能。其核心流程是这样的:
获取 cachedClasses 缓存,根据扩展名从 cachedClasses 缓存中获取扩展实现类。如果 cachedClasses 未初始化,则会扫描前面介绍的三个 SPI 目录获取查找相应的 SPI 配置文件,然后加载其中的扩展实现类,最后将扩展名和扩展实现类的映射关系记录到 cachedClasses 缓存中。这部分逻辑在 loadExtensionClasses() 和 loadDirectory() 方法中。
根据扩展实现类从 EXTENSION_INSTANCES 缓存中查找相应的实例。如果查找失败,会通过反射创建扩展实现对象。
自动装配扩展实现对象中的属性(即调用其 setter。这里涉及 ExtensionFactory 以及自动装配的相关内容,本课时后面会进行详细介绍。
自动包装扩展实现对象。这里涉及 Wrapper 类以及自动包装特性的相关内容,本课时后面会进行详细介绍。
如果扩展实现类实现了 Lifecycle 接口,在 initExtension() 方法中会调用 initialize() 方法进行初始化。
private T createExtension(String name) {
Class<?> clazz = getExtensionClasses().get(name); // --- 1
if (clazz == null) {
throw findException(name);
}
try {
T instance = (T) EXTENSION_INSTANCES.get(clazz); // --- 2
if (instance == null) {
EXTENSION_INSTANCES.putIfAbsent(clazz, clazz.newInstance());
instance = (T) EXTENSION_INSTANCES.get(clazz);
}
injectExtension(instance); // --- 3
Set<Class<?>> wrapperClasses = cachedWrapperClasses; // --- 4
if (CollectionUtils.isNotEmpty(wrapperClasses)) {
for (Class<?> wrapperClass : wrapperClasses) {
instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance));
}
}
initExtension(instance); // ---5
return instance;
} catch (Throwable t) {
throw new IllegalStateException("Extension instance (name: " + name + ", class: " +
type + ") couldn't be instantiated: " + t.getMessage(), t);
}
}
2. @Adaptive 注解与适配器
@Adaptive 注解用来实现 Dubbo 的适配器功能那什么是适配器呢这里我们通过一个示例进行说明。Dubbo 中的 ExtensionFactory 接口有三个实现类如下图所示ExtensionFactory 接口上有 @SPI 注解AdaptiveExtensionFactory 实现类上有 @Adaptive 注解。
AdaptiveExtensionFactory 不实现任何具体的功能,而是用来适配 ExtensionFactory 的 SpiExtensionFactory 和 SpringExtensionFactory 这两种实现。AdaptiveExtensionFactory 会根据运行时的一些状态来选择具体调用 ExtensionFactory 的哪个实现。
@Adaptive 注解还可以加到接口方法之上Dubbo 会动态生成适配器类。例如Transporter接口有两个被 @Adaptive 注解修饰的方法:
@SPI("netty")
public interface Transporter {
@Adaptive({Constants.SERVER_KEY, Constants.TRANSPORTER_KEY})
RemotingServer bind(URL url, ChannelHandler handler) throws RemotingException;
@Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY})
Client connect(URL url, ChannelHandler handler) throws RemotingException;
}
Dubbo 会生成一个 Transporter$Adaptive 适配器类,该类继承了 Transporter 接口:
public class Transporter$Adaptive implements Transporter {
public org.apache.dubbo.remoting.Client connect(URL arg0, ChannelHandler arg1) throws RemotingException {
// 必须传递URL参数
if (arg0 == null) throw new IllegalArgumentException("url == null");
URL url = arg0;
// 确定扩展名优先从URL中的client参数获取其次是transporter参数
// 这两个参数名称由@Adaptive注解指定,最后是@SPI注解中的默认值
String extName = url.getParameter("client",
url.getParameter("transporter", "netty"));
if (extName == null)
throw new IllegalStateException("...");
// 通过ExtensionLoader加载Transporter接口的指定扩展实现
Transporter extension = (Transporter) ExtensionLoader
.getExtensionLoader(Transporter.class)
.getExtension(extName);
return extension.connect(arg0, arg1);
}
... // 省略bind()方法
}
生成 Transporter$Adaptive 这个类的逻辑位于 ExtensionLoader.createAdaptiveExtensionClass() 方法,若感兴趣你可以看一下相关代码,其中涉及的 javassist 等方面的知识,在后面的课时中我们会进行介绍。
明确了 @Adaptive 注解的作用之后,我们回到 ExtensionLoader.createExtension() 方法,其中在扫描 SPI 配置文件的时候,会调用 loadClass() 方法加载 SPI 配置文件中指定的类,如下图所示:
loadClass() 方法中会识别加载扩展实现类上的 @Adaptive 注解,将该扩展实现的类型缓存到 cachedAdaptiveClass 这个实例字段上volatile修饰
private void loadClass(){
if (clazz.isAnnotationPresent(Adaptive.class)) {
// 缓存到cachedAdaptiveClass字段
cacheAdaptiveClass(clazz, overridden);
} else ... // 省略其他分支
}
我们可以通过 ExtensionLoader.getAdaptiveExtension() 方法获取适配器实例,并将该实例缓存到 cachedAdaptiveInstance 字段Holder类型核心流程如下
首先,检查 cachedAdaptiveInstance 字段中是否已缓存了适配器实例,如果已缓存,则直接返回该实例即可。
然后,调用 getExtensionClasses() 方法,其中就会触发前文介绍的 loadClass() 方法,完成 cachedAdaptiveClass 字段的填充。
如果存在 @Adaptive 注解修饰的扩展实现类,该类就是适配器类,通过 newInstance() 将其实例化即可。如果不存在 @Adaptive 注解修饰的扩展实现类,就需要通过 createAdaptiveExtensionClass() 方法扫描扩展接口中方法上的 @Adaptive 注解,动态生成适配器类,然后实例化。
接下来,调用 injectExtension() 方法进行自动装配,就能得到一个完整的适配器实例。
最后,将适配器实例缓存到 cachedAdaptiveInstance 字段,然后返回适配器实例。
getAdaptiveExtension() 方法的流程涉及多个方法,这里不再粘贴代码,感兴趣的同学可以参考上述流程分析相应源码。
此外,我们还可以通过 API 方式addExtension() 方法)设置 cachedAdaptiveClass 这个字段,指定适配器类型(这个方法你知道即可)。
总之,适配器什么实际工作都不用做,就是根据参数和状态选择其他实现来完成工作。 。
3. 自动包装特性
Dubbo 中的一个扩展接口可能有多个扩展实现类这些扩展实现类可能会包含一些相同的逻辑如果在每个实现类中都写一遍那么这些重复代码就会变得很难维护。Dubbo 提供的自动包装特性,就可以解决这个问题。 Dubbo 将多个扩展实现类的公共逻辑,抽象到 Wrapper 类中Wrapper 类与普通的扩展实现类一样,也实现了扩展接口,在获取真正的扩展实现对象时,在其外面包装一层 Wrapper 对象,你可以理解成一层装饰器。
了解了 Wrapper 类的基本功能,我们回到 ExtensionLoader.loadClass() 方法中,可以看到:
private void loadClass(){
... // 省略前面对@Adaptive注解的处理
} else if (isWrapperClass(clazz)) { // ---1
cacheWrapperClass(clazz); // ---2
} else ... // 省略其他分支
}
在 isWrapperClass() 方法中,会判断该扩展实现类是否包含拷贝构造函数(即构造函数只有一个参数且为扩展接口类型),如果包含,则为 Wrapper 类,这就是判断 Wrapper 类的标准。
将 Wrapper 类记录到 cachedWrapperClassesSet>类型)这个实例字段中进行缓存。
前面在介绍 createExtension() 方法时的 4 处,有下面这段代码,其中会遍历全部 Wrapper 类并一层层包装到真正的扩展实例对象外层:
Set<Class<?>> wrapperClasses = cachedWrapperClasses;
if (CollectionUtils.isNotEmpty(wrapperClasses)) {
for (Class<?> wrapperClass : wrapperClasses) {
instance = injectExtension((T) wrapperClass
.getConstructor(type).newInstance(instance));
}
}
4. 自动装配特性
在 createExtension() 方法中我们看到Dubbo SPI 在拿到扩展实现类的对象(以及 Wrapper 类的对象)之后,还会调用 injectExtension() 方法扫描其全部 setter 方法,并根据 setter 方法的名称以及参数的类型,加载相应的扩展实现,然后调用相应的 setter 方法填充属性,这就实现了 Dubbo SPI 的自动装配特性。简单来说,自动装配属性就是在加载一个扩展点的时候,将其依赖的扩展点一并加载,并进行装配。
下面简单看一下 injectExtension() 方法的具体实现:
private T injectExtension(T instance) {
if (objectFactory == null) { // 检测objectFactory字段
return instance;
}
for (Method method : instance.getClass().getMethods()) {
... // 如果不是setter方法忽略该方法(略)
if (method.getAnnotation(DisableInject.class) != null) {
continue; // 如果方法上明确标注了@DisableInject注解,忽略该方法
}
// 根据setter方法的参数确定扩展接口
Class<?> pt = method.getParameterTypes()[0];
... // 如果参数为简单类型忽略该setter方法(略)
// 根据setter方法的名称确定属性名称
String property = getSetterProperty(method);
// 加载并实例化扩展实现类
Object object = objectFactory.getExtension(pt, property);
if (object != null) {
method.invoke(instance, object); // 调用setter方法进行装配
}
}
return instance;
}
injectExtension() 方法实现的自动装配依赖了 ExtensionFactory即 objectFactory 字段),前面我们提到过 ExtensionFactory 有 SpringExtensionFactory 和 SpiExtensionFactory 两个真正的实现(还有一个实现是 AdaptiveExtensionFactory 是适配器)。下面我们分别介绍下这两个真正的实现。
第一个SpiExtensionFactory。 根据扩展接口获取相应的适配器,没有到属性名称:
@Override
public <T> T getExtension(Class<T> type, String name) {
if (type.isInterface() && type.isAnnotationPresent(SPI.class)) {
// 查找type对应的ExtensionLoader实例
ExtensionLoader<T> loader = ExtensionLoader
.getExtensionLoader(type);
if (!loader.getSupportedExtensions().isEmpty()) {
return loader.getAdaptiveExtension(); // 获取适配器实现
}
}
return null;
}
第二个SpringExtensionFactory。 将属性名称作为 Spring Bean 的名称,从 Spring 容器中获取 Bean
public <T> T getExtension(Class<T> type, String name) {
... // 检查:type必须为接口且必须包含@SPI注解(略)
for (ApplicationContext context : CONTEXTS) {
// 从Spring容器中查找Bean
T bean = BeanFactoryUtils.getOptionalBean(context,name,type);
if (bean != null) {
return bean;
}
}
return null;
}
5. @Activate注解与自动激活特性
这里以 Dubbo 中的 Filter 为例说明自动激活特性的含义org.apache.dubbo.rpc.Filter 接口有非常多的扩展实现类,在一个场景中可能需要某几个 Filter 扩展实现类协同工作,而另一个场景中可能需要另外几个实现类一起工作。这样,就需要一套配置来指定当前场景中哪些 Filter 实现是可用的,这就是 @Activate 注解要做的事情。
@Activate 注解标注在扩展实现类上,有 group、value 以及 order 三个属性。
group 属性:修饰的实现类是在 Provider 端被激活还是在 Consumer 端被激活。
value 属性:修饰的实现类只在 URL 参数中出现指定的 key 时才会被激活。
order 属性:用来确定扩展实现类的排序。
我们先来看 loadClass() 方法对 @Activate 的扫描,其中会将包含 @Activate 注解的实现类缓存到 cachedActivates 这个实例字段Map类型Key为扩展名Value为 @Activate 注解):
private void loadClass(){
if (clazz.isAnnotationPresent(Adaptive.class)) {
// 处理@Adaptive注解
cacheAdaptiveClass(clazz, overridden);
} else if (isWrapperClass(clazz)) { // 处理Wrapper类
cacheWrapperClass(clazz);
} else { // 处理真正的扩展实现类
clazz.getConstructor(); // 扩展实现类必须有无参构造函数
...// 兜底:SPI配置文件中未指定扩展名称则用类的简单名称作为扩展名(略)
String[] names = NAME_SEPARATOR.split(name);
if (ArrayUtils.isNotEmpty(names)) {
// 将包含@Activate注解的实现类缓存到cachedActivates集合中
cacheActivateClass(clazz, names[0]);
for (String n : names) {
// 在cachedNames集合中缓存实现类->扩展名的映射
cacheName(clazz, n);
// 在cachedClasses集合中缓存扩展名->实现类的映射
saveInExtensionClass(extensionClasses, clazz, n,
overridden);
}
}
}
}
使用 cachedActivates 这个集合的地方是 getActivateExtension() 方法。首先来关注 getActivateExtension() 方法的参数url 中包含了配置信息values 是配置中指定的扩展名group 为 Provider 或 Consumer。下面是 getActivateExtension() 方法的核心逻辑:
首先,获取默认激活的扩展集合。默认激活的扩展实现类有几个条件:①在 cachedActivates 集合中存在;②@Activate 注解指定的 group 属性与当前 group 匹配;③扩展名没有出现在 values 中即未在配置中明确指定也未在配置中明确指定删除④URL 中出现了 @Activate 注解中指定的 Key。
然后,按照 @Activate 注解中的 order 属性对默认激活的扩展集合进行排序。
最后,按序添加自定义扩展实现类的对象。
public List<T> getActivateExtension(URL url, String[] values,
String group) {
List<T> activateExtensions = new ArrayList<>();
// values配置就是扩展名
List<String> names = values == null ?
new ArrayList<>(0) : asList(values);
if (!names.contains(REMOVE_VALUE_PREFIX + DEFAULT_KEY)) {// ---1
getExtensionClasses(); // 触发cachedActivates等缓存字段的加载
for (Map.Entry<String, Object> entry :
cachedActivates.entrySet()) {
String name = entry.getKey(); // 扩展名
Object activate = entry.getValue(); // @Activate注解
String[] activateGroup, activateValue;
if (activate instanceof Activate) { // @Activate注解中的配置
activateGroup = ((Activate) activate).group();
activateValue = ((Activate) activate).value();
} else {
continue;
}
if (isMatchGroup(group, activateGroup) // 匹配group
// 没有出现在values配置中的即为默认激活的扩展实现
&& !names.contains(name)
// 通过"-"明确指定不激活该扩展实现
&& !names.contains(REMOVE_VALUE_PREFIX + name)
// 检测URL中是否出现了指定的Key
&& isActive(activateValue, url)) {
// 加载扩展实现的实例对象,这些都是激活的
activateExtensions.add(getExtension(name));
}
}
// 排序 --- 2
activateExtensions.sort(ActivateComparator.COMPARATOR);
}
List<T> loadedExtensions = new ArrayList<>();
for (int i = 0; i < names.size(); i++) { // ---3
String name = names.get(i);
// 通过"-"开头的配置明确指定不激活的扩展实现直接就忽略了
if (!name.startsWith(REMOVE_VALUE_PREFIX)
&& !names.contains(REMOVE_VALUE_PREFIX + name)) {
if (DEFAULT_KEY.equals(name)) {
if (!loadedExtensions.isEmpty()) {
// 按照顺序将自定义的扩展添加到默认扩展集合前面
activateExtensions.addAll(0, loadedExtensions);
loadedExtensions.clear();
}
} else {
loadedExtensions.add(getExtension(name));
}
}
}
if (!loadedExtensions.isEmpty()) {
// 按照顺序将自定义的扩展添加到默认扩展集合后面
activateExtensions.addAll(loadedExtensions);
}
return activateExtensions;
}
最后举个简单的例子说明上述处理流程假设 cachedActivates 集合缓存的扩展实现如下表所示
Provider 端调用 getActivateExtension() 方法时传入的 values 配置为 demoFilter3-demoFilter2defaultdemoFilter1”,那么根据上面的逻辑
得到默认激活的扩展实实现集合中有 [ demoFilter4, demoFilter6 ]
排序后为 [ demoFilter6, demoFilter4 ]
按序添加自定义扩展实例之后得到 [ demoFilter3, demoFilter6, demoFilter4, demoFilter1 ]。
总结
本课时我们深入全面地讲解了 Dubbo SPI 的核心实现首先介绍了 @SPI 注解的底层实现这是 Dubbo SPI 最核心的基础然后介绍了 @Adaptive 注解与动态生成适配器类的核心原理和实现最后分析了 Dubbo SPI 中的自动包装和自动装配特性以及 @Activate 注解的原理
Dubbo SPI Dubbo 框架实现扩展机制的核心希望你仔细研究其实现为后续源码分析过程打下基础
也欢迎你在留言区分享你的学习心得和实践经验

View File

@@ -0,0 +1,141 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 海量定时任务,一个时间轮搞定
在很多开源框架中,都需要定时任务的管理功能,例如 ZooKeeper、Netty、Quartz、Kafka 以及 Linux 操作系统。
JDK 提供的 java.util.Timer 和 DelayedQueue 等工具类,可以帮助我们实现简单的定时任务管理,其底层实现使用的是堆这种数据结构,存取操作的复杂度都是 O(nlog(n)),无法支持大量的定时任务。在定时任务量比较大、性能要求比较高的场景中,为了将定时任务的存取操作以及取消操作的时间复杂度降为 O(1),一般会使用时间轮的方式。
时间轮是一种高效的、批量管理定时任务的调度模型。时间轮一般会实现成一个环形结构,类似一个时钟,分为很多槽,一个槽代表一个时间间隔,每个槽使用双向链表存储定时任务;指针周期性地跳动,跳动到一个槽位,就执行该槽位的定时任务。
时间轮环形结构示意图
需要注意的是,单层时间轮的容量和精度都是有限的,对于精度要求特别高、时间跨度特别大或是海量定时任务需要调度的场景,通常会使用多级时间轮以及持久化存储与时间轮结合的方案。
那在 Dubbo 中时间轮的具体实现方式是怎样的呢本课时我们就重点探讨下。Dubbo 的时间轮实现位于 dubbo-common 模块的 org.apache.dubbo.common.timer 包中,下面我们就来分析时间轮涉及的核心接口和实现。
核心接口
在 Dubbo 中,所有的定时任务都要继承 TimerTask 接口。TimerTask 接口非常简单,只定义了一个 run() 方法,该方法的入参是一个 Timeout 接口的对象。Timeout 对象与 TimerTask 对象一一对应,两者的关系类似于线程池返回的 Future 对象与提交到线程池中的任务对象之间的关系。通过 Timeout 对象我们不仅可以查看定时任务的状态还可以操作定时任务例如取消关联的定时任务。Timeout 接口中的方法如下图所示:
.png
Timer 接口定义了定时器的基本行为,如下图所示,其核心是 newTimeout() 方法提交一个定时任务TimerTask并返回关联的 Timeout 对象,这有点类似于向线程池提交任务的感觉。
HashedWheelTimeout
HashedWheelTimeout 是 Timeout 接口的唯一实现,是 HashedWheelTimer 的内部类。HashedWheelTimeout 扮演了两个角色:
第一个,时间轮中双向链表的节点,即定时任务 TimerTask 在 HashedWheelTimer 中的容器。
第二个,定时任务 TimerTask 提交到 HashedWheelTimer 之后返回的句柄Handle用于在时间轮外部查看和控制定时任务。
HashedWheelTimeout 中的核心字段如下:
prev、nextHashedWheelTimeout类型分别对应当前定时任务在链表中的前驱节点和后继节点。
taskTimerTask类型指实际被调度的任务。
deadlinelong类型指定时任务执行的时间。这个时间是在创建 HashedWheelTimeout 时指定的计算公式是currentTime创建 HashedWheelTimeout 的时间) + delay任务延迟时间 - startTimeHashedWheelTimer 的启动时间),时间单位为纳秒。
statevolatile int类型指定时任务当前所处状态可选的有三个分别是 INIT0、CANCELLED1和 EXPIRED2。另外还有一个 STATE_UPDATER 字段AtomicIntegerFieldUpdater类型实现 state 状态变更的原子性。
remainingRoundslong类型指当前任务剩余的时钟周期数。时间轮所能表示的时间长度是有限的在任务到期时间与当前时刻的时间差超过时间轮单圈能表示的时长就出现了套圈的情况需要该字段值表示剩余的时钟周期。
HashedWheelTimeout 中的核心方法有:
isCancelled()、isExpired() 、state() 方法, 主要用于检查当前 HashedWheelTimeout 状态。
cancel() 方法, 将当前 HashedWheelTimeout 的状态设置为 CANCELLED并将当前 HashedWheelTimeout 添加到 cancelledTimeouts 队列中等待销毁。
expire() 方法, 当任务到期时,会调用该方法将当前 HashedWheelTimeout 设置为 EXPIRED 状态,然后调用其中的 TimerTask 的 run() 方法执行定时任务。
remove() 方法, 将当前 HashedWheelTimeout 从时间轮中删除。
HashedWheelBucket
HashedWheelBucket 是时间轮中的一个槽,时间轮中的槽实际上就是一个用于缓存和管理双向链表的容器,双向链表中的每一个节点就是一个 HashedWheelTimeout 对象,也就关联了一个 TimerTask 定时任务。
HashedWheelBucket 持有双向链表的首尾两个节点,分别是 head 和 tail 两个字段,再加上每个 HashedWheelTimeout 节点均持有前驱和后继的引用,这样就可以正向或是逆向遍历整个双向链表了。
下面我们来看 HashedWheelBucket 中的核心方法。
addTimeout() 方法:新增 HashedWheelTimeout 到双向链表的尾部。
pollTimeout() 方法:移除双向链表中的头结点,并将其返回。
remove() 方法:从双向链表中移除指定的 HashedWheelTimeout 节点。
clearTimeouts() 方法:循环调用 pollTimeout() 方法处理整个双向链表,并返回所有未超时或者未被取消的任务。
expireTimeouts() 方法:遍历双向链表中的全部 HashedWheelTimeout 节点。 在处理到期的定时任务时,会通过 remove() 方法取出,并调用其 expire() 方法执行;对于已取消的任务,通过 remove() 方法取出后直接丢弃;对于未到期的任务,会将 remainingRounds 字段(剩余时钟周期数)减一。
HashedWheelTimer
HashedWheelTimer 是 Timer 接口的实现它通过时间轮算法实现了一个定时器。HashedWheelTimer 会根据当前时间轮指针选定对应的槽HashedWheelBucket从双向链表的头部开始迭代对每个定时任务HashedWheelTimeout进行计算属于当前时钟周期则取出运行不属于则将其剩余的时钟周期数减一操作。
下面我们来看 HashedWheelTimer 的核心属性。
workerStatevolatile int类型时间轮当前所处状态可选值有 init、started、shutdown。同时有相应的 AtomicIntegerFieldUpdater 实现 workerState 的原子修改。
startTimelong类型当前时间轮的启动时间提交到该时间轮的定时任务的 deadline 字段值均以该时间戳为起点进行计算。
wheelHashedWheelBucket[]类型):该数组就是时间轮的环形队列,每一个元素都是一个槽。当指定时间轮槽数为 n 时,实际上会取大于且最靠近 n 的 2 的幂次方值。
timeouts、cancelledTimeoutsLinkedBlockingQueue类型timeouts 队列用于缓冲外部提交时间轮中的定时任务cancelledTimeouts 队列用于暂存取消的定时任务。HashedWheelTimer 会在处理 HashedWheelBucket 的双向链表之前,先处理这两个队列中的数据。
ticklong类型该字段在 HashedWheelTimer$Worker 中,是时间轮的指针,是一个步长为 1 的单调递增计数器。
maskint类型掩码 mask = wheel.length - 1执行 ticks & mask 便能定位到对应的时钟槽。
ticksDurationlong类型时间指针每次加 1 所代表的实际时间,单位为纳秒。
pendingTimeoutsAtomicLong类型当前时间轮剩余的定时任务总数。
workerThreadThread类型时间轮内部真正执行定时任务的线程。
workerWorker类型真正执行定时任务的逻辑封装这个 Runnable 对象中。
时间轮对外提供了一个 newTimeout() 接口用于提交定时任务,在定时任务进入到 timeouts 队列之前会先调用 start() 方法启动时间轮,其中会完成下面两个关键步骤:
确定时间轮的 startTime 字段;
启动 workerThread 线程,开始执行 worker 任务。
之后根据 startTime 计算该定时任务的 deadline 字段,最后才能将定时任务封装成 HashedWheelTimeout 并添加到 timeouts 队列。
下面我们来分析时间轮指针一次转动的全流程。
时间轮指针转动,时间轮周期开始。
清理用户主动取消的定时任务,这些定时任务在用户取消时,会记录到 cancelledTimeouts 队列中。在每次指针转动的时候,时间轮都会清理该队列。
将缓存在 timeouts 队列中的定时任务转移到时间轮中对应的槽中。
根据当前指针定位对应槽,处理该槽位的双向链表中的定时任务。
检测时间轮的状态。如果时间轮处于运行状态,则循环执行上述步骤,不断执行定时任务。如果时间轮处于停止状态,则执行下面的步骤获取到未被执行的定时任务并加入 unprocessedTimeouts 队列:遍历时间轮中每个槽位,并调用 clearTimeouts() 方法;对 timeouts 队列中未被加入槽中循环调用 poll()。
最后再次清理 cancelledTimeouts 队列中用户主动取消的定时任务。
上述核心逻辑在 HashedWheelTimer$Worker.run() 方法中,若你感兴趣的话,可以翻看一下源码进行分析。
Dubbo 中如何使用定时任务
在 Dubbo 中,时间轮并不直接用于周期性操作,而是只向时间轮提交执行单次的定时任务,在上一次任务执行完成的时候,调用 newTimeout() 方法再次提交当前任务,这样就会在下个周期执行该任务。即使在任务执行过程中出现了 GC、I/O 阻塞等情况,导致任务延迟或卡住,也不会有同样的任务源源不断地提交进来,导致任务堆积。
Dubbo 中对时间轮的应用主要体现在如下两个方面:
失败重试, 例如Provider 向注册中心进行注册失败时的重试操作,或是 Consumer 向注册中心订阅时的失败重试等。
周期性定时任务, 例如,定期发送心跳请求,请求超时的处理,或是网络连接断开后的重连机制。
总结
本课时我们重点介绍了 Dubbo 中时间轮相关的内容:
首先介绍了 JDK 提供的 Timer 定时器以及 DelayedQueue 等工具类的问题,并说明了时间轮的解决方案;
然后深入讲解了 Dubbo 对时间轮的抽象,以及具体实现细节;
最后还说明了 Dubbo 中时间轮的应用场景,在我们后面介绍 Dubbo 其他模块的时候,你还会看到时间轮的身影。
这里再给你留个课后思考题:如果存在海量定时任务,并且这些任务的开始时间跨度非常长,例如,有的是 1 分钟之后执行,有的是 1 小时之后执行,有的是 1 年之后执行,那你该如何对时间轮进行扩展,处理这些定时任务呢?欢迎你在留言区分享你的想法,期待看到你的答案。

View File

@@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 ZooKeeper 与 Curator求你别用 ZkClient 了(上)
在前面我们介绍 Dubbo 简化架构的时候提到过Dubbo Provider 在启动时会将自身的服务信息整理成 URL 注册到注册中心Dubbo Consumer 在启动时会向注册中心订阅感兴趣的 Provider 信息,之后 Provider 和 Consumer 才能建立连接,进行后续的交互。可见,一个稳定、高效的注册中心对基于 Dubbo 的微服务来说是至关重要的。
Dubbo 目前支持 Consul、etcd、Nacos、ZooKeeper、Redis 等多种开源组件作为注册中心,并且在 Dubbo 源码也有相应的接入模块,如下图所示:
Dubbo 官方推荐使用 ZooKeeper 作为注册中心,它是在实际生产中最常用的注册中心实现,这也是我们本课时要介绍 ZooKeeper 核心原理的原因。
要与 ZooKeeper 集群进行交互,我们可以使用 ZooKeeper 原生客户端或是 ZkClient、Apache Curator 等第三方开源客户端。在后面介绍 dubbo-registry-zookeeper 模块的具体实现时你会看到Dubbo 底层使用的是 Apache Curator。Apache Curator 是实践中最常用的 ZooKeeper 客户端。
ZooKeeper 核心概念
Apache ZooKeeper 是一个针对分布式系统的、可靠的、可扩展的协调服务它通常作为统一命名服务、统一配置管理、注册中心分布式集群管理、分布式锁服务、Leader 选举服务等角色出现。很多分布式系统都依赖与 ZooKeeper 集群实现分布式系统间的协调调度例如Dubbo、HDFS 2.x、HBase、Kafka 等。ZooKeeper 已经成为现代分布式系统的标配。
ZooKeeper 本身也是一个分布式应用程序,下图展示了 ZooKeeper 集群的核心架构。
ZooKeeper 集群的核心架构图
Client 节点:从业务角度来看,这是分布式应用中的一个节点,通过 ZkClient 或是其他 ZooKeeper 客户端与 ZooKeeper 集群中的一个 Server 实例维持长连接,并定时发送心跳。从 ZooKeeper 集群的角度来看,它是 ZooKeeper 集群的一个客户端,可以主动查询或操作 ZooKeeper 集群中的数据,也可以在某些 ZooKeeper 节点ZNode上添加监听。当被监听的 ZNode 节点发生变化时,例如,该 ZNode 节点被删除、新增子节点或是其中数据被修改等ZooKeeper 集群都会立即通过长连接通知 Client。
Leader 节点ZooKeeper 集群的主节点,负责整个 ZooKeeper 集群的写操作,保证集群内事务处理的顺序性。同时,还要负责整个集群中所有 Follower 节点与 Observer 节点的数据同步。
Follower 节点ZooKeeper 集群中的从节点,可以接收 Client 读请求并向 Client 返回结果,并不处理写请求,而是转发到 Leader 节点完成写入操作。另外Follower 节点还会参与 Leader 节点的选举。
Observer 节点ZooKeeper 集群中特殊的从节点,不会参与 Leader 节点的选举,其他功能与 Follower 节点相同。引入 Observer 角色的目的是增加 ZooKeeper 集群读操作的吞吐量,如果单纯依靠增加 Follower 节点来提高 ZooKeeper 的读吞吐量,那么有一个很严重的副作用,就是 ZooKeeper 集群的写能力会大大降低,因为 ZooKeeper 写数据时需要 Leader 将写操作同步给半数以上的 Follower 节点。引入 Observer 节点使得 ZooKeeper 集群在写能力不降低的情况下,大大提升了读操作的吞吐量。
了解了 ZooKeeper 整体的架构之后,我们再来了解一下 ZooKeeper 集群存储数据的逻辑结构。ZooKeeper 逻辑上是按照树型结构进行数据存储的(如下图),其中的节点称为 ZNode。每个 ZNode 有一个名称标识,即树根到该节点的路径(用 “/” 分隔ZooKeeper 树中的每个节点都可以拥有子节点,这与文件系统的目录树类似。
ZooKeeper 树型存储结构
ZNode 节点类型有如下四种:
持久节点。 持久节点创建后,会一直存在,不会因创建该节点的 Client 会话失效而删除。
持久顺序节点。 持久顺序节点的基本特性与持久节点一致创建节点的过程中ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。
临时节点。 创建临时节点的 ZooKeeper Client 会话失效之后,其创建的临时节点会被 ZooKeeper 集群自动删除。与持久节点的另一点区别是,临时节点下面不能再创建子节点。
临时顺序节点。 基本特性与临时节点一致创建节点的过程中ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。
在每个 ZNode 中都维护着一个 stat 结构,记录了该 ZNode 的元数据其中包括版本号、操作控制列表ACL、时间戳和数据长度等信息如下表所示
我们除了可以通过 ZooKeeper Client 对 ZNode 进行增删改查等基本操作,还可以注册 Watcher 监听 ZNode 节点、其中的数据以及子节点的变化。一旦监听到变化,则相应的 Watcher 即被触发,相应的 ZooKeeper Client 会立即得到通知。Watcher 有如下特点:
主动推送。 Watcher 被触发时,由 ZooKeeper 集群主动将更新推送给客户端,而不需要客户端轮询。
一次性。 数据变化时Watcher 只会被触发一次。如果客户端想得到后续更新的通知,必须要在 Watcher 被触发后重新注册一个 Watcher。
可见性。 如果一个客户端在读请求中附带 WatcherWatcher 被触发的同时再次读取数据,客户端在得到 Watcher 消息之前肯定不可能看到更新后的数据。换句话说,更新通知先于更新结果。
顺序性。 如果多个更新触发了多个 Watcher ,那 Watcher 被触发的顺序与更新顺序一致。
消息广播流程概述
ZooKeeper 集群中三种角色的节点Leader、Follower 和 Observer都可以处理 Client 的读请求,因为每个节点都保存了相同的数据副本,直接进行读取即可返回给 Client。
对于写请求,如果 Client 连接的是 Follower 节点(或 Observer 节点),则在 Follower 节点(或 Observer 节点)收到写请求将会被转发到 Leader 节点。下面是 Leader 处理写请求的核心流程:
Leader 节点接收写请求后,会为写请求赋予一个全局唯一的 zxid64 位自增 id通过 zxid 的大小比较就可以实现写操作的顺序一致性。
Leader 通过先进先出队列(会给每个 Follower 节点都创建一个队列,保证发送的顺序性),将带有 zxid 的消息作为一个 proposal提案分发给所有 Follower 节点。
当 Follower 节点接收到 proposal 之后,会先将 proposal 写到本地事务日志,写事务成功后再向 Leader 节点回一个 ACK 响应。
当 Leader 节点接收到过半 Follower 的 ACK 响应之后Leader 节点就向所有 Follower 节点发送 COMMIT 命令,并在本地执行提交。
当 Follower 收到消息的 COMMIT 命令之后也会提交操作,写操作到此完成。
最后Follower 节点会返回 Client 写请求相应的响应。
下图展示了写操作的核心流程:
写操作核心流程图
崩溃恢复
上面写请求处理流程中,如果发生 Leader 节点宕机,整个 ZooKeeper 集群可能处于如下两种状态:
当 Leader 节点收到半数以上 Follower 节点的 ACK 响应之后,会向各个 Follower 节点广播 COMMIT 命令,同时也会在本地执行 COMMIT 并向连接的客户端进行响应。如果在各个 Follower 收到 COMMIT 命令前 Leader 就宕机了,就会导致剩下的服务器没法执行这条消息。
当 Leader 节点生成 proposal 之后就宕机了,而其他 Follower 并没有收到此 proposal或者只有一小部分 Follower 节点收到了这条 proposal那么此次写操作就是执行失败的。
在 Leader 宕机后ZooKeeper 会进入崩溃恢复模式,重新进行 Leader 节点的选举。
ZooKeeper 对新 Leader 有如下两个要求:
对于原 Leader 已经提交了的 proposal新 Leader 必须能够广播并提交,这样就需要选择拥有最大 zxid 值的节点作为 Leader。
对于原 Leader 还未广播或只部分广播成功的 proposal新 Leader 能够通知原 Leader 和已经同步了的 Follower 删除,从而保证集群数据的一致性。
ZooKeeper 选主使用的是 ZAB 协议,如果展开介绍的话内容会非常多,这里我们就通过一个示例简单介绍 ZooKeeper 选主的大致流程。
比如,当前集群中有 5 个 ZooKeeper 节点构成sid 分别为 1、2、3、4 和 5zxid 分别为 10、10、9、9 和 8此时sid 为 1 的节点是 Leader 节点。实际上zxid 包含了 epoch高 32 位)和自增计数器(低 32 位) 两部分。其中epoch 是“纪元”的意思,标识当前 Leader 周期,每次选举时 epoch 部分都会递增,这就防止了网络隔离之后,上一周期的旧 Leader 重新连入集群造成不必要的重新选举。该示例中我们假设各个节点的 epoch 都相同。
某一时刻,节点 1 的服务器宕机了ZooKeeper 集群开始进行选主。由于无法检测到集群中其他节点的状态信息(处于 Looking 状态),因此每个节点都将自己作为被选举的对象来进行投票。于是 sid 为 2、3、4、5 的节点投票情况分别为2,103,94,95,8同时各个节点也会接收到来自其他节点的投票这里以sid, zxid的形式来标识一次投票信息
对于节点 2 来说接收到3,94,95,8的投票对比后发现自己的 zxid 最大,因此不需要做任何投票变更。
对于节点 3 来说接收到2,104,95,8的投票对比后由于 2 的 zxid 比自己的 zxid 要大因此需要更改投票改投2,10并将改投后的票发给其他节点。
对于节点 4 来说接收到2,103,95,8的投票对比后由于 2 的 zxid 比自己的 zxid 要大因此需要更改投票改投2,10并将改投后的票发给其他节点。
对于节点 5 来说也是一样最终改投2,10
经过第二轮投票后,集群中的每个节点都会再次收到其他机器的投票,然后开始统计投票,如果有过半的节点投了同一个节点,则该节点成为新的 Leader这里显然节点 2 成了新 Leader节点。
Leader 节点此时会将 epoch 值加 1并将新生成的 epoch 分发给各个 Follower 节点。各个 Follower 节点收到全新的 epoch 后,返回 ACK 给 Leader 节点,并带上各自最大的 zxid 和历史事务日志信息。Leader 选出最大的 zxid并更新自身历史事务日志示例中的节点 2 无须更新。Leader 节点紧接着会将最新的事务日志同步给集群中所有的 Follower 节点,只有当半数 Follower 同步成功,这个准 Leader 节点才能成为正式的 Leader 节点并开始工作。
总结
本课时我们重点介绍了 ZooKeeper 的核心概念以及 ZooKeeper 集群的基本工作原理:
首先介绍了 ZooKeeper 集群中各个节点的角色以及职能;
然后介绍了 ZooKeeper 中存储数据的逻辑结构以及 ZNode 节点的相关特性;
紧接着又讲解了 ZooKeeper 集群读写数据的核心流程;
最后我们通过示例分析了 ZooKeeper 集群的崩溃恢复流程。
在下一课时,我们将介绍 Apache Curator 的相关内容。

View File

@@ -0,0 +1,856 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 ZooKeeper 与 Curator求你别用 ZkClient 了(下)
在上一课时我们介绍了 ZooKeeper 的核心概念以及工作原理,这里我们再简单了解一下 ZooKeeper 客户端的相关内容,毕竟在实际工作中,直接使用客户端与 ZooKeeper 进行交互的次数比深入 ZooKeeper 底层进行扩展和二次开发的次数要多得多。从 ZooKeeper 架构的角度看,使用 Dubbo 的业务节点也只是一个 ZooKeeper 客户端罢了。
ZooKeeper 官方提供的客户端支持了一些基本操作例如创建会话、创建节点、读取节点、更新数据、删除节点和检查节点是否存在等但在实际开发中只有这些简单功能是根本不够的。而且ZooKeeper 本身的一些 API 也存在不足,例如:
ZooKeeper 的 Watcher 是一次性的,每次触发之后都需要重新进行注册。
会话超时之后,没有实现自动重连的机制。
ZooKeeper 提供了非常详细的异常,异常处理显得非常烦琐,对开发新手来说,非常不友好。
只提供了简单的 byte[] 数组的接口,没有提供基本类型以及对象级别的序列化。
创建节点时,如果节点存在抛出异常,需要自行检查节点是否存在。
删除节点就无法实现级联删除。
常见的第三方开源 ZooKeeper 客户端有 ZkClient 和 Apache Curator。
ZkClient 是在 ZooKeeper 原生 API 接口的基础上进行了包装,虽然 ZkClient 解决了 ZooKeeper 原生 API 接口的很多问题,提供了非常简洁的 API 接口,实现了会话超时自动重连的机制,解决了 Watcher 反复注册等问题,但其缺陷也非常明显。例如,文档不全、重试机制难用、异常全部转换成了 RuntimeException、没有足够的参考示例等。可见一个简单易用、高效可靠的 ZooKeeper 客户端是多么重要。
Apache Curator 基础
Apache Curator 是 Apache 基金会提供的一款 ZooKeeper 客户端,它提供了一套易用性和可读性非常强的 Fluent 风格的客户端 API ,可以帮助我们快速搭建稳定可靠的 ZooKeeper 客户端程序。
为便于你更全面了解 Curator 的功能,我整理出了如下表格,展示了 Curator 提供的 jar 包:
下面我们从最基础的使用展开,逐一介绍 Apache Curator 在实践中常用的核心功能,开始我们的 Apache Curator 之旅。
1. 基本操作
简单了解了 Apache Curator 各个组件的定位之后,下面我们立刻通过一个示例上手使用 Curator。首先我们创建一个 Maven 项目,并添加 Apache Curator 的依赖:
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.0.1</version>
</dependency>
然后写一个 main 方法,其中会说明 Curator 提供的基础 API 的使用:
public class Main {
public static void main(String[] args) throws Exception {
// Zookeeper集群地址多个节点地址可以用逗号分隔
String zkAddress = "127.0.0.1:2181";
// 重试策略如果连接不上ZooKeeper集群会重试三次重试间隔会递增
RetryPolicy retryPolicy =
new ExponentialBackoffRetry(1000, 3);
// 创建Curator Client并启动启动成功之后就可以与Zookeeper进行交互了
CuratorFramework client =
CuratorFrameworkFactory.newClient(zkAddress, retryPolicy);
client.start();
// 下面简单说明Curator中常用的API
// create()方法创建ZNode可以调用额外方法来设置节点类型、添加Watcher
// 下面是创建一个名为"user"的持久节点其中会存储一个test字符串
String path = client.create().withMode(CreateMode.PERSISTENT)
.forPath("/user", "test".getBytes());
System.out.println(path);
// 输出:/user
// checkExists()方法可以检查一个节点是否存在
Stat stat = client.checkExists().forPath("/user");
System.out.println(stat!=null);
// 输出:true返回的Stat不为null即表示节点存在
// getData()方法可以获取一个节点中的数据
byte[] data = client.getData().forPath("/user");
System.out.println(new String(data));
// 输出:test
// setData()方法可以设置一个节点中的数据
stat = client.setData().forPath("/user","data".getBytes());
data = client.getData().forPath("/user");
System.out.println(new String(data));
// 输出:data
// 在/user节点下创建多个临时顺序节点
for (int i = 0; i < 3; i++) {
client.create().withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.forPath("/user/child-";
}
// 获取所有子节点
List<String> children = client.getChildren().forPath("/user");
System.out.println(children);
// 输出:[child-0000000002, child-0000000001, child-0000000000]
// delete()方法可以删除指定节点deletingChildrenIfNeeded()方法
// 会级联删除子节点
client.delete().deletingChildrenIfNeeded().forPath("/user");
}
}
2. Background
上面介绍的创建、删除、更新、读取等方法都是同步的Curator 提供异步接口引入了BackgroundCallback 这个回调接口以及 CuratorListener 这个监听器,用于处理 Background 调用之后服务端返回的结果信息。BackgroundCallback 接口和 CuratorListener 监听器中接收一个 CuratorEvent 的参数,里面包含事件类型、响应码、节点路径等详细信息。
下面我们通过一个示例说明 BackgroundCallback 接口以及 CuratorListener 监听器的基本使用:
public class Main2 {
public static void main(String[] args) throws Exception {
// Zookeeper集群地址多个节点地址可以用逗号分隔
String zkAddress = "127.0.0.1:2181";
// 重试策略如果连接不上ZooKeeper集群会重试三次重试间隔会递增
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3);
// 创建Curator Client并启动启动成功之后就可以与Zookeeper进行交互了
CuratorFramework client = CuratorFrameworkFactory
.newClient(zkAddress, retryPolicy);
client.start();
// 添加CuratorListener监听器针对不同的事件进行处理
client.getCuratorListenable().addListener(
new CuratorListener() {
public void eventReceived(CuratorFramework client,
CuratorEvent event) throws Exception {
switch (event.getType()) {
case CREATE:
System.out.println("CREATE:" +
event.getPath());
break;
case DELETE:
System.out.println("DELETE:" +
event.getPath());
break;
case EXISTS:
System.out.println("EXISTS:" +
event.getPath());
break;
case GET_DATA:
System.out.println("GET_DATA:" +
event.getPath() + ","
+ new String(event.getData()));
break;
case SET_DATA:
System.out.println("SET_DATA:" +
new String(event.getData()));
break;
case CHILDREN:
System.out.println("CHILDREN:" +
event.getPath());
break;
default:
}
}
});
// 注意:下面所有的操作都添加了inBackground()方法,转换为后台操作
client.create().withMode(CreateMode.PERSISTENT)
.inBackground().forPath("/user", "test".getBytes());
client.checkExists().inBackground().forPath("/user");
client.setData().inBackground().forPath("/user",
"setData-Test".getBytes());
client.getData().inBackground().forPath("/user");
for (int i = 0; i < 3; i++) {
client.create().withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.inBackground().forPath("/user/child-");
}
client.getChildren().inBackground().forPath("/user");
// 添加BackgroundCallback
client.getChildren().inBackground(new BackgroundCallback() {
public void processResult(CuratorFramework client,
CuratorEvent event) throws Exception {
System.out.println("in background:"
+ event.getType() + "," + event.getPath());
}
}).forPath("/user");
client.delete().deletingChildrenIfNeeded().inBackground()
.forPath("/user");
System.in.read();
}
}
// 输出
// CREATE:/user
// EXISTS:/user
// GET_DATA:/user,setData-Test
// CREATE:/user/child-
// CREATE:/user/child-
// CREATE:/user/child-
// CHILDREN:/user
// DELETE:/user
3. 连接状态监听
除了基础的数据操作Curator 还提供了监听连接状态的监听器——ConnectionStateListener它主要是处理 Curator 客户端和 ZooKeeper 服务器间连接的异常情况例如 短暂或者长时间断开连接
短暂断开连接时ZooKeeper 客户端会检测到与服务端的连接已经断开但是服务端维护的客户端 Session 尚未过期之后客户端和服务端重新建立了连接当客户端重新连接后由于 Session 没有过期ZooKeeper 能够保证连接恢复后保持正常服务
而长时间断开连接时Session 已过期与先前 Session 相关的 Watcher 和临时节点都会丢失 Curator 重新创建了与 ZooKeeper 的连接时会获取到 Session 过期的相关异常Curator 会销毁老 Session并且创建一个新的 Session由于老 Session 关联的数据不存在了 ConnectionStateListener 监听到 LOST 事件时就可以依靠本地存储的数据恢复 Session
这里 Session 指的是 ZooKeeper 服务器与客户端的会话客户端启动的时候会与服务器建立一个 TCP 连接从第一次连接建立开始客户端会话的生命周期也开始了客户端能够通过心跳检测与服务器保持有效的会话也能够向 ZooKeeper 服务器发送请求并接受响应同时还能够通过该连接接收来自服务器的 Watch 事件通知
我们可以设置客户端会话的超时时间sessionTimeout当服务器压力太大网络故障或是客户端主动断开连接等原因导致连接断开时只要客户端在 sessionTimeout 规定的时间内能够重新连接到 ZooKeeper 集群中任意一个实例那么之前创建的会话仍然有效ZooKeeper 通过 sessionID 唯一标识 Session所以在 ZooKeeper 集群中sessionID 需要保证全局唯一 由于 ZooKeeper 会将 Session 信息存放到硬盘中即使节点重启之前未过期的 Session 仍然会存在
public class Main3 {
public static void main(String[] args) throws Exception {
// Zookeeper集群地址多个节点地址可以用逗号分隔
String zkAddress = "127.0.0.1:2181";
// 重试策略如果连接不上ZooKeeper集群会重试三次重试间隔会递增
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3);
// 创建Curator Client并启动启动成功之后就可以与Zookeeper进行交互了
CuratorFramework client = CuratorFrameworkFactory
.newClient(zkAddress, retryPolicy);
client.start();
// 添加ConnectionStateListener监听器
client.getConnectionStateListenable().addListener(
new ConnectionStateListener() {
public void stateChanged(CuratorFramework client,
ConnectionState newState) {
// 这里我们可以针对不同的连接状态进行特殊的处理
switch (newState) {
case CONNECTED:
// 第一次成功连接到ZooKeeper之后会进入该状态
// 对于每个CuratorFramework对象此状态仅出现一次
break;
case SUSPENDED: // ZooKeeper的连接丢失
break;
case RECONNECTED: // 丢失的连接被重新建立
break;
case LOST:
// 当Curator认为会话已经过期时则进入此状态
break;
case READ_ONLY: // 连接进入只读模式
break;
}
}
});
}
}
4. Watcher
Watcher 监听机制是 ZooKeeper 中非常重要的特性可以监听某个节点上发生的特定事件例如监听节点数据变更节点删除子节点状态变更等事件当相应事件发生时ZooKeeper 会产生一个 Watcher 事件并且发送到客户端通过 Watcher 机制就可以使用 ZooKeeper 实现分布式锁集群管理等功能
Curator 客户端中我们可以使用 usingWatcher() 方法添加 Watcher前面示例中能够添加 Watcher 的有 checkExists()、getData()以及 getChildren() 三个方法下面我们来看一个具体的示例
public class Main4 {
public static void main(String[] args) throws Exception {
// Zookeeper集群地址多个节点地址可以用逗号分隔
String zkAddress = "127.0.0.1:2181";
// 重试策略如果连接不上ZooKeeper集群会重试三次重试间隔会递增
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3);
// 创建Curator Client并启动启动成功之后就可以与Zookeeper进行交互了
CuratorFramework client = CuratorFrameworkFactory
.newClient(zkAddress, retryPolicy);
client.start();
try {
client.create().withMode(CreateMode.PERSISTENT)
.forPath("/user", "test".getBytes());
} catch (Exception e) {
}
// 这里通过usingWatcher()方法添加一个Watcher
List<String> children = client.getChildren().usingWatcher(
new CuratorWatcher() {
public void process(WatchedEvent event) throws Exception {
System.out.println(event.getType() + "," +
event.getPath());
}
}).forPath("/user");
System.out.println(children);
System.in.read();
}
}
接下来,我们打开 ZooKeeper 的命令行客户端,在 /user 节点下先后添加两个子节点,如下所示:
此时我们只得到一行输出:
NodeChildrenChanged,/user
之所以这样,是因为通过 usingWatcher() 方法添加的 CuratorWatcher 只会触发一次触发完毕后就会销毁。checkExists() 方法、getData() 方法通过 usingWatcher() 方法添加的 Watcher 也是一样的原理,只不过监听的事件不同,你若感兴趣的话,可以自行尝试一下。
相信你已经感受到,直接通过注册 Watcher 进行事件监听不是特别方便,需要我们自己反复注册 Watcher。Apache Curator 引入了 Cache 来实现对 ZooKeeper 服务端事件的监听。Cache 是 Curator 中对事件监听的包装其对事件的监听其实可以近似看作是一个本地缓存视图和远程ZooKeeper 视图的对比过程。同时Curator 能够自动为开发人员处理反复注册监听,从而大大简化了代码的复杂程度。
实践中常用的 Cache 有三大类:
NodeCache。 对一个节点进行监听监听事件包括指定节点的增删改操作。注意哦NodeCache 不仅可以监听数据节点的内容变更,也能监听指定节点是否存在,如果原本节点不存在,那么 Cache 就会在节点被创建后触发 NodeCacheListener删除操作亦然。
PathChildrenCache。 对指定节点的一级子节点进行监听,监听事件包括子节点的增删改操作,但是不对该节点的操作监听。
TreeCache。 综合 NodeCache 和 PathChildrenCache 的功能,是对指定节点以及其子节点进行监听,同时还可以设置监听的深度。
下面通过示例介绍上述三种 Cache 的基本使用:
public class Main5 {
public static void main(String[] args) throws Exception {
// Zookeeper集群地址多个节点地址可以用逗号分隔
String zkAddress = "127.0.0.1:2181";
// 重试策略如果连接不上ZooKeeper集群会重试三次重试间隔会递增
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3);
// 创建Curator Client并启动启动成功之后就可以与Zookeeper进行交互了
CuratorFramework client = CuratorFrameworkFactory
.newClient(zkAddress, retryPolicy);
client.start();
// 创建NodeCache监听的是"/user"这个节点
NodeCache nodeCache = new NodeCache(client, "/user");
// start()方法有个boolean类型的参数默认是false。如果设置为true
// 那么NodeCache在第一次启动的时候就会立刻从ZooKeeper上读取对应节点的
// 数据内容并保存在Cache中。
nodeCache.start(true);
if (nodeCache.getCurrentData() != null) {
System.out.println("NodeCache节点初始化数据为"
+ new String(nodeCache.getCurrentData().getData()));
} else {
System.out.println("NodeCache节点数据为空");
}
// 添加监听器
nodeCache.getListenable().addListener(() -> {
String data = new String(nodeCache.getCurrentData().getData());
System.out.println("NodeCache节点路径" + nodeCache.getCurrentData().getPath()
+ ",节点数据为:" + data);
});
// 创建PathChildrenCache实例监听的是"user"这个节点
PathChildrenCache childrenCache = new PathChildrenCache(client, "/user", true);
// StartMode指定的初始化的模式
// NORMAL:普通异步初始化
// BUILD_INITIAL_CACHE:同步初始化
// POST_INITIALIZED_EVENT:异步初始化,初始化之后会触发事件
childrenCache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE);
// childrenCache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT);
// childrenCache.start(PathChildrenCache.StartMode.NORMAL);
List<ChildData> children = childrenCache.getCurrentData();
System.out.println("获取子节点列表:");
// 如果是BUILD_INITIAL_CACHE可以获取这个数据如果不是就不行
children.forEach(childData -> {
System.out.println(new String(childData.getData()));
});
childrenCache.getListenable().addListener(((client1, event) -> {
System.out.println(LocalDateTime.now() + " " + event.getType());
if (event.getType().equals(PathChildrenCacheEvent.Type.INITIALIZED)) {
System.out.println("PathChildrenCache:子节点初始化成功...");
} else if (event.getType().equals(PathChildrenCacheEvent.Type.CHILD_ADDED)) {
String path = event.getData().getPath();
System.out.println("PathChildrenCache添加子节点:" + event.getData().getPath());
System.out.println("PathChildrenCache子节点数据:" + new String(event.getData().getData()));
} else if (event.getType().equals(PathChildrenCacheEvent.Type.CHILD_REMOVED)) {
System.out.println("PathChildrenCache删除子节点:" + event.getData().getPath());
} else if (event.getType().equals(PathChildrenCacheEvent.Type.CHILD_UPDATED)) {
System.out.println("PathChildrenCache修改子节点路径:" + event.getData().getPath());
System.out.println("PathChildrenCache修改子节点数据:" + new String(event.getData().getData()));
}
}));
// 创建TreeCache实例监听"user"节点
TreeCache cache = TreeCache.newBuilder(client, "/user").setCacheData(false).build();
cache.getListenable().addListener((c, event) -> {
if (event.getData() != null) {
System.out.println("TreeCache,type=" + event.getType() + " path=" + event.getData().getPath());
} else {
System.out.println("TreeCache,type=" + event.getType());
}
});
cache.start();
System.in.read();
}
}
此时ZooKeeper 集群中存在 /user/test1 和 /user/test2 两个节点,启动上述测试代码,得到的输出如下:
NodeCache节点初始化数据为test //NodeCache的相关输出
获取子节点列表:// PathChildrenCache的相关输出
xxx
xxx2
// TreeCache监听到的事件
TreeCache,type=NODE_ADDED path=/user
TreeCache,type=NODE_ADDED path=/user/test1
TreeCache,type=NODE_ADDED path=/user/test2
TreeCache,type=INITIALIZED
接下来,我们在 ZooKeeper 命令行客户端中更新 /user 节点中的数据:
得到如下输出:
TreeCache,type=NODE_UPDATED path=/user
NodeCache节点路径/user节点数据为userData
创建 /user/test3 节点:
得到输出:
TreeCache,type=NODE_ADDED path=/user/test3
2020-06-26T08:35:22.393 CHILD_ADDED
PathChildrenCache添加子节点:/user/test3
PathChildrenCache子节点数据:xxx3
更新 /user/test3 节点的数据:
得到输出:
TreeCache,type=NODE_UPDATED path=/user/test3
2020-06-26T08:43:54.604 CHILD_UPDATED
PathChildrenCache修改子节点路径:/user/test3
PathChildrenCache修改子节点数据:xxx33
删除 /user/test3 节点:
得到输出:
TreeCache,type=NODE_REMOVED path=/user/test3
2020-06-26T08:44:06.329 CHILD_REMOVED
PathChildrenCache删除子节点:/user/test3
curator-x-discovery 扩展库
为了避免 curator-framework 包过于膨胀Curator 将很多其他解决方案都拆出来了作为单独的一个包例如curator-recipes、curator-x-discovery、curator-x-rpc 等。
在后面我们会使用到 curator-x-discovery 来完成一个简易 RPC 框架的注册中心模块。curator-x-discovery 扩展包是一个服务发现的解决方案。在 ZooKeeper 中,我们可以使用临时节点实现一个服务注册机制。当服务启动后在 ZooKeeper 的指定 Path 下创建临时节点,服务断掉与 ZooKeeper 的会话之后,其相应的临时节点就会被删除。这个 curator-x-discovery 扩展包抽象了这种功能,并提供了一套简单的 API 来实现服务发现机制。curator-x-discovery 扩展包的核心概念如下:
ServiceInstance。 这是 curator-x-discovery 扩展包对服务实例的抽象,由 name、id、address、port 以及一个可选的 payload 属性构成。其存储在 ZooKeeper 中的方式如下图展示的这样。
ServiceProvider。 这是 curator-x-discovery 扩展包的核心组件之一,提供了多种不同策略的服务发现方式,具体策略有轮询调度、随机和黏性(总是选择相同的一个)。得到 ServiceProvider 对象之后,我们可以调用其 getInstance() 方法,按照指定策略获取 ServiceInstance 对象(即发现可用服务实例);还可以调用 getAllInstances() 方法,获取所有 ServiceInstance 对象(即获取全部可用服务实例)。
ServiceDiscovery。 这是 curator-x-discovery 扩展包的入口类。开始必须调用 start() 方法,当使用完成应该调用 close() 方法进行销毁。
ServiceCache。 如果程序中会频繁地查询 ServiceInstance 对象,我们可以添加 ServiceCache 缓存ServiceCache 会在内存中缓存 ServiceInstance 实例的列表,并且添加相应的 Watcher 来同步更新缓存。查询 ServiceCache 的方式也是 getInstances() 方法。另外ServiceCache 上还可以添加 Listener 来监听缓存变化。
下面通过一个简单示例来说明一下 curator-x-discovery 包的使用,该示例中的 ServerInfo 记录了一个服务的 host、port 以及描述信息。
public class ZookeeperCoordinator {
private ServiceDiscovery<ServerInfo> serviceDiscovery;
private ServiceCache<ServerInfo> serviceCache;
private CuratorFramework client;
private String root;
// 这里的JsonInstanceSerializer是将ServerInfo序列化成Json
private InstanceSerializer serializer =
new JsonInstanceSerializer<>(ServerInfo.class);
ZookeeperCoordinator(Config config) throws Exception {
this.root = config.getPath();
// 创建Curator客户端
client = CuratorFrameworkFactory.newClient(
config.getHostPort(), new ExponentialBackoffRetry(...));
client.start(); // 启动Curator客户端
client.blockUntilConnected(); // 阻塞当前线程,等待连接成功
// 创建ServiceDiscovery
serviceDiscovery = ServiceDiscoveryBuilder
.builder(ServerInfo.class)
.client(client) // 依赖Curator客户端
.basePath(root) // 管理的Zk路径
.watchInstances(true) // 当ServiceInstance加载
.serializer(serializer)
.build();
serviceDiscovery.start(); // 启动ServiceDiscovery
// 创建ServiceCache监Zookeeper相应节点的变化也方便后续的读取
serviceCache = serviceDiscovery.serviceCacheBuilder()
.name(root)
.build();
serviceCache.start(); // 启动ServiceCache
}
public void registerRemote(ServerInfo serverInfo)throws Exception{
// 将ServerInfo对象转换成ServiceInstance对象
ServiceInstance<ServerInfo> thisInstance =
ServiceInstance.<ServerInfo>builder()
.name(root)
.id(UUID.randomUUID().toString()) // 随机生成的UUID
.address(serverInfo.getHost()) // host
.port(serverInfo.getPort()) // port
.payload(serverInfo) // payload
.build();
// 将ServiceInstance写入到Zookeeper中
serviceDiscovery.registerService(thisInstance);
}
public List<ServerInfo> queryRemoteNodes() {
List<ServerInfo> ServerInfoDetails = new ArrayList<>();
// 查询 ServiceCache 获取全部的 ServiceInstance 对象
List<ServiceInstance<ServerInfo>> serviceInstances =
serviceCache.getInstances();
serviceInstances.forEach(serviceInstance -> {
// 从每个ServiceInstance对象的playload字段中反序列化得
// 到ServerInfo实例
ServerInfo instance = serviceInstance.getPayload();
ServerInfoDetails.add(instance);
});
return ServerInfoDetails;
}
}
curator-recipes 简介
Recipes 是 Curator 对常见分布式场景的解决方案,这里我们只是简单介绍一下,具体的使用和原理,就先不做深入分析了。
Queues。提供了多种的分布式队列解决方法比如权重队列、延迟队列等。在生产环境中很少将 ZooKeeper 用作分布式队列,只适合在压力非常小的情况下,才使用该解决方案,所以建议你要适度使用。
Counters。全局计数器是分布式系统中很常用的工具curator-recipes 提供了 SharedCount、DistributedAtomicLong 等组件,帮助开发人员实现分布式计数器功能。
Locks。java.util.concurrent.locks 中提供的各种锁相信你已经有所了解了在微服务架构中分布式锁也是一项非常基础的服务组件curator-recipes 提供了多种基于 ZooKeeper 实现的分布式锁,满足日常工作中对分布式锁的需求。
Barries。curator-recipes 提供的分布式栅栏可以实现多个服务之间协同工作,具体实现有 DistributedBarrier 和 DistributedDoubleBarrier。
Elections。实现的主要功能是在多个参与者中选举出 Leader然后由 Leader 节点作为操作调度、任务监控或是队列消费的执行者。curator-recipes 给出的实现是 LeaderLatch。
总结
本课时我们重点介绍了 Apache Curator 相关的内容:
首先将 Apache Curator 与其他 ZooKeeper 客户端进行了对比Apache Curator 的易用性是选择 Apache Curator 的重要原因。
接下来,我们通过示例介绍了 Apache Curator 的基本使用方式以及实际使用过程中的一些注意点。
然后,介绍了 curator-x-discovery 扩展库的基本概念和使用。
最后,简单介绍了 curator-recipes 提供的强大功能。
关于 Apache Curator你有什么其他的见解欢迎你在评论区给我留言与我分享。
zk-demo 链接https://github.com/xxxlxy2008/zk-demo 。

View File

@@ -0,0 +1,630 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 代理模式与常见实现
动态代理机制在 Java 中有着广泛的应用例如Spring AOP、MyBatis、Hibernate 等常用的开源框架都使用到了动态代理机制。当然Dubbo 中也使用到了动态代理,在后面开发简易版 RPC 框架的时候,我们还会参考 Dubbo 使用动态代理机制来屏蔽底层的网络传输以及服务发现的相关实现。
本课时我们主要从基础知识开始讲起,首先介绍代理模式的基本概念,之后重点介绍 JDK 动态代理的使用以及底层实现原理,同时还会说明 JDK 动态代理的一些局限性,最后再介绍基于字节码生成的动态代理。
代理模式
代理模式是 23 种面向对象的设计模式中的一种,它的类图如下所示:
图中的 Subject 是程序中的业务逻辑接口RealSubject 是实现了 Subject 接口的真正业务类Proxy 是实现了 Subject 接口的代理类,封装了一个 RealSubject 引用。在程序中不会直接调用 RealSubject 对象的方法,而是使用 Proxy 对象实现相关功能。
Proxy.operation() 方法的实现会调用其中封装的 RealSubject 对象的 operation() 方法执行真正的业务逻辑。代理的作用不仅仅是正常地完成业务逻辑还会在业务逻辑前后添加一些代理逻辑也就是说Proxy.operation() 方法会在 RealSubject.operation() 方法调用前后进行一些预处理以及一些后置处理。这就是我们常说的“代理模式”。
使用代理模式可以控制程序对 RealSubject 对象的访问,如果发现异常的访问,可以直接限流或是返回,也可以在执行业务处理的前后进行相关的预处理和后置处理,帮助上层调用方屏蔽底层的细节。例如,在 RPC 框架中,代理可以完成序列化、网络 I/O 操作、负载均衡、故障恢复以及服务发现等一系列操作,而上层调用方只感知到了一次本地调用。
代理模式还可以用于实现延迟加载的功能。我们知道查询数据库是一个耗时的操作,而有些时候查询到的数据也并没有真正被程序使用。延迟加载功能就可以有效地避免这种浪费,系统访问数据库时,首先可以得到一个代理对象,此时并没有执行任何数据库查询操作,代理对象中自然也没有真正的数据;当系统真正需要使用数据时,再调用代理对象完成数据库查询并返回数据。常见 ORM 框架例如MyBatis、 Hibernate中的延迟加载的原理大致也是如此。
另外代理对象可以协调真正RealSubject 对象与调用者之间的关系,在一定程度上实现了解耦的效果。
JDK 动态代理
上面介绍的这种代理模式实现也被称为“静态代理模式”这是因为在编译阶段就要为每个RealSubject 类创建一个 Proxy 类,当需要代理的类很多时,就会出现大量的 Proxy 类。
这种场景下,我们可以使用 JDK 动态代理解决这个问题。JDK 动态代理的核心是InvocationHandler 接口。这里提供一个 InvocationHandler 的Demo 实现,代码如下:
public class DemoInvokerHandler implements InvocationHandler {
private Object target; // 真正的业务对象也就是RealSubject对象
public DemoInvokerHandler(Object target) { // 构造方法
this.target = target;
}
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
// ...在执行业务方法之前的预处理...
Object result = method.invoke(target, args);
// ...在执行业务方法之后的后置处理...
return result;
}
public Object getProxy() {
// 创建代理对象
return Proxy.newProxyInstance(Thread.currentThread()
.getContextClassLoader(),
target.getClass().getInterfaces(), this);
}
}
接下来,我们可以创建一个 main() 方法来模拟上层调用者,创建并使用动态代理:
public class Main {
public static void main(String[] args) {
Subject subject = new RealSubject();
DemoInvokerHandler invokerHandler =
new DemoInvokerHandler(subject);
// 获取代理对象
Subject proxy = (Subject) invokerHandler.getProxy();
// 调用代理对象的方法它会调用DemoInvokerHandler.invoke()方法
proxy.operation();
}
}
对于需要相同代理逻辑的业务类,只需要提供一个 InvocationHandler 接口实现类即可。在 Java 运行的过程中JDK会为每个 RealSubject 类动态生成相应的代理类并加载到 JVM 中,然后创建对应的代理实例对象,返回给上层调用者。
了解了 JDK 动态代理的基本使用之后,下面我们就来分析 JDK动态代理创建代理类的底层实现原理。不同JDK版本的 Proxy 类实现可能有细微差别,但核心思路不变,这里使用 1.8.0 版本的 JDK。
JDK 动态代理相关实现的入口是 Proxy.newProxyInstance() 这个静态方法它的三个参数分别是加载动态生成的代理类的类加载器、业务类实现的接口和上面介绍的InvocationHandler对象。Proxy.newProxyInstance()方法的具体实现如下:
public static Object newProxyInstance(ClassLoader loader,
Class[] interfaces, InvocationHandler h)
throws IllegalArgumentException {
final Class<?>[] intfs = interfaces.clone();
// ...省略权限检查等代码
Class<?> cl = getProxyClass0(loader, intfs); // 获取代理类
// ...省略try/catch代码块和相关异常处理
// 获取代理类的构造方法
final Constructor<?> cons = cl.getConstructor(constructorParams);
final InvocationHandler ih = h;
return cons.newInstance(new Object[]{h}); // 创建代理对象
}
通过 newProxyInstance()方法的实现可以看到JDK 动态代理是在 getProxyClass0() 方法中完成代理类的生成和加载。getProxyClass0() 方法的具体实现如下:
private static Class getProxyClass0 (ClassLoader loader,
Class... interfaces) {
// 边界检查,限制接口数量(略)
// 如果指定的类加载器中已经创建了实现指定接口的代理类,则查找缓存;
// 否则通过ProxyClassFactory创建实现指定接口的代理类
return proxyClassCache.get(loader, interfaces);
}
proxyClassCache 是定义在 Proxy 类中的静态字段,主要用于缓存已经创建过的代理类,定义如下:
private static final WeakCache[], Class> proxyClassCache
= new WeakCache<>(new KeyFactory(),
new ProxyClassFactory());
WeakCache.get() 方法会首先尝试从缓存中查找代理类,如果查找不到,则会创建 Factory 对象并调用其 get() 方法获取代理类。Factory 是 WeakCache 中的内部类Factory.get() 方法会调用 ProxyClassFactory.apply() 方法创建并加载代理类。
ProxyClassFactory.apply() 方法首先会检测代理类需要实现的接口集合,然后确定代理类的名称,之后创建代理类并将其写入文件中,最后加载代理类,返回对应的 Class 对象用于后续的实例化代理类对象。该方法的具体实现如下:
public Class apply(ClassLoader loader, Class[] interfaces) {
// ... 对interfaces集合进行一系列检测
// ... 选择定义代理类的包名(略)
// 代理类的名称是通过包名、代理类名称前缀以及编号这三项组成的
long num = nextUniqueNumber.getAndIncrement();
String proxyName = proxyPkg + proxyClassNamePrefix + num;
// 生成代理类,并写入文件
byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
proxyName, interfaces, accessFlags);
// 加载代理类并返回Class对象
return defineClass0(loader, proxyName, proxyClassFile, 0,
proxyClassFile.length);
}
ProxyGenerator.generateProxyClass() 方法会按照指定的名称和接口集合生成代理类的字节码,并根据条件决定是否保存到磁盘上。该方法的具体代码如下:
public static byte[] generateProxyClass(final String name,
Class[] interfaces) {
ProxyGenerator gen = new ProxyGenerator(name, interfaces);
// 动态生成代理类的字节码,具体生成过程不再详细介绍,感兴趣的读者可以继续分析
final byte[] classFile = gen.generateClassFile();
// 如果saveGeneratedFiles值为true会将生成的代理类的字节码保存到文件中
if (saveGeneratedFiles) {
java.security.AccessController.doPrivileged(
new java.security.PrivilegedAction() {
public Void run() {
// 省略try/catch代码块
FileOutputStream file = new FileOutputStream(
dotToSlash(name) + ".class");
file.write(classFile);
file.close();
return null;
}
}
);
}
return classFile; // 返回上面生成的代理类的字节码
}
最后为了清晰地看到JDK动态生成的代理类的真正定义我们需要将上述生成的代理类的字节码进行反编译。上述示例为RealSubject生成的代理类反编译后得到的代码如下
public final class $Proxy37
extends Proxy implements Subject { // 实现了Subject接口
// 这里省略了从Object类继承下来的相关方法和属性
private static Method m3;
static {
// 省略了try/catch代码块
// 记录了operation()方法对应的Method对象
m3 = Class.forName("com.xxx.Subject")
.getMethod("operation", new Class[0]);
}
// 构造方法的参数就是我们在示例中使用的DemoInvokerHandler对象
public $Proxy11(InvocationHandler var1) throws {
super(var1);
}
public final void operation() throws {
// 省略了try/catch代码块
// 调用DemoInvokerHandler对象的invoke()方法
// 最终调用RealSubject对象的对应方法
super.h.invoke(this, m3, (Object[]) null);
}
}
至此JDK 动态代理的基本使用以及核心原理就介绍完了。简单总结一下JDK 动态代理的实现原理是动态创建代理类并通过指定类加载器进行加载在创建代理对象时将InvocationHandler对象作为构造参数传入。当调用代理对象时会调用 InvocationHandler.invoke() 方法,从而执行代理逻辑,并最终调用真正业务对象的相应方法。
CGLib
JDK 动态代理是 Java 原生支持的不需要任何外部依赖但是正如上面分析的那样它只能基于接口进行代理对于没有继承任何接口的类JDK 动态代理就没有用武之地了。
如果想对没有实现任何接口的类进行代理,可以考虑使用 CGLib。
CGLibCode Generation Library是一个基于 ASM 的字节码生成库它允许我们在运行时对字节码进行修改和动态生成。CGLib 采用字节码技术实现动态代理功能,其底层原理是通过字节码技术为目标类生成一个子类,并在该子类中采用方法拦截的方式拦截所有父类方法的调用,从而实现代理的功能。
因为 CGLib 使用生成子类的方式实现动态代理,所以无法代理 final 关键字修饰的方法因为final 方法是不能够被重写的。这样的话CGLib 与 JDK 动态代理之间可以相互补充:在目标类实现接口时,使用 JDK 动态代理创建代理对象;当目标类没有实现接口时,使用 CGLib 实现动态代理的功能。在 Spring、MyBatis 等多种开源框架中都可以看到JDK动态代理与 CGLib 结合使用的场景。
CGLib 的实现有两个重要的成员组成。
Enhancer指定要代理的目标对象以及实际处理代理逻辑的对象最终通过调用 create() 方法得到代理对象,对这个对象所有的非 final 方法的调用都会转发给 MethodInterceptor 进行处理。
MethodInterceptor动态代理对象的方法调用都会转发到intercept方法进行增强。
这两个组件的使用与 JDK 动态代理中的 Proxy 和 InvocationHandler 相似。
下面我们通过一个示例简单介绍 CGLib 的使用。在使用 CGLib 创建动态代理类时,首先需要定义一个 Callback 接口的实现, CGLib 中也提供了多个Callback接口的子接口如下图所示
这里以 MethodInterceptor 接口为例进行介绍,首先我们引入 CGLib 的 maven 依赖:
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.3.0</version>
</dependency>
下面是 CglibProxy 类的具体代码,它实现了 MethodInterceptor 接口:
public class CglibProxy implements MethodInterceptor {
// 初始化Enhancer对象
private Enhancer enhancer = new Enhancer();
public Object getProxy(Class clazz) {
enhancer.setSuperclass(clazz); // 指定生成的代理类的父类
enhancer.setCallback(this); // 设置Callback对象
return enhancer.create(); // 通过ASM字节码技术动态创建子类实例
}
// 实现MethodInterceptor接口的intercept()方法
public Object intercept(Object obj, Method method, Object[] args,
MethodProxy proxy) throws Throwable {
System.out.println("前置处理");
Object result = proxy.invokeSuper(obj, args); // 调用父类中的方法
System.out.println("后置处理");
return result;
}
}
下面我们再编写一个要代理的目标类以及 main 方法进行测试,具体如下:
public class CGLibTest { // 目标类
public String method(String str) { // 目标方法
System.out.println(str);
return "CGLibTest.method():" + str;
}
public static void main(String[] args) {
CglibProxy proxy = new CglibProxy();
// 生成CBLibTest的代理对象
CGLibTest proxyImp = (CGLibTest)
proxy.getProxy(CGLibTest.class);
// 调用代理对象的method()方法
String result = proxyImp.method("test");
System.out.println(result);
// ----------------
// 输出如下:
// 前置代理
// test
// 后置代理
// CGLibTest.method():test
}
}
到此CGLib 基础使用的内容就介绍完了,在后面介绍 Dubbo 源码时我们还会继续介绍涉及的 CGLib 内容。
Javassist
Javassist 是一个开源的生成 Java 字节码的类库其主要优点在于简单、快速直接使用Javassist 提供的 Java API 就能动态修改类的结构,或是动态生成类。
Javassist 的使用比较简单,首先来看如何使用 Javassist 提供的 Java API 动态创建类。示例代码如下:
public class JavassistMain {
public static void main(String[] args) throws Exception {
ClassPool cp = ClassPool.getDefault(); // 创建ClassPool
// 要生成的类名称为com.test.JavassistDemo
CtClass clazz = cp.makeClass("com.test.JavassistDemo");
StringBuffer body = null;
// 创建字段,指定了字段类型、字段名称、字段所属的类
CtField field = new CtField(cp.get("java.lang.String"),
"prop", clazz);
// 指定该字段使用private修饰
field.setModifiers(Modifier.PRIVATE);
// 设置prop字段的getter/setter方法
clazz.addMethod(CtNewMethod.setter("getProp", field));
clazz.addMethod(CtNewMethod.getter("setProp", field));
// 设置prop字段的初始化值并将prop字段添加到clazz中
clazz.addField(field, CtField.Initializer.constant("MyName"));
// 创建构造方法,指定了构造方法的参数类型和构造方法所属的类
CtConstructor ctConstructor = new CtConstructor(
new CtClass[]{}, clazz);
// 设置方法体
body = new StringBuffer();
body.append("{\n prop=\"MyName\";\n}");
ctConstructor.setBody(body.toString());
clazz.addConstructor(ctConstructor); // 将构造方法添加到clazz中
// 创建execute()方法,指定了方法返回值、方法名称、方法参数列表以及
// 方法所属的类
CtMethod ctMethod = new CtMethod(CtClass.voidType, "execute",
new CtClass[]{}, clazz);
// 指定该方法使用public修饰
ctMethod.setModifiers(Modifier.PUBLIC);
// 设置方法体
body = new StringBuffer();
body.append("{\n System.out.println(\"execute():\" " +
"+ this.prop);");
body.append("\n}");
ctMethod.setBody(body.toString());
clazz.addMethod(ctMethod); // 将execute()方法添加到clazz中
// 将上面定义的JavassistDemo类保存到指定的目录
clazz.writeFile("/Users/xxx/");
// 加载clazz类并创建对象
Class<?> c = clazz.toClass();
Object o = c.newInstance();
// 调用execute()方法
Method method = o.getClass().getMethod("execute",
new Class[]{});
method.invoke(o, new Object[]{});
}
}
执行上述代码之后,在指定的目录下可以找到生成的 JavassistDemo.class 文件,将其反编译,得到 JavassistDemo 的代码如下:
public class JavassistDemo {
private String prop = "MyName";
public JavassistDemo() {
prop = "MyName";
}
public void setProp(String paramString) {
this.prop = paramString;
}
public String getProp() {
return this.prop;
}
public void execute() {
System.out.println("execute():" + this.prop);
}
}
Javassist 也可以实现动态代理功能,底层的原理也是通过创建目标类的子类的方式实现的。这里使用 Javassist 为上面生成的 JavassitDemo 创建一个代理对象,具体实现如下:
public class JavassitMain2 {
public static void main(String[] args) throws Exception {
ProxyFactory factory = new ProxyFactory();
// 指定父类ProxyFactory会动态生成继承该父类的子类
factory.setSuperclass(JavassistDemo.class);
// 设置过滤器,判断哪些方法调用需要被拦截
factory.setFilter(new MethodFilter() {
public boolean isHandled(Method m) {
if (m.getName().equals("execute")) {
return true;
}
return false;
}
});
// 设置拦截处理
factory.setHandler(new MethodHandler() {
@Override
public Object invoke(Object self, Method thisMethod,
Method proceed, Object[] args) throws Throwable {
System.out.println("前置处理");
Object result = proceed.invoke(self, args);
System.out.println("执行结果:" + result);
System.out.println("后置处理");
return result;
}
});
// 创建JavassistDemo的代理类并创建代理对象
Class<?> c = factory.createClass();
JavassistDemo JavassistDemo = (JavassistDemo) c.newInstance();
JavassistDemo.execute(); // 执行execute()方法,会被拦截
System.out.println(JavassistDemo.getProp());
}
}
Javassist 的基础知识就介绍到这里。Javassist可以直接使用 Java 语言的字符串生成类还是比较好用的。Javassist 的性能也比较好,是 Dubbo 默认的代理生成方式。
总结
本课时我们首先介绍了代理模式的核心概念和用途,让你对代理模式有初步的了解;然后介绍了 JDK 动态代理使用,并深入到 JDK 源码中分析了 JDK 动态代理的实现原理,以及 JDK 动态代理的局限;最后我们介绍了 CGLib和Javassist这两款代码生成工具的基本使用简述了两者生成代理的原理。
那你还知道哪些实现动态代理的方式呢?欢迎你在评论区留言讨论。

View File

@@ -0,0 +1,122 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 Netty 入门,用它做网络编程都说好(上)
了解 Java 的同学应该知道JDK 本身提供了一套 NIO 的 API但是这一套原生的 API 存在一系列的问题。
Java NIO 的 API 非常复杂。 要写出成熟可用的 Java NIO 代码,需要熟练掌握 JDK 中的 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等组件,还要理解其中一些反人类的设计以及底层原理,这对新手来说是非常不友好的。
如果直接使用 Java NIO 进行开发,难度和开发量会非常大。我们需要自己补齐很多可靠性方面的实现,例如,网络波动导致的连接重连、半包读写等。这就会导致一些本末倒置的情况出现:核心业务逻辑比较简单,但补齐其他公共能力的代码非常多,开发耗时比较长。这时就需要一个统一的 NIO 框架来封装这些公共能力了。
JDK 自身的 Bug。其中比较出名的就要属 Epoll Bug 了,这个 Bug 会导致 Selector 空轮询CPU 使用率达到 100%,这样就会导致业务逻辑无法执行,降低服务性能。
Netty 在 JDK 自带的 NIO API 基础之上进行了封装,解决了 JDK 自身的一些问题,具备如下优点:
入门简单,使用方便,文档齐全,无其他依赖,只依赖 JDK 就够了。
高性能,高吞吐,低延迟,资源消耗少。
灵活的线程模型支持阻塞和非阻塞的I/O 模型。
代码质量高,目前主流版本基本没有 Bug。
正因为 Netty 有以上优点,所以很多互联网公司以及开源的 RPC 框架都将其作为网络通信的基础库例如Apache Spark、Apache Flink、 Elastic Search 以及我们本课程分析的 Dubbo 等。
下面我们将从 I/O 模型和线程模型的角度详细为你介绍 Netty 的核心设计,进而帮助你全面掌握 Netty 原理。
Netty I/O 模型设计
在进行网络 I/O 操作的时候,用什么样的方式读写数据将在很大程度上决定了 I/O 的性能。作为一款优秀的网络基础库Netty 就采用了 NIO 的 I/O 模型,这也是其高性能的重要原因之一。
1. 传统阻塞 I/O 模型
在传统阻塞型 I/O 模型(即我们常说的 BIO如下图所示每个请求都需要独立的线程完成读数据、业务处理以及写回数据的完整操作。
一个线程在同一时刻只能与一个连接绑定,如下图所示,当请求的并发量较大时,就需要创建大量线程来处理连接,这就会导致系统浪费大量的资源进行线程切换,降低程序的性能。我们知道,网络数据的传输速度是远远慢于 CPU 的处理速度连接建立后并不总是有数据可读连接也并不总是可写那么线程就只能阻塞等待CPU 的计算能力不能得到充分发挥,同时还会导致大量线程的切换,浪费资源。
2. I/O 多路复用模型
针对传统的阻塞 I/O 模型的缺点I/O 复用的模型在性能方面有不小的提升。I/O 复用模型中的多个连接会共用一个 Selector 对象,由 Selector 感知连接的读写事件,而此时的线程数并不需要和连接数一致,只需要很少的线程定期从 Selector 上查询连接的读写状态即可无须大量线程阻塞等待连接。当某个连接有新的数据可以处理时操作系统会通知线程线程从阻塞状态返回开始进行读写操作以及后续的业务逻辑处理。I/O 复用的模型如下图所示:
Netty 就是采用了上述 I/O 复用的模型。由于多路复用器 Selector 的存在可以同时并发处理成百上千个网络连接大大增加了服务器的处理能力。另外Selector 并不会阻塞线程,也就是说当一个连接不可读或不可写的时候,线程可以去处理其他可读或可写的连接,这就充分提升了 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程切换。如下图所示:
从数据处理的角度来看,传统的阻塞 I/O 模型处理的是字节流或字符流,也就是以流式的方式顺序地从一个数据流中读取一个或多个字节,并且不能随意改变读取指针的位置。而在 NIO 中则抛弃了这种传统的 I/O 流概念,引入了 Channel 和 Buffer 的概念,可以从 Channel 中读取数据到 Buffer 中或将数据从 Buffer 中写入到 Channel。Buffer 不像传统 I/O 中的流那样必须顺序操作,在 NIO 中可以读写 Buffer 中任意位置的数据。
Netty 线程模型设计
服务器程序在读取到二进制数据之后,首先需要通过编解码,得到程序逻辑可以理解的消息,然后将消息传入业务逻辑进行处理,并产生相应的结果,返回给客户端。编解码逻辑、消息派发逻辑、业务处理逻辑以及返回响应的逻辑,是放到一个线程里面串行执行,还是分配到不同的线程中执行,会对程序的性能产生很大的影响。所以,优秀的线程模型对一个高性能网络库来说是至关重要的。
Netty 采用了 Reactor 线程模型的设计。 Reactor 模式,也被称为 Dispatcher 模式,核心原理是 Selector 负责监听 I/O 事件,在监听到 I/O 事件之后分发Dispatch给相关线程进行处理。
为了帮助你更好地了解 Netty 线程模型的设计理念,我们将从最基础的单 Reactor 单线程模型开始介绍,然后逐步增加模型的复杂度,最终到 Netty 目前使用的非常成熟的线程模型设计。
1. 单 Reactor 单线程
Reactor 对象监听客户端请求事件,收到事件后通过 Dispatch 进行分发。如果是连接建立的事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立的事件,而是数据的读写事件,则 Reactor 会将事件分发对应的 Handler 来处理,由这里唯一的线程调用 Handler 对象来完成读取数据、业务处理、发送响应的完整流程。当然,该过程中也可能会出现连接不可读或不可写等情况,该单线程会去执行其他 Handler 的逻辑,而不是阻塞等待。具体情况如下图所示:
单 Reactor 单线程的优点就是:线程模型简单,没有引入多线程,自然也就没有多线程并发和竞争的问题。
但其缺点也非常明显,那就是性能瓶颈问题,一个线程只能跑在一个 CPU 上,能处理的连接数是有限的,无法完全发挥多核 CPU 的优势。一旦某个业务逻辑耗时较长,这唯一的线程就会卡在上面,无法处理其他连接的请求,程序进入假死的状态,可用性也就降低了。正是由于这种限制,一般只会在客户端使用这种线程模型。
2. 单 Reactor 多线程
在单 Reactor 多线程的架构中Reactor 监控到客户端请求之后如果连接建立的请求则由Acceptor 通过 accept 处理,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立请求,则 Reactor 会将事件分发给调用连接对应的 Handler 来处理。到此为止,该流程与单 Reactor 单线程的模型基本一致,唯一的区别就是执行 Handler 逻辑的线程隶属于一个线程池。
单 Reactor 多线程模型
很明显,单 Reactor 多线程的模型可以充分利用多核 CPU 的处理能力,提高整个系统的吞吐量,但引入多线程模型就要考虑线程并发、数据共享、线程调度等问题。在这个模型中,只有一个线程来处理 Reactor 监听到的所有 I/O 事件,其中就包括连接建立事件以及读写事件,当连接数不断增大的时候,这个唯一的 Reactor 线程也会遇到瓶颈。
3. 主从 Reactor 多线程
为了解决单 Reactor 多线程模型中的问题,我们可以引入多个 Reactor。其中Reactor 主线程负责通过 Acceptor 对象处理 MainReactor 监听到的连接建立事件当Acceptor 完成网络连接的建立之后MainReactor 会将建立好的连接分配给 SubReactor 进行后续监听。
当一个连接被分配到一个 SubReactor 之上时,会由 SubReactor 负责监听该连接上的读写事件。当有新的读事件OP_READ发生时Reactor 子线程就会调用对应的 Handler 读取数据,然后分发给 Worker 线程池中的线程进行处理并返回结果。待处理结束之后Handler 会根据处理结果调用 send 将响应返回给客户端当然此时连接要有可写事件OP_WRITE才能发送数据。
主从 Reactor 多线程模型
主从 Reactor 多线程的设计模式解决了单一 Reactor 的瓶颈。主从 Reactor 职责明确,主 Reactor 只负责监听连接建立事件SubReactor只负责监听读写事件。整个主从 Reactor 多线程架构充分利用了多核 CPU 的优势,可以支持扩展,而且与具体的业务逻辑充分解耦,复用性高。但不足的地方是,在交互上略显复杂,需要一定的编程门槛。
4. Netty 线程模型
Netty 同时支持上述几种线程模式Netty 针对服务器端的设计是在主从 Reactor 多线程模型的基础上进行的修改,如下图所示:
Netty 抽象出两组线程池BossGroup 专门用于接收客户端的连接WorkerGroup 专门用于网络的读写。BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup相当于一个事件循环组其中包含多个事件循环 ,每一个事件循环是 NioEventLoop。
NioEventLoop 表示一个不断循环的、执行处理任务的线程,每个 NioEventLoop 都有一个Selector 对象与之对应,用于监听绑定在其上的连接,这些连接上的事件由 Selector 对应的这条线程处理。每个 NioEventLoopGroup 可以含有多个 NioEventLoop也就是多个线程。
每个 Boss NioEventLoop 会监听 Selector 上连接建立的 accept 事件,然后处理 accept 事件与客户端建立网络连接,生成相应的 NioSocketChannel 对象,一个 NioSocketChannel 就表示一条网络连接。之后会将 NioSocketChannel 注册到某个 Worker NioEventLoop 上的 Selector 中。
每个 Worker NioEventLoop 会监听对应 Selector 上的 read/write 事件,当监听到 read/write 事件的时候,会通过 Pipeline 进行处理。一个 Pipeline 与一个 Channel 绑定,在 Pipeline 上可以添加多个 ChannelHandler每个 ChannelHandler 中都可以包含一定的逻辑例如编解码等。Pipeline 在处理请求的时候,会按照我们指定的顺序调用 ChannelHandler。
总结
在本课时我们重点介绍了网络 I/O 的一些背景知识,以及 Netty 的一些宏观设计模型。
首先,我们介绍了 Java NIO 的一些缺陷和不足,这也是 Netty 等网络库出现的重要原因之一。
接下来,我们介绍了 Netty 在 I/O 模型上的设计,阐述了 I/O 多路复用的优势。
最后,我们从基础的单 Reactor 单线程模型开始,一步步深入,介绍了常见的网络 I/O 线程模型,并介绍了 Netty 目前使用的线程模型。
当然,关于 Netty 的相关内容,也欢迎你在留言区与我分享和交流。

View File

@@ -0,0 +1,230 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 Netty 入门,用它做网络编程都说好(下)
在上一课时,我们从 I/O 模型以及线程模型两个角度,宏观介绍了 Netty 的设计。在本课时,我们就深入到 Netty 内部,介绍一下 Netty 框架核心组件的功能,并概述它们的实现原理,进一步帮助你了解 Netty 的内核。
这里我们依旧采用之前的思路来介绍 Netty 的核心组件:首先是 Netty 对 I/O 模型设计中概念的抽象,如 Selector 等组件;接下来是线程模型的相关组件介绍,主要是 NioEventLoop、NioEventLoopGroup 等;最后再深入剖析 Netty 处理数据的相关组件,例如 ByteBuf、内存管理的相关知识。
Channel
Channel 是 Netty 对网络连接的抽象,核心功能是执行网络 I/O 操作。不同协议、不同阻塞类型的连接对应不同的 Channel 类型。我们一般用的都是 NIO 的 Channel下面是一些常用的 NIO Channel 类型。
NioSocketChannel对应异步的 TCP Socket 连接。
NioServerSocketChannel对应异步的服务器端 TCP Socket 连接。
NioDatagramChannel对应异步的 UDP 连接。
上述异步 Channel 主要提供了异步的网络 I/O 操作,例如:建立连接、读写操作等。异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用返回时所请求的 I/O 操作已完成。I/O 操作返回的是一个 ChannelFuture 对象,无论 I/O 操作是否成功Channel 都可以通过监听器通知调用方,我们通过向 ChannelFuture 上注册监听器来监听 I/O 操作的结果。
Netty 也支持同步 I/O 操作,但在实践中几乎不使用。绝大多数情况下,我们使用的是 Netty 中异步 I/O 操作。虽然立即返回一个 ChannelFuture 对象,但不能立刻知晓 I/O 操作是否成功,这时我们就需要向 ChannelFuture 中注册一个监听器,当操作执行成功或失败时,监听器会自动触发注册的监听事件。
另外Channel 还提供了检测当前网络连接状态等功能,这些可以帮助我们实现网络异常断开后自动重连的功能。
Selector
Selector 是对多路复用器的抽象,也是 Java NIO 的核心基础组件之一。Netty 就是基于 Selector 对象实现 I/O 多路复用的,在 Selector 内部,会通过系统调用不断地查询这些注册在其上的 Channel 是否有已就绪的 I/O 事件例如可读事件OP_READ、可写事件OP_WRITE或是网络连接事件OP_ACCEPT而无须使用用户线程进行轮询。这样我们就可以用一个线程监听多个 Channel 上发生的事件。
ChannelPipeline&ChannelHandler
提到 Pipeline你可能最先想到的是 Linux 命令中的管道它可以实现将一条命令的输出作为另一条命令的输入。Netty 中的 ChannelPipeline 也可以实现类似的功能ChannelPipeline 会将一个 ChannelHandler 处理后的数据作为下一个 ChannelHandler 的输入。
下图我们引用了 Netty Javadoc 中对 ChannelPipeline 的说明,描述了 ChannelPipeline 中 ChannelHandler 通常是如何处理 I/O 事件的。Netty 中定义了两种事件类型入站Inbound事件和出站Outbound事件。这两种事件就像 Linux 管道中的数据一样,在 ChannelPipeline 中传递事件之中也可能会附加数据。ChannelPipeline 之上可以注册多个 ChannelHandlerChannelInboundHandler 或 ChannelOutboundHandler我们在 ChannelHandler 注册的时候决定处理 I/O 事件的顺序,这就是典型的责任链模式。
从图中我们还可以看到I/O 事件不会在 ChannelPipeline 中自动传播而是需要调用ChannelHandlerContext 中定义的相应方法进行传播例如fireChannelRead() 方法和 write() 方法等。
这里我们举一个简单的例子,如下所示,在该 ChannelPipeline 上,我们添加了 5 个 ChannelHandler 对象:
ChannelPipeline p = socketChannel.pipeline();
p.addLast("1", new InboundHandlerA());
p.addLast("2", new InboundHandlerB());
p.addLast("3", new OutboundHandlerA());
p.addLast("4", new OutboundHandlerB());
p.addLast("5", new InboundOutboundHandlerX());
对于入站Inbound事件处理序列为1 → 2 → 5
对于出站Outbound事件处理序列为5 → 4 → 3。
可见入站Inbound与出站Outbound事件处理顺序正好相反。
入站Inbound事件一般由 I/O 线程触发。举个例子,我们自定义了一种消息协议,一条完整的消息是由消息头和消息体两部分组成,其中消息头会含有消息类型、控制位、数据长度等元数据,消息体则包含了真正传输的数据。在面对一块较大的数据时,客户端一般会将数据切分成多条消息发送,服务端接收到数据后,一般会先进行解码和缓存,待收集到长度足够的字节数据,组装成有固定含义的消息之后,才会传递给下一个 ChannelInboudHandler 进行后续处理。
在 Netty 中就提供了很多 Encoder 的实现用来解码读取到的数据Encoder 会处理多次 channelRead() 事件,等拿到有意义的数据之后,才会触发一次下一个 ChannelInboundHandler 的 channelRead() 方法。
出站Outbound事件与入站Inbound事件相反一般是由用户触发的。
ChannelHandler 接口中并没有定义方法来处理事件而是由其子类进行处理的如下图所示ChannelInboundHandler 拦截并处理入站事件ChannelOutboundHandler 拦截并处理出站事件。
Netty 提供的 ChannelInboundHandlerAdapter 和 ChannelOutboundHandlerAdapter 主要是帮助完成事件流转功能的,即自动调用传递事件的相应方法。这样,我们在自定义 ChannelHandler 实现类的时候,就可以直接继承相应的 Adapter 类,并覆盖需要的事件处理方法,其他不关心的事件方法直接使用默认实现即可,从而提高开发效率。
ChannelHandler 中的很多方法都需要一个 ChannelHandlerContext 类型的参数ChannelHandlerContext 抽象的是 ChannleHandler 之间的关系以及 ChannelHandler 与ChannelPipeline 之间的关系。ChannelPipeline 中的事件传播主要依赖于ChannelHandlerContext 实现,在 ChannelHandlerContext 中维护了 ChannelHandler 之间的关系,所以我们可以从 ChannelHandlerContext 中得到当前 ChannelHandler 的后继节点,从而将事件传播到后续的 ChannelHandler。
ChannelHandlerContext 继承了 AttributeMap所以提供了 attr() 方法设置和删除一些状态属性信息,我们可将业务逻辑中所需使用的状态属性值存入到 ChannelHandlerContext 中然后这些属性就可以随它传播了。Channel 中也维护了一个 AttributeMap与 ChannelHandlerContext 中的 AttributeMap从 Netty 4.1 开始,都是作用于整个 ChannelPipeline。
通过上述分析,我们可以了解到,一个 Channel 对应一个 ChannelPipeline一个 ChannelHandlerContext 对应一个ChannelHandler。 如下图所示:
最后,需要注意的是,如果要在 ChannelHandler 中执行耗时较长的逻辑,例如,操作 DB 、进行网络或磁盘 I/O 等操作,一般会在注册到 ChannelPipeline 的同时,指定一个线程池异步执行 ChannelHandler 中的操作。
NioEventLoop
在前文介绍 Netty 线程模型的时候,我们简单提到了 NioEventLoop 这个组件,当时为了便于理解,只是简单将其描述成了一个线程。
一个 EventLoop 对象由一个永远都不会改变的线程驱动,同时一个 NioEventLoop 包含了一个 Selector 对象,可以支持多个 Channel 注册在其上,该 NioEventLoop 可以同时服务多个 Channel每个 Channel 只能与一个 NioEventLoop 绑定,这样就实现了线程与 Channel 之间的关联。
我们知道Channel 中的 I/O 操作是由 ChannelPipeline 中注册的 ChannelHandler 进行处理的,而 ChannelHandler 的逻辑都是由相应 NioEventLoop 关联的那个线程执行的。
除了与一个线程绑定之外NioEvenLoop 中还维护了两个任务队列:
普通任务队列。用户产生的普通任务可以提交到该队列中暂存NioEventLoop 发现该队列中的任务后会立即执行。这是一个多生产者、单消费者的队列Netty 使用该队列将外部用户线程产生的任务收集到一起,并在 Reactor 线程内部用单线程的方式串行执行队列中的任务。例如,外部非 I/O 线程调用了 Channel 的 write() 方法Netty 会将其封装成一个任务放入 TaskQueue 队列中,这样,所有的 I/O 操作都会在 I/O 线程中串行执行。
定时任务队列。当用户在非 I/O 线程产生定时操作时Netty 将用户的定时操作封装成定时任务,并将其放入该定时任务队列中等待相应 NioEventLoop 串行执行。
到这里我们可以看出NioEventLoop 主要做三件事:监听 I/O 事件、执行普通任务以及执行定时任务。NioEventLoop 到底分配多少时间在不同类型的任务上,是可以配置的。另外,为了防止 NioEventLoop 长时间阻塞在一个任务上,一般会将耗时的操作提交到其他业务线程池处理。
NioEventLoopGroup
NioEventLoopGroup 表示的是一组 NioEventLoop。Netty 为了能更充分地利用多核 CPU 资源,一般会有多个 NioEventLoop 同时工作至于多少线程可由用户决定Netty 会根据实际上的处理器核数计算一个默认值具体计算公式是CPU 的核心数 * 2当然我们也可以根据实际情况手动调整。
当一个 Channel 创建之后Netty 会调用 NioEventLoopGroup 提供的 next() 方法,按照一定规则获取其中一个 NioEventLoop 实例,并将 Channel 注册到该 NioEventLoop 实例,之后,就由该 NioEventLoop 来处理 Channel 上的事件。EventLoopGroup、EventLoop 以及 Channel 三者的关联关系,如下图所示:
前面我们提到过,在 Netty 服务器端中,会有 BossEventLoopGroup 和 WorkerEventLoopGroup 两个 NioEventLoopGroup。通常一个服务端口只需要一个ServerSocketChannel对应一个 Selector 和一个 NioEventLoop 线程。
BossEventLoop 负责接收客户端的连接事件,即 OP_ACCEPT 事件,然后将创建的 NioSocketChannel 交给 WorkerEventLoopGroup WorkerEventLoopGroup 会由 next() 方法选择其中一个 NioEventLoopGroup并将这个 NioSocketChannel 注册到其维护的 Selector 并对其后续的I/O事件进行处理。
如上图BossEventLoopGroup 通常是一个单线程的 EventLoopEventLoop 维护着一个 Selector 对象,其上注册了一个 ServerSocketChannelBoosEventLoop 会不断轮询 Selector 监听连接事件,在发生连接事件时,通过 accept 操作与客户端创建连接,创建 SocketChannel 对象。然后将 accept 操作得到的 SocketChannel 交给 WorkerEventLoopGroup在Reactor 模式中 WorkerEventLoopGroup 中会维护多个 EventLoop而每个 EventLoop 都会监听分配给它的 SocketChannel 上发生的 I/O 事件,并将这些具体的事件分发给业务线程池处理。
ByteBuf
通过前文的介绍,我们了解了 Netty 中数据的流向这里我们再来介绍一下数据的容器——ByteBuf。
在进行跨进程远程交互的时候我们需要以字节的形式发送和接收数据发送端和接收端都需要一个高效的数据容器来缓存字节数据ByteBuf 就扮演了这样一个数据容器的角色。
ByteBuf 类似于一个字节数组,其中维护了一个读索引和一个写索引,分别用来控制对 ByteBuf 中数据的读写操作,两者符合下面的不等式:
0 <= readerIndex <= writerIndex <= capacity
ByteBuf 提供的读写操作 API 主要操作底层的字节容器byte[]、ByteBuffer 等)以及读写索引这两指针,你若感兴趣的话,可以查阅相关的 API 说明,这里不再展开介绍。
Netty 中主要分为以下三大类 ByteBuf
Heap Buffer堆缓冲区。这是最常用的一种 ByteBuf它将数据存储在 JVM 的堆空间,其底层实现是在 JVM 堆内分配一个数组,实现数据的存储。堆缓冲区可以快速分配,当不使用时也可以由 GC 轻松释放。它还提供了直接访问底层数组的方法,通过 ByteBuf.array() 来获取底层存储数据的 byte[] 。
Direct Buffer直接缓冲区。直接缓冲区会使用堆外内存存储数据不会占用 JVM 堆的空间,使用时应该考虑应用程序要使用的最大内存容量以及如何及时释放。直接缓冲区在使用 Socket 传递数据时性能很好,当然,它也是有缺点的,因为没有了 JVM GC 的管理在分配内存空间和释放内存时比堆缓冲区更复杂Netty 主要使用内存池来解决这样的问题,这也是 Netty 使用内存池的原因之一。
Composite Buffer复合缓冲区。我们可以创建多个不同的 ByteBuf然后提供一个这些 ByteBuf 组合的视图,也就是 CompositeByteBuf。它就像一个列表可以动态添加和删除其中的 ByteBuf。
内存管理
Netty 使用 ByteBuf 对象作为数据容器,进行 I/O 读写操作,其实 Netty 的内存管理也是围绕着ByteBuf 对象高效地分配和释放。从内存管理角度来看ByteBuf 可分为 Unpooled 和 Pooled 两类。
Unpooled是指非池化的内存管理方式。每次分配时直接调用系统 API 向操作系统申请 ByteBuf在使用完成之后通过系统调用进行释放。Unpooled 将内存管理完全交给系统,不做任何特殊处理,使用起来比较方便,对于申请和释放操作不频繁、操作成本比较低的 ByteBuf 来说,是比较好的选择。
Pooled是指池化的内存管理方式。该方式会预先申请一块大内存形成内存池在需要申请 ByteBuf 空间的时候,会将内存池中一部分合理的空间封装成 ByteBuf 给服务使用,使用完成后回收到内存池中。前面提到 DirectByteBuf 底层使用的堆外内存管理比较复杂,池化技术很好地解决了这一问题。
下面我们从如何高效分配和释放内存、如何减少内存碎片以及在多线程环境下如何减少锁竞争这三个方面介绍一下 Netty 提供的 ByteBuf 池化技术。
Netty 首先会向系统申请一整块连续内存,称为 Chunk默认大小为 16 MB这一块连续的内存通过 PoolChunk 对象进行封装。之后Netty 将 Chunk 空间进一步拆分为 Page每个 Chunk 默认包含 2048 个 Page每个 Page 的大小为 8 KB。
在同一个 Chunk 中Netty 将 Page 按照不同粒度进行分层管理。如下图所示,从下数第 1 层中每个分组的大小为 1 * PageSize一共有 2048 个分组;第 2 层中每个分组大小为 2 * PageSize一共有 1024 个组;第 3 层中每个分组大小为 4 * PageSize一共有 512 个组;依次类推,直至最顶层。
1. 内存分配&释放
当服务向内存池请求内存时Netty 会将请求分配的内存数向上取整到最接近的分组大小,然后在该分组的相应层级中从左至右寻找空闲分组。例如,服务请求分配 3 * PageSize 的内存,向上取整得到的分组大小为 4 * PageSize在该层分组中找到完全空闲的一组内存进行分配即可如下图
当分组大小 4 * PageSize 的内存分配出去后,为了方便下次内存分配,分组被标记为全部已使用(图中红色标记),向上更粗粒度的内存分组被标记为部分已使用(图中黄色标记)。
Netty 使用完全平衡树的结构实现了上述算法,这个完全平衡树底层是基于一个 byte 数组构建的,如下图所示:
具体的实现逻辑这里就不再展开讲述了,你若感兴趣的话,可以参考 Netty 代码。
2. 大对象&小对象的处理
当申请分配的对象是超过 Chunk 容量的大型对象Netty 就不再使用池化管理方式了,在每次请求分配内存时单独创建特殊的非池化 PoolChunk 对象进行管理当对象内存释放时整个PoolChunk 内存释放。
如果需要一定数量空间远小于 PageSize 的 ByteBuf 对象,例如,创建 256 Byte 的 ByteBuf按照上述算法就需要为每个小 ByteBuf 对象分配一个 Page这就出现了很多内存碎片。Netty 通过再将 Page 细分的方式解决这个问题。Netty 将请求的空间大小向上取最近的 16 的倍数(或 2 的幂),规整后小于 PageSize 的小 Buffer 可分为两类。
微型对象:规整后的大小为 16 的整倍数,如 16、32、48、……、496一共 31 种大小。
小型对象:规整后的大小为 2 的幂,如 512、1024、2048、4096一共 4 种大小。
Netty 的实现会先从 PoolChunk 中申请空闲 Page同一个 Page 分为相同大小的小 Buffer 进行存储;这些 Page 用 PoolSubpage 对象进行封装PoolSubpage 内部会记录它自己能分配的小 Buffer 的规格大小、可用内存数量,并通过 bitmap 的方式记录各个小内存的使用情况(如下图所示)。虽然这种方案不能完美消灭内存碎片,但是很大程度上还是减少了内存浪费。
为了解决单个 PoolChunk 容量有限的问题Netty 将多个 PoolChunk 组成链表一起管理,然后用 PoolChunkList 对象持有链表的 head。
Netty 通过 PoolArena 管理 PoolChunkList 以及 PoolSubpage。
PoolArena 内部持有 6 个 PoolChunkList各个 PoolChunkList 持有的 PoolChunk 的使用率区间有所不同,如下图所示:
6 个 PoolChunkList 对象组成双向链表,当 PoolChunk 内存分配、释放,导致使用率变化,需要判断 PoolChunk 是否超过所在 PoolChunkList 的限定使用率范围,如果超出了,需要沿着 6 个 PoolChunkList 的双向链表找到新的合适的 PoolChunkList ,成为新的 head。同样当新建 PoolChunk 分配内存或释放空间时PoolChunk 也需要按照上面逻辑放入合适的PoolChunkList 中。
从上图可以看出,这 6 个 PoolChunkList 额定使用率区间存在交叉,这样设计的原因是:如果使用单个临界值的话,当一个 PoolChunk 被来回申请和释放,内存使用率会在临界值上下徘徊,这就会导致它在两个 PoolChunkList 链表中来回移动。
PoolArena 内部持有 2 个 PoolSubpage 数组,分别存储微型 Buffer 和小型 Buffer 的PoolSubpage。相同大小的 PoolSubpage 组成链表,不同大小的 PoolSubpage 链表的 head 节点保存在 tinySubpagePools 或者 smallSubpagePools 数组中,如下图:
3. 并发处理
内存分配释放不可避免地会遇到多线程并发场景PoolChunk 的完全平衡树标记以及 PoolSubpage 的 bitmap 标记都是多线程不安全的都是需要加锁同步的。为了减少线程间的竞争Netty 会提前创建多个 PoolArena默认数量为 2 * CPU 核心数),当线程首次请求池化内存分配,会找被最少线程持有的 PoolArena并保存线程局部变量 PoolThreadCache 中,实现线程与 PoolArena 的关联绑定。
Netty 还提供了延迟释放的功能来提升并发性能。当内存释放时PoolArena 并没有马上释放,而是先尝试将该内存关联的 PoolChunk 和 Chunk 中的偏移位置等信息存入 ThreadLocal 的固定大小缓存队列中如果该缓存队列满了则马上释放内存。当有新的分配请求时PoolArena 会优先访问线程本地的缓存队列,查询是否有缓存可用,如果有,则直接分配,提高分配效率。
总结
在本课时,我们主要介绍了 Netty 核心组件的功能和原理:
首先介绍了 Channel、ChannelFuture、Selector 等组件,它们是构成 I/O 多路复用的核心。
之后介绍了 EventLoop、EventLoopGroup 等组件,它们与 Netty 使用的主从 Reactor 线程模型息息相关。
最后深入介绍了 Netty 的内存管理,主要从内存分配管理、内存碎片优化以及并发分配内存等角度进行了介绍。
那你还知道哪些优秀的网络库或网络层设计呢?欢迎你留言讨论。

View File

@@ -0,0 +1,359 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 简易版 RPC 框架实现(上)
这是“基础知识”部分的最后一课时,我们将会运用前面介绍的基础知识来做一个实践项目 —— 编写一个简易版本的 RPC 框架,作为“基础知识”部分的总结和回顾。
RPC 是“远程过程调用Remote Procedure Call”的缩写形式比较通俗的解释是像本地方法调用一样调用远程的服务。虽然 RPC 的定义非常简单,但是相对完整的、通用的 RPC 框架涉及很多方面的内容例如注册发现、服务治理、负载均衡、集群容错、RPC 协议等,如下图所示:
简易 RPC 框架的架构图
本课时我们主要实现RPC 框架的基石部分——远程调用,简易版 RPC 框架一次远程调用的核心流程是这样的:
Client 首先会调用本地的代理,也就是图中的 Proxy。
Client 端 Proxy 会按照协议Protocol将调用中传入的数据序列化成字节流。
之后 Client 会通过网络,将字节数据发送到 Server 端。
Server 端接收到字节数据之后,会按照协议进行反序列化,得到相应的请求信息。
Server 端 Proxy 会根据序列化后的请求信息,调用相应的业务逻辑。
Server 端业务逻辑的返回值,也会按照上述逻辑返回给 Client 端。
这个远程调用的过程,就是我们简易版本 RPC 框架的核心实现,只有理解了这个流程,才能进行后续的开发。
项目结构
了解了简易版 RPC 框架的工作流程和实现目标之后,我们再来看下项目的结构,为了方便起见,这里我们将整个项目放到了一个 Module 中了,如下图所示,你可以按照自己的需求进行模块划分。
那这各个包的功能是怎样的呢?我们就来一一说明。
protocol简易版 RPC 框架的自定义协议。
serialization提供了自定义协议对应的序列化、反序列化的相关工具类。
codec提供了自定义协议对应的编码器和解码器。
transport基于 Netty 提供了底层网络通信的功能,其中会使用到 codec 包中定义编码器和解码器,以及 serialization 包中的序列化器和反序列化器。
registry基于 ZooKeeper 和 Curator 实现了简易版本的注册中心功能。
proxy使用 JDK 动态代理实现了一层代理。
自定义协议
当前已经有很多成熟的协议了,例如 HTTP、HTTPS 等,那为什么我们还要自定义 RPC 协议呢?
从功能角度考虑HTTP 协议在 1.X 时代只支持半双工传输模式虽然支持长连接但是不支持服务端主动推送数据。从效率角度来看在一次简单的远程调用中只需要传递方法名和加个简单的参数此时HTTP 请求中大部分数据都被 HTTP Header 占据,真正的有效负载非常少,效率就比较低。
当然HTTP 协议也有自己的优势,例如,天然穿透防火墙,大量的框架和开源软件支持 HTTP 接口,而且配合 REST 规范使用也是很便捷的,所以有很多 RPC 框架直接使用 HTTP 协议,尤其是在 HTTP 2.0 之后,如 gRPC、Spring Cloud 等。
这里我们自定义一个简易版的 Demo RPC 协议,如下图所示:
在 Demo RPC 的消息头中,包含了整个 RPC 消息的一些控制信息,例如,版本号、魔数、消息类型、附加信息、消息 ID 以及消息体的长度在附加信息extraInfo按位进行划分分别定义消息的类型、序列化方式、压缩方式以及请求类型。当然你也可以自己扩充 Demo RPC 协议,实现更加复杂的功能。
Demo RPC 消息头对应的实体类是 Header其定义如下
public class Header {
private short magic; // 魔数
private byte version; // 协议版本
private byte extraInfo; // 附加信息
private Long messageId; // 消息ID
private Integer size; // 消息体长度
... // 省略getter/setter方法
}
确定了 Demo RPC 协议消息头的结构之后,我们再来看 Demo RPC 协议消息体由哪些字段构成,这里我们通过 Request 和 Response 两个实体类来表示请求消息和响应消息的消息体:
public class Request implements Serializable {
private String serviceName; // 请求的Service类名
private String methodName; // 请求的方法名称
private Class[] argTypes; // 请求方法的参数类型
private Object[] args; // 请求方法的参数
... // 省略getter/setter方法
}
public class Response implements Serializable {
private int code = 0; // 响应的错误码正常响应为0非0表示异常响应
private String errMsg; // 异常信息
private Object result; // 响应结果
... // 省略getter/setter方法
}
注意Request 和 Response 对象是要进行序列化的,需要实现 Serializable 接口。为了让这两个类的对象能够在 Client 和 Server 之间跨进程传输,需要进行序列化和反序列化操作,这里定义一个 Serialization 接口,统一完成序列化相关的操作:
public interface Serialization {
<T> byte[] serialize(T obj)throws IOException;
<T> T deSerialize(byte[] data, Class<T> clz)throws IOException;
}
在 Demo RPC 中默认使用 Hessian 序列化方式,下面的 HessianSerialization 就是基于 Hessian 序列化方式对 Serialization 接口的实现:
public class HessianSerialization implements Serialization {
public <T> byte[] serialize(T obj) throws IOException {
ByteArrayOutputStream os = new ByteArrayOutputStream();
HessianOutput hessianOutput = new HessianOutput(os);
hessianOutput.writeObject(obj);
return os.toByteArray();
}
public <T> T deSerialize(byte[] data, Class<T> clazz)
throws IOException {
ByteArrayInputStream is = new ByteArrayInputStream(data);
HessianInput hessianInput = new HessianInput(is);
return (T) hessianInput.readObject(clazz);
}
}
在有的场景中,请求或响应传输的数据比较大,直接传输比较消耗带宽,所以一般会采用压缩后再发送的方式。在前面介绍的 Demo RPC 消息头中的 extraInfo 字段中,就包含了标识消息体压缩方式的 bit 位。这里我们定义一个 Compressor 接口抽象所有压缩算法:
public interface Compressor {
byte[] compress(byte[] array) throws IOException;
byte[] unCompress(byte[] array) throws IOException;
}
同时提供了一个基于 Snappy 压缩算法的实现,作为 Demo RPC 的默认压缩算法:
public class SnappyCompressor implements Compressor {
public byte[] compress(byte[] array) throws IOException {
if (array == null) { return null; }
return Snappy.compress(array);
}
public byte[] unCompress(byte[] array) throws IOException {
if (array == null) { return null; }
return Snappy.uncompress(array);
}
}
编解码实现
了解了自定义协议的结构之后,我们再来解决协议的编解码问题。
前面课时介绍 Netty 核心概念的时候我们提到过Netty 每个 Channel 绑定一个 ChannelPipeline并依赖 ChannelPipeline 中添加的 ChannelHandler 处理接收到或要发送的数据其中就包括字节到消息以及消息到字节的转换。Netty 中提供了 ByteToMessageDecoder、 MessageToByteEncoder、MessageToMessageEncoder、MessageToMessageDecoder 等抽象类来实现 Message 与 ByteBuf 之间的转换以及 Message 之间的转换,如下图所示:
Netty 提供的 Decoder 和 Encoder 实现
在 Netty 的源码中我们可以看到对很多已有协议的序列化和反序列化都是基于上述抽象类实现的例如HttpServerCodec 中通过依赖 HttpServerRequestDecoder 和 HttpServerResponseEncoder 来实现 HTTP 请求的解码和 HTTP 响应的编码。如下图所示HttpServerRequestDecoder 继承自 ByteToMessageDecoder实现了 ByteBuf 到 HTTP 请求之间的转换HttpServerResponseEncoder 继承自 MessageToMessageEncoder实现 HTTP 响应到其他消息的转换(其中包括转换成 ByteBuf 的能力)。
Netty 中 HTTP 协议的 Decoder 和 Encoder 实现
在简易版 RPC 框架中,我们的自定义请求暂时没有 HTTP 协议那么复杂,只要简单继承 ByteToMessageDecoder 和 MessageToMessageEncoder 即可。
首先来看 DemoRpcDecoder它实现了 ByteBuf 到 Demo RPC Message 的转换,具体实现如下:
public class DemoRpcDecoder extends ByteToMessageDecoder {
protected void decode(ChannelHandlerContext ctx,
ByteBuf byteBuf, List<Object> out) throws Exception {
if (byteBuf.readableBytes() < Constants.HEADER_SIZE) {
return; // 不到16字节的话无法解析消息头暂不读取
}
// 记录当前readIndex指针的位置方便重置
byteBuf.markReaderIndex();
// 尝试读取消息头的魔数部分
short magic = byteBuf.readShort();
if (magic != Constants.MAGIC) { // 魔数不匹配会抛出异常
byteBuf.resetReaderIndex(); // 重置readIndex指针
throw new RuntimeException("magic number error:" + magic);
}
// 依次读取消息版本附加信息消息ID以及消息体长度四部分
byte version = byteBuf.readByte();
byte extraInfo = byteBuf.readByte();
long messageId = byteBuf.readLong();
int size = byteBuf.readInt();
Object request = null;
// 心跳消息是没有消息体的无须读取
if (!Constants.isHeartBeat(extraInfo)) {
// 对于非心跳消息没有积累到足够的数据是无法进行反序列化的
if (byteBuf.readableBytes() < size) {
byteBuf.resetReaderIndex();
return;
}
// 读取消息体并进行反序列化
byte[] payload = new byte[size];
byteBuf.readBytes(payload);
// 这里根据消息头中的extraInfo部分选择相应的序列化和压缩方式
Serialization serialization =
SerializationFactory.get(extraInfo);
Compressor compressor = CompressorFactory.get(extraInfo);
// 经过解压缩和反序列化得到消息体
request = serialization.deserialize(
compressor.unCompress(payload), Request.class);
}
// 将上面读取到的消息头和消息体拼装成完整的Message并向后传递
Header header = new Header(magic, version, extraInfo,
messageId, size);
Message message = new Message(header, request);
out.add(message);
}
}
接下来看 DemoRpcEncoder它实现了 Demo RPC Message ByteBuf 的转换具体实现如下
class DemoRpcEncoder extends MessageToByteEncoder<Message>{
@Override
protected void encode(ChannelHandlerContext channelHandlerContext,
Message message, ByteBuf byteBuf) throws Exception {
Header header = message.getHeader();
// 依次序列化消息头中的魔数、版本、附加信息以及消息ID
byteBuf.writeShort(header.getMagic());
byteBuf.writeByte(header.getVersion());
byteBuf.writeByte(header.getExtraInfo());
byteBuf.writeLong(header.getMessageId());
Object content = message.getContent();
if (Constants.isHeartBeat(header.getExtraInfo())) {
byteBuf.writeInt(0); // 心跳消息没有消息体这里写入0
return;
}
// 按照extraInfo部分指定的序列化方式和压缩方式进行处理
Serialization serialization =
SerializationFactory.get(header.getExtraInfo());
Compressor compressor =
CompressorFactory.get(header.getExtraInfo());
byte[] payload = compressor.compress(
serialization.serialize(content));
byteBuf.writeInt(payload.length); // 写入消息体长度
byteBuf.writeBytes(payload); // 写入消息体
}
}
总结
本课时我们首先介绍了简易 RPC 框架的基础架构以及其处理一次远程调用的基本流程,并对整个简易 RPC 框架项目的结构进行了简单介绍。接下来,我们讲解了简易 RPC 框架使用的自定义协议格式、序列化/反序列化方式以及压缩方式,这些都是远程数据传输不可或缺的基础。然后,我们又介绍了 Netty 中的编解码体系,以及 HTTP 协议相关的编解码器实现。最后,我们还分析了简易 RPC 协议对应的编解码器,即 DemoRpcEncoder 和 DemoRpcDecoder。
在下一课时,我们将自底向上,继续介绍简易 RPC 框架的剩余部分实现。
简易版 RPC 框架 Demo 的链接https://github.com/xxxlxy2008/demo-prc 。

View File

@@ -0,0 +1,773 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 简易版 RPC 框架实现(下)
在上一课时中,我们介绍了整个简易 RPC 框架项目的结构和工作原理,并且介绍了简易 RPC 框架底层的协议结构、序列化/反序列化实现、压缩实现以及编解码器的具体实现。本课时我们将继续自底向上,介绍简易 RPC 框架的剩余部分实现。
transport 相关实现
正如前文介绍 Netty 线程模型的时候提到,我们不能在 Netty 的 I/O 线程中执行耗时的业务逻辑。在 Demo RPC 框架的 Server 端接收到请求时,首先会通过上面介绍的 DemoRpcDecoder 反序列化得到请求消息,之后我们会通过一个自定义的 ChannelHandlerDemoRpcServerHandler将请求提交给业务线程池进行处理。
在 Demo RPC 框架的 Client 端接收到响应消息的时候,也是先通过 DemoRpcDecoder 反序列化得到响应消息,之后通过一个自定义的 ChannelHandlerDemoRpcClientHandler将响应返回给上层业务。
DemoRpcServerHandler 和 DemoRpcClientHandler 都继承自 SimpleChannelInboundHandler如下图所示
DemoRpcClientHandler 和 DemoRpcServerHandler 的继承关系图
下面我们就来看一下这两个自定义的 ChannelHandler 实现:
public class DemoRpcServerHandler extends
SimpleChannelInboundHandler<Message<Request>> {
// 业务线程池
static Executor executor = Executors.newCachedThreadPool();
protected void channelRead0(final ChannelHandlerContext ctx,
Message<Request> message) throws Exception {
byte extraInfo = message.getHeader().getExtraInfo();
if (Constants.isHeartBeat(extraInfo)) { // 心跳消息,直接返回即可
channelHandlerContext.writeAndFlush(message);
return;
}
// 非心跳消息直接封装成Runnable提交到业务线程
executor.execute(new InvokeRunnable(message, cxt));
}
}
public class DemoRpcClientHandler extends
SimpleChannelInboundHandler<Message<Response>> {
protected void channelRead0(ChannelHandlerContext ctx,
Message<Response> message) throws Exception {
NettyResponseFuture responseFuture =
Connection.IN_FLIGHT_REQUEST_MAP
.remove(message.getHeader().getMessageId());
Response response = message.getContent();
// 心跳消息特殊处理
if (response == null && Constants.isHeartBeat(
message.getHeader().getExtraInfo())) {
response = new Response();
response.setCode(Constants.HEARTBEAT_CODE);
}
responseFuture.getPromise().setSuccess(response);
}
}
注意,这里有两个点需要特别说明一下。一个点是 Server 端的 InvokeRunnable在这个 Runnable 任务中会根据请求的 serviceName、methodName 以及参数信息,调用相应的方法:
class InvokeRunnable implements Runnable {
private ChannelHandlerContext ctx;
private Message<Request> message;
public void run() {
Response response = new Response();
Object result = null;
try {
Request request = message.getContent();
String serviceName = request.getServiceName();
// 这里提供BeanManager对所有业务Bean进行管理其底层在内存中维护了
// 一个业务Bean实例的集合。感兴趣的同学可以尝试接入Spring等容器管
// 理业务Bean
Object bean = BeanManager.getBean(serviceName);
// 下面通过反射调用Bean中的相应方法
Method method = bean.getClass().getMethod(
request.getMethodName(), request.getArgTypes());
result = method.invoke(bean, request.getArgs());
} catch (Exception e) { // 省略异常处理
} finally {
}
response.setResult(result); // 设置响应结果
// 将响应消息返回给客户端
ctx.writeAndFlush(new Message(message.getHeader(), response));
}
}
另一个点是 Client 端的 Connection它是用来暂存已发送出去但未得到响应的请求这样在响应返回时就可以查找到相应的请求以及 Future从而将响应结果返回给上层业务逻辑具体实现如下
public class Connection implements Closeable {
private static AtomicLong ID_GENERATOR = new AtomicLong(0);
public static Map<Long, NettyResponseFuture<Response>>
IN_FLIGHT_REQUEST_MAP = new ConcurrentHashMap<>();
private ChannelFuture future;
private AtomicBoolean isConnected = new AtomicBoolean();
public Connection(ChannelFuture future, boolean isConnected) {
this.future = future;
this.isConnected.set(isConnected);
}
public NettyResponseFuture<Response> request(Message<Request> message, long timeOut) {
// 生成并设置消息ID
long messageId = ID_GENERATOR.incrementAndGet();
message.getHeader().setMessageId(messageId);
// 创建消息关联的Future
NettyResponseFuture responseFuture = new NettyResponseFuture(System.currentTimeMillis(),
timeOut, message, future.channel(), new DefaultPromise(new DefaultEventLoop()));
// 将消息ID和关联的Future记录到IN_FLIGHT_REQUEST_MAP集合中
IN_FLIGHT_REQUEST_MAP.put(messageId, responseFuture);
try {
future.channel().writeAndFlush(message); // 发送请求
} catch (Exception e) {
// 发送请求异常时删除对应的Future
IN_FLIGHT_REQUEST_MAP.remove(messageId);
throw e;
}
return responseFuture;
}
// 省略getter/setter以及close()方法
}
我们可以看到Connection 中没有定时清理 IN_FLIGHT_REQUEST_MAP 集合的操作,在无法正常获取响应的时候,就会导致 IN_FLIGHT_REQUEST_MAP 不断膨胀,最终 OOM。你也可以添加一个时间轮定时器定时清理过期的请求消息这里我们就不再展开讲述了。
完成自定义 ChannelHandler 的编写之后,我们需要再定义两个类—— DemoRpcClient 和 DemoRpcServer分别作为 Client 和 Server 的启动入口。DemoRpcClient 的实现如下:
public class DemoRpcClient implements Closeable {
protected Bootstrap clientBootstrap;
protected EventLoopGroup group;
private String host;
private int port;
public DemoRpcClient(String host, int port) throws Exception {
this.host = host;
this.port = port;
clientBootstrap = new Bootstrap();
// 创建并配置客户端Bootstrap
group = NettyEventLoopFactory.eventLoopGroup(
Constants.DEFAULT_IO_THREADS, "NettyClientWorker");
clientBootstrap.group(group)
.option(ChannelOption.TCP_NODELAY, true)
.option(ChannelOption.SO_KEEPALIVE, true)
.channel(NioSocketChannel.class)
// 指定ChannelHandler的顺序
.handler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast("demo-rpc-encoder",
new DemoRpcEncoder());
ch.pipeline().addLast("demo-rpc-decoder",
new DemoRpcDecoder());
ch.pipeline().addLast("client-handler",
new DemoRpcClientHandler());
}
});
}
public ChannelFuture connect() { // 连接指定的地址和端口
ChannelFuture connect = clientBootstrap.connect(host, port);
connect.awaitUninterruptibly();
return connect;
}
public void close() {
group.shutdownGracefully();
}
}
通过 DemoRpcClient 的代码我们可以看到其 ChannelHandler 的执行顺序如下:
客户端 ChannelHandler 结构图
另外在创建EventLoopGroup时并没有直接使用NioEventLoopGroup而是在 NettyEventLoopFactory 中根据当前操作系统进行选择,对于 Linux 系统,会使用 EpollEventLoopGroup其他系统则使用 NioEventLoopGroup。
接下来我们再看DemoRpcServer 的具体实现:
public class DemoRpcServer {
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
private ServerBootstrap serverBootstrap;
private Channel channel;
protected int port;
public DemoRpcServer(int port) throws InterruptedException {
this.port = port;
// 创建boss和worker两个EventLoopGroup注意一些小细节
// workerGroup 是按照中的线程数是按照 CPU 核数计算得到的,
bossGroup = NettyEventLoopFactory.eventLoopGroup(1, "boos");
workerGroup = NettyEventLoopFactory.eventLoopGroup(
Math.min(Runtime.getRuntime().availableProcessors() + 1,
32), "worker");
serverBootstrap = new ServerBootstrap().group(bossGroup,
workerGroup).channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_REUSEADDR, Boolean.TRUE)
.childOption(ChannelOption.TCP_NODELAY, Boolean.TRUE)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new ChannelInitializer<SocketChannel>()
{ // 指定每个Channel上注册的ChannelHandler以及顺序
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast("demp-rpc-decoder",
new DemoRpcDecoder());
ch.pipeline().addLast("demo-rpc-encoder",
new DemoRpcEncoder());
ch.pipeline().addLast("server-handler",
new DemoRpcServerHandler());
}
});
}
public ChannelFuture start() throws InterruptedException {
ChannelFuture channelFuture = serverBootstrap.bind(port);
channel = channelFuture.channel();
channel.closeFuture();
return channelFuture;
}
}
通过对 DemoRpcServer 实现的分析,我们可以知道每个 Channel 上的 ChannelHandler 顺序如下:
服务端 ChannelHandler 结构图
registry 相关实现
介绍完客户端和服务端的通信之后,我们再来看简易 RPC 框架的另一个基础能力——服务注册与服务发现能力,对应 demo-rpc 项目源码中的 registry 包。
registry 包主要是依赖 Apache Curator 实现了一个简易版本的 ZooKeeper 客户端,并基于 ZooKeeper 实现了注册中心最基本的两个功能Provider 注册以及 Consumer 订阅。
这里我们先定义一个 Registry 接口,其中提供了注册以及查询服务实例的方法,如下图所示:
ZooKeeperRegistry 是基于 curator-x-discovery 对 Registry 接口的实现类型,其中封装了之前课时介绍的 ServiceDiscovery并在其上添加了 ServiceCache 缓存提高查询效率。ZooKeeperRegistry 的具体实现如下:
public class ZookeeperRegistry<T> implements Registry<T> {
private InstanceSerializer serializer =
new JsonInstanceSerializer<>(ServerInfo.class);
private ServiceDiscovery<T> serviceDiscovery;
private ServiceCache<T> serviceCache;
private String address = "localhost:2181";
public void start() throws Exception {
String root = "/demo/rpc";
// 初始化CuratorFramework
CuratorFramework client = CuratorFrameworkFactory
.newClient(address, new ExponentialBackoffRetry(1000, 3));
client.start(); // 启动Curator客户端
client.blockUntilConnected(); // 阻塞当前线程,等待连接成
client.createContainers(root);
// 初始化ServiceDiscovery
serviceDiscovery = ServiceDiscoveryBuilder
.builder(ServerInfo.class)
.client(client).basePath(root)
.serializer(serializer)
.build();
serviceDiscovery.start(); // 启动ServiceDiscovery
// 创建ServiceCache监Zookeeper相应节点的变化也方便后续的读取
serviceCache = serviceDiscovery.serviceCacheBuilder()
.name(root)
.build();
serviceCache.start(); // 启动ServiceCache
}
@Override
public void registerService(ServiceInstance<T> service)
throws Exception {
serviceDiscovery.registerService(service);
}
@Override
public void unregisterService(ServiceInstance service)
throws Exception {
serviceDiscovery.unregisterService(service);
}
@Override
public List<ServiceInstance<T>> queryForInstances(
String name) throws Exception {
// 直接根据name进行过滤ServiceCache中的缓存数据
return serviceCache.getInstances().stream()
.filter(s -> s.getName().equals(name))
.collect(Collectors.toList());
}
}
通过对 ZooKeeperRegistry的分析可以得知它是基于 Curator 中的 ServiceDiscovery 组件与 ZooKeeper 进行交互的,并且对 Registry 接口的实现也是通过直接调用 ServiceDiscovery 的相关方法实现的。在查询时,直接读取 ServiceCache 中的缓存数据ServiceCache 底层在本地维护了一个 ConcurrentHashMap 缓存,通过 PathChildrenCache 监听 ZooKeeper 中各个子节点的变化,同步更新本地缓存。这里我们简单看一下 ServiceCache 的核心实现:
public class ServiceCacheImpl<T> implements ServiceCache<T>,
PathChildrenCacheListener{//实现PathChildrenCacheListener接口
// 关联的ServiceDiscovery实例
private final ServiceDiscoveryImpl<T> discovery;
// 底层的PathChildrenCache用于监听子节点的变化
private final PathChildrenCache cache;
// 本地缓存
private final ConcurrentMap<String, ServiceInstance<T>> instances
= Maps.newConcurrentMap();
public List<ServiceInstance<T>> getInstances(){ // 返回本地缓存内容
return Lists.newArrayList(instances.values());
}
public void childEvent(CuratorFramework client,
PathChildrenCacheEvent event) throws Exception{
switch(event.getType()){
case CHILD_ADDED:
case CHILD_UPDATED:{
addInstance(event.getData(), false); // 更新本地缓存
notifyListeners = true;
break;
}
case CHILD_REMOVED:{ // 更新本地缓存
instances.remove(instanceIdFromData(event.getData()));
notifyListeners = true;
break;
}
}
... // 通知ServiceCache上注册的监听器
}
}
proxy 相关实现
在简易版 Demo RPC 框架中Proxy 主要是为 Client 端创建一个代理,帮助客户端程序屏蔽底层的网络操作以及与注册中心之间的交互。
简易版 Demo RPC 使用 JDK 动态代理的方式生成代理,这里需要编写一个 InvocationHandler 接口的实现,即下面的 DemoRpcProxy。其中有两个核心方法一个是 newInstance() 方法,用于生成代理对象;另一个是 invoke() 方法,当调用目标对象的时候,会执行 invoke() 方法中的代理逻辑。
下面是 DemoRpcProxy 的具体实现:
public class DemoRpcProxy implements InvocationHandler {
// 需要代理的服务(接口)名称
private String serviceName;
// 用于与Zookeeper交互其中自带缓存
private Registry<ServerInfo> registry;
public DemoRpcProxy(String serviceName, Registry<ServerInfo>
registry) throws Exception { // 初始化上述两个字段
this.serviceName = serviceName;
this.registry = registry;
}
public static <T> T newInstance(Class<T> clazz,
Registry<ServerInfo> registry) throws Exception {
// 创建代理对象
return (T) Proxy.newProxyInstance(Thread.currentThread()
.getContextClassLoader(), new Class[]{clazz},
new DemoRpcProxy(clazz.getName(), registry));
}
@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
// 从Zookeeper缓存中获取可用的Server地址,并随机从中选择一个
List<ServiceInstance<ServerInfo>> serviceInstances =
registry.queryForInstances(serviceName);
ServiceInstance<ServerInfo> serviceInstance = serviceInstances
.get(ThreadLocalRandom.current()
.nextInt(serviceInstances.size()));
// 创建请求消息然后调用remoteCall()方法请求上面选定的Server端
String methodName = method.getName();
Header header =new Header(MAGIC, VERSION_1...);
Message<Request> message = new Message(header,
new Request(serviceName, methodName, args));
return remoteCall(serviceInstance.getPayload(), message);
}
protected Object remoteCall(ServerInfo serverInfo,
Message message) throws Exception {
if (serverInfo == null) {
throw new RuntimeException("get available server error");
}
// 创建DemoRpcClient连接指定的Server端
DemoRpcClient demoRpcClient = new DemoRpcClient(
serverInfo.getHost(), serverInfo.getPort());
ChannelFuture channelFuture = demoRpcClient.connect()
.awaitUninterruptibly();
// 创建对应的Connection对象并发送请求
Connection connection = new Connection(channelFuture, true);
NettyResponseFuture responseFuture =
connection.request(message, Constants.DEFAULT_TIMEOUT);
// 等待请求对应的响应
return responseFuture.getPromise().get(
Constants.DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS);
}
}
从 DemoRpcProxy 的实现中我们可以看到,它依赖了 ServiceInstanceCache 获取ZooKeeper 中注册的 Server 端地址,同时依赖了 DemoRpcClient 与Server 端进行通信,上层调用方拿到这个代理对象后,就可以像调用本地方法一样进行调用,而不再关心底层网络通信和服务发现的细节。当然,这个简易版 DemoRpcProxy 的实现还有很多可以优化的地方,例如:
缓存 DemoRpcClient 客户端对象以及相应的 Connection 对象,不必每次进行创建。
可以添加失败重试机制,在请求出现超时的时候,进行重试。
可以添加更加复杂和灵活的负载均衡机制,例如,根据 Hash 值散列进行负载均衡、根据节点 load 情况进行负载均衡等。
你若感兴趣的话可以尝试进行扩展,以实现一个更加完善的代理层。
使用方接入
介绍完 Demo RPC 的核心实现之后下面我们讲解下Demo RPC 框架的使用方式。这里涉及Consumer、DemoServiceImp、Provider三个类以及 DemoService 业务接口。
使用接入的相关类
首先我们定义DemoService 接口作为业务 Server 接口,具体定义如下:
public interface DemoService {
String sayHello(String param);
}
DemoServiceImpl对 DemoService 接口的实现也非常简单,如下所示,将参数做简单修改后返回:
public class DemoServiceImpl implements DemoService {
public String sayHello(String param) {
return "hello:" + param;
}
}
了解完相应的业务接口和实现之后我们再来看Provider的实现它的角色类似于 Dubbo 中的 Provider其会创建 DemoServiceImpl 这个业务 Bean 并将自身的地址信息暴露出去,如下所示:
public class Provider {
public static void main(String[] args) throws Exception {
// 创建DemoServiceImpl并注册到BeanManager中
BeanManager.registerBean("demoService",
new DemoServiceImpl());
// 创建ZookeeperRegistry并将Provider的地址信息封装成ServerInfo
// 对象注册到Zookeeper
ZookeeperRegistry<ServerInfo> discovery =
new ZookeeperRegistry<>();
discovery.start();
ServerInfo serverInfo = new ServerInfo("127.0.0.1", 20880);
discovery.registerService(
ServiceInstance.<ServerInfo>builder().name("demoService")
.payload(serverInfo).build());
// 启动DemoRpcServer等待Client的请求
DemoRpcServer rpcServer = new DemoRpcServer(20880);
rpcServer.start();
}
}
最后是Consumer它类似于 Dubbo 中的 Consumer其会订阅 Provider 地址信息,然后根据这些信息选择一个 Provider 建立连接,发送请求并得到响应,这些过程在 Proxy 中都予以了封装那Consumer 的实现就很简单了,可参考如下示例代码:
public class Consumer {
public static void main(String[] args) throws Exception {
// 创建ZookeeperRegistr对象
ZookeeperRegistry<ServerInfo> discovery = new ZookeeperRegistry<>();
// 创建代理对象通过代理调用远端Server
DemoService demoService = DemoRpcProxy.newInstance(DemoService.class, discovery);
// 调用sayHello()方法,并输出结果
String result = demoService.sayHello("hello");
System.out.println(result);
}
}
总结
本课时我们首先介绍了简易 RPC 框架中的transport 包它在上一课时介绍的编解码器基础之上实现了服务端和客户端的通信能力。之后讲解了registry 包如何实现与 ZooKeeper 的交互,完善了简易 RPC 框架的服务注册与服务发现的能力。接下来又分析了proxy 包的实现,其中通过 JDK 动态代理的方式,帮接入方屏蔽了底层网络通信的复杂性。最后,我们编写了一个简单的 DemoService 业务接口,以及相应的 Provider 和 Consumer 接入简易 RPC 框架。
在本课时最后,留给你一个小问题:在 transport 中创建 EventLoopGroup 的时候,为什么针对 Linux 系统使用的 EventLoopGroup会有所不同呢期待你的留言。
简易版 RPC 框架 Demo 的链接https://github.com/xxxlxy2008/demo-prc 。

View File

@@ -0,0 +1,221 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 本地缓存:降低 ZooKeeper 压力的一个常用手段
从这一课时开始我们就进入了第二部分注册中心。注册中心Registry在微服务架构中的作用举足轻重有了它服务提供者Provider 和消费者Consumer 就能感知彼此。从下面的 Dubbo 架构图中可知:
Dubbo 架构图
Provider 从容器启动后的初始化阶段便会向注册中心完成注册操作;
Consumer 启动初始化阶段会完成对所需 Prov·ider 的订阅操作;
另外,在 Provider 发生变化时,需要通知监听的 Consumer。
Registry 只是 Consumer 和 Provider 感知彼此状态变化的一种便捷途径而已,它们彼此的实际通讯交互过程是直接进行的,对于 Registry 来说是透明无感的。Provider 状态发生变化了,会由 Registry 主动推送订阅了该 Provider 的所有 Consumer这保证了 Consumer 感知 Provider 状态变化的及时性,也将和具体业务需求逻辑交互解耦,提升了系统的稳定性。
Dubbo 中存在很多概念,但有些理解起来就特别费劲,如本文的 Registry翻译过来的意思是“注册中心”但它其实是应用本地的注册中心客户端真正的“注册中心”服务是其他独立部署的进程或进程组成的集群比如 ZooKeeper 集群。本地的 Registry 通过和 ZooKeeper 等进行实时的信息同步,维持这些内容的一致性,从而实现了注册中心这个特性。另外,就 Registry 而言Consumer 和 Provider 只是个用户视角的概念,它们被抽象为了一条 URL 。
从本课时开始,我们就真正开始分析 Dubbo 源码了。首先看一下本课程第二部分内容在 Dubbo 架构中所处的位置(如下图红框所示),可以看到这部分内容在整个 Dubbo 体系中还是相对独立的,没有涉及 Protocol、Invoker 等 Dubbo 内部的概念。等介绍完这些概念之后,我们还会回看图中 Registry 红框之外的内容。
整个 Dubbo 体系图
核心接口
作为“注册中心”部分的第一课时,我们有必要介绍下 dubbo-registry-api 模块中的核心抽象接口,如下图所示:
在 Dubbo 中,一般使用 Node 这个接口来抽象节点的概念。Node不仅可以表示 Provider 和 Consumer 节点还可以表示注册中心节点。Node 接口中定义了三个非常基础的方法(如下图所示):
getUrl() 方法返回表示当前节点的 URL
isAvailable() 检测当前节点是否可用;
destroy() 方法负责销毁当前节点并释放底层资源。
RegistryService 接口抽象了注册服务的基本行为,如下图所示:
register() 方法和 unregister() 方法分别表示注册和取消注册一个 URL。
subscribe() 方法和 unsubscribe() 方法分别表示订阅和取消订阅一个 URL。订阅成功之后当订阅的数据发生变化时注册中心会主动通知第二个参数指定的 NotifyListener 对象NotifyListener 接口中定义的 notify() 方法就是用来接收该通知的。
lookup() 方法能够查询符合条件的注册数据,它与 subscribe() 方法有一定的区别subscribe() 方法采用的是 push 模式lookup() 方法采用的是 pull 模式。
Registry 接口继承了 RegistryService 接口和 Node 接口,如下图所示,它表示的就是一个拥有注册中心能力的节点,其中的 reExportRegister() 和 reExportUnregister() 方法都是委托给 RegistryService 中的相应方法。
RegistryFactory 接口是 Registry 的工厂接口,负责创建 Registry 对象,具体定义如下所示,其中 @SPI 注解指定了默认的扩展名为 dubbo@Adaptive 注解表示会生成适配器类并根据 URL 参数中的 protocol 参数值选择相应的实现。
@SPI("dubbo")
public interface RegistryFactory {
@Adaptive({"protocol"})
Registry getRegistry(URL url);
}
通过下面两张继承关系图可以看出,每个 Registry 实现类都有对应的 RegistryFactory 工厂实现,每个 RegistryFactory 工厂实现只负责创建对应的 Registry 对象。
RegistryFactory 继承关系图
Registry 继承关系图
其中RegistryFactoryWrapper 是 RegistryFactory 接口的 Wrapper 类,它在底层 RegistryFactory 创建的 Registry 对象外层封装了一个 ListenerRegistryWrapper ListenerRegistryWrapper 中维护了一个 RegistryServiceListener 集合,会将 register()、subscribe() 等事件通知到 RegistryServiceListener 监听器。
AbstractRegistryFactory 是一个实现了 RegistryFactory 接口的抽象类,提供了规范 URL 的操作以及缓存 Registry 对象的公共能力。其中,缓存 Registry 对象是使用 HashMap 集合实现的REGISTRIES 静态字段)。在规范 URL 的实现逻辑中AbstractRegistryFactory 会将 RegistryService 的类名设置为 URL path 和 interface 参数,同时删除 export 和 refer 参数。
AbstractRegistry
AbstractRegistry 实现了 Registry 接口,虽然 AbstractRegistry 本身在内存中实现了注册数据的读写功能也没有什么抽象方法但它依然被标记成了抽象类从前面的Registry 继承关系图中可以看出Registry 接口的所有实现类都继承了 AbstractRegistry。
为了减轻注册中心组件的压力AbstractRegistry 会把当前节点订阅的 URL 信息缓存到本地的 Properties 文件中,其核心字段如下:
registryUrlURL类型。 该 URL 包含了创建该 Registry 对象的全部配置信息,是 AbstractRegistryFactory 修改后的产物。
propertiesProperties 类型、fileFile 类型)。 本地的 Properties 文件缓存properties 是加载到内存的 Properties 对象file 是磁盘上对应的文件,两者的数据是同步的。在 AbstractRegistry 初始化时,会根据 registryUrl 中的 file.cache 参数值决定是否开启文件缓存。如果开启文件缓存功能,就会立即将 file 文件中的 KV 缓存加载到 properties 字段中。当 properties 中的注册数据发生变化时,会写入本地的 file 文件进行同步。properties 是一个 KV 结构,其中 Key 是当前节点作为 Consumer 的一个 URLValue 是对应的 Provider 列表,包含了所有 Category例如providers、routes、configurators 等) 下的 URL。properties 中有一个特殊的 Key 值为 registies对应的 Value 是注册中心列表,其他记录的都是 Provider 列表。
syncSaveFileboolean 类型)。 是否同步保存文件的配置,对应的是 registryUrl 中的 save.file 参数。
registryCacheExecutorExecutorService 类型)。 这是一个单线程的线程池,在一个 Provider 的注册数据发生变化的时候,会将该 Provider 的全量数据同步到 properties 字段和缓存文件中,如果 syncSaveFile 配置为 false就由该线程池异步完成文件写入。
lastCacheChangedAtomicLong 类型)。 注册数据的版本号,每次写入 file 文件时,都是全覆盖写入,而不是修改文件,所以需要版本控制,防止旧数据覆盖新数据。
registeredSet 类型)。 这个比较简单,它是注册的 URL 集合。
subscribedConcurrentMap 类型)。 表示订阅 URL 的监听器集合,其中 Key 是被监听的 URL Value 是相应的监听器集合。
notifiedConcurrentMap>类型)。 该集合第一层 Key 是当前节点作为 Consumer 的一个 URL表示的是该节点的某个 Consumer 角色(一个节点可以同时消费多个 Provider 节点Value 是一个 Map 集合,该 Map 集合的 Key 是 Provider URL 的分类Category例如 providers、routes、configurators 等Value 就是相应分类下的 URL 集合。
介绍完 AbstractRegistry 的核心字段之后,我们接下来就再看看 AbstractRegistry 依赖这些字段都提供了哪些公共能力。
1. 本地缓存
作为一个 RPC 框架Dubbo 在微服务架构中解决了各个服务间协作的难题;作为 Provider 和 Consumer 的底层依赖它会与服务一起打包部署。dubbo-registry 也仅仅是其中一个依赖包,负责完成与 ZooKeeper、etcd、Consul 等服务发现组件的交互。
当 Provider 端暴露的 URL 发生变化时ZooKeeper 等服务发现组件会通知 Consumer 端的 Registry 组件Registry 组件会调用 notify() 方法,被通知的 Consumer 能匹配到所有 Provider 的 URL 列表并写入 properties 集合中。
下面我们来看 notify() 方法的核心实现:
// 注意入参第一个URL参数表示的是Consumer第二个NotifyListener是第一个参数对应的监听器第三个参数是Provider端暴露的URL的全量数据
protected void notify(URL url, NotifyListener listener,
List<URL> urls) {
... // 省略一系列边界条件的检查
Map<String, List<URL>> result = new HashMap<>();
for (URL u : urls) {
// 需要Consumer URL与Provider URL匹配具体匹配规则后面详述
if (UrlUtils.isMatch(url, u)) {
// 根据Provider URL中的category参数进行分类
String category = u.getParameter("category", "providers");
List<URL> categoryList = result.computeIfAbsent(category,
k -> new ArrayList<>());
categoryList.add(u);
}
}
if (result.size() == 0) {
return;
}
Map<String, List<URL>> categoryNotified =
notified.computeIfAbsent(url, u -> new ConcurrentHashMap<>());
for (Map.Entry<String, List<URL>> entry : result.entrySet()) {
String category = entry.getKey();
List<URL> categoryList = entry.getValue();
categoryNotified.put(category, categoryList); // 更新notified
listener.notify(categoryList); // 调用NotifyListener
// 更新properties集合以及底层的文件缓存
saveProperties(url);
}
}
在 saveProperties() 方法中会取出 Consumer 订阅的各个分类的 URL 连接起来(中间以空格分隔),然后以 Consumer 的 ServiceKey 为键值写到 properties 中,同时 lastCacheChanged 版本号会自增。完成 properties 字段的更新之后,会根据 syncSaveFile 字段值来决定是在当前线程同步更新 file 文件,还是向 registryCacheExecutor 线程池提交任务,异步完成 file 文件的同步。本地缓存文件的具体路径是:
/.dubbo/dubbo-registry-[当前应用名]-[当前Registry所在的IP地址].cache
这里首先关注第一个细节UrlUtils.isMatch() 方法。该方法会完成 Consumer URL 与 Provider URL 的匹配,依次匹配的部分如下所示:
匹配 Consumer 和 Provider 的接口(优先取 interface 参数,其次再取 path。双方接口相同或者其中一方为“*”,则匹配成功,执行下一步。
匹配 Consumer 和 Provider 的 category。
检测 Consumer URL 和 Provider URL 中的 enable 参数是否符合条件。
检测 Consumer 和 Provider 端的 group、version 以及 classifier 是否符合条件。
第二个细节是URL.getServiceKey() 方法。该方法返回的 ServiceKey 是 properties 集合以及相应缓存文件中的 Key。ServiceKey 的格式如下:
[group]/{interface(或path)}[:version]
AbstractRegistry 的核心是本地文件缓存的功能。 在 AbstractRegistry 的构造方法中,会调用 loadProperties() 方法将上面写入的本地缓存文件,加载到 properties 对象中。
在网络抖动等原因而导致订阅失败时Consumer 端的 Registry 就可以调用 getCacheUrls() 方法获取本地缓存,从而得到最近注册的 Provider URL。可见AbstractRegistry 通过本地缓存提供了一种容错机制,保证了服务的可靠性。
2. 注册/订阅
AbstractRegistry 实现了 Registry 接口,它实现的 registry() 方法会将当前节点要注册的 URL 缓存到 registered 集合,而 unregistry() 方法会从 registered 集合删除指定的 URL例如当前节点下线的时候。
subscribe() 方法会将当前节点作为 Consumer 的 URL 以及相关的 NotifyListener 记录到 subscribed 集合unsubscribe() 方法会将当前节点的 URL 以及关联的 NotifyListener 从 subscribed 集合删除。
这四个方法都是简单的集合操作,这里我们就不再展示具体代码了。
单看 AbstractRegistry 的实现,上述四个基础的注册、订阅方法都是内存操作,但是 Java 有继承和多态的特性AbstractRegistry 的子类会覆盖上述四个基础的注册、订阅方法进行增强。
3. 恢复/销毁
AbstractRegistry 中还有另外两个需要关注的方法recover() 方法和destroy() 方法。
在 Provider 因为网络问题与注册中心断开连接之后,会进行重连,重新连接成功之后,会调用 recover() 方法将 registered 集合中的全部 URL 重新走一遍 register() 方法恢复注册数据。同样recover() 方法也会将 subscribed 集合中的 URL 重新走一遍 subscribe() 方法恢复订阅监听器。recover() 方法的具体实现比较简单,这里就不再展示,你若感兴趣的话,可以参考源码进行学习。
在当前节点下线的时候,会调用 Node.destroy() 方法释放底层资源。AbstractRegistry 实现的 destroy() 方法会调用 unregister() 方法和 unsubscribe() 方法将当前节点注册的 URL 以及订阅的监听全部清理掉,其中不会清理非动态注册的 URL即 dynamic 参数明确指定为 false。AbstractRegistry 中 destroy() 方法的实现比较简单,这里我们也不再展示,如果你感兴趣话,同样可以参考源码进行学习。
总结
本课时是 Dubbo 注册中心分析的第一个课时,我们首先介绍了注册中心在整个 Dubbo 架构中的位置,以及 Registry、 RegistryService、 RegistryFactory 等核心接口的功能。接下来我们还详细讲解了 AbstractRegistry 这个抽象类提供的公共能力,主要是从本地缓存、注册/订阅、恢复/销毁这三方面进行了分析。

View File

@@ -0,0 +1,356 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 重试机制是网络操作的基本保证
在真实的微服务系统中, ZooKeeper、etcd 等服务发现组件一般会独立部署成一个集群,业务服务通过网络连接这些服务发现节点,完成注册和订阅操作。但即使是机房内部的稳定网络,也无法保证两个节点之间的请求一定成功,因此 Dubbo 这类 RPC 框架在稳定性和容错性方面,就受到了比较大的挑战。为了保证服务的可靠性,重试机制就变得必不可少了。
所谓的 “重试机制”就是在请求失败时,客户端重新发起一个一模一样的请求,尝试调用相同或不同的服务端,完成相应的业务操作。能够使用重试机制的业务接口得是“幂等”的,也就是无论请求发送多少次,得到的结果都是一样的,例如查询操作。
核心设计
在上一课时中,我们介绍了 AbstractRegistry 中的 register()/unregister()、subscribe()/unsubscribe() 以及 notify() 等核心操作,详细分析了通过本地缓存实现的容错功能。其实,这几个核心方法同样也是重试机制的关注点。
dubbo-registry 将重试机制的相关实现放到了 AbstractRegistry 的子类—— FailbackRegistry 中。如下图所示,接入 ZooKeeper、etcd 等开源服务发现组件的 Registry 实现,都继承了 FailbackRegistry也就都拥有了失败重试的能力。
FailbackRegistry 设计核心是:覆盖了 AbstractRegistry 中 register()/unregister()、subscribe()/unsubscribe() 以及 notify() 这五个核心方法,结合前面介绍的时间轮,实现失败重试的能力;真正与服务发现组件的交互能力则是放到了 doRegister()/doUnregister()、doSubscribe()/doUnsubscribe() 以及 doNotify() 这五个抽象方法中,由具体子类实现。这是典型的模板方法模式的应用。
核心字段介绍
分析一个实现类的第一步就是了解其核心字段,那 FailbackRegistry 的核心字段有哪些呢?
retryTimerHashedWheelTimer 类型):用于定时执行失败重试操作的时间轮。
retryPeriodint 类型):重试操作的时间间隔。
failedRegisteredConcurrentMap类型注册失败的 URL 集合,其中 Key 是注册失败的 URLValue 是对应的重试任务。
failedUnregisteredConcurrentMap类型取消注册失败的 URL 集合,其中 Key 是取消注册失败的 URLValue 是对应的重试任务。
failedSubscribedConcurrentMap类型订阅失败 URL 集合,其中 Key 是订阅失败的 URL + Listener 集合Value 是相应的重试任务。
failedUnsubscribedConcurrentMap类型取消订阅失败的 URL 集合,其中 Key 是取消订阅失败的 URL + Listener 集合Value 是相应的重试任务。
failedNotifiedConcurrentMap类型通知失败的 URL 集合,其中 Key 是通知失败的 URL + Listener 集合Value 是相应的重试任务。
在 FailbackRegistry 的构造方法中,首先会调用父类 AbstractRegistry 的构造方法完成本地缓存相关的初始化操作,然后从传入的 URL 参数中获取重试操作的时间间隔即retry.period 参数)来初始化 retryPeriod 字段,最后初始化 retryTimer****时间轮。整个代码比较简单,这里就不展示了。
核心方法实现分析
FailbackRegistry 对 register()/unregister() 方法和 subscribe()/unsubscribe() 方法的具体实现非常类似所以这里我们就只介绍其中register() 方法的具体实现流程。
根据 registryUrl 中 accepts 参数指定的匹配模式,决定是否接受当前要注册的 Provider URL。
调用父类 AbstractRegistry 的 register() 方法,将 Provider URL 写入 registered 集合中。
调用 removeFailedRegistered() 方法和 removeFailedUnregistered() 方法,将该 Provider URL 从 failedRegistered 集合和 failedUnregistered 集合中删除,并停止相关的重试任务。
调用 doRegister() 方法,与服务发现组件进行交互。该方法由子类实现,每个子类只负责接入一个特定的服务发现组件。
在 doRegister() 方法出现异常的时候,会根据 URL 参数以及异常的类型,进行分类处理:待注册 URL 的 check 参数为 true默认值为 true待注册的 URL 不是 consumer 协议registryUrl 的 check 参数也为 true默认值为 true。若满足这三个条件或者抛出的异常为 SkipFailbackWrapperException则直接抛出异常。否则就会创建重试任务并添加到 failedRegistered 集合中。
明确 register() 方法的核心流程之后,我们再来看 register() 方法的具体代码实现:
public void register(URL url) {
if (!acceptable(url)) {
logger.info("..."); // 打印相关的提示日志
return;
}
super.register(url); // 完成本地文件缓存的初始化
// 清理failedRegistered集合和failedUnregistered集合并取消相关任务
removeFailedRegistered(url);
removeFailedUnregistered(url);
try {
doRegister(url); // 与服务发现组件进行交互,具体由子类实现
} catch (Exception e) {
Throwable t = e;
// 检测check参数决定是否直接抛出异常
boolean check = getUrl().getParameter(Constants.CHECK_KEY,
true) && url.getParameter(Constants.CHECK_KEY, true)
&& !CONSUMER_PROTOCOL.equals(url.getProtocol());
boolean skipFailback = t instanceof
SkipFailbackWrapperException;
if (check || skipFailback) {
if (skipFailback) {
t = t.getCause();
}
throw new IllegalStateException("Failed to register");
}
// 如果不抛出异常则创建失败重试的任务并添加到failedRegistered集合中
addFailedRegistered(url);
}
}
从以上代码可以看出,当 Provider 向 Registry 注册 URL 的时候,如果注册失败,且未设置 check 属性,则创建一个定时任务,添加到时间轮中。
下面我们再来看看创建并添加这个重试任务的相关方法——addFailedRegistered() 方法,具体实现如下:
private void addFailedRegistered(URL url) {
FailedRegisteredTask oldOne = failedRegistered.get(url);
if (oldOne != null) { // 已经存在重试任务,则无须创建,直接返回
return;
}
FailedRegisteredTask newTask = new FailedRegisteredTask(url,
this);
oldOne = failedRegistered.putIfAbsent(url, newTask);
if (oldOne == null) {
// 如果是新建的重试任务则提交到时间轮中等待retryPeriod毫秒后执行
retryTimer.newTimeout(newTask, retryPeriod,
TimeUnit.MILLISECONDS);
}
}
重试任务
FailbackRegistry.addFailedRegistered() 方法中创建的 FailedRegisteredTask 任务以及其他的重试任务,都继承了 AbstractRetryTask 抽象类,如下图所示:
在 AbstractRetryTask 中维护了当前任务关联的 URL、当前重试的次数等信息在其 run() 方法中,会根据重试 URL 中指定的重试次数retry.times 参数,默认值为 3、任务是否被取消以及时间轮的状态决定此次任务的 doRetry() 方法是否正常执行。
public void run(Timeout timeout) throws Exception {
if (timeout.isCancelled() || timeout.timer().isStop() || isCancel()) { // 检测定时任务状态和时间轮状态
return;
}
if (times > retryTimes) { // 检查重试次数
logger.warn("...");
return;
}
try {
doRetry(url, registry, timeout); // 执行重试
} catch (Throwable t) {
reput(timeout, retryPeriod); // 重新添加定时任务,等待重试
}
}
如果任务的 doRetry() 方法执行出现异常AbstractRetryTask 会通过 reput() 方法将当前任务重新放入时间轮中,并递增当前任务的执行次数。
protected void reput(Timeout timeout, long tick) {
if (timeout == null) { // 边界检查
throw new IllegalArgumentException();
}
Timer timer = timeout.timer(); // 检查定时任务
if (timer.isStop() || timeout.isCancelled() || isCancel()) {
return;
}
times++; // 递增times
// 添加定时任务
timer.newTimeout(timeout.task(), tick, TimeUnit.MILLISECONDS);
}
AbstractRetryTask 将 doRetry() 方法作为抽象方法,留给子类实现具体的重试逻辑,这也是模板方法的使用。
在子类 FailedRegisteredTask 的 doRetry() 方法实现中,会再次执行关联 Registry 的 doRegister() 方法,完成与服务发现组件交互。如果注册成功,则会调用 removeFailedRegisteredTask() 方法将当前关联的 URL 以及当前重试任务从 failedRegistered 集合中删除。如果注册失败,则会抛出异常,执行上文介绍的 reput ()方法重试。
protected void doRetry(URL url, FailbackRegistry registry, Timeout timeout) {
registry.doRegister(url); // 重新注册
registry.removeFailedRegisteredTask(url); // 删除重试任务
}
public void removeFailedRegisteredTask(URL url) {
failedRegistered.remove(url);
}
另外,在 register() 方法入口处,会主动调用 removeFailedRegistered() 方法和 removeFailedUnregistered() 方法来清理指定 URL 关联的定时任务:
public void register(URL url) {
super.register(url);
removeFailedRegistered(url); // 清理FailedRegisteredTask定时任务
removeFailedUnregistered(url); // 清理FailedUnregisteredTask定时任务
try {
doRegister(url);
} catch (Exception e) {
addFailedRegistered(url);
}
}
其他核心方法
unregister() 方法以及 unsubscribe() 方法的实现方式与 register() 方法类似,只是调用的 do*() 抽象方法、依赖的 AbstractRetryTask 有所不同而已,这里就不再展开细讲。
你还记得上一课时我们介绍的 AbstractRegistry 通过本地文件缓存实现的容错机制吗FailbackRegistry.subscribe() 方法在处理异常的时候,会先获取缓存的订阅数据并调用 notify() 方法,如果没有缓存相应的订阅数据,才会检查 check 参数决定是否抛出异常。
通过上一课时对 AbstractRegistry.notify() 方法的介绍,我们知道其核心逻辑之一就是回调 NotifyListener。下面我们就来看一下 FailbackRegistry 对 notify() 方法的覆盖:
protected void notify(URL url, NotifyListener listener,
List<URL> urls) {
... // 检查url和listener不为空(略)
try {
// FailbackRegistry.doNotify()方法实际上就是调用父类
// AbstractRegistry.notify()方法,没有其他逻辑
doNotify(url, listener, urls);
} catch (Exception t) {
// doNotify()方法出现异常,则会添加一个定时任务
addFailedNotified(url, listener, urls);
}
}
addFailedNotified() 方法会创建相应的 FailedNotifiedTask 任务,添加到 failedNotified 集合中,同时也会添加到时间轮中等待执行。如果已存在相应的 FailedNotifiedTask 重试任务,则会更新任务需要处理的 URL 集合。
在 FailedNotifiedTask 中维护了一个 URL 集合,用来记录当前任务一次运行需要通知的 URL每执行完一次任务就会清空该集合具体实现如下
protected void doRetry(URL url, FailbackRegistry registry,
Timeout timeout) {
// 如果urls集合为空则会通知所有Listener该任务也就啥都不做了
if (CollectionUtils.isNotEmpty(urls)) {
listener.notify(urls);
urls.clear();
}
reput(timeout, retryPeriod); // 将任务重新添加到时间轮中等待执行
}
从上面的代码可以看出FailedNotifiedTask 重试任务一旦被添加,就会一直运行下去,但真的是这样吗?在 FailbackRegistry 的 subscribe()、unsubscribe() 方法中,可以看到 removeFailedNotified() 方法的调用,这里就是清理 FailedNotifiedTask 任务的地方。我们以 FailbackRegistry.subscribe() 方法为例进行介绍:
public void subscribe(URL url, NotifyListener listener) {
super.subscribe(url, listener);
removeFailedSubscribed(url, listener); // 关注这个方法
try {
doSubscribe(url, listener);
} catch (Exception e) {
addFailedSubscribed(url, listener);
}
}
// removeFailedSubscribed()方法中会清理FailedSubscribedTask、FailedUnsubscribedTask、FailedNotifiedTask三类定时任务
private void removeFailedSubscribed(URL url, NotifyListener listener) {
Holder h = new Holder(url, listener); // 清理FailedSubscribedTask
FailedSubscribedTask f = failedSubscribed.remove(h);
if (f != null) {
f.cancel();
}
removeFailedUnsubscribed(url, listener);// 清理FailedUnsubscribedTask
removeFailedNotified(url, listener); // 清理FailedNotifiedTask
}
介绍完 FailbackRegistry 中最核心的注册/订阅实现之后,我们再来关注其实现的恢复功能,也就是 recover() 方法。该方法会直接通过 FailedRegisteredTask 任务处理 registered 集合中的全部 URL通过 FailedSubscribedTask 任务处理 subscribed 集合中的 URL 以及关联的 NotifyListener。
FailbackRegistry 在生命周期结束时,会调用自身的 destroy() 方法,其中除了调用父类的 destroy() 方法之外,还会调用时间轮(即 retryTimer 字段)的 stop() 方法,释放时间轮相关的资源。
总结
本课时重点介绍了 AbstractRegistry 的实现类——FailbackRegistry 的核心实现,它主要是在 AbstractRegistry 的基础上,提供了重试机制。具体方法就是通过之前课时介绍的时间轮,在 register()/ unregister()、subscribe()/ unsubscribe() 等核心方法失败时,添加重试定时任务,实现重试机制,同时也添加了相应的定时任务清理逻辑。

View File

@@ -0,0 +1,447 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 ZooKeeper 注册中心实现,官方推荐注册中心实践
Dubbo 支持 ZooKeeper 作为注册中心服务,这也是 Dubbo 推荐使用的注册中心。为了让你能更好地理解 ZooKeeper 在 Dubbo 中的应用,接下来我们就先简单回顾下 ZooKeeper。
Dubbo 本身是一个分布式的 RPC 开源框架,各个依赖于 Dubbo 的服务节点都是单独部署的,为了让 Provider 和 Consumer 能够实时获取彼此的信息就得依赖于一个一致性的服务发现组件实现注册和订阅。Dubbo 可以接入多种服务发现组件例如ZooKeeper、etcd、Consul、Eureka 等。其中Dubbo 特别推荐使用 ZooKeeper。
ZooKeeper 是为分布式应用所设计的高可用且一致性的开源协调服务。它是一个树型的目录服务,支持变更推送,非常适合应用在生产环境中。
下面是 Dubbo 官方文档中的一张图,展示了 Dubbo 在 Zookeeper 中的节点层级结构:
Zookeeper 存储的 Dubbo 数据
图中的“dubbo”节点是 Dubbo 在 Zookeeper 中的根节点“dubbo”是这个根节点的默认名称当然我们也可以通过配置进行修改。
图中 Service 这一层的节点名称是服务接口的全名,例如 demo 示例中该节点的名称为“org.apache.dubbo.demo.DemoService”。
图中 Type 这一层的节点是 URL 的分类一共有四种分类分别是providers服务提供者列表、consumers服务消费者列表、routes路由规则列表和 configurations配置规则列表
根据不同的 Type 节点,图中 URL 这一层中的节点包括Provider URL 、Consumer URL 、Routes URL 和 Configurations URL。
ZookeeperRegistryFactory
在前面第 13 课时介绍 Dubbo 注册中心核心概念的时候,我们讲解了 RegistryFactory 这个工厂接口以及其子类 AbstractRegistryFactoryAbstractRegistryFactory 仅仅是提供了缓存 Registry 对象的功能,并未真正实现 Registry 的创建,具体的创建逻辑是由子类完成的。在 dubbo-registry-zookeeper 模块中的 SPI 配置文件目录位置如下图所示指定了RegistryFactory 的实现类—— ZookeeperRegistryFactory。
RegistryFactory 的 SPI 配置文件位置
ZookeeperRegistryFactory 实现了 AbstractRegistryFactory其中的 createRegistry() 方法会创建 ZookeeperRegistry 实例,后续将由该 ZookeeperRegistry 实例完成与 Zookeeper 的交互。
另外ZookeeperRegistryFactory 中还提供了一个 setZookeeperTransporter() 方法,你可以回顾一下之前我们介绍的 Dubbo SPI 机制,会通过 SPI 或 Spring Ioc 的方式完成自动装载。
ZookeeperTransporter
dubbo-remoting-zookeeper 模块是 dubbo-remoting 模块的子模块,但它并不依赖 dubbo-remoting 中的其他模块,是相对独立的,所以这里我们可以直接介绍该模块。
简单来说dubbo-remoting-zookeeper 模块是在 Apache Curator 的基础上封装了一套 Zookeeper 客户端,将与 Zookeeper 的交互融合到 Dubbo 的体系之中。
dubbo-remoting-zookeeper 模块中有两个核心接口ZookeeperTransporter 接口和 ZookeeperClient 接口。
ZookeeperTransporter 只负责一件事情,那就是创建 ZookeeperClient 对象。
@SPI("curator")
public interface ZookeeperTransporter {
@Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY})
ZookeeperClient connect(URL url);
}
我们从代码中可以看到ZookeeperTransporter 接口被 @SPI 注解修饰,成为一个扩展点,默认选择扩展名 “curator” 的实现,其中的 connect() 方法用于创建 ZookeeperClient 实例(该方法被 @Adaptive 注解修饰,我们可以通过 URL 参数中的 client 或 transporter 参数覆盖 @SPI 注解指定的默认扩展名)。
按照前面对 Registry 分析的思路作为一个抽象实现AbstractZookeeperTransporter 肯定是实现了创建 ZookeeperClient 之外的其他一些增强功能,然后由子类继承。不然的话,直接由 CuratorZookeeperTransporter 实现 ZookeeperTransporter 接口创建 ZookeeperClient 实例并返回即可,没必要在继承关系中再增加一层抽象类。
public class CuratorZookeeperTransporter extends
AbstractZookeeperTransporter {
// 创建ZookeeperClient实例
public ZookeeperClient createZookeeperClient(URL url) {
return new CuratorZookeeperClient(url);
}
}
AbstractZookeeperTransporter 的核心功能有如下:
缓存 ZookeeperClient 实例;
在某个 Zookeeper 节点无法连接时,切换到备用 Zookeeper 地址。
在配置 Zookeeper 地址的时候,我们可以配置多个 Zookeeper 节点的地址,这样的话,当一个 Zookeeper 节点宕机之后Dubbo 就可以主动切换到其他 Zookeeper 节点。例如,我们提供了如下的 URL 配置:
zookeeper://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService?backup=127.0.0.1:8989,127.0.0.1:9999
AbstractZookeeperTransporter 的 connect() 方法首先会得到上述 URL 中配置的 127.0.0.1:2181、127.0.0.1:8989 和 127.0.0.1:9999 这三个 Zookeeper 节点地址,然后从 ZookeeperClientMap 缓存(这是一个 MapKey 为 Zookeeper 节点地址Value 是相应的 ZookeeperClient 实例)中查找一个可用 ZookeeperClient 实例。如果查找成功,则复用 ZookeeperClient 实例;如果查找失败,则创建一个新的 ZookeeperClient 实例返回并更新 ZookeeperClientMap 缓存。
ZookeeperClient 实例连接到 Zookeeper 集群之后,就可以了解整个 Zookeeper 集群的拓扑,后续再出现 Zookeeper 节点宕机的情况,就是由 Zookeeper 集群本身以及 Apache Curator 共同完成故障转移。
ZookeeperClient
从名字就可以看出ZookeeperClient 接口是 Dubbo 封装的 Zookeeper 客户端,该接口定义了大量的方法,都是用来与 Zookeeper 进行交互的。
create() 方法:创建 ZNode 节点,还提供了创建临时 ZNode 节点的重载方法。
getChildren() 方法:获取指定节点的子节点集合。
getContent() 方法:获取某个节点存储的内容。
delete() 方法:删除节点。
add*Listener() / remove*Listener() 方法:添加/删除监听器。
close() 方法:关闭当前 ZookeeperClient 实例。
AbstractZookeeperClient 作为 ZookeeperClient 接口的抽象实现,主要提供了如下几项能力:
缓存当前 ZookeeperClient 实例创建的持久 ZNode 节点;
管理当前 ZookeeperClient 实例添加的各类监听器;
管理当前 ZookeeperClient 的运行状态。
我们来看 AbstractZookeeperClient 的核心字段,首先是 persistentExistNodePathConcurrentHashSet<String>类型)字段,它缓存了当前 ZookeeperClient 创建的持久 ZNode 节点路径,在创建 ZNode 节点之前,会先查这个缓存,而不是与 Zookeeper 交互来判断持久 ZNode 节点是否存在,这就减少了一次与 Zookeeper 的交互。
dubbo-remoting-zookeeper 对外提供了 StateListener、DataListener 和 ChildListener 三种类型的监听器。
StateListener主要负责监听 Dubbo 与 Zookeeper 集群的连接状态,包括 SESSION_LOST、CONNECTED、RECONNECTED、SUSPENDED 和 NEW_SESSION_CREATED。
DataListener主要监听某个节点存储的数据变化。
ChildListener主要监听某个 ZNode 节点下的子节点变化。
在 AbstractZookeeperClient 中维护了 stateListeners、listeners 以及 childListeners 三个集合,分别管理上述三种类型的监听器。虽然监听内容不同,但是它们的管理方式是类似的,所以这里我们只分析 listeners 集合的操作:
public void addDataListener(String path,
DataListener listener, Executor executor) {
// 获取指定path上的DataListener集合
ConcurrentMap<DataListener, TargetDataListener> dataListenerMap =
listeners.computeIfAbsent(path, k -> new ConcurrentHashMap<>());
// 查询该DataListener关联的TargetDataListener
TargetDataListener targetListener =
dataListenerMap.computeIfAbsent(listener,
k -> createTargetDataListener(path, k));
// 通过TargetDataListener在指定的path上添加监听
addTargetDataListener(path, targetListener, executor);
}
这里的 createTargetDataListener() 方法和 addTargetDataListener() 方法都是抽象方法,由 AbstractZookeeperClient 的子类实现TargetDataListener 是 AbstractZookeeperClient 中标记的一个泛型。
为什么 AbstractZookeeperClient 要使用泛型定义?这是因为不同的 ZookeeperClient 实现可能依赖不同的 Zookeeper 客户端组件,不同 Zookeeper 客户端组件的监听器实现也有所不同,而整个 dubbo-remoting-zookeeper 模块对外暴露的监听器是统一的,就是上面介绍的那三种。因此,这时就需要一层转换进行解耦,这层解耦就是通过 TargetDataListener 完成的。
虽然在 Dubbo 2.7.7 版本中只支持 Curator但是在 Dubbo 2.6.5 版本的源码中可以看到ZookeeperClient 还有使用 ZkClient 的实现。
在最新的 Dubbo 版本中CuratorZookeeperClient 是 AbstractZookeeperClient 的唯一实现类,在其构造方法中会初始化 Curator 客户端并阻塞等待连接成功:
public CuratorZookeeperClient(URL url) {
super(url);
int timeout = url.getParameter("timeout", 5000);
int sessionExpireMs = url.getParameter("zk.session.expire",
60000);
CuratorFrameworkFactory.Builder builder =
CuratorFrameworkFactory.builder()
.connectString(url.getBackupAddress())//zk地址(包括备用地址)
.retryPolicy(new RetryNTimes(1, 1000)) // 重试配置
.connectionTimeoutMs(timeout) // 连接超时时长
.sessionTimeoutMs(sessionExpireMs); // session过期时间
... // 省略处理身份验证的逻辑
client = builder.build();
// 添加连接状态的监听
client.getConnectionStateListenable().addListener(
new CuratorConnectionStateListener(url));
client.start();
boolean connected = client.blockUntilConnected(timeout,
TimeUnit.MILLISECONDS);
... // 检测connected这个返回值连接失败抛出异常
}
CuratorZookeeperClient 与 Zookeeper 交互的全部操作,都是围绕着这个 Apache Curator 客户端展开的, Apache Curator 的具体使用方式在前面的第 6 和 7 课时已经介绍过了,这里就不再赘述。
内部类 CuratorWatcherImpl 就是 CuratorZookeeperClient 实现 AbstractZookeeperClient 时指定的泛型类,它实现了 TreeCacheListener 接口,可以添加到 TreeCache 上监听自身节点以及子节点的变化。在 childEvent() 方法的实现中我们可以看到,当 TreeCache 关注的树型结构发生变化时,会将触发事件的路径、节点内容以及事件类型传递给关联的 DataListener 实例进行回调:
public void childEvent(CuratorFramework client, TreeCacheEvent event) throws Exception {
if (dataListener != null) {
TreeCacheEvent.Type type = event.getType();
EventType eventType = null;
String content = null;
String path = null;
switch (type) {
case NODE_ADDED:
eventType = EventType.NodeCreated;
path = event.getData().getPath();
content = event.getData().getData() == null ? "" : new String(event.getData().getData(), CHARSET);
break;
case NODE_UPDATED:
...
case NODE_REMOVED:
...
// 省略其他时间的处理
}
// 回调DataListener传递触发事件的path、节点内容以及事件类型
dataListener.dataChanged(path, content, eventType);
}
}
在 CuratorZookeeperClient 的 addTargetDataListener() 方法实现中,我们可以看到 TreeCache 的创建、启动逻辑以及添加 CuratorWatcherImpl 监听的逻辑:
protected void addTargetDataListener(String path, CuratorZookeeperClient.CuratorWatcherImpl treeCacheListener, Executor executor) {
// 创建TreeCache
TreeCache treeCache = TreeCache.newBuilder(client, path).setCacheData(false).build();
treeCacheMap.putIfAbsent(path, treeCache); // 缓存TreeCache
if (executor == null) { // 添加监听
treeCache.getListenable().addListener(treeCacheListener);
} else {
treeCache.getListenable().addListener(treeCacheListener, executor);
}
treeCache.start(); // 启动
}
如果需要在回调中获取全部 Child 节点,那么 dubbo-remoting-zookeeper 调用方需要使用 ChildListener在下面即将介绍的 ZookeeperRegistry 中可以看到 ChildListener 相关使用方式。CuratorWatcherImpl 也是 ChildListener 与 CuratorWatcher 的桥梁,具体实现方式与上述逻辑类似,这里不再展开。
到此为止dubbo-remoting-zookeeper 模块的核心实现就介绍完了,该模块作为 Dubbo 与 Zookeeper 交互的基础,不仅支撑了基于 Zookeeper 的注册中心的实现,还支撑了基于 Zookeeper 的服务发现的实现。这里我们重点关注基于 Zookeeper 的注册中心实现。
ZookeeperRegistry
下面我们回到 dubbo-registry-zookeeper 模块,继续分析基于 Zookeeper 的注册中心实现。
在 ZookeeperRegistry 的构造方法中,会通过 ZookeeperTransporter 创建 ZookeeperClient 实例并连接到 Zookeeper 集群同时还会添加一个连接状态的监听器。在该监听器中主要关注RECONNECTED 状态和 NEW_SESSION_CREATED 状态,在当前 Dubbo 节点与 Zookeeper 的连接恢复或是 Session 恢复的时候,会重新进行注册/订阅,防止数据丢失。这段代码比较简单,我们就不展开分析了。
doRegister() 方法和 doUnregister() 方法的实现都是通过 ZookeeperClient 找到合适的路径,然后创建(或删除)相应的 ZNode 节点。这里唯一需要注意的是doRegister() 方法注册 Provider URL 的时候,会根据 dynamic 参数决定创建临时 ZNode 节点还是持久 ZNode 节点(默认创建临时 ZNode 节点),这样当 Provider 端与 Zookeeper 会话关闭时,可以快速将变更推送到 Consumer 端。
这里注意一下 toUrlPath() 这个方法得到的路径,是由下图中展示的方法拼装而成的,其中每个方法对应本课时开始展示的 Zookeeper 节点层级图中的一层。
doSubscribe() 方法的核心是通过 ZookeeperClient 在指定的 path 上添加 ChildListener 监听器,当订阅的节点发现变化的时候,会通过 ChildListener 监听器触发 notify() 方法,在 notify() 方法中会触发传入的 NotifyListener 监听器。
从 doSubscribe() 方法的代码结构可看出doSubscribe() 方法的逻辑分为了两个大的分支。
一个分支是处理:订阅 URL 中明确指定了 Service 层接口的订阅请求。该分支会从 URL 拿到 Consumer 关注的 category 节点集合,然后在每个 category 节点上添加 ChildListener 监听器。下面是 Demo 示例中 Consumer 订阅的三个 path图中展示了构造 path 各个部分的相关方法:
下面是这个分支的核心源码分析:
List<URL> urls = new ArrayList<>();
for (String path : toCategoriesPath(url)) { // 要订阅的所有path
// 订阅URL对应的Listener集合
ConcurrentMap<NotifyListener, ChildListener> listeners =
zkListeners.computeIfAbsent(url,
k -> new ConcurrentHashMap<>());
// 一个NotifyListener关联一个ChildListener这个ChildListener会回调
// ZookeeperRegistry.notify()方法其中会回调当前NotifyListener
ChildListener zkListener = listeners.computeIfAbsent(listener,
k -> (parentPath, currentChilds) ->
ZookeeperRegistry.this.notify(url, k,
toUrlsWithEmpty(url, parentPath, currentChilds)));
// 尝试创建持久节点主要是为了确保当前path在Zookeeper上存在
zkClient.create(path, false);
// 这一个ChildListener会添加到多个path上
List<String> children = zkClient.addChildListener(path,
zkListener);
if (children != null) {
// 如果没有Provider注册toUrlsWithEmpty()方法会返回empty协议的URL
urls.addAll(toUrlsWithEmpty(url, path, children));
}
}
// 初次订阅的时候会主动调用一次notify()方法通知NotifyListener处理当前已有的
// URL等注册数据
notify(url, listener, urls);
doSubscribe() 方法的另一个分支是处理:监听所有 Service 层节点的订阅请求例如Monitor 就会发出这种订阅请求,因为它需要监控所有 Service 节点的变化。这个分支的处理逻辑是在根节点上添加一个 ChildListener 监听器,当有 Service 层的节点出现的时候,会触发这个 ChildListener其中会重新触发 doSubscribe() 方法执行上一个分支的逻辑(即前面分析的针对确定的 Service 层接口订阅分支)。
下面是针对这个分支核心代码的分析:
String root = toRootPath(); // 获取根节点
// 获取NotifyListener对应的ChildListener
ConcurrentMap<NotifyListener, ChildListener> listeners =
zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
ChildListener zkListener = listeners.computeIfAbsent(listener, k ->
(parentPath, currentChilds) -> {
for (String child : currentChilds) {
child = URL.decode(child);
if (!anyServices.contains(child)) {
anyServices.add(child); // 记录该节点已经订阅过
// 该ChildListener要做的就是触发对具体Service节点的订阅
subscribe(url.setPath(child).addParameters("interface",
child, "check", String.valueOf(false)), k);
}
}
});
zkClient.create(root, false); // 保证根节点存在
// 第一次订阅的时候要处理当前已有的Service层节点
List<String> services = zkClient.addChildListener(root, zkListener);
if (CollectionUtils.isNotEmpty(services)) {
for (String service : services) {
service = URL.decode(service);
anyServices.add(service);
subscribe(url.setPath(service).addParameters(INTERFACE_KEY,
service, "check", String.valueOf(false)), listener);
}
}
ZookeeperRegistry 提供的 doUnsubscribe() 方法实现会将 URL 和 NotifyListener 对应的 ChildListener 从相关的 path 上删除,从而达到不再监听该 path 的效果。
总结
本课时我们重点介绍了 Dubbo 接入 Zookeeper 作为注册中心的核心实现。
首先我们快速回顾了 Zookeeper 的基础内容,以及作为 Dubbo 注册中心时 Zookeeper 存储的具体内容,之后介绍了针对 Zookeeper 的 RegistryFactory 实现—— ZookeeperRegistryFactory。
接下来我们讲解了 Dubbo 接入 Zookeeper 时使用的组件实现,重点分析了 ZookeeperTransporter 和 ZookeeperClient 实现,它们底层依赖 Apache Curator 与 Zookeeper 完成交互。
最后,我们还说明了 ZookeeperRegistry 是如何通过 ZookeeperClient 接入 Zookeeper实现 Registry 的相关功能。
关于本课时,你若还有什么疑问或想法,欢迎你留言跟我分享。

View File

@@ -0,0 +1,193 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 Dubbo Serialize 层:多种序列化算法,总有一款适合你
通过前面课时的介绍,我们知道一个 RPC 框架需要通过网络通信实现跨 JVM 的调用。既然需要网络通信那就必然会使用到序列化与反序列化的相关技术Dubbo 也不例外。下面我们从 Java 序列化的基础内容开始,介绍一下常见的序列化算法,最后再分析一下 Dubbo 是如何支持这些序列化算法的。
Java 序列化基础
Java 中的序列化操作一般有如下四个步骤。
第一步,被序列化的对象需要实现 Serializable 接口,示例代码如下:
public class Student implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private transient StudentUtil studentUtil;
}
在这个示例中我们可以看到transient 关键字,它的作用就是:在对象序列化过程中忽略被其修饰的成员属性变量。一般情况下,它可以用来修饰一些非数据型的字段以及一些可以通过其他字段计算得到的值。通过合理地使用 transient 关键字,可以降低序列化后的数据量,提高网络传输效率。
第二步,生成一个序列号 serialVersionUID这个序列号不是必需的但还是建议你生成。serialVersionUID 的字面含义是序列化的版本号,只有序列化和反序列化的 serialVersionUID 都相同的情况下,才能够成功地反序列化。如果类中没有定义 serialVersionUID那么 JDK 也会随机生成一个 serialVersionUID。如果在某些场景中你希望不同版本的类序列化和反序列化相互兼容那就需要定义相同的 serialVersionUID。
第三步,根据需求决定是否要重写 writeObject()/readObject() 方法,实现自定义序列化。
最后一步,调用 java.io.ObjectOutputStream 的 writeObject()/readObject() 进行序列化与反序列化。
既然 Java 本身的序列化操作如此简单,那为什么市面上还依旧出现了各种各样的序列化框架呢?因为这些第三方序列化框架的速度更快、序列化的效率更高,而且支持跨语言操作。
常见序列化算法
为了帮助你快速了解 Dubbo 支持的序列化算法,我们这里就对其中常见的序列化算法进行简单介绍。
Apache Avro 是一种与编程语言无关的序列化格式。Avro 依赖于用户自定义的 Schema在进行序列化数据的时候无须多余的开销就可以快速完成序列化并且生成的序列化数据也较小。当进行反序列化的时候需要获取到写入数据时用到的 Schema。在 Kafka、Hadoop 以及 Dubbo 中都可以使用 Avro 作为序列化方案。
FastJson 是阿里开源的 JSON 解析库,可以解析 JSON 格式的字符串。它支持将 Java 对象序列化为 JSON 字符串,反过来从 JSON 字符串也可以反序列化为 Java 对象。FastJson 是 Java 程序员常用到的类库之一正如其名“快”是其主要卖点。从官方的测试结果来看FastJson 确实是最快的,比 Jackson 快 20% 左右,但是近几年 FastJson 的安全漏洞比较多,所以你在选择版本的时候,还是需要谨慎一些。
Fst全称是 fast-serialization是一款高性能 Java 对象序列化工具包100% 兼容 JDK 原生环境序列化速度大概是JDK 原生序列化的 4~10 倍,序列化后的数据大小是 JDK 原生序列化大小的 13 左右。目前Fst 已经更新到 3.x 版本,支持 JDK 14。
Kryo 是一个高效的 Java 序列化/反序列化库,目前 Twitter、Yahoo、Apache 等都在使用该序列化技术,特别是 Spark、Hive 等大数据领域用得较多。Kryo 提供了一套快速、高效和易用的序列化 API。无论是数据库存储还是网络传输都可以使用 Kryo 完成 Java 对象的序列化。Kryo 还可以执行自动深拷贝和浅拷贝支持环形引用。Kryo 的特点是 API 代码简单序列化速度快并且序列化之后得到的数据比较小。另外Kryo 还提供了 NIO 的网络通信库——KryoNet你若感兴趣的话可以自行查询和了解一下。
Hessian2 序列化是一种支持动态类型、跨语言的序列化协议Java 对象序列化的二进制流可以被其他语言使用。Hessian2 序列化之后的数据可以进行自描述,不会像 Avro 那样依赖外部的 Schema 描述文件或者接口定义。Hessian2 可以用一个字节表示常用的基础类型,这极大缩短了序列化之后的二进制流。需要注意的是,在 Dubbo 中使用的 Hessian2 序列化并不是原生的 Hessian2 序列化,而是阿里修改过的 Hessian Lite它是 Dubbo 默认使用的序列化方式。其序列化之后的二进制流大小大约是 Java 序列化的 50%,序列化耗时大约是 Java 序列化的 30%,反序列化耗时大约是 Java 序列化的 20%。
ProtobufGoogle Protocol Buffers是 Google 公司开发的一套灵活、高效、自动化的、用于对结构化数据进行序列化的协议。但相比于常用的 JSON 格式Protobuf 有更高的转化效率,时间效率和空间效率都是 JSON 的 5 倍左右。Protobuf 可用于通信协议、数据存储等领域,它本身是语言无关、平台无关、可扩展的序列化结构数据格式。目前 Protobuf提供了 C++、Java、Python、Go 等多种语言的 APIgRPC 底层就是使用 Protobuf 实现的序列化。
dubbo-serialization
Dubbo 为了支持多种序列化算法,单独抽象了一层 Serialize 层,在整个 Dubbo 架构中处于最底层,对应的模块是 dubbo-serialization 模块。 dubbo-serialization 模块的结构如下图所示:
dubbo-serialization-api 模块中定义了 Dubbo 序列化层的核心接口,其中最核心的是 Serialization 这个接口,它是一个扩展接口,被 @SPI 接口修饰,默认扩展实现是 Hessian2Serialization。Serialization 接口的具体实现如下:
@SPI("hessian2") // 被@SPI注解修饰默认是使用hessian2序列化算法
public interface Serialization {
// 每一种序列化算法都对应一个ContentType该方法用于获取ContentType
String getContentType();
// 获取ContentType的ID值是一个byte类型的值唯一确定一个算法
byte getContentTypeId();
// 创建一个ObjectOutput对象ObjectOutput负责实现序列化的功能即将Java
// 对象转化为字节序列
@Adaptive
ObjectOutput serialize(URL url, OutputStream output) throws IOException;
// 创建一个ObjectInput对象ObjectInput负责实现反序列化的功能即将
// 字节序列转换成Java对象
@Adaptive
ObjectInput deserialize(URL url, InputStream input) throws IOException;
}
Dubbo 提供了多个 Serialization 接口实现,用于接入各种各样的序列化算法,如下图所示:
这里我们以默认的 hessian2 序列化方式为例,介绍 Serialization 接口的实现以及其他相关实现。 Hessian2Serialization 实现如下所示:
public class Hessian2Serialization implements Serialization {
public byte getContentTypeId() {
return HESSIAN2_SERIALIZATION_ID; // hessian2的ContentType ID
}
public String getContentType() { // hessian2的ContentType
return "x-application/hessian2";
}
public ObjectOutput serialize(URL url, OutputStream out) throws IOException { // 创建ObjectOutput对象
return new Hessian2ObjectOutput(out);
}
public ObjectInput deserialize(URL url, InputStream is) throws IOException { // 创建ObjectInput对象
return new Hessian2ObjectInput(is);
}
}
Hessian2Serialization 中的 serialize() 方法创建的 ObjectOutput 接口实现为 Hessian2ObjectOutput继承关系如下图所示
在 DataOutput 接口中定义了序列化 Java 中各种数据类型的相应方法,如下图所示,其中有序列化 boolean、short、int、long 等基础类型的方法,也有序列化 String、byte[] 的方法。
ObjectOutput 接口继承了 DataOutput 接口,并在其基础之上,添加了序列化对象的功能,具体定义如下图所示,其中的 writeThrowable()、writeEvent() 和 writeAttachments() 方法都是调用 writeObject() 方法实现的。
Hessian2ObjectOutput 中会封装一个 Hessian2Output 对象,需要注意,这个对象是 ThreadLocal 的,与线程绑定。在 DataOutput 接口以及 ObjectOutput 接口中,序列化各类型数据的方法都会委托给 Hessian2Output 对象的相应方法完成,实现如下:
public class Hessian2ObjectOutput implements ObjectOutput {
private static ThreadLocal<Hessian2Output> OUTPUT_TL = ThreadLocal.withInitial(() -> {
// 初始化Hessian2Output对象
Hessian2Output h2o = new Hessian2Output(null); h2o.setSerializerFactory(Hessian2SerializerFactory.SERIALIZER_FACTORY);
h2o.setCloseStreamOnClose(true);
return h2o;
});
private final Hessian2Output mH2o;
public Hessian2ObjectOutput(OutputStream os) {
mH2o = OUTPUT_TL.get(); // 触发OUTPUT_TL的初始化
mH2o.init(os);
}
public void writeObject(Object obj) throws IOException {
mH2o.writeObject(obj);
}
... // 省略序列化其他类型数据的方法
}
Hessian2Serialization 中的 deserialize() 方法创建的 ObjectInput 接口实现为 Hessian2ObjectInput继承关系如下所示
Hessian2ObjectInput 具体的实现与 Hessian2ObjectOutput 类似:在 DataInput 接口中实现了反序列化各种类型的方法,在 ObjectInput 接口中提供了反序列化 Java 对象的功能,在 Hessian2ObjectInput 中会将所有反序列化的实现委托为 Hessian2Input。
了解了 Dubbo Serialize 层的核心接口以及 Hessian2 序列化算法的接入方式之后,你就可以亲自动手,去阅读其他序列化算法对应模块的代码。
总结
在本课时,我们首先介绍了 Java 序列化的基础知识帮助你快速了解序列化和反序列化的基本概念。然后介绍了常见的序列化算法例如Arvo、Fastjson、Fst、Kryo、Hessian、Protobuf 等。最后,深入分析了 dubbo-serialization 模块对各个序列化算法的接入方式,其中重点说明了 Hessian2 序列化方式。
关于本课时,你若还有什么疑问或想法,欢迎你留言跟我分享。

View File

@@ -0,0 +1,234 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?
在本专栏的第二部分,我们深入介绍了 Dubbo 注册中心的相关实现,下面我们开始介绍 dubbo-remoting 模块,该模块提供了多种客户端和服务端通信的功能。在 Dubbo 的整体架构设计图中,我们可以看到最底层红色框选中的部分即为 Remoting 层,其中包括了 Exchange、Transport和Serialize 三个子层次。这里我们要介绍的 dubbo-remoting 模块主要对应 Exchange 和 Transport 两层。
Dubbo 整体架构设计图
Dubbo 并没有自己实现一套完整的网络库而是使用现有的、相对成熟的第三方网络库例如Netty、Mina 或是 Grizzly 等 NIO 框架。我们可以根据自己的实际场景和需求修改配置,选择底层使用的 NIO 框架。
下图展示了 dubbo-remoting 模块的结构,其中每个子模块对应一个第三方 NIO 框架例如dubbo-remoting-netty4 子模块使用 Netty4 实现 Dubbo 的远程通信dubbo-remoting-grizzly 子模块使用 Grizzly 实现 Dubbo 的远程通信。
其中的 dubbo-remoting-zookeeper我们在前面第 15 课时介绍基于 Zookeeper 的注册中心实现时已经讲解过了,它使用 Apache Curator 实现了与 Zookeeper 的交互。
dubbo-remoting-api 模块
需要注意的是Dubbo 的 dubbo-remoting-api 是其他 dubbo-remoting-* 模块的顶层抽象,其他 dubbo-remoting 子模块都是依赖第三方 NIO 库实现 dubbo-remoting-api 模块的,依赖关系如下图所示:
我们先来看一下 dubbo-remoting-api 中对整个 Remoting 层的抽象dubbo-remoting-api 模块的结构如下图所示:
一般情况下,我们会将功能类似或是相关联的类放到一个包中,所以我们需要先来了解 dubbo-remoting-api 模块中各个包的功能。
buffer 包定义了缓冲区相关的接口、抽象类以及实现类。缓冲区在NIO框架中是一个不可或缺的角色在各个 NIO 框架中都有自己的缓冲区实现。这里的 buffer 包在更高的层面,抽象了各个 NIO 框架的缓冲区,同时也提供了一些基础实现。
exchange 包:抽象了 Request 和 Response 两个概念,并为其添加很多特性。这是整个远程调用非常核心的部分。
transport 包:对网络传输层的抽象,但它只负责抽象单向消息的传输,即请求消息由 Client 端发出Server 端接收;响应消息由 Server 端发出Client端接收。有很多网络库可以实现网络传输的功能例如 Netty、Grizzly 等, transport 包是在这些网络库上层的一层抽象。
其他接口Endpoint、Channel、Transporter、Dispatcher 等顶层接口放到了org.apache.dubbo.remoting 这个包,这些接口是 Dubbo Remoting 的核心接口。
下面我们就来介绍 Dubbo 是如何抽象这些核心接口的。
传输层核心接口
在 Dubbo 中会抽象出一个“端点Endpoint”的概念我们可以通过一个 ip 和 port 唯一确定一个端点,两个端点之间会创建 TCP 连接可以双向传输数据。Dubbo 将 Endpoint 之间的 TCP 连接抽象为通道Channel将发起请求的 Endpoint 抽象为客户端Client将接收请求的 Endpoint 抽象为服务端Server。这些抽象出来的概念也是整个 dubbo-remoting-api 模块的基础,下面我们会逐个进行介绍。
Dubbo 中Endpoint 接口的定义如下:
如上图所示,这里的 get*() 方法是获得 Endpoint 本身的一些属性,其中包括获取 Endpoint 的本地地址、关联的 URL 信息以及底层 Channel 关联的 ChannelHandler。send() 方法负责数据发送,两个重载的区别在后面介绍 Endpoint 实现的时候我们再详细说明。最后两个 close() 方法的重载以及 startClose() 方法用于关闭底层 Channel isClosed() 方法用于检测底层 Channel 是否已关闭。
Channel 是对两个 Endpoint 连接的抽象,好比连接两个位置的传送带,两个 Endpoint 传输的消息就好比传送带上的货物,消息发送端会往 Channel 写入消息,而接收端会从 Channel 读取消息。这与第 10 课时介绍的 Netty 中的 Channel 基本一致。
下面是Channel 接口的定义,我们可以看出两点:一个是 Channel 接口继承了 Endpoint 接口,也具备开关状态以及发送数据的能力;另一个是可以在 Channel 上附加 KV 属性。
ChannelHandler 是注册在 Channel 上的消息处理器,在 Netty 中也有类似的抽象,相信你对此应该不会陌生。下图展示了 ChannelHandler 接口的定义,在 ChannelHandler 中可以处理 Channel 的连接建立以及连接断开事件,还可以处理读取到的数据、发送的数据以及捕获到的异常。从这些方法的命名可以看到,它们都是动词的过去式,说明相应事件已经发生过了。
需要注意的是ChannelHandler 接口被 @SPI 注解修饰,表示该接口是一个扩展点。
在前面课时介绍 Netty 的时候,我们提到过有一类特殊的 ChannelHandler 专门负责实现编解码功能从而实现字节数据与有意义的消息之间的转换或是消息之间的相互转换。在dubbo-remoting-api 中也有相似的抽象,如下所示:
@SPI
public interface Codec2 {
@Adaptive({Constants.CODEC_KEY})
void encode(Channel channel, ChannelBuffer buffer, Object message)
throws IOException;
@Adaptive({Constants.CODEC_KEY})
Object decode(Channel channel, ChannelBuffer buffer)
throws IOException;
enum DecodeResult {
NEED_MORE_INPUT, SKIP_SOME_INPUT
}
}
这里需要关注的是 Codec2 接口被 @SPI 接口修饰了,表示该接口是一个扩展接口,同时其 encode() 方法和 decode() 方法都被 @Adaptive 注解修饰,也就会生成适配器类,其中会根据 URL 中的 codec 值确定具体的扩展实现类。
DecodeResult 这个枚举是在处理 TCP 传输时粘包和拆包使用的,之前简易版本 RPC 也处理过这种问题,例如,当前能读取到的数据不足以构成一个消息时,就会使用 NEED_MORE_INPUT 这个枚举。
接下来看Client 和 RemotingServer 两个接口,分别抽象了客户端和服务端,两者都继承了 Channel、Resetable 等接口,也就是说两者都具备了读写数据能力。
Client 和 Server 本身都是 Endpoint只不过在语义上区分了请求和响应的职责两者都具备发送的能力所以都继承了 Endpoint 接口。Client 和 Server 的主要区别是 Client 只能关联一个 Channel而 Server 可以接收多个 Client 发起的 Channel 连接。所以在 RemotingServer 接口中定义了查询 Channel 的相关方法,如下图所示:
Dubbo 在 Client 和 Server 之上又封装了一层Transporter 接口,其具体定义如下:
@SPI("netty")
public interface Transporter {
@Adaptive({Constants.SERVER_KEY, Constants.TRANSPORTER_KEY})
RemotingServer bind(URL url, ChannelHandler handler)
throws RemotingException;
@Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY})
Client connect(URL url, ChannelHandler handler)
throws RemotingException;
}
我们看到 Transporter 接口上有 @SPI 注解它是一个扩展接口默认使用“netty”这个扩展名@Adaptive 注解的出现表示动态生成适配器类会先后根据“server”“transporter”的值确定 RemotingServer 的扩展实现类先后根据“client”“transporter”的值确定 Client 接口的扩展实现。
Transporter 接口的实现有哪些呢?如下图所示,针对每个支持的 NIO 库,都有一个 Transporter 接口实现,散落在各个 dubbo-remoting-* 实现模块中。
这些 Transporter 接口实现返回的 Client 和 RemotingServer 具体是什么呢?如下图所示,返回的是 NIO 库对应的 RemotingServer 实现和 Client 实现。
相信看到这里,你应该已经发现 Transporter 这一层抽象出来的接口,与 Netty 的核心接口是非常相似的。那为什么要单独抽象出 Transporter层而不是像简易版 RPC 框架那样,直接让上层使用 Netty 呢?
其实这个问题的答案也呼之欲出了Netty、Mina、Grizzly 这个 NIO 库对外接口和使用方式不一样,如果在上层直接依赖了 Netty 或是 Grizzly就依赖了具体的 NIO 库实现,而不是依赖一个有传输能力的抽象,后续要切换实现的话,就需要修改依赖和接入的相关代码,非常容易改出 Bug。这也不符合设计模式中的开放-封闭原则。
有了 Transporter 层之后,我们可以通过 Dubbo SPI 修改使用的具体 Transporter 扩展实现,从而切换到不同的 Client 和 RemotingServer 实现,达到底层 NIO 库切换的目的,而且无须修改任何代码。即使有更先进的 NIO 库出现,我们也只需要开发相应的 dubbo-remoting-* 实现模块提供 Transporter、Client、RemotingServer 等核心接口的实现,即可接入,完全符合开放-封闭原则。
在最后我们还要看一个类——Transporters它不是一个接口而是门面类其中封装了 Transporter 对象的创建(通过 Dubbo SPI以及 ChannelHandler 的处理,如下所示:
public class Transporters {
private Transporters() {
// 省略bind()和connect()方法的重载
public static RemotingServer bind(URL url,
ChannelHandler... handlers) throws RemotingException {
ChannelHandler handler;
if (handlers.length == 1) {
handler = handlers[0];
} else {
handler = new ChannelHandlerDispatcher(handlers);
}
return getTransporter().bind(url, handler);
}
public static Client connect(URL url, ChannelHandler... handlers)
throws RemotingException {
ChannelHandler handler;
if (handlers == null || handlers.length == 0) {
handler = new ChannelHandlerAdapter();
} else if (handlers.length == 1) {
handler = handlers[0];
} else { // ChannelHandlerDispatcher
handler = new ChannelHandlerDispatcher(handlers);
}
return getTransporter().connect(url, handler);
}
public static Transporter getTransporter() {
// 自动生成Transporter适配器并加载
return ExtensionLoader.getExtensionLoader(Transporter.class)
.getAdaptiveExtension();
}
}
在创建 Client 和 RemotingServer 的时候,可以指定多个 ChannelHandler 绑定到 Channel 来处理其中传输的数据。Transporters.connect() 方法和 bind() 方法中,会将多个 ChannelHandler 封装成一个 ChannelHandlerDispatcher 对象。
ChannelHandlerDispatcher 也是 ChannelHandler 接口的实现类之一,维护了一个 CopyOnWriteArraySet 集合,它所有的 ChannelHandler 接口实现都会调用其中每个 ChannelHandler 元素的相应方法。另外ChannelHandlerDispatcher 还提供了增删该 ChannelHandler 集合的相关方法。
到此为止Dubbo Transport 层的核心接口就介绍完了,这里简单总结一下:
Endpoint 接口抽象了“端点”的概念,这是所有抽象接口的基础。
上层使用方会通过 Transporters 门面类获取到 Transporter 的具体扩展实现,然后通过 Transporter 拿到相应的 Client 和 RemotingServer 实现就可以建立或接收Channel 与远端进行交互了。
无论是 Client 还是 RemotingServer都会使用 ChannelHandler 处理 Channel 中传输的数据,其中负责编解码的 ChannelHandler 被抽象出为 Codec2 接口。
整个架构如下图所示,与 Netty 的架构非常类似。
Transporter 层整体结构图
总结
本课时我们首先介绍了 dubbo-remoting 模块在 Dubbo 架构中的位置,以及 dubbo-remoting 模块的结构。接下来分析了 dubbo-remoting 模块中各个子模块之间的依赖关系,并重点介绍了 dubbo-remoting-api 子模块中各个包的核心功能。最后我们还深入分析了整个 Transport 层的核心接口,以及这些接口抽象出来的 Transporter 架构。
关于本课时,你若还有什么疑问或想法,欢迎你留言跟我分享。

View File

@@ -0,0 +1,282 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工
Buffer 是一种字节容器,在 Netty 等 NIO 框架中都有类似的设计例如Java NIO 中的ByteBuffer、Netty4 中的 ByteBuf。Dubbo 抽象出了 ChannelBuffer 接口对底层 NIO 框架中的 Buffer 设计进行统一,其子类如下图所示:
ChannelBuffer 继承关系图
下面我们就按照 ChannelBuffer 的继承结构,从顶层的 ChannelBuffer 接口开始,逐个向下介绍,直至最底层的各个实现类。
ChannelBuffer 接口
ChannelBuffer 接口的设计与 Netty4 中 ByteBuf 抽象类的设计基本一致,也有 readerIndex 和 writerIndex 指针的概念,如下所示,它们的核心方法也是如出一辙。
getBytes()、setBytes() 方法:从参数指定的位置读、写当前 ChannelBuffer不会修改 readerIndex 和 writerIndex 指针的位置。
readBytes() 、writeBytes() 方法:也是读、写当前 ChannelBuffer但是 readBytes() 方法会从 readerIndex 指针开始读取数据,并移动 readerIndex 指针writeBytes() 方法会从 writerIndex 指针位置开始写入数据,并移动 writerIndex 指针。
markReaderIndex()、markWriterIndex() 方法:记录当前 readerIndex 指针和 writerIndex 指针的位置,一般会和 resetReaderIndex()、resetWriterIndex() 方法配套使用。resetReaderIndex() 方法会将 readerIndex 指针重置到 markReaderIndex() 方法标记的位置resetwriterIndex() 方法同理。
capacity()、clear()、copy() 等辅助方法用来获取 ChannelBuffer 容量以及实现清理、拷贝数据的功能,这里不再赘述。
factory() 方法:该方法返回创建 ChannelBuffer 的工厂对象ChannelBufferFactory 中定义了多个 getBuffer() 方法重载来创建 ChannelBuffer如下图所示这些 ChannelBufferFactory的实现都是单例的。
ChannelBufferFactory 继承关系图
AbstractChannelBuffer 抽象类实现了 ChannelBuffer 接口的大部分方法,其核心是维护了以下四个索引。
readerIndex、writerIndexint 类型):通过 readBytes() 方法及其重载读取数据时,会后移 readerIndex 索引;通过 writeBytes() 方法及其重载写入数据的时候,会后移 writerIndex 索引。
markedReaderIndex、markedWriterIndexint 类型):实现记录 readerIndexwriterIndex以及回滚 readerIndexwriterIndex的功能前面我们已经介绍过markReaderIndex() 方法、resetReaderIndex() 方法以及 markWriterIndex() 方法、resetWriterIndex() 方法,你可以对比学习。
AbstractChannelBuffer 中 readBytes() 和 writeBytes() 方法的各个重载最终会通过 getBytes() 方法和 setBytes() 方法实现数据的读写,这些方法在 AbstractChannelBuffer 子类中实现。下面以读写一个 byte 数组为例,进行介绍:
public void readBytes(byte[] dst, int dstIndex, int length) {
// 检测可读字节数是否足够
checkReadableBytes(length);
// 将readerIndex之后的length个字节数读取到dst数组中dstIndex~
// dstIndex+length的位置
getBytes(readerIndex, dst, dstIndex, length);
// 将readerIndex后移length个字节
readerIndex += length;
}
public void writeBytes(byte[] src, int srcIndex, int length) {
// 将src数组中srcIndex~srcIndex+length的数据写入当前buffer中
// writerIndex~writerIndex+length的位置
setBytes(writerIndex, src, srcIndex, length);
// 将writeIndex后移length个字节
writerIndex += length;
}
Buffer 各实现类解析
了解了 ChannelBuffer 接口的核心方法以及 AbstractChannelBuffer 的公共实现之后,我们再来看 ChannelBuffer 的具体实现。
HeapChannelBuffer 是基于字节数组的 ChannelBuffer 实现,我们可以看到其中有一个 arraybyte[]数组)字段,它就是 HeapChannelBuffer 存储数据的地方。HeapChannelBuffer 的 setBytes() 以及 getBytes() 方法实现是调用 System.arraycopy() 方法完成数组操作的,具体实现如下:
public void setBytes(int index, byte[] src, int srcIndex, int length) {
System.arraycopy(src, srcIndex, array, index, length);
}
public void getBytes(int index, byte[] dst, int dstIndex, int length) {
System.arraycopy(array, index, dst, dstIndex, length);
}
HeapChannelBuffer 对应的 ChannelBufferFactory 实现是 HeapChannelBufferFactory其 getBuffer() 方法会通过 ChannelBuffers 这个工具类创建一个指定大小 HeapChannelBuffer 对象,下面简单介绍两个 getBuffer() 方法重载:
@Override
public ChannelBuffer getBuffer(int capacity) {
// 新建一个HeapChannelBuffer底层的会新建一个长度为capacity的byte数组
return ChannelBuffers.buffer(capacity);
}
@Override
public ChannelBuffer getBuffer(byte[] array, int offset, int length) {
// 新建一个HeapChannelBuffer并且会拷贝array数组中offset~offset+lenght
// 的数据到新HeapChannelBuffer中
return ChannelBuffers.wrappedBuffer(array, offset, length);
}
其他 getBuffer() 方法重载这里就不再展示,你若感兴趣的话可以参考源码进行学习。
DynamicChannelBuffer 可以认为是其他 ChannelBuffer 的装饰器,它可以为其他 ChannelBuffer 添加动态扩展容量的功能。DynamicChannelBuffer 中有两个核心字段:
bufferChannelBuffer 类型),是被修饰的 ChannelBuffer默认为 HeapChannelBuffer。
factoryChannelBufferFactory 类型),用于创建被修饰的 HeapChannelBuffer 对象的 ChannelBufferFactory 工厂,默认为 HeapChannelBufferFactory。
DynamicChannelBuffer 需要关注的是 ensureWritableBytes() 方法,该方法实现了动态扩容的功能,在每次写入数据之前,都需要调用该方法确定当前可用空间是否足够,调用位置如下图所示:
ensureWritableBytes() 方法如果检测到底层 ChannelBuffer 对象的空间不足,则会创建一个新的 ChannelBuffer空间扩大为原来的两倍然后将原来 ChannelBuffer 中的数据拷贝到新 ChannelBuffer 中,最后将 buffer 字段指向新 ChannelBuffer 对象完成整个扩容操作。ensureWritableBytes() 方法的具体实现如下:
public void ensureWritableBytes(int minWritableBytes) {
if (minWritableBytes <= writableBytes()) {
return;
}
int newCapacity;
if (capacity() == 0) {
newCapacity = 1;
} else {
newCapacity = capacity();
}
int minNewCapacity = writerIndex() + minWritableBytes;
while (newCapacity < minNewCapacity) {
newCapacity <<= 1;
}
ChannelBuffer newBuffer = factory().getBuffer(newCapacity);
newBuffer.writeBytes(buffer, 0, writerIndex());
buffer = newBuffer;
}
ByteBufferBackedChannelBuffer 是基于 Java NIO ByteBuffer ChannelBuffer 实现其中的方法基本都是通过组合 ByteBuffer API 实现的下面以 getBytes() 方法和 setBytes() 方法的一个重载为例进行分析
public void getBytes(int index, byte[] dst, int dstIndex, int length) {
ByteBuffer data = buffer.duplicate();
try {
// 移动ByteBuffer中的指针
data.limit(index + length).position(index);
} catch (IllegalArgumentException e) {
throw new IndexOutOfBoundsException();
}
// 通过ByteBuffer的get()方法实现读取
data.get(dst, dstIndex, length);
}
public void setBytes(int index, byte[] src, int srcIndex, int length) {
ByteBuffer data = buffer.duplicate();
// 移动ByteBuffer中的指针
data.limit(index + length).position(index);
// 将数据写入底层的ByteBuffer中
data.put(src, srcIndex, length);
}
ByteBufferBackedChannelBuffer 的其他方法实现比较简单这里就不再展示你若感兴趣的话可以参考源码进行学习
NettyBackedChannelBuffer 是基于 Netty ByteBuf ChannelBuffer 实现Netty 中的 ByteBuf 内部维护了 readerIndex writerIndex 以及 markedReaderIndexmarkedWriterIndex 这四个索引所以 NettyBackedChannelBuffer 没有再继承 AbstractChannelBuffer 抽象类而是直接实现了 ChannelBuffer 接口
NettyBackedChannelBuffer ChannelBuffer 接口的实现都是调用底层封装的 Netty ByteBuf 实现的这里就不再展开介绍你若感兴趣的话也可以参考相关代码进行学习
相关 Stream 以及门面类
ChannelBuffer 基础上Dubbo 提供了一套输入输出流如下图所示
ChannelBufferInputStream 底层封装了一个 ChannelBuffer其实现 InputStream 接口的 read*() 方法全部都是从 ChannelBuffer 中读取数据ChannelBufferInputStream 中还维护了一个 startIndex 和一个endIndex 索引作为读取数据的起止位置ChannelBufferOutputStream ChannelBufferInputStream 类似会向底层的 ChannelBuffer 写入数据这里就不再展开你若感兴趣的话可以参考源码进行分析
最后要介绍 ChannelBuffers 这个门面类下图展示了 ChannelBuffers 这个门面类的所有方法
对这些方法进行分类可归纳出如下这些方法
dynamicBuffer() 方法创建 DynamicChannelBuffer 对象初始化大小由第一个参数指定默认为 256
buffer() 方法创建指定大小的 HeapChannelBuffer 对象
wrappedBuffer() 方法将传入的 byte[] 数字封装成 HeapChannelBuffer 对象
directBuffer() 方法创建 ByteBufferBackedChannelBuffer 对象需要注意的是底层的 ByteBuffer 使用的堆外内存需要特别关注堆外内存的管理
equals() 方法用于比较两个 ChannelBuffer 是否相同其中会逐个比较两个 ChannelBuffer 中的前 7 个可读字节只有两者完全一致才算两个 ChannelBuffer 相同其核心实现如下示例代码
public static boolean equals(ChannelBuffer bufferA, ChannelBuffer bufferB) {
final int aLen = bufferA.readableBytes();
if (aLen != bufferB.readableBytes()) {
return false; // 比较两个ChannelBuffer的可读字节数
}
final int byteCount = aLen & 7; // 只比较前7个字节
int aIndex = bufferA.readerIndex();
int bIndex = bufferB.readerIndex();
for (int i = byteCount; i > 0; i--) {
if (bufferA.getByte(aIndex) != bufferB.getByte(bIndex)) {
return false; // 前7个字节发现不同则返回false
}
aIndex++;
bIndex++;
}
return true;
}
compare() 方法:用于比较两个 ChannelBuffer 的大小,会逐个比较两个 ChannelBuffer 中的全部可读字节,具体实现与 equals() 方法类似,这里就不再重复讲述。
总结
本课时重点介绍了 dubbo-remoting 模块 buffers 包中的核心实现。我们首先介绍了 ChannelBuffer 接口这一个顶层接口,了解了 ChannelBuffer 提供的核心功能和运作原理;接下来介绍了 ChannelBuffer 的多种实现,其中包括 HeapChannelBuffer、DynamicChannelBuffer、ByteBufferBackedChannelBuffer 等具体实现类,以及 AbstractChannelBuffer 这个抽象类;最后分析了 ChannelBufferFactory 使用到的 ChannelBuffers 工具类以及在 ChannelBuffer 之上封装的 InputStream 和 OutputStream 实现。
关于本课时,你若还有什么疑问或想法,欢迎你留言跟我分享。

View File

@@ -0,0 +1,567 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 Transporter 层核心实现:编解码与线程模型一文打尽(上)
在第 17 课时中,我们详细介绍了 dubbo-remoting-api 模块中 Transporter 相关的核心抽象接口,本课时将继续介绍 dubbo-remoting-api 模块的其他内容。这里我们依旧从 Transporter 层的 RemotingServer、Client、Channel、ChannelHandler 等核心接口出发,介绍这些核心接口的实现。
AbstractPeer 抽象类
首先,我们来看 AbstractPeer 这个抽象类,它同时实现了 Endpoint 接口和 ChannelHandler 接口,如下图所示,它也是 AbstractChannel、AbstractEndpoint 抽象类的父类。
AbstractPeer 继承关系
Netty 中也有 ChannelHandler、Channel 等接口,但无特殊说明的情况下,这里的接口指的都是 Dubbo 中定义的接口。如果涉及 Netty 中的接口,会进行特殊说明。
AbstractPeer 中有四个字段:一个是表示该端点自身的 URL 类型的字段,还有两个 Boolean 类型的字段closing 和 closed用来记录当前端点的状态这三个字段都与 Endpoint 接口相关;第四个字段指向了一个 ChannelHandler 对象AbstractPeer 对 ChannelHandler 接口的所有实现,都是委托给了这个 ChannelHandler 对象。从上面的继承关系图中我们可以得出这样一个结论AbstractChannel、AbstractServer、AbstractClient 都是要关联一个 ChannelHandler 对象的。
AbstractEndpoint 抽象类
我们顺着上图的继承关系继续向下看AbstractEndpoint 继承了 AbstractPeer 这个抽象类。AbstractEndpoint 中维护了一个 Codec2 对象codec 字段和两个超时时间timeout 字段和 connectTimeout 字段),在 AbstractEndpoint 的构造方法中会根据传入的 URL 初始化这三个字段:
public AbstractEndpoint(URL url, ChannelHandler handler) {
super(url, handler); // 调用父类AbstractPeer的构造方法
// 根据URL中的codec参数值确定此处具体的Codec2实现类
this.codec = getChannelCodec(url);
// 根据URL中的timeout参数确定timeout字段的值默认1000
this.timeout = url.getPositiveParameter(TIMEOUT_KEY,
DEFAULT_TIMEOUT);
// 根据URL中的connect.timeout参数确定connectTimeout字段的值默认3000
this.connectTimeout = url.getPositiveParameter(
Constants.CONNECT_TIMEOUT_KEY, Constants.DEFAULT_CONNECT_TIMEOUT);
}
在[第 17 课时]介绍 Codec2 接口的时候提到它是一个 SPI 扩展点,这里的 AbstractEndpoint.getChannelCodec() 方法就是基于 Dubbo SPI 选择其扩展实现的,具体实现如下:
protected static Codec2 getChannelCodec(URL url) {
// 根据URL的codec参数获取扩展名
String codecName = url.getParameter(Constants.CODEC_KEY, "telnet");
if (ExtensionLoader.getExtensionLoader(Codec2.class).hasExtension(codecName)) { // 通过ExtensionLoader加载并实例化Codec2的具体扩展实现
return ExtensionLoader.getExtensionLoader(Codec2.class).getExtension(codecName);
} else { // Codec2接口不存在相应的扩展名就尝试从Codec这个老接口的扩展名中查找目前Codec接口已经废弃了所以省略这部分逻辑
}
}
另外AbstractEndpoint 还实现了 Resetable 接口(只有一个 reset() 方法需要实现),虽然 AbstractEndpoint 中的 reset() 方法比较长,但是逻辑非常简单,就是根据传入的 URL 参数重置 AbstractEndpoint 的三个字段。下面是重置 codec 字段的代码片段,还是调用 getChannelCodec() 方法实现的:
public void reset(URL url) {
// 检测当前AbstractEndpoint是否已经关闭(略)
// 省略重置timeout、connectTimeout两个字段的逻辑
try {
if (url.hasParameter(Constants.CODEC_KEY)) {
this.codec = getChannelCodec(url);
}
} catch (Throwable t) {
logger.error(t.getMessage(), t);
}
}
Server 继承路线分析
AbstractServer 和 AbstractClient 都实现了 AbstractEndpoint 抽象类,我们先来看 AbstractServer 的实现。AbstractServer 在继承了 AbstractEndpoint 的同时,还实现了 RemotingServer 接口,如下图所示:
AbstractServer 继承关系图
AbstractServer 是对服务端的抽象实现了服务端的公共逻辑。AbstractServer 的核心字段有下面几个。
localAddress、bindAddressInetSocketAddress 类型):分别对应该 Server 的本地地址和绑定的地址,都是从 URL 中的参数中获取。bindAddress 默认值与 localAddress 一致。
acceptsint 类型):该 Server 能接收的最大连接数,从 URL 的 accepts 参数中获取,默认值为 0表示没有限制。
executorRepositoryExecutorRepository 类型):负责管理线程池,后面我们会深入介绍 ExecutorRepository 的具体实现。
executorExecutorService 类型):当前 Server 关联的线程池,由上面的 ExecutorRepository 创建并管理。
在 AbstractServer 的构造方法中会根据传入的 URL初始化上述字段并调用 doOpen() 这个抽象方法完成该 Server 的启动,具体实现如下:
public AbstractServer(URL url, ChannelHandler handler) {
super(url, handler); // 调用父类的构造方法
// 根据传入的URL初始化localAddress和bindAddress
localAddress = getUrl().toInetSocketAddress();
String bindIp = getUrl().getParameter(Constants.BIND_IP_KEY, getUrl().getHost());
int bindPort = getUrl().getParameter(Constants.BIND_PORT_KEY, getUrl().getPort());
if (url.getParameter(ANYHOST_KEY, false) || NetUtils.isInvalidLocalHost(bindIp)) {
bindIp = ANYHOST_VALUE;
}
bindAddress = new InetSocketAddress(bindIp, bindPort);
// 初始化accepts等字段
this.accepts = url.getParameter(ACCEPTS_KEY, DEFAULT_ACCEPTS);
this.idleTimeout = url.getParameter(IDLE_TIMEOUT_KEY, DEFAULT_IDLE_TIMEOUT);
try {
doOpen(); // 调用doOpen()这个抽象方法启动该Server
} catch (Throwable t) {
throw new RemotingException("...");
}
// 获取该Server关联的线程池
executor = executorRepository.createExecutorIfAbsent(url);
}
ExecutorRepository
在继续分析 AbstractServer 的具体实现类之前,我们先来了解一下 ExecutorRepository 这个接口。
ExecutorRepository 负责创建并管理 Dubbo 中的线程池,该接口虽然是个 SPI 扩展点,但是只有一个默认实现—— DefaultExecutorRepository。在该默认实现中维护了一个 ConcurrentMap> 集合data 字段)缓存已有的线程池,第一层 Key 值表示线程池属于 Provider 端还是 Consumer 端,第二层 Key 值表示线程池关联服务的端口。
DefaultExecutorRepository.createExecutorIfAbsent() 方法会根据 URL 参数创建相应的线程池并缓存在合适的位置,具体实现如下:
public synchronized ExecutorService createExecutorIfAbsent(URL url) {
// 根据URL中的side参数值决定第一层key
String componentKey = EXECUTOR_SERVICE_COMPONENT_KEY;
if (CONSUMER_SIDE.equalsIgnoreCase(url.getParameter(SIDE_KEY))) {
componentKey = CONSUMER_SIDE;
}
Map<Integer, ExecutorService> executors = data.computeIfAbsent(componentKey, k -> new ConcurrentHashMap<>());
// 根据URL中的port值确定第二层key
Integer portKey = url.getPort();
ExecutorService executor = executors.computeIfAbsent(portKey, k -> createExecutor(url));
// 如果缓存中相应的线程池已关闭则同样需要调用createExecutor()方法
// 创建新的线程池,并替换掉缓存中已关闭的线程持,这里省略这段逻辑
return executor;
}
在 createExecutor() 方法中,会通过 Dubbo SPI 查找 ThreadPool 接口的扩展实现,并调用其 getExecutor() 方法创建线程池。ThreadPool 接口被 @SPI 注解修饰,默认使用 FixedThreadPool 实现,但是 ThreadPool 接口中的 getExecutor() 方法被 @Adaptive 注解修饰,动态生成的适配器类会优先根据 URL 中的 threadpool 参数选择 ThreadPool 的扩展实现。ThreadPool 接口的实现类如下图所示:
ThreadPool 继承关系图
不同实现会根据 URL 参数创建不同特性的线程池这里以CacheThreadPool为例进行分析
public Executor getExecutor(URL url) {
String name = url.getParameter(THREAD_NAME_KEY, DEFAULT_THREAD_NAME);
// 核心线程数量
int cores = url.getParameter(CORE_THREADS_KEY, DEFAULT_CORE_THREADS);
// 最大线程数量
int threads = url.getParameter(THREADS_KEY, Integer.MAX_VALUE);
// 缓冲队列的最大长度
int queues = url.getParameter(QUEUES_KEY, DEFAULT_QUEUES);
// 非核心线程的最大空闲时长,当非核心线程空闲时间超过该值时,会被回收
int alive = url.getParameter(ALIVE_KEY, DEFAULT_ALIVE);
// 下面就是依赖JDK的ThreadPoolExecutor创建指定特性的线程池并返回
return new ThreadPoolExecutor(cores, threads, alive, TimeUnit.MILLISECONDS,
queues == 0 ? new SynchronousQueue<Runnable>() :
(queues < 0 ? new LinkedBlockingQueue<Runnable>()
: new LinkedBlockingQueue<Runnable>(queues)),
new NamedInternalThreadFactory(name, true), new AbortPolicyWithReport(name, url));
}
再简单说一下其他 ThreadPool 实现创建的线程池。
LimitedThreadPool与 CacheThreadPool 一样可以指定核心线程数、最大线程数以及缓冲队列长度。区别在于LimitedThreadPool 创建的线程池的非核心线程不会被回收。
FixedThreadPool核心线程数和最大线程数一致且不会被回收。
上述三种类型的线程池都是基于 JDK ThreadPoolExecutor 线程池,在核心线程全部被占用的时候,会优先将任务放到缓冲队列中缓存,在缓冲队列满了之后,才会尝试创建新线程来处理任务。
EagerThreadPool 创建的线程池是 EagerThreadPoolExecutor继承了 JDK 提供的 ThreadPoolExecutor使用的队列是 TaskQueue继承了LinkedBlockingQueue。该线程池与 ThreadPoolExecutor 不同的是在线程数没有达到最大线程数的前提下EagerThreadPoolExecutor 会优先创建线程来执行任务而不是放到缓冲队列中当线程数达到最大值时EagerThreadPoolExecutor 会将任务放入缓冲队列,等待空闲线程。
EagerThreadPoolExecutor 覆盖了 ThreadPoolExecutor 中的两个方法execute() 方法和 afterExecute() 方法,具体实现如下,我们可以看到其中维护了一个 submittedTaskCount 字段AtomicInteger 类型),用来记录当前在线程池中的任务总数(正在线程中执行的任务数+队列中等待的任务数)。
public void execute(Runnable command) {
// 任务提交之前递增submittedTaskCount
submittedTaskCount.incrementAndGet();
try {
super.execute(command); // 提交任务
} catch (RejectedExecutionException rx) {
final TaskQueue queue = (TaskQueue) super.getQueue();
try {
// 任务被拒绝之后,会尝试再次放入队列中缓存,等待空闲线程执行
if (!queue.retryOffer(command, 0, TimeUnit.MILLISECONDS)) {
// 再次入队被拒绝,则队列已满,无法执行任务
// 递减submittedTaskCount
submittedTaskCount.decrementAndGet();
throw new RejectedExecutionException("Queue capacity is full.", rx);
}
} catch (InterruptedException x) {
// 再次入队列异常递减submittedTaskCount
submittedTaskCount.decrementAndGet();
throw new RejectedExecutionException(x);
}
} catch (Throwable t) { // 任务提交异常递减submittedTaskCount
submittedTaskCount.decrementAndGet();
throw t;
}
}
protected void afterExecute(Runnable r, Throwable t) {
// 任务指定结束递减submittedTaskCount
submittedTaskCount.decrementAndGet();
}
看到这里,你可能会有些疑惑:没有看到优先创建线程执行任务的逻辑啊。其实重点在关联的 TaskQueue 实现中,它覆盖了 LinkedBlockingQueue.offer() 方法,会判断线程池的 submittedTaskCount 值是否已经达到最大线程数,如果未超过,则会返回 false迫使线程池创建新线程来执行任务。示例代码如下
public boolean offer(Runnable runnable) {
// 获取当前线程池中的活跃线程数
int currentPoolThreadSize = executor.getPoolSize();
// 当前有线程空闲,直接将任务提交到队列中,空闲线程会直接从中获取任务执行
if (executor.getSubmittedTaskCount() < currentPoolThreadSize) {
return super.offer(runnable);
}
// 当前没有空闲线程但是还可以创建新线程则返回false迫使线程池创建
// 新线程来执行任务
if (currentPoolThreadSize < executor.getMaximumPoolSize()) {
return false;
}
// 当前线程数已经达到上限只能放到队列中缓存了
return super.offer(runnable);
}
线程池最后一个相关的小细节是 AbortPolicyWithReport 它继承了 ThreadPoolExecutor.AbortPolicy覆盖的 rejectedExecution 方法中会输出包含线程池相关信息的 WARN 级别日志然后进行 dumpJStack() 方法最后才会抛出RejectedExecutionException 异常
我们回到 Server 的继承线上下面来看基于 Netty 4 实现的 NettyServer它继承了前文介绍的 AbstractServer实现了 doOpen() 方法和 doClose() 方法这里重点看 doOpen() 方法如下所示
protected void doOpen() throws Throwable {
// 创建ServerBootstrap
bootstrap = new ServerBootstrap();
// 创建boss EventLoopGroup
bossGroup = NettyEventLoopFactory.eventLoopGroup(1, "NettyServerBoss");
// 创建worker EventLoopGroup
workerGroup = NettyEventLoopFactory.eventLoopGroup(
getUrl().getPositiveParameter(IO_THREADS_KEY, Constants.DEFAULT_IO_THREADS),
"NettyServerWorker");
// 创建NettyServerHandler它是一个Netty中的ChannelHandler实现
// 不是Dubbo Remoting层的ChannelHandler接口的实现
final NettyServerHandler nettyServerHandler = new NettyServerHandler(getUrl(), this);
// 获取当前NettyServer创建的所有Channel这里的channels集合中的
// Channel不是Netty中的Channel对象而是Dubbo Remoting层的Channel对象
channels = nettyServerHandler.getChannels();
// 初始化ServerBootstrap指定boss和worker EventLoopGroup
bootstrap.group(bossGroup, workerGroup)
.channel(NettyEventLoopFactory.serverSocketChannelClass())
.option(ChannelOption.SO_REUSEADDR, Boolean.TRUE)
.childOption(ChannelOption.TCP_NODELAY, Boolean.TRUE)
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 连接空闲超时时间
int idleTimeout = UrlUtils.getIdleTimeout(getUrl());
// NettyCodecAdapter中会创建Decoder和Encoder
NettyCodecAdapter adapter = new NettyCodecAdapter(getCodec(), getUrl(), NettyServer.this);
ch.pipeline()
// 注册Decoder和Encoder
.addLast("decoder", adapter.getDecoder())
.addLast("encoder", adapter.getEncoder())
// 注册IdleStateHandler
.addLast("server-idle-handler", new IdleStateHandler(0, 0, idleTimeout, MILLISECONDS))
// 注册NettyServerHandler
.addLast("handler", nettyServerHandler);
}
});
// 绑定指定的地址和端口
ChannelFuture channelFuture = bootstrap.bind(getBindAddress());
channelFuture.syncUninterruptibly(); // 等待bind操作完成
channel = channelFuture.channel();
}
看完 NettyServer 实现的 doOpen() 方法之后,你会发现它和简易版 RPC 框架中启动一个 Netty 的 Server 端基本流程类似:初始化 ServerBootstrap、创建 Boss EventLoopGroup 和 Worker EventLoopGroup、创建 ChannelInitializer 指定如何初始化 Channel 上的 ChannelHandler 等一系列 Netty 使用的标准化流程。
其实在 Transporter 这一层看,功能的不同其实就是注册在 Channel 上的 ChannelHandler 不同,通过 doOpen() 方法得到的 Server 端结构如下:
NettyServer 模型
核心 ChannelHandler
下面我们来逐个看看这四个 ChannelHandler 的核心功能。
首先是decoder 和 encoder它们都是 NettyCodecAdapter 的内部类,如下图所示,分别继承了 Netty 中的 ByteToMessageDecoder 和 MessageToByteEncoder
还记得 AbstractEndpoint 抽象类中的 codec 字段Codec2 类型InternalDecoder 和 InternalEncoder 会将真正的编解码功能委托给 NettyServer 关联的这个 Codec2 对象去处理,这里以 InternalDecoder 为例进行分析:
private class InternalDecoder extends ByteToMessageDecoder {
protected void decode(ChannelHandlerContext ctx, ByteBuf input, List<Object> out) throws Exception {
// 将ByteBuf封装成统一的ChannelBuffer
ChannelBuffer message = new NettyBackedChannelBuffer(input);
// 拿到关联的Channel
NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler);
do {
// 记录当前readerIndex的位置
int saveReaderIndex = message.readerIndex();
// 委托给Codec2进行解码
Object msg = codec.decode(channel, message);
// 当前接收到的数据不足一个消息的长度会返回NEED_MORE_INPUT
// 这里会重置readerIndex继续等待接收更多的数据
if (msg == Codec2.DecodeResult.NEED_MORE_INPUT) {
message.readerIndex(saveReaderIndex);
break;
} else {
if (msg != null) { // 将读取到的消息传递给后面的Handler处理
out.add(msg);
}
}
} while (message.readable());
}
}
你是不是发现 InternalDecoder 的实现与我们简易版 RPC 的 Decoder 实现非常相似呢?
InternalEncoder 的具体实现就不再展开讲解了,你若感兴趣可以翻看源码进行研究和分析。
接下来是IdleStateHandler它是 Netty 提供的一个工具型 ChannelHandler用于定时心跳请求的功能或是自动关闭长时间空闲连接的功能。它的原理到底是怎样的呢在 IdleStateHandler 中通过 lastReadTime、lastWriteTime 等几个字段,记录了最近一次读/写事件的时间IdleStateHandler 初始化的时候,会创建一个定时任务,定时检测当前时间与最后一次读/写时间的差值。如果超过我们设置的阈值(也就是上面 NettyServer 中设置的 idleTimeout就会触发 IdleStateEvent 事件,并传递给后续的 ChannelHandler 进行处理。后续 ChannelHandler 的 userEventTriggered() 方法会根据接收到的 IdleStateEvent 事件,决定是关闭长时间空闲的连接,还是发送心跳探活。
最后来看NettyServerHandler它继承了 ChannelDuplexHandler这是 Netty 提供的一个同时处理 Inbound 数据和 Outbound 数据的 ChannelHandler从下面的继承图就能看出来。
NettyServerHandler 继承关系图
在 NettyServerHandler 中有 channels 和 handler 两个核心字段。
channelsMap集合记录了当前 Server 创建的所有 Channel从下图中可以看到连接创建触发 channelActive() 方法)、连接断开(触发 channelInactive()方法)会操作 channels 集合进行相应的增删。
handlerChannelHandler 类型NettyServerHandler 内几乎所有方法都会触发该 Dubbo ChannelHandler 对象(如下图)。
这里以 write() 方法为例进行简单分析:
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
super.write(ctx, msg, promise); // 将发送的数据继续向下传递
// 并不影响消息的继续发送只是触发sent()方法进行相关的处理,这也是方法
// 名称是动词过去式的原因,可以仔细体会一下。其他方法可能没有那么明显,
// 这里以write()方法为例进行说明
NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler);
handler.sent(channel, msg);
}
在 NettyServer 创建 NettyServerHandler 的时候,可以看到下面的这行代码:
final NettyServerHandler nettyServerHandler = new NettyServerHandler(getUrl(), this);
其中第二个参数传入的是 NettyServer 这个对象,你可以追溯一下 NettyServer 的继承结构,会发现它的最顶层父类 AbstractPeer 实现了 ChannelHandler并且将所有的方法委托给其中封装的 ChannelHandler 对象,如下图所示:
也就是说NettyServerHandler 会将数据委托给这个 ChannelHandler。
到此为止Server 这条继承线就介绍完了。你可以回顾一下,从 AbstractPeer 开始往下一路继承下来NettyServer 拥有了 Endpoint、ChannelHandler 以及RemotingServer多个接口的能力关联了一个 ChannelHandler 对象以及 Codec2 对象,并最终将数据委托给这两个对象进行处理。所以,上层调用方只需要实现 ChannelHandler 和 Codec2 这两个接口就可以了。
总结
本课时重点介绍了 Dubbo Transporter 层中 Server 相关的实现。
首先,我们介绍了 AbstractPeer 这个最顶层的抽象类,了解了 Server、Client 和 Channel 的公共属性。接下来,介绍了 AbstractEndpoint 抽象类,它提供了编解码等 Server 和 Client 所需的公共能力。最后,我们深入分析了 AbstractServer 抽象类以及基于 Netty 4 实现的 NettyServer同时还深入剖析了涉及的各种组件例如ExecutorRepository、NettyServerHandler 等。

View File

@@ -0,0 +1,571 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 Transporter 层核心实现:编解码与线程模型一文打尽(下)
在上一课时中,我们深入分析了 Transporter 层中 Server 相关的核心抽象类以及基于 Netty 4 的实现类。本课时我们继续分析 Transporter 层中剩余的核心接口实现,主要涉及 Client 接口、Channel 接口、ChannelHandler 接口,以及相关的关键组件。
Client 继承路线分析
在上一课时分析 AbstractEndpoint 的时候可以看到,除了 AbstractServer 这一条继承线之外,还有 AbstractClient 这条继承线它是对客户端的抽象。AbstractClient 中的核心字段有如下几个。
connectLockLock 类型):在 Client 底层进行连接、断开、重连等操作时,需要获取该锁进行同步。
needReconnectBoolean 类型):在发送数据之前,会检查 Client 底层的连接是否断开,如果断开了,则会根据 needReconnect 字段,决定是否重连。
executorExecutorService 类型):当前 Client 关联的线程池,线程池的具体内容在上一课时已经详细介绍过了,这里不再赘述。
在 AbstractClient 的构造方法中,会解析 URL 初始化 needReconnect 字段和 executor字段如下示例代码
public AbstractClient(URL url, ChannelHandler handler) throws RemotingException {
super(url, handler); // 调用父类的构造方法
// 解析URL初始化needReconnect值
needReconnect = url.getParameter("send.reconnect", false);
initExecutor(url); // 解析URL初始化executor
doOpen(); // 初始化底层的NIO库的相关组件
// 创建底层连接
connect(); // 省略异常处理的逻辑
}
与 AbstractServer 类似AbstractClient 定义了 doOpen()、doClose()、doConnect()和doDisConnect() 四个抽象方法给子类实现。
下面来看基于 Netty 4 实现的 NettyClient它继承了 AbstractClient 抽象类,实现了上述四个 do*() 抽象方法,我们这里重点关注 doOpen() 方法和 doConnect() 方法。在 NettyClient 的 doOpen() 方法中会通过 Bootstrap 构建客户端其中会完成连接超时时间、keepalive 等参数的设置,以及 ChannelHandler 的创建和注册,具体实现如下所示:
protected void doOpen() throws Throwable {
// 创建NettyClientHandler
final NettyClientHandler nettyClientHandler = new NettyClientHandler(getUrl(), this);
bootstrap = new Bootstrap(); // 创建Bootstrap
bootstrap.group(NIO_EVENT_LOOP_GROUP)
.option(ChannelOption.SO_KEEPALIVE, true)
.option(ChannelOption.TCP_NODELAY, true)
.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.channel(socketChannelClass());
// 设置连接超时时间这里使用到AbstractEndpoint中的connectTimeout字段
bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, Math.max(3000, getConnectTimeout()));
bootstrap.handler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel ch) throws Exception {
// 心跳请求的时间间隔
int heartbeatInterval = UrlUtils.getHeartbeat(getUrl());
// 通过NettyCodecAdapter创建Netty中的编解码器这里不再重复介绍
NettyCodecAdapter adapter = new NettyCodecAdapter(getCodec(), getUrl(), NettyClient.this);
// 注册ChannelHandler
ch.pipeline().addLast("decoder", adapter.getDecoder())
.addLast("encoder", adapter.getEncoder())
.addLast("client-idle-handler", new IdleStateHandler(heartbeatInterval, 0, 0, MILLISECONDS))
.addLast("handler", nettyClientHandler);
// 如果需要Socks5Proxy需要添加Socks5ProxyHandler(略)
}
});
}
得到的 NettyClient 结构如下图所示:
NettyClient 结构图
NettyClientHandler 的实现方法与上一课时介绍的 NettyServerHandler 类似,同样是实现了 Netty 中的 ChannelDuplexHandler其中会将所有方法委托给 NettyClient 关联的 ChannelHandler 对象进行处理。两者在 userEventTriggered() 方法的实现上有所不同NettyServerHandler 在收到 IdleStateEvent 事件时会断开连接,而 NettyClientHandler 则会发送心跳消息,具体实现如下:
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler);
Request req = new Request();
req.setVersion(Version.getProtocolVersion());
req.setTwoWay(true);
req.setEvent(HEARTBEAT_EVENT); // 发送心跳请求
channel.send(req);
} else {
super.userEventTriggered(ctx, evt);
}
}
Channel 继承线分析
除了上一课时介绍的 AbstractEndpoint 之外AbstractChannel 也继承了 AbstractPeer 这个抽象类,同时还继承了 Channel 接口。AbstractChannel 实现非常简单,只是在 send() 方法中检测了底层连接的状态,没有实现具体的发送消息的逻辑。
这里我们依然以基于 Netty 4 的实现—— NettyChannel 为例,分析它对 AbstractChannel 的实现。NettyChannel 中的核心字段有如下几个。
channelChannel类型Netty 框架中的 Channel与当前的 Dubbo Channel 对象一一对应。
attributesMap类型当前 Channel 中附加属性,都会记录到该 Map 中。NettyChannel 中提供的 getAttribute()、hasAttribute()、setAttribute() 等方法,都是操作该集合。
activeAtomicBoolean用于标识当前 Channel 是否可用。
另外,在 NettyChannel 中还有一个静态的 Map 集合CHANNEL_MAP 字段),用来缓存当前 JVM 中 Netty 框架 Channel 与 Dubbo Channel 之间的映射关系。从下图的调用关系中可以看到NettyChannel 提供了读写 CHANNEL_MAP 集合的方法:
NettyChannel 中还有一个要介绍的是 send() 方法,它会通过底层关联的 Netty 框架 Channel将数据发送到对端。其中可以通过第二个参数指定是否等待发送操作结束具体实现如下
public void send(Object message, boolean sent) throws RemotingException {
// 调用AbstractChannel的send()方法检测连接是否可用
super.send(message, sent);
boolean success = true;
int timeout = 0;
// 依赖Netty框架的Channel发送数据
ChannelFuture future = channel.writeAndFlush(message);
if (sent) { // 等待发送结束,有超时时间
timeout = getUrl().getPositiveParameter(TIMEOUT_KEY, DEFAULT_TIMEOUT);
success = future.await(timeout);
}
Throwable cause = future.cause();
if (cause != null) {
throw cause;
}
// 出现异常会调用removeChannelIfDisconnected()方法,在底层连接断开时,
// 会清理CHANNEL_MAP缓存(略)
}
ChannelHandler 继承线分析
前文介绍的 AbstractServer、AbstractClient 以及 Channel 实现,都是通过 AbstractPeer 实现了 ChannelHandler 接口,但只是做了一层简单的委托(也可以说成是装饰器),将全部方法委托给了其底层关联的 ChannelHandler 对象。
这里我们就深入分析 ChannelHandler 的其他实现类,涉及的实现类如下所示:
ChannelHandler 继承关系图
其中ChannelHandlerDispatcher在[第 17 课时]已经介绍过了,它负责将多个 ChannelHandler 对象聚合成一个 ChannelHandler 对象。
ChannelHandlerAdapter是 ChannelHandler 的一个空实现TelnetHandlerAdapter 继承了它并实现了 TelnetHandler 接口。至于Dubbo 对 Telnet 的支持,我们会在后面的课时中单独介绍,这里就先不展开分析了。
从名字上看ChannelHandlerDelegate接口是对另一个 ChannelHandler 对象的封装,它的两个实现类 AbstractChannelHandlerDelegate 和 WrappedChannelHandler 中也仅仅是封装了另一个 ChannelHandler 对象。
其中AbstractChannelHandlerDelegate有三个实现类都比较简单我们来逐个讲解。
MultiMessageHandler专门处理 MultiMessage 的 ChannelHandler 实现。MultiMessage 是 Exchange 层的一种消息类型,它其中封装了多个消息。在 MultiMessageHandler 收到 MultiMessage 消息的时候received() 方法会遍历其中的所有消息,并交给底层的 ChannelHandler 对象进行处理。
DecodeHandler专门处理 Decodeable 的 ChannelHandler 实现。实现了 Decodeable 接口的类都会提供了一个 decode() 方法实现对自身的解码DecodeHandler.received() 方法就是通过该方法得到解码后的消息,然后传递给底层的 ChannelHandler 对象继续处理。
HeartbeatHandler专门处理心跳消息的 ChannelHandler 实现。在 HeartbeatHandler.received() 方法接收心跳请求的时候,会生成相应的心跳响应并返回;在收到心跳响应的时候,会打印相应的日志;在收到其他类型的消息时,会传递给底层的 ChannelHandler 对象进行处理。下面是其核心实现:
public void received(Channel channel, Object message) throws RemotingException {
setReadTimestamp(channel); // 记录最近的读写事件时间戳
if (isHeartbeatRequest(message)) { // 收到心跳请求
Request req = (Request) message;
if (req.isTwoWay()) { // 返回心跳响应注意携带请求的ID
Response res = new Response(req.getId(), req.getVersion());
res.setEvent(HEARTBEAT_EVENT);
channel.send(res);
return;
}
if (isHeartbeatResponse(message)) { // 收到心跳响应
// 打印日志(略)
return;
}
handler.received(channel, message);
}
另外,我们可以看到,在 received() 和 send() 方法中HeartbeatHandler 会将最近一次的读写时间作为附加属性记录到 Channel 中。
通过上述介绍,我们发现 AbstractChannelHandlerDelegate 下的三个实现,其实都是在原有 ChannelHandler 的基础上添加了一些增强功能,这是典型的装饰器模式的应用。
Dispatcher 与 ChannelHandler
接下来,我们介绍 ChannelHandlerDelegate 接口的另一条继承线——WrappedChannelHandler其子类主要是决定了 Dubbo 以何种线程模型处理收到的事件和消息,就是所谓的“消息派发机制”,与前面介绍的 ThreadPool 有紧密的联系。
WrappedChannelHandler 继承关系图
从上图中我们可以看到,每个 WrappedChannelHandler 实现类的对象都由一个相应的 Dispatcher 实现类创建,下面是 Dispatcher 接口的定义:
@SPI(AllDispatcher.NAME) // 默认扩展名是all
public interface Dispatcher {
// 通过URL中的参数可以指定扩展名覆盖默认扩展名
@Adaptive({"dispatcher", "dispather", "channel.handler"})
ChannelHandler dispatch(ChannelHandler handler, URL url);
}
AllDispatcher 创建的是 AllChannelHandler 对象它会将所有网络事件以及消息交给关联的线程池进行处理。AllChannelHandler覆盖了 WrappedChannelHandler 中除了 sent() 方法之外的其他网络事件处理方法,将调用其底层的 ChannelHandler 的逻辑放到关联的线程池中执行。
我们先来看 connect() 方法其中会将CONNECTED 事件的处理封装成ChannelEventRunnable提交到线程池中执行具体实现如下
public void connected(Channel channel) throws RemotingException {
ExecutorService executor = getExecutorService(); // 获取公共线程池
// 将CONNECTED事件的处理封装成ChannelEventRunnable提交到线程池中执行
executor.execute(new ChannelEventRunnable(channel, handler, ChannelState.CONNECTED));
// 省略异常处理的逻辑
}
这里的 getExecutorService() 方法会按照当前端点Server/Client的 URL 从 ExecutorRepository 中获取相应的公共线程池。
disconnected()方法处理连接断开事件caught() 方法处理异常事件,它们也是按照上述方式实现的,这里不再展开赘述。
received() 方法会在当前端点收到数据的时候被调用,具体执行流程是先由 IO 线程(也就是 Netty 中的 EventLoopGroup从二进制流中解码出请求然后调用 AllChannelHandler 的 received() 方法,其中会将请求提交给线程池执行,执行完后调用 sent()方法向对端写回响应结果。received() 方法的具体实现如下:
public void received(Channel channel, Object message) throws RemotingException {
// 获取线程池
ExecutorService executor = getPreferredExecutorService(message);
try {
// 将消息封装成ChannelEventRunnable任务提交到线程池中执行
executor.execute(new ChannelEventRunnable(channel, handler, ChannelState.RECEIVED, message));
} catch (Throwable t) {
// 如果线程池满了,请求会被拒绝,这里会根据请求配置决定是否返回一个说明性的响应
if(message instanceof Request && t instanceof RejectedExecutionException){
sendFeedback(channel, (Request) message, t);
return;
}
throw new ExecutionException("...");
}
}
getPreferredExecutorService() 方法对响应做了特殊处理:如果请求在发送的时候指定了关联的线程池,在收到对应的响应消息的时候,会优先根据请求的 ID 查找请求关联的线程池处理响应。
public ExecutorService getPreferredExecutorService(Object msg) {
if (msg instanceof Response) {
Response response = (Response) msg;
DefaultFuture responseFuture = DefaultFuture.getFuture(response.getId()); // 获取请求关联的DefaultFuture
if (responseFuture == null) {
return getSharedExecutorService();
} else { // 如果请求关联了线程池,则会获取相关的线程来处理响应
ExecutorService executor = responseFuture.getExecutor();
if (executor == null || executor.isShutdown()) {
executor = getSharedExecutorService();
}
return executor;
}
} else { // 如果是请求消息,则直接使用公共的线程池处理
return getSharedExecutorService();
}
}
这里涉及了 Request 和 Response 的概念,是 Exchange 层的概念,在后面会展开介绍,这里你只需要知道它们是不同的消息类型即可。
注意AllChannelHandler 并没有覆盖父类的 sent() 方法,也就是说,发送消息是直接在当前线程调用 sent() 方法完成的。
下面我们来看剩余的 WrappedChannelHandler 的实现。ExecutionChannelHandler由 ExecutionDispatcher 创建)只会将请求消息派发到线程池进行处理,也就是只重写了 received() 方法。对于响应消息以及其他网络事件例如连接建立事件、连接断开事件、心跳消息等ExecutionChannelHandler 会直接在 IO 线程中进行处理。
DirectChannelHandler 实现(由 DirectDispatcher 创建)会在 IO 线程中处理所有的消息和网络事件。
MessageOnlyChannelHandler 实现(由 MessageOnlyDispatcher 创建)会将所有收到的消息提交到线程池处理,其他网络事件则是由 IO 线程直接处理。
ConnectionOrderedChannelHandler 实现(由 ConnectionOrderedDispatcher 创建)会将收到的消息交给线程池进行处理,对于连接建立以及断开事件,会提交到一个独立的线程池并排队进行处理。在 ConnectionOrderedChannelHandler 的构造方法中,会初始化一个线程池,该线程池的队列长度是固定的:
public ConnectionOrderedChannelHandler(ChannelHandler handler, URL url) {
super(handler, url);
String threadName = url.getParameter(THREAD_NAME_KEY, DEFAULT_THREAD_NAME);
// 注意,该线程池只有一个线程,队列的长度也是固定的,
// 由URL中的connect.queue.capacity参数指定
connectionExecutor = new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(url.getPositiveParameter(CONNECT_QUEUE_CAPACITY, Integer.MAX_VALUE)),
new NamedThreadFactory(threadName, true),
new AbortPolicyWithReport(threadName, url)
);
queuewarninglimit = url.getParameter(CONNECT_QUEUE_WARNING_SIZE, DEFAULT_CONNECT_QUEUE_WARNING_SIZE);
}
在 ConnectionOrderedChannelHandler 的 connected() 方法和 disconnected() 方法实现中,会将连接建立和断开事件交给上述 connectionExecutor 线程池排队处理。
在上面介绍 WrappedChannelHandler 各个实现的时候,我们会看到其中有针对 ThreadlessExecutor 这种线程池类型的特殊处理例如ExecutionChannelHandler.received() 方法中就有如下的分支逻辑:
public void received(Channel channel, Object message) throws RemotingException {
// 获取线程池(请求绑定的线程池或是公共线程池)
ExecutorService executor = getPreferredExecutorService(message);
if (message instanceof Request) { // 请求消息直接提交给线程池处理
executor.execute(new ChannelEventRunnable(channel, handler, ChannelState.RECEIVED, message));
} else if (executor instanceof ThreadlessExecutor) {
// 针对ThreadlessExecutor这种线程池类型的特殊处理
executor.execute(new ChannelEventRunnable(channel, handler, ChannelState.RECEIVED, message));
} else {
handler.received(channel, message);
}
}
ThreadlessExecutor 优化
ThreadlessExecutor 是一种特殊类型的线程池与其他正常的线程池最主要的区别是ThreadlessExecutor 内部不管理任何线程。
我们可以调用 ThreadlessExecutor 的execute() 方法,将任务提交给这个线程池,但是这些提交的任务不会被调度到任何线程执行,而是存储在阻塞队列中,只有当其他线程调用 ThreadlessExecutor.waitAndDrain() 方法时才会真正执行。也说就是,执行任务的与调用 waitAndDrain() 方法的是同一个线程。
那为什么会有 ThreadlessExecutor 这个实现呢?这主要是因为在 Dubbo 2.7.5 版本之前,在 WrappedChannelHandler 中会为每个连接启动一个线程池。
老版本中没有 ExecutorRepository 的概念,不会根据 URL 复用同一个线程池,而是通过 SPI 找到 ThreadPool 实现创建新线程池。
此时Dubbo Consumer 同步请求的线程模型如下图所示:
Dubbo Consumer 同步请求线程模型
从图中我们可以看到下面的请求-响应流程:
业务线程发出请求之后,拿到一个 Future 实例。
业务线程紧接着调用 Future.get() 阻塞等待请求结果返回。
当响应返回之后,交由连接关联的独立线程池进行反序列化等解析处理。
待处理完成之后,将业务结果通过 Future.set() 方法返回给业务线程。
在这个设计里面Consumer 端会维护一个线程池,而且线程池是按照连接隔离的,即每个连接独享一个线程池。这样,当面临需要消费大量服务且并发数比较大的场景时,例如,典型网关类场景,可能会导致 Consumer 端线程个数不断增加,导致线程调度消耗过多 CPU ,也可能因为线程创建过多而导致 OOM。
为了解决上述问题Dubbo 在 2.7.5 版本之后,引入了 ThreadlessExecutor将线程模型修改成了下图的样子
引入 ThreadlessExecutor 后的结构图
业务线程发出请求之后,拿到一个 Future 对象。
业务线程会调用 ThreadlessExecutor.waitAndDrain() 方法waitAndDrain() 方法会在阻塞队列上等待。
当收到响应时IO 线程会生成一个任务,填充到 ThreadlessExecutor 队列中,
业务线程会将上面添加的任务取出,并在本线程中执行。得到业务结果之后,调用 Future.set() 方法进行设置,此时 waitAndDrain() 方法返回。
业务线程从 Future 中拿到结果值。
了解了 ThreadlessExecutor 出现的缘由之后,接下来我们再深入了解一下 ThreadlessExecutor 的核心实现。首先是 ThreadlessExecutor 的核心字段,有如下几个。
queueLinkedBlockingQueue类型阻塞队列用来在 IO 线程和业务线程之间传递任务。
waiting、finishedBoolean类型ThreadlessExecutor 中的 waitAndDrain() 方法一般与一次 RPC 调用绑定,只会执行一次。当后续再次调用 waitAndDrain() 方法时,会检查 finished 字段若为true则此次调用直接返回。当后续再次调用 execute() 方法提交任务时,会根据 waiting 字段决定任务是放入 queue 队列等待业务线程执行,还是直接由 sharedExecutor 线程池执行。
sharedExecutorExecutorService类型ThreadlessExecutor 底层关联的共享线程池,当业务线程已经不再等待响应时,会由该共享线程执行提交的任务。
waitingFutureCompletableFuture类型指向请求对应的 DefaultFuture 对象,其具体实现我们会在后面的课时详细展开介绍。
ThreadlessExecutor 的核心逻辑在 execute() 方法和 waitAndDrain() 方法。execute() 方法相对简单,它会根据 waiting 状态决定任务提交到哪里,相关示例代码如下:
public void execute(Runnable runnable) {
synchronized (lock) {
if (!waiting) { // 判断业务线程是否还在等待响应结果
// 不等待,则直接交给共享线程池处理任务
sharedExecutor.execute(runnable);
} else {// 业务线程还在等待,则将任务写入队列,然后由业务线程自己执行
queue.add(runnable);
}
}
}
waitAndDrain() 方法中首先会检测 finished 字段值然后获取阻塞队列中的全部任务并执行执行完成之后会修改finished和 waiting 字段,标识当前 ThreadlessExecutor 已使用完毕,无业务线程等待。
public void waitAndDrain() throws InterruptedException {
if (finished) { // 检测当前ThreadlessExecutor状态
return;
}
// 获取阻塞队列中获取任务
Runnable runnable = queue.take();
synchronized (lock) {
waiting = false; // 修改waiting状态
runnable.run(); // 执行任务
}
runnable = queue.poll(); // 如果阻塞队列中还有其他任务,也需要一并执行
while (runnable != null) {
runnable.run(); // 省略异常处理逻辑
runnable = queue.poll();
}
finished = true; // 修改finished状态
}
到此为止Transporter 层对 ChannelHandler 的实现就介绍完了,其中涉及了多个 ChannelHandler 的装饰器,为了帮助你更好地理解,这里我们回到 NettyServer 中,看看它是如何对上层 ChannelHandler 进行封装的。
在 NettyServer 的构造方法中会调用 ChannelHandlers.wrap() 方法对传入的 ChannelHandler 对象进行修饰:
protected ChannelHandler wrapInternal(ChannelHandler handler, URL url) {
return new MultiMessageHandler(new HeartbeatHandler(ExtensionLoader.getExtensionLoader(Dispatcher.class)
.getAdaptiveExtension().dispatch(handler, url)));
}
结合前面的分析,我们可以得到下面这张图:
Server 端 ChannelHandler 结构图
我们可以在创建 NettyServerHandler 的地方添加断点 Debug 得到下图,也印证了上图的内容:
总结
本课时我们重点介绍了 Dubbo Transporter 层中 Client、 Channel、ChannelHandler 相关的实现以及优化。
首先我们介绍了 AbstractClient 抽象接口以及基于 Netty 4 的 NettyClient 实现。接下来,介绍了 AbstractChannel 抽象类以及 NettyChannel 实现。最后,我们深入分析了 ChannelHandler 接口实现,其中详细分析 WrappedChannelHandler 等关键 ChannelHandler 实现,以及 ThreadlessExecutor 优化。

View File

@@ -0,0 +1,417 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 Exchange 层剖析:彻底搞懂 Request-Response 模型(上)
在前面的课程中,我们深入介绍了 Dubbo Remoting 中的 Transport 层,了解了 Dubbo 抽象出来的端到端的统一传输层接口,并分析了以 Netty 为基础的相关实现。当然,其他 NIO 框架的接入也是类似的,本课程就不再展开赘述了。
在本课时中,我们将介绍 Transport 层的上一层,也是 Dubbo Remoting 层中的最顶层—— Exchange 层。Dubbo 将信息交换行为抽象成 Exchange 层,官方文档对这一层的说明是:封装了请求-响应的语义,即关注一问一答的交互模式,实现了同步转异步。在 Exchange 这一层,以 Request 和 Response 为中心,针对 Channel、ChannelHandler、Client、RemotingServer 等接口进行实现。
下面我们从 Request 和 Response 这一对基础类开始,依次介绍 Exchange 层中 ExchangeChannel、HeaderExchangeHandler 的核心实现。
Request 和 Response
Exchange 层的 Request 和 Response 这两个类是 Exchange 层的核心对象是对请求和响应的抽象。我们先来看Request 类的核心字段:
public class Request {
// 用于生成请求的自增ID当递增到Long.MAX_VALUE之后会溢出到Long.MIN_VALUE我们可以继续使用该负数作为消息ID
private static final AtomicLong INVOKE_ID = new AtomicLong(0);
private final long mId; // 请求的ID
private String mVersion; // 请求版本号
// 请求的双向标识如果该字段设置为true则Server端在收到请求后
// 需要给Client返回一个响应
private boolean mTwoWay = true;
// 事件标识,例如心跳请求、只读请求等,都会带有这个标识
private boolean mEvent = false;
// 请求发送到Server之后由Decoder将二进制数据解码成Request对象
// 如果解码环节遇到异常则会设置该标识然后交由其他ChannelHandler根据
// 该标识做进一步处理
private boolean mBroken = false;
// 请求体可以是任何Java类型的对象,也可以是null
private Object mData;
}
接下来是 Response 的核心字段:
public class Response {
// 响应ID与相应请求的ID一致
private long mId = 0;
// 当前协议的版本号,与请求消息的版本号一致
private String mVersion;
// 响应状态码有OK、CLIENT_TIMEOUT、SERVER_TIMEOUT等10多个可选值
private byte mStatus = OK;
private boolean mEvent = false;
private String mErrorMsg; // 可读的错误响应消息
private Object mResult; // 响应体
}
ExchangeChannel & DefaultFuture
在前面的课时中,我们介绍了 Channel 接口的功能以及 Transport 层对 Channel 接口的实现。在 Exchange 层中定义了 ExchangeChannel 接口,它在 Channel 接口之上抽象了 Exchange 层的网络连接。ExchangeChannel 接口的定义如下:
ExchangeChannel 接口
其中request() 方法负责发送请求,从图中可以看到这里有两个重载,其中一个重载可以指定请求的超时时间,返回值都是 Future 对象。
HeaderExchangeChannel 继承关系图
从上图中可以看出HeaderExchangeChannel 是 ExchangeChannel 的实现,它本身是 Channel 的装饰器,封装了一个 Channel 对象,其 send() 方法和 request() 方法的实现都是依赖底层修饰的这个 Channel 对象实现的。
public void send(Object message, boolean sent) throws RemotingException {
if (message instanceof Request || message instanceof Response
|| message instanceof String) {
channel.send(message, sent);
} else {
Request request = new Request();
request.setVersion(Version.getProtocolVersion());
request.setTwoWay(false);
request.setData(message);
channel.send(request, sent);
}
}
public CompletableFuture<Object> request(Object request, int timeout, ExecutorService executor) throws RemotingException {
Request req = new Request(); // 创建Request对象
req.setVersion(Version.getProtocolVersion());
req.setTwoWay(true);
req.setData(request);
DefaultFuture future = DefaultFuture.newFuture(channel,
req, timeout, executor); // 创建DefaultFuture
channel.send(req);
return future;
}
注意这里的 request() 方法,它返回的是一个 DefaultFuture 对象。通过前面课时的介绍我们知道io.netty.channel.Channel 的 send() 方法会返回一个 ChannelFuture 方法表示此次发送操作是否完成而这里的DefaultFuture 就表示此次请求-响应是否完成,也就是说,要收到响应为 Future 才算完成。
下面我们就来深入介绍一下请求发送过程中涉及的 DefaultFuture 以及HeaderExchangeChannel的内容。
首先来了解一下 DefaultFuture 的具体实现,它继承了 JDK 中的 CompletableFuture其中维护了两个 static 集合。
CHANNELSMap集合管理请求与 Channel 之间的关联关系,其中 Key 为请求 IDValue 为发送请求的 Channel。
FUTURESMap集合管理请求与 DefaultFuture 之间的关联关系,其中 Key 为请求 IDValue 为请求对应的 Future。
DefaultFuture 中核心的实例字段包括如下几个。
requestRequest 类型)和 idLong 类型):对应请求以及请求的 ID。
channelChannel 类型):发送请求的 Channel。
timeoutint 类型):整个请求-响应交互完成的超时时间。
startlong 类型):该 DefaultFuture 的创建时间。
sentvolatile long 类型):请求发送的时间。
timeoutCheckTaskTimeout 类型):该定时任务到期时,表示对端响应超时。
executorExecutorService 类型):请求关联的线程池。
DefaultFuture.newFuture() 方法创建 DefaultFuture 对象时,需要先初始化上述字段,并创建请求相应的超时定时任务:
public static DefaultFuture newFuture(Channel channel, Request request, int timeout, ExecutorService executor) {
// 创建DefaultFuture对象并初始化其中的核心字段
final DefaultFuture future = new DefaultFuture(channel, request, timeout);
future.setExecutor(executor);
// 对于ThreadlessExecutor的特殊处理ThreadlessExecutor可以关联一个waitingFuture就是这里创建DefaultFuture对象
if (executor instanceof ThreadlessExecutor) {
((ThreadlessExecutor) executor).setWaitingFuture(future);
}
// 创建一个定时任务,用处理响应超时的情况
timeoutCheck(future);
return future;
}
在 HeaderExchangeChannel.request() 方法中完成 DefaultFuture 对象的创建之后,会将请求通过底层的 Dubbo Channel 发送出去,发送过程中会触发沿途 ChannelHandler 的 sent() 方法,其中的 HeaderExchangeHandler 会调用 DefaultFuture.sent() 方法更新 sent 字段,记录请求发送的时间戳。后续如果响应超时,则会将该发送时间戳添加到提示信息中。
过一段时间之后Consumer 会收到对端返回的响应,在读取到完整响应之后,会触发 Dubbo Channel 中各个 ChannelHandler 的 received() 方法,其中就包括上一课时介绍的 WrappedChannelHandler。例如AllChannelHandler 子类会将后续 ChannelHandler.received() 方法的调用封装成任务提交到线程池中,响应会提交到 DefaultFuture 关联的线程池中,如上一课时介绍的 ThreadlessExecutor然后由业务线程继续后续的 ChannelHandler 调用。(你也可以回顾一下上一课时对 Transport 层 Dispatcher 以及 ThreadlessExecutor 的介绍。)
当响应传递到 HeaderExchangeHandler 的时候,会通过调用 handleResponse() 方法进行处理,其中调用了 DefaultFuture.received() 方法,该方法会找到响应关联的 DefaultFuture 对象(根据请求 ID 从 FUTURES 集合查找)并调用 doReceived() 方法,将 DefaultFuture 设置为完成状态。
public static void received(Channel channel, Response response, boolean timeout) { // 省略try/finally代码块
// 清理FUTURES中记录的请求ID与DefaultFuture之间的映射关系
DefaultFuture future = FUTURES.remove(response.getId());
if (future != null) {
Timeout t = future.timeoutCheckTask;
if (!timeout) { // 未超时,取消定时任务
t.cancel();
}
future.doReceived(response); // 调用doReceived()方法
}else{ // 查找不到关联的DefaultFuture会打印日志(略)}
// 清理CHANNELS中记录的请求ID与Channel之间的映射关系
CHANNELS.remove(response.getId());
}
// DefaultFuture.doReceived()方法的代码片段
private void doReceived(Response res) {
if (res == null) {
throw new IllegalStateException("response cannot be null");
}
if (res.getStatus() == Response.OK) { // 正常响应
this.complete(res.getResult());
} else if (res.getStatus() == Response.CLIENT_TIMEOUT || res.getStatus() == Response.SERVER_TIMEOUT) { // 超时
this.completeExceptionally(new TimeoutException(res.getStatus() == Response.SERVER_TIMEOUT, channel, res.getErrorMessage()));
} else { // 其他异常
this.completeExceptionally(new RemotingException(channel, res.getErrorMessage()));
}
// 下面是针对ThreadlessExecutor的兜底处理主要是防止业务线程一直阻塞在ThreadlessExecutor上
if (executor != null && executor instanceof ThreadlessExecutor) {
ThreadlessExecutor threadlessExecutor = (ThreadlessExecutor) executor;
if (threadlessExecutor.isWaiting()) {
// notifyReturn()方法会向ThreadlessExecutor提交一个任务这样业务线程就不会阻塞了提交的任务会尝试将DefaultFuture设置为异常结束
threadlessExecutor.notifyReturn(new IllegalStateException("The result has returned..."));
}
}
}
下面我们再来看看响应超时的场景。在创建 DefaultFuture 时调用的 timeoutCheck() 方法中,会创建 TimeoutCheckTask 定时任务,并添加到时间轮中,具体实现如下:
private static void timeoutCheck(DefaultFuture future) {
TimeoutCheckTask task = new TimeoutCheckTask(future.getId());
future.timeoutCheckTask = TIME_OUT_TIMER.newTimeout(task, future.getTimeout(), TimeUnit.MILLISECONDS);
}
TIME_OUT_TIMER 是一个 HashedWheelTimer 对象,即 Dubbo 中对时间轮的实现,这是一个 static 字段,所有 DefaultFuture 对象共用一个。
TimeoutCheckTask 是 DefaultFuture 中的内部类,实现了 TimerTask 接口可以提交到时间轮中等待执行。当响应超时的时候TimeoutCheckTask 会创建一个 Response并调用前面介绍的 DefaultFuture.received() 方法。示例代码如下:
public void run(Timeout timeout) {
// 检查该任务关联的DefaultFuture对象是否已经完成
if (future.getExecutor() != null) { // 提交到线程池执行注意ThreadlessExecutor的情况
future.getExecutor().execute(() -> notifyTimeout(future));
} else {
notifyTimeout(future);
}
}
private void notifyTimeout(DefaultFuture future) {
// 没有收到对端的响应这里会创建一个Response表示超时的响应
Response timeoutResponse = new Response(future.getId());
timeoutResponse.setStatus(future.isSent() ? Response.SERVER_TIMEOUT : Response.CLIENT_TIMEOUT);
timeoutResponse.setErrorMessage(future.getTimeoutMessage(true));
// 将关联的DefaultFuture标记为超时异常完成
DefaultFuture.received(future.getChannel(), timeoutResponse, true);
}
HeaderExchangeHandler
在前面介绍 DefaultFuture 时,我们简单说明了请求-响应的流程,其实无论是发送请求还是处理响应,都会涉及 HeaderExchangeHandler所以这里我们就来介绍一下 HeaderExchangeHandler 的内容。
HeaderExchangeHandler 是 ExchangeHandler 的装饰器,其中维护了一个 ExchangeHandler 对象ExchangeHandler 接口是 Exchange 层与上层交互的接口之一,上层调用方可以实现该接口完成自身的功能;然后再由 HeaderExchangeHandler 修饰,具备 Exchange 层处理 Request-Response 的能力;最后再由 Transport ChannelHandler 修饰,具备 Transport 层的能力。如下图所示:
ChannelHandler 继承关系总览图
HeaderExchangeHandler 作为一个装饰器,其 connected()、disconnected()、sent()、received()、caught() 方法最终都会转发给上层提供的 ExchangeHandler 进行处理。这里我们需要聚焦的是 HeaderExchangeHandler 本身对 Request 和 Response 的处理逻辑。
received() 方法处理的消息分类
结合上图我们可以看到在received() 方法中,对收到的消息进行了分类处理。
只读请求会由handlerEvent() 方法进行处理,它会在 Channel 上设置 channel.readonly 标志,后续介绍的上层调用中会读取该值。
void handlerEvent(Channel channel, Request req) throws RemotingException {
if (req.getData() != null && req.getData().equals(READONLY_EVENT)) {
channel.setAttribute(Constants.CHANNEL_ATTRIBUTE_READONLY_KEY, Boolean.TRUE);
}
}
双向请求由handleRequest() 方法进行处理,会先对解码失败的请求进行处理,返回异常响应;然后将正常解码的请求交给上层实现的 ExchangeHandler 进行处理,并添加回调。上层 ExchangeHandler 处理完请求后,会触发回调,根据处理结果填充响应结果和响应码,并向对端发送。
void handleRequest(final ExchangeChannel channel, Request req) throws RemotingException {
Response res = new Response(req.getId(), req.getVersion());
if (req.isBroken()) { // 请求解码失败
Object data = req.getData();
// 设置异常信息和响应码
res.setErrorMessage("Fail to decode request due to: " + msg);
res.setStatus(Response.BAD_REQUEST);
channel.send(res); // 将异常响应返回给对端
return;
}
Object msg = req.getData();
// 交给上层实现的ExchangeHandler进行处理
CompletionStage<Object> future = handler.reply(channel, msg);
future.whenComplete((appResult, t) -> { // 处理结束后的回调
if (t == null) { // 返回正常响应
res.setStatus(Response.OK);
res.setResult(appResult);
} else { // 处理过程发生异常,设置异常信息和错误码
res.setStatus(Response.SERVICE_ERROR);
res.setErrorMessage(StringUtils.toString(t));
}
channel.send(res); // 发送响应
});
}
单向请求直接委托给上层 ExchangeHandler 实现的 received() 方法进行处理由于不需要响应HeaderExchangeHandler 不会关注处理结果。
对于 Response 的处理前文已提到了HeaderExchangeHandler 会通过handleResponse() 方法将关联的 DefaultFuture 设置为完成状态(或是异常完成状态),具体内容这里不再展开讲述。
对于 String 类型的消息HeaderExchangeHandler 会根据当前服务的角色进行分类,具体与 Dubbo 对 telnet 的支持相关,后面的课时会详细介绍,这里就不展开分析了。
接下来我们再来看sent() 方法,该方法会通知上层 ExchangeHandler 实现的 sent() 方法,同时还会针对 Request 请求调用 DefaultFuture.sent() 方法记录请求的具体发送时间,该逻辑在前文也已经介绍过了,这里不再重复。
在connected() 方法中,会为 Dubbo Channel 创建相应的 HeaderExchangeChannel并将两者绑定然后通知上层 ExchangeHandler 处理 connect 事件。
在disconnected() 方法中,首先通知上层 ExchangeHandler 进行处理,之后在 DefaultFuture.closeChannel() 通知 DefaultFuture 连接断开(其实就是创建并传递一个 Response该 Response 的状态码为 CHANNEL_INACTIVE这样就不会继续阻塞业务线程了最后再将 HeaderExchangeChannel 与底层的 Dubbo Channel 解绑。
总结
本课时我们重点介绍了 Dubbo Exchange 层中对 Channel 和 ChannelHandler 接口的实现。
我们首先介绍了 Exchange 层中请求-响应模型的基本抽象,即 Request 类和 Response 类。然后又介绍了 ExchangeChannel 对 Channel 接口的实现,同时还说明了发送请求之后得到的 DefaultFuture 对象,这也是上一课时遗留的小问题。最后,讲解了 HeaderExchangeHandler 是如何将 Transporter 层的 ChannelHandler 对象与上层的 ExchangeHandler 对象相关联的。

View File

@@ -0,0 +1,432 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 Exchange 层剖析:彻底搞懂 Request-Response 模型(下)
在上一课时中,我们重点分析了 Exchange 层中 Channel 接口以及 ChannelHandler 接口的核心实现,同时还介绍 Request、Response 两个基础类,以及 DefaultFuture 这个 Future 实现。本课时,我们将继续讲解 Exchange 层其他接口的实现逻辑。
HeaderExchangeClient
HeaderExchangeClient 是 Client 装饰器,主要为其装饰的 Client 添加两个功能:
维持与 Server 的长连状态,这是通过定时发送心跳消息实现的;
在因故障掉线之后,进行重连,这是通过定时检查连接状态实现的。
因此HeaderExchangeClient 侧重定时轮资源的分配、定时任务的创建和取消。
HeaderExchangeClient 实现的是 ExchangeClient 接口,如下图所示,间接实现了 ExchangeChannel 和 Client 接口ExchangeClient 接口是个空接口,没有定义任何方法。
HeaderExchangeClient 继承关系图
HeaderExchangeClient 中有以下两个核心字段。
clientClient 类型):被修饰的 Client 对象。HeaderExchangeClient 中对 Client 接口的实现,都会委托给该对象进行处理。
channelExchangeChannel 类型Client 与服务端建立的连接HeaderExchangeChannel 也是一个装饰器在前面我们已经详细介绍过了这里就不再展开介绍。HeaderExchangeClient 中对 ExchangeChannel 接口的实现,都会委托给该对象进行处理。
HeaderExchangeClient 构造方法的第一个参数封装 Transport 层的 Client 对象,第二个参数 startTimer参与控制是否开启心跳定时任务和重连定时任务如果为 true才会进一步根据其他条件最终决定是否启动定时任务。这里我们以心跳定时任务为例
private void startHeartBeatTask(URL url) {
if (!client.canHandleIdle()) { // Client的具体实现决定是否启动该心跳任务
AbstractTimerTask.ChannelProvider cp = () -> Collections.singletonList(HeaderExchangeClient.this);
// 计算心跳间隔最小间隔不能低于1s
int heartbeat = getHeartbeat(url);
long heartbeatTick = calculateLeastDuration(heartbeat);
// 创建心跳任务
this.heartBeatTimerTask = new HeartbeatTimerTask(cp, heartbeatTick, heartbeat);
// 提交到IDLE_CHECK_TIMER这个时间轮中等待执行
IDLE_CHECK_TIMER.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS);
}
}
重连定时任务是在 startReconnectTask() 方法中启动的,其中会根据 URL 中的参数决定是否启动任务。重连定时任务最终也是提交到 IDLE_CHECK_TIMER 这个时间轮中,时间轮定义如下:
private static final HashedWheelTimer IDLE_CHECK_TIMER = new HashedWheelTimer(
new NamedThreadFactory("dubbo-client-idleCheck", true), 1, TimeUnit.SECONDS, TICKS_PER_WHEEL);
其实startReconnectTask() 方法的具体实现与前面展示的 startHeartBeatTask() 方法类似,这里就不再赘述。
下面我们继续回到心跳定时任务进行分析,你可以回顾第 20 课时介绍的 NettyClient 实现,其 canHandleIdle() 方法返回 true表示该实现可以自己发送心跳请求无须 HeaderExchangeClient 再启动一个定时任务。NettyClient 主要依靠 IdleStateHandler 中的定时任务来触发心跳事件,依靠 NettyClientHandler 来发送心跳请求。
对于无法自己发送心跳请求的 Client 实现HeaderExchangeClient 会为其启动 HeartbeatTimerTask 心跳定时任务,其继承关系如下图所示:
TimerTask 继承关系图
我们先来看 AbstractTimerTask 这个抽象类,它有三个字段。
channelProviderChannelProvider类型ChannelProvider 是 AbstractTimerTask 抽象类中定义的内部接口,定时任务会从该对象中获取 Channel。
tickLong类型任务的过期时间。
cancelboolean类型任务是否已取消。
AbstractTimerTask 抽象类实现了 TimerTask 接口的 run() 方法,首先会从 ChannelProvider 中获取此次任务相关的 Channel 集合(在 Client 端只有一个 Channel在 Server 端有多个 Channel然后检查 Channel 的状态,针对未关闭的 Channel 执行 doTask() 方法处理,最后通过 reput() 方法将当前任务重新加入时间轮中,等待再次到期执行。
AbstractTimerTask.run() 方法的具体实现如下:
public void run(Timeout timeout) throws Exception {
// 从ChannelProvider中获取任务要操作的Channel集合
Collection<Channel> c = channelProvider.getChannels();
for (Channel channel : c) {
if (channel.isClosed()) { // 检测Channel状态
continue;
}
doTask(channel); // 执行任务
}
reput(timeout, tick); // 将当前任务重新加入时间轮中,等待执行
}
doTask() 是一个 AbstractTimerTask 留给子类实现的抽象方法不同的定时任务执行不同的操作。例如HeartbeatTimerTask.doTask() 方法中会读取最后一次读写时间,然后计算距离当前的时间,如果大于心跳间隔,就会发送一个心跳请求,核心实现如下:
protected void doTask(Channel channel) {
// 获取最后一次读写时间
Long lastRead = lastRead(channel);
Long lastWrite = lastWrite(channel);
if ((lastRead != null && now() - lastRead > heartbeat)
|| (lastWrite != null && now() - lastWrite > heartbeat)) {
// 最后一次读写时间超过心跳时间,就会发送心跳请求
Request req = new Request();
req.setVersion(Version.getProtocolVersion());
req.setTwoWay(true);
req.setEvent(HEARTBEAT_EVENT);
channel.send(req);
}
}
这里 lastRead 和 lastWrite 时间戳,都是从要待处理 Channel 的附加属性中获取的,对应的 Key 分别是KEY_READ_TIMESTAMP、KEY_WRITE_TIMESTAMP。你可以回顾前面课程中介绍的 HeartbeatHandler它属于 Transport 层,是一个 ChannelHandler 的装饰器,在其 connected() 、sent() 方法中会记录最后一次写操作时间,在其 connected()、received() 方法中会记录最后一次读操作时间,在其 disconnected() 方法中会清理这两个时间戳。
在 ReconnectTimerTask 中会检测待处理 Channel 的连接状态,以及读操作的空闲时间,对于断开或是空闲时间较长的 Channel 进行重连,具体逻辑这里就不再展开了。
HeaderExchangeClient 最后要关注的是它的关闭流程,具体实现在 close() 方法中,如下所示:
public void close(int timeout) {
startClose(); // 将closing字段设置为true
doClose(); // 关闭心跳定时任务和重连定时任务
channel.close(timeout); // 关闭HeaderExchangeChannel
}
在 HeaderExchangeChannel.close(timeout) 方法中首先会将自身的 closed 字段设置为 true这样就不会继续发送请求。如果当前 Channel 上还有请求未收到响应,会循环等待至收到响应,如果超时未收到响应,会自己创建一个状态码将连接关闭的 Response 交给 DefaultFuture 处理,与收到 disconnected 事件相同。然后会关闭 Transport 层的 Channel以 NettyChannel 为例NettyChannel.close() 方法会先将自身的 closed 字段设置为 true清理 CHANNEL_MAP 缓存中的记录,以及 Channel 的附加属性,最后才是关闭 io.netty.channel.Channel。
HeaderExchangeServer
下面再来看 HeaderExchangeServer其继承关系如下图所示其中 Endpoint、RemotingServer、Resetable 这三个接口我们在前面已经详细介绍过了,这里不再重复。
HeaderExchangeServer 的继承关系图
与前面介绍的 HeaderExchangeClient 一样HeaderExchangeServer 是 RemotingServer 的装饰器,实现自 RemotingServer 接口的大部分方法都委托给了所修饰的 RemotingServer 对象。
在 HeaderExchangeServer 的构造方法中,会启动一个 CloseTimerTask 定时任务,定期关闭长时间空闲的连接,具体的实现方式与 HeaderExchangeClient 中的两个定时任务类似,这里不再展开分析。
需要注意的是,前面课时介绍的 NettyServer 并没有启动该定时任务,而是靠 NettyServerHandler 和 IdleStateHandler 实现的,原理与 NettyClient 类似,这里不再展开,你若感兴趣的话,可以回顾第 20课时或是查看 CloseTimerTask 的具体实现。
在 19 课时介绍 Transport Server 的时候,我们并没有过多介绍其关闭流程,这里我们就通过 HeaderExchangeServer 自顶向下梳理整个 Server 端关闭流程。先来看 HeaderExchangeServer.close() 方法的关闭流程:
将被修饰的 RemotingServer 的 closing 字段设置为 true表示这个 Server 端正在关闭,不再接受新 Client 的连接。你可以回顾第 19 课时中介绍的 AbstractServer.connected() 方法,会发现 Server 正在关闭或是已经关闭时,则直接关闭新建的 Client 连接。
向 Client 发送一个携带 ReadOnly 事件的请求(根据 URL 中的配置决定是否发送默认为发送。在接收到该请求之后Client 端的 HeaderExchangeHandler 会在 Channel 上添加 Key 为 “channel.readonly” 的附加信息,上层调用方会根据该附加信息,判断该连接是否可写。
循环去检测是否还存在 Client 与当前 Server 维持着长连接,直至全部 Client 断开连接或超时。
更新 closed 字段为 true之后 Client 不会再发送任何请求或是回复响应了。
取消 CloseTimerTask 定时任务。
调用底层 RemotingServer 对象的 close() 方法。以 NettyServer 为例,其 close() 方法会先调用 AbstractPeer 的 close() 方法将自身的 closed 字段设置为 true然后调用 doClose() 方法关闭 boss Channel即用来接收客户端连接的 Channel关闭 channels 集合中记录的 Channel这些 Channel 是与 Client 之间的连接),清理 channels 集合;最后,关闭 bossGroup 和 workerGroup 两个线程池。
HeaderExchangeServer.close() 方法的核心逻辑如下:
public void close(final int timeout) {
startClose(); // 将底层RemotingServer的closing字段设置为true表示当前Server正在关闭不再接收连接
if (timeout > 0) {
final long max = (long) timeout;
final long start = System.currentTimeMillis();
if (getUrl().getParameter(Constants.CHANNEL_SEND_READONLYEVENT_KEY, true)) {
// 发送ReadOnly事件请求通知客户端
sendChannelReadOnlyEvent();
}
while (HeaderExchangeServer.this.isRunning()
&& System.currentTimeMillis() - start < max) {
Thread.sleep(10); // 循环等待客户端断开连接
}
}
doClose(); // 将自身closed字段设置为true取消CloseTimerTask定时任务
server.close(timeout); // 关闭Transport层的Server
}
通过对上述关闭流程的分析你就可以清晰地知道 HeaderExchangeServer 优雅关闭的原理
HeaderExchanger
对于上层来说Exchange 层的入口是 Exchangers 这个门面类其中提供了多个 bind() 以及 connect() 方法的重载这些重载方法最终会通过 SPI 机制获取 Exchanger 接口的扩展实现这个流程与第 17 课时介绍的 Transport 层的入口—— Transporters 门面类相同
我们可以看到 Exchanger 接口的定义与前面介绍的 Transporter 接口非常类似同样是被 @SPI 接口修饰默认扩展名为header”,对应的是 HeaderExchanger 这个实现bind() 方法和 connect() 方法也同样是被 @Adaptive 注解修饰可以通过 URL 参数中的 exchanger 参数值指定扩展名称来覆盖默认值
@SPI(HeaderExchanger.NAME)
public interface Exchanger {
@Adaptive({Constants.EXCHANGER_KEY})
ExchangeServer bind(URL url, ExchangeHandler handler) throws RemotingException;
@Adaptive({Constants.EXCHANGER_KEY})
ExchangeClient connect(URL url, ExchangeHandler handler) throws RemotingException;
}
Dubbo 只为 Exchanger 接口提供了 HeaderExchanger 这一个实现其中 connect() 方法创建的是 HeaderExchangeClient 对象bind() 方法创建的是 HeaderExchangeServer 对象如下图所示
HeaderExchanger 门面类
HeaderExchanger 的实现可以看到它会在 Transport 层的 Client Server 实现基础之上添加前文介绍的 HeaderExchangeClient HeaderExchangeServer 装饰器同时为上层实现的 ExchangeHandler 实例添加了 HeaderExchangeHandler 以及 DecodeHandler 两个修饰器
public class HeaderExchanger implements Exchanger {
public static final String NAME = "header";
@Override
public ExchangeClient connect(URL url, ExchangeHandler handler) throws RemotingException {
return new HeaderExchangeClient(Transporters.connect(url, new DecodeHandler(new HeaderExchangeHandler(handler))), true);
}
@Override
public ExchangeServer bind(URL url, ExchangeHandler handler) throws RemotingException {
return new HeaderExchangeServer(Transporters.bind(url, new DecodeHandler(new HeaderExchangeHandler(handler))));
}
}
再谈 Codec2
在前面第 17 课时介绍 Dubbo Remoting 核心接口的时候提到Codec2 接口提供了 encode() decode() 两个方法来实现消息与字节流之间的相互转换需要注意与 DecodeHandler 区分开来DecodeHandler 是对请求体和响应结果的解码Codec2 是对整个请求和响应的编解码
这里重点介绍 Transport 层和 Exchange 层对 Codec2 接口的实现涉及的类如下图所示
AbstractCodec抽象类并没有实现 Codec2 中定义的接口方法而是提供了几个给子类用的基础方法下面简单说明这些方法的功能
getSerialization() 方法通过 SPI 获取当前使用的序列化方式
checkPayload() 方法检查编解码数据的长度如果数据超长会抛出异常
isClientSide()、isServerSide() 方法判断当前是 Client 端还是 Server
接下来看TransportCodec我们可以看到这类上被标记了 @Deprecated 注解表示已经废弃TransportCodec 的实现非常简单其中根据 getSerialization() 方法选择的序列化方法对传入消息或 ChannelBuffer 进行序列化或反序列化这里就不再介绍 TransportCodec 实现了
TelnetCodec继承了 TransportCodec 序列化和反序列化的基本能力同时还提供了对 Telnet 命令处理的能力
最后来看ExchangeCodec它在 TelnetCodec 的基础之上添加了处理协议头的能力下面是 Dubbo 协议的格式能够清晰地看出协议中各个数据所占的位数
Dubbo 协议格式
结合上图我们来深入了解一下 Dubbo 协议中各个部分的含义
0~7 位和 8~15 位分别是 Magic High Magic Low是固定魔数值0xdabb我们可以通过这两个 Byte快速判断一个数据包是否为 Dubbo 协议这也类似 Java 字节码文件里的魔数
16 位是 Req/Res 标识用于标识当前消息是请求还是响应
17 位是 2Way 标识用于标识当前消息是单向还是双向
18 位是 Event 标识用于标识当前消息是否为事件消息
19~23 位是序列化类型的标志用于标识当前消息使用哪一种序列化算法
24~31 位是 Status 状态用于记录响应的状态仅在 Req/Res 0响应时有用
32~95 位是 Request ID用于记录请求的唯一标识类型为 long
96~127 位是序列化后的内容长度该值是按字节计数int 类型
128 位之后是可变的数据被特定的序列化算法由序列化类型标志确定序列化后每个部分都是一个 byte [] 或者 byte如果是请求包Req/Res = 1则每个部分依次为Dubbo versionService nameService versionMethod nameMethod parameter typesMethod arguments Attachments如果是响应包Req/Res = 0则每个部分依次为①返回值类型byte标识从服务器端返回的值类型包括返回空值RESPONSE_NULL_VALUE 2)、正常响应值RESPONSE_VALUE 1和异常RESPONSE_WITH_EXCEPTION 0三种;②返回值从服务端返回的响应 bytes
可以看到 Dubbo 协议中前 128 位是协议头之后的内容是具体的负载数据协议头就是通过 ExchangeCodec 实现编解码的
ExchangeCodec 的核心字段有如下几个
HEADER_LENGTHint 类型值为 16协议头的字节数16 字节 128
MAGICshort 类型值为 0xdabb协议头的前 16 分为 MAGIC_HIGH MAGIC_LOW 两个字节
FLAG_REQUESTbyte 类型值为 0x80用于设置 Req/Res 标志位
FLAG_TWOWAYbyte 类型值为 0x40用于设置 2Way 标志位
FLAG_EVENTbyte 类型值为 0x20用于设置 Event 标志位
SERIALIZATION_MASKint 类型值为 0x1f用于获取序列化类型的标志位的掩码
ExchangeCodec encode() 方法中会根据需要编码的消息类型进行分类其中 encodeRequest() 方法专门对 Request 对象进行编码具体实现如下
protected void encodeRequest(Channel channel, ChannelBuffer buffer, Request req) throws IOException {
Serialization serialization = getSerialization(channel);
byte[] header = new byte[HEADER_LENGTH]; // 该数组用来暂存协议头
// 在header数组的前两个字节中写入魔数
Bytes.short2bytes(MAGIC, header);
// 根据当前使用的序列化设置协议头中的序列化标志位
header[2] = (byte) (FLAG_REQUEST | serialization.getContentTypeId());
if (req.isTwoWay()) { // 设置协议头中的2Way标志位
header[2] |= FLAG_TWOWAY;
}
if (req.isEvent()) { // 设置协议头中的Event标志位
header[2] |= FLAG_EVENT;
}
// 将请求ID记录到请求头中
Bytes.long2bytes(req.getId(), header, 4);
// 下面开始序列化请求并统计序列化后的字节数
// 首先使用savedWriteIndex记录ChannelBuffer当前的写入位置
int savedWriteIndex = buffer.writerIndex();
// 将写入位置后移16字节
buffer.writerIndex(savedWriteIndex + HEADER_LENGTH);
// 根据选定的序列化方式对请求进行序列化
ChannelBufferOutputStream bos = new ChannelBufferOutputStream(buffer);
ObjectOutput out = serialization.serialize(channel.getUrl(), bos);
if (req.isEvent()) { // 对事件进行序列化
encodeEventData(channel, out, req.getData());
} else { // 对Dubbo请求进行序列化具体在DubboCodec中实现
encodeRequestData(channel, out, req.getData(), req.getVersion());
}
out.flushBuffer();
if (out instanceof Cleanable) {
((Cleanable) out).cleanup();
}
bos.flush();
bos.close(); // 完成序列化
int len = bos.writtenBytes(); // 统计请求序列化之后得到的字节数
checkPayload(channel, len); // 限制一下请求的字节长度
Bytes.int2bytes(len, header, 12); // 将字节数写入header数组中
// 下面调整ChannelBuffer当前的写入位置并将协议头写入Buffer中
buffer.writerIndex(savedWriteIndex);
buffer.writeBytes(header);
// 最后将ChannelBuffer的写入位置移动到正确的位置
buffer.writerIndex(savedWriteIndex + HEADER_LENGTH + len);
}
encodeResponse() 方法编码响应的方式与 encodeRequest() 方法编码请求的方式类似这里就不再展开介绍了感兴趣的同学可以参考源码进行学习对于既不是 Request也不是 Response 的消息ExchangeCodec 会使用从父类继承下来的能力来编码例如对 telnet 命令的编码
ExchangeCodec decode() 方法是 encode() 方法的逆过程会先检查魔数然后读取协议头和后续消息的长度最后根据协议头中的各个标志位构造相应的对象以及反序列化数据在了解协议头结构的前提下再去阅读这段逻辑就十分轻松了这就留给你自己尝试分析一下
总结
本课时我们重点介绍了 Dubbo Exchange 层中对 Client Server 接口的实现
我们首先介绍了 HeaderExchangeClient ExchangeClient 接口的实现以及 HeaderExchangeServer ExchangeServer 接口的实现这两者是在 Transport Client Server 的基础上添加了新的功能接下来又讲解了 HeaderExchanger 这个用来创建 HeaderExchangeClient HeaderExchangeServer 的门面类最后分析了 Dubbo 协议的格式以及处理 Dubbo 协议的 ExchangeCodec 实现

View File

@@ -0,0 +1,412 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 核心接口介绍RPC 层骨架梳理
在前面的课程中,我们深入介绍了 Dubbo 架构中的 Dubbo Remoting 层的相关内容,了解了 Dubbo 底层的网络模型以及线程模型。从本课时开始,我们就开始介绍 Dubbo Remoting 上面的一层—— Protocol 层如下图所示Protocol 层是 Remoting 层的使用者,会通过 Exchangers 门面类创建 ExchangeClient 以及 ExchangeServer还会创建相应的 ChannelHandler 实现以及 Codec2 实现并交给 Exchange 层进行装饰。
Dubbo 架构中 Protocol 层的位置图
Protocol 层在 Dubbo 源码中对应的是 dubbo-rpc 模块,该模块的结构如下图所示:
dubbo-rpc 模块结构图
我们可以看到有很多模块,和 dubbo-remoting 模块类似,其中 dubbo-rpc-api 是对具体协议、服务暴露、服务引用、代理等的抽象,是整个 Protocol 层的核心。剩余的模块例如dubbo-rpc-dubbo、dubbo-rpc-grpc、dubbo-rpc-http 等,都是 Dubbo 支持的具体协议可以看作dubbo-rpc-api 模块的具体实现。
dubbo-rpc-api
这里我们首先来看 dubbo-rpc-api 模块的包结构,如下图所示:
dubbo-rpc-api 模块的包结构图
根据上图展示的 dubbo-rpc-api 模块的结构,我们可以看到 dubbo-rpc-api 模块包括了以下几个核心包。
filter 包:在进行服务引用时会进行一系列的过滤,其中包括了很多过滤器。
listener 包:在服务发布和服务引用的过程中,我们可以添加一些 Listener 来监听相应的事件,与 Listener 相关的接口 Adapter、Wrapper 实现就在这个包内。
protocol 包:一些实现了 Protocol 接口以及 Invoker 接口的抽象类位于该包之中,它们主要是为 Protocol 接口的具体实现以及 Invoker 接口的具体实现提供一些公共逻辑。
proxy 包:提供了创建代理的能力,在这个包中支持 JDK 动态代理以及 Javassist 字节码两种方式生成本地代理类。
support 包:包括了 RpcUtils 工具类、Mock 相关的 Protocol 实现以及 Invoker 实现。
没有在上述 package 中的接口和类,是更为核心的抽象接口,上述 package 内的类更多的是这些接口的实现类。下面我们就来介绍这些在 org.apache.dubbo.rpc 包下的核心接口。
核心接口
在 Dubbo RPC 层中涉及的核心接口有 Invoker、Invocation、Protocol、Result、Exporter、ProtocolServer、Filter 等,这些接口分别抽象了 Dubbo RPC 层的不同概念,看似相互独立,但又相互协同,一起构建出了 DubboRPC 层的骨架。下面我们将逐一介绍这些核心接口的含义。
首先要介绍的是 Dubbo 中非常重要的一个接口——Invoker 接口。可以说Invoker 渗透在整个 Dubbo 代码实现里Dubbo 中的很多设计思路都会向 Invoker 这个概念靠拢,但这对于刚接触这部分代码的同学们来说,可能不是很友好。
这里我们借助如下这样一个精简的示意图来对比说明两种最关键的 Invoker服务提供 Invoker 和服务消费 Invoker。
Invoker 核心示意图
以 dubbo-demo-annotation-consumer 这个示例项目中的 Consumer 为例,它会拿到一个 DemoService 对象,如下所示,这其实是一个代理(即上图中的 Proxy这个 Proxy 底层就会通过 Invoker 完成网络调用:
@Component("demoServiceComponent")
public class DemoServiceComponent implements DemoService {
@Reference
private DemoService demoService;
@Override
public String sayHello(String name) {
return demoService.sayHello(name);
}
}
紧接着我们再来看一个 dubbo-demo-annotation-provider 示例中的 Provider 实现:
@Service
public class DemoServiceImpl implements DemoService {
@Override
public String sayHello(String name) {
return "Hello " + name + ", response from provider: " + RpcContext.getContext().getLocalAddress();
}
}
这里的 DemoServiceImpl 类会被封装成为一个 AbstractProxyInvoker 实例,并新生成对应的 Exporter 实例。当 Dubbo Protocol 层收到一个请求之后,会找到这个 Exporter 实例,并调用其对应的 AbstractProxyInvoker 实例,从而完成 Provider 逻辑的调用。这里我先帮你找出了最重要的两类 Invoker ,简单介绍了它们工作场景,当然 Dubbo 中还有其他类型的 Invoker后面我们再一一介绍。
下面来看 Invoker 这个接口的具体定义,如下所示:
public interface Invoker<T> extends Node {
// 服务接口
Class<T> getInterface();
// 进行一次调用,也有人称之为一次"会话",你可以理解为一次调用
Result invoke(Invocation invocation) throws RpcException;
}
Invocation 接口是 Invoker.invoke() 方法的参数,抽象了一次 RPC 调用的目标服务和方法信息、相关参数信息、具体的参数值以及一些附加信息,具体定义如下:
public interface Invocation {
// 调用Service的唯一标识
String getTargetServiceUniqueName();
// 调用的方法名称
String getMethodName();
// 调用的服务名称
String getServiceName();
// 参数类型集合
Class<?>[] getParameterTypes();
// 参数签名集合
default String[] getCompatibleParamSignatures() {
return Stream.of(getParameterTypes())
.map(Class::getName)
.toArray(String[]::new);
}
// 此次调用具体的参数值
Object[] getArguments();
// 此次调用关联的Invoker对象
Invoker<?> getInvoker();
// Invoker对象可以设置一些KV属性这些属性并不会传递给Provider
Object put(Object key, Object value);
Object get(Object key);
Map<Object, Object> getAttributes();
// Invocation可以携带一个KV信息作为附加信息一并传递给Provider
// 注意与 attribute 的区分
Map<String, String> getAttachments();
Map<String, Object> getObjectAttachments();
void setAttachment(String key, String value);
void setAttachment(String key, Object value);
void setObjectAttachment(String key, Object value);
void setAttachmentIfAbsent(String key, String value);
void setAttachmentIfAbsent(String key, Object value);
void setObjectAttachmentIfAbsent(String key, Object value);
String getAttachment(String key);
Object getObjectAttachment(String key);
String getAttachment(String key, String defaultValue);
Object getObjectAttachment(String key, Object defaultValue);
}
Result 接口是 Invoker.invoke() 方法的返回值,抽象了一次调用的返回值,其中包含了被调用方返回值(或是异常)以及附加信息,我们也可以添加回调方法,在 RPC 调用方法结束时会触发这些回调。Result 接口的具体定义如下:
public interface Result extends Serializable {
// 获取/设置此次调用的返回值
Object getValue();
void setValue(Object value);
// 如果此次调用发生异常,则可以通过下面三个方法获取
Throwable getException();
void setException(Throwable t);
boolean hasException();
// recreate()方法是一个复合操作,如果此次调用发生异常,则直接抛出异常,
// 如果没有异常,则返回结果
Object recreate() throws Throwable;
// 添加一个回调当RPC调用完成时会触发这里添加的回调
Result whenCompleteWithContext(BiConsumer<Result, Throwable> fn);
<U> CompletableFuture<U> thenApply(Function<Result, ? extends U> fn);
// 阻塞线程等待此次RPC调用完成(或是超时)
Result get() throws InterruptedException, ExecutionException;
Result get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
// Result中同样可以携带附加信息
Map<String, String> getAttachments();
Map<String, Object> getObjectAttachments();
void addAttachments(Map<String, String> map);
void addObjectAttachments(Map<String, Object> map);
void setAttachments(Map<String, String> map);
void setObjectAttachments(Map<String, Object> map);
String getAttachment(String key);
Object getObjectAttachment(String key);
String getAttachment(String key, String defaultValue);
Object getObjectAttachment(String key, Object defaultValue);
void setAttachment(String key, String value);
void setAttachment(String key, Object value);
void setObjectAttachment(String key, Object valu
}
在上面介绍 Provider 端的 Invoker 时提到,我们的业务接口实现会被包装成一个 AbstractProxyInvoker 对象,然后由 Exporter 暴露出去,让 Consumer 可以调用到该服务。Exporter 暴露 Invoker 的实现,说白了,就是让 Provider 能够根据请求的各种信息,找到对应的 Invoker。我们可以维护一个 Map其中 Key 可以根据请求中的信息构建Value 为封装相应服务 Bean 的 Exporter 对象,这样就可以实现上述服务发布的要求了。
我们先来看 Exporter 接口的定义:
public interface Exporter<T> {
// 获取底层封装的Invoker对象
Invoker<T> getInvoker();
// 取消发布底层的Invoker对象
void unexport();
}
为了监听服务发布事件以及取消暴露事件Dubbo 定义了一个 SPI 扩展接口——ExporterListener 接口,其定义如下:
@SPI
public interface ExporterListener {
// 当有服务发布的时候,会触发该方法
void exported(Exporter<?> exporter) throws RpcException;
// 当有服务取消发布的时候,会触发该方法
void unexported(Exporter<?> exporter);
}
虽然 ExporterListener 是个扩展接口,但是 Dubbo 本身并没有提供什么有用的扩展实现,我们需要自己提供具体实现监听感兴趣的事情。
相应地,我们可以添加 InvokerListener 监听器,监听 Consumer 引用服务时触发的事件InvokerListener 接口的定义如下:
@SPI
public interface InvokerListener {
// 当服务引用的时候,会触发该方法
void referred(Invoker<?> invoker) throws RpcException;
// 当销毁引用的服务时,会触发该方法
void destroyed(Invoker<?> invoker);
}
Protocol 接口是整个 Dubbo Protocol 层的核心接口之一,其中定义了 export() 和 refer() 两个核心方法,具体定义如下:
@SPI("dubbo") // 默认使用DubboProtocol实现
public interface Protocol {
// 默认端口
int getDefaultPort();
// 将一个Invoker暴露出去export()方法实现需要是幂等的,
// 即同一个服务暴露多次和暴露一次的效果是相同的
@Adaptive
<T> Exporter<T> export(Invoker<T> invoker) throws RpcException;
// 引用一个Invokerrefer()方法会根据参数返回一个Invoker对象
// Consumer端可以通过这个Invoker请求到Provider端的服务
@Adaptive
<T> Invoker<T> refer(Class<T> type, URL url) throws RpcException;
// 销毁export()方法以及refer()方法使用到的Invoker对象释放
// 当前Protocol对象底层占用的资源
void destroy();
// 返回当前Protocol底层的全部ProtocolServer
default List<ProtocolServer> getServers() {
return Collections.emptyList();
}
}
在 Protocol 接口的实现中export() 方法并不是简单地将 Invoker 对象包装成 Exporter 对象返回,其中还涉及代理对象的创建、底层 Server 的启动等操作refer() 方法除了根据传入的 type 类型以及 URL 参数查询 Invoker 之外,还涉及相关 Client 的创建等操作。
Dubbo 在 Protocol 层专门定义了一个 ProxyFactory 接口作为创建代理对象的工厂。ProxyFactory 接口是一个扩展接口,其中定义了 getProxy() 方法为 Invoker 创建代理对象,还定义了 getInvoker() 方法将代理对象反向封装成 Invoker 对象。
@SPI("javassist")
public interface ProxyFactory {
// 为传入的Invoker对象创建代理对象
@Adaptive({PROXY_KEY})
<T> T getProxy(Invoker<T> invoker) throws RpcException;
@Adaptive({PROXY_KEY})
<T> T getProxy(Invoker<T> invoker, boolean generic) throws RpcException;
// 将传入的代理对象封装成Invoker对象可以暂时理解为getProxy()的逆操作
@Adaptive({PROXY_KEY})
<T> Invoker<T> getInvoker(T proxy, Class<T> type, URL url) throws RpcException;
}
看到 ProxyFactory 上的 @SPI 注解,我们知道其默认实现使用 javassist 来创建代码对象当然Dubbo 还提供了其他方式来创建代码,例如 JDK 动态代理。
ProtocolServer 接口是对前文介绍的 RemotingServer 的一层简单封装,其实现也都非常简单,这里就不再展开。
最后一个要介绍的核心接口是 Filter 接口。关于 Filter相信做过 Java Web 编程的同学们会非常熟悉这个基础概念Java Web 开发中的 Filter 是用来拦截 HTTP 请求的Dubbo 中的 Filter 接口功能与之类似,是用来拦截 Dubbo 请求的。
在 Dubbo 的 Filter 接口中,定义了一个 invoke() 方法将请求传递给后续的 Invoker 进行处理(后续的这个 Invoker 对象可能是一个 Filter 封装而成的。Filter 接口的具体定义如下:
@SPI
public interface Filter {
// 将请求传给后续的Invoker进行处理
Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException;
interface Listener { // 用于监听响应以及异常
void onResponse(Result appResponse, Invoker<?> invoker, Invocation invocation);
void onError(Throwable t, Invoker<?> invoker, Invocation invocation);
}
}
Filter 也是一个扩展接口Dubbo 提供了丰富的 Filter 实现来进行功能扩展,当然我们也可以提供自己的 Filter 实现来扩展 Dubbo 的功能。
总结
本课时我们首先介绍了 Dubbo RPC 层在整个 Dubbo 框架中所处的位置,然后说明了 dubbo-rpc-api 层的结构以及其中各个包提供的基本功能。接下来,我们还详细介绍了 Dubbo RPC 层中涉及的核心接口,包括 Invoker、Invocation、Protocol、Result、ProxyFactory、ProtocolServer 等核心接口,以及 ExporterListener、Filter 等扩展类的接口。

View File

@@ -0,0 +1,516 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 从 Protocol 起手,看服务暴露和服务引用的全流程(上)
在上一课时我们讲解了 Protocol 的核心接口,那本课时我们就以 Protocol 接口为核心,详细介绍整个 Protocol 的核心实现。下图展示了 Protocol 接口的继承关系:
Protocol 接口继承关系图
其中AbstractProtocol提供了一些 Protocol 实现需要的公共能力以及公共字段,它的核心字段有如下三个。
exporterMapMap>类型):用于存储出去的服务集合,其中的 Key 通过 ProtocolUtils.serviceKey() 方法创建的服务标识,在 ProtocolUtils 中维护了多层的 Map 结构(如下图所示)。首先按照 group 分组,在实践中我们可以根据需求设置 group例如按照机房、地域等进行 group 划分,做到就近调用;在 GroupServiceKeyCache 中,依次按照 serviceName、serviceVersion、port 进行分类,最终缓存的 serviceKey 是前面三者拼接而成的。
groupServiceKeyCacheMap 结构图
serverMapMap类型记录了全部的 ProtocolServer 实例,其中的 Key 是 host 和 port 组成的字符串Value 是监听该地址的 ProtocolServer。ProtocolServer 就是对 RemotingServer 的一层简单封装,表示一个服务端。
invokersSet>类型):服务引用的集合。
AbstractProtocol 没有对 Protocol 的 export() 方法进行实现,对 refer() 方法的实现也是委托给了 protocolBindingRefer() 这个抽象方法然后由子类实现。AbstractProtocol 唯一实现的方法就是 destory() 方法,其首先会遍历 Invokers 集合,销毁全部的服务引用,然后遍历全部的 exporterMap 集合,销毁发布出去的服务,具体实现如下:
public void destroy() {
for (Invoker<?> invoker : invokers) {
if (invoker != null) {
invokers.remove(invoker);
invoker.destroy(); // 关闭全部的服务引用
}
}
for (String key : new ArrayList<String>(exporterMap.keySet())) {
Exporter<?> exporter = exporterMap.remove(key);
if (exporter != null) {
exporter.unexport(); // 关闭暴露出去的服务
}
}
}
export 流程简析
了解了 AbstractProtocol 提供的公共能力之后我们再来分析Dubbo 默认使用的 Protocol 实现类—— DubboProtocol 实现。这里我们首先关注 DubboProtocol 的 export() 方法,也就是服务发布的相关实现,如下所示:
public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException {
URL url = invoker.getUrl();
// 创建ServiceKey其核心实现在前文已经详细分析过了这里不再重复
String key = serviceKey(url);
// 将上层传入的Invoker对象封装成DubboExporter对象然后记录到exporterMap集合中
DubboExporter<T> exporter = new DubboExporter<T>(invoker, key, exporterMap);
exporterMap.put(key, exporter);
... // 省略一些日志操作
// 启动ProtocolServer
openServer(url);
// 进行序列化的优化处理
optimizeSerialization(url);
return exporter;
}
1. DubboExporter
这里涉及的第一个点是 DubboExporter 对 Invoker 的封装DubboExporter 的继承关系如下图所示:
DubboExporter 继承关系图
AbstractExporter 中维护了一个 Invoker 对象,以及一个 unexported 字段boolean 类型),在 unexport() 方法中会设置 unexported 字段为 true并调用 Invoker 对象的 destory() 方法进行销毁。
DubboExporter 也比较简单,其中会维护底层 Invoker 对应的 ServiceKey 以及 DubboProtocol 中的 exportMap 集合,在其 unexport() 方法中除了会调用父类 AbstractExporter 的 unexport() 方法之外,还会清理该 DubboExporter 实例在 exportMap 中相应的元素。
2. 服务端初始化
了解了 Exporter 实现之后,我们继续看 DubboProtocol 中服务发布的流程。从下面这张调用关系图中可以看出openServer() 方法会一路调用前面介绍的 Exchange 层、Transport 层,并最终创建 NettyServer 来接收客户端的请求。
export() 方法调用栈
下面我们将逐个介绍 export() 方法栈中的每个被调用的方法。
首先,在 openServer() 方法中会根据 URL 判断当前是否为服务端,只有服务端才能创建 ProtocolServer 并对外服务。如果是来自服务端的调用,会依靠 serverMap 集合检查是否已有 ProtocolServer 在监听 URL 指定的地址;如果没有,会调用 createServer() 方法进行创建。openServer() 方法的具体实现如下:
private void openServer(URL url) {
String key = url.getAddress(); // 获取host:port这个地址
boolean isServer = url.getParameter(IS_SERVER_KEY, true);
if (isServer) { // 只有Server端才能启动Server对象
ProtocolServer server = serverMap.get(key);
if (server == null) { // 无ProtocolServer监听该地址
synchronized (this) { // DoubleCheck防止并发问题
server = serverMap.get(key);
if (server == null) {
// 调用createServer()方法创建ProtocolServer对象
serverMap.put(key, createServer(url));
}
}
} else {
// 如果已有ProtocolServer实例则尝试根据URL信息重置ProtocolServer
server.reset(url);
}
}
}
createServer() 方法首先会为 URL 添加一些默认值,同时会进行一些参数值的检测,主要有五个。
HEARTBEAT_KEY 参数值,默认值为 60000表示默认的心跳时间间隔为 60 秒。
CHANNEL_READONLYEVENT_SENT_KEY 参数值,默认值为 true表示 ReadOnly 请求需要阻塞等待响应返回。在 Server 关闭的时候,只能发送 ReadOnly 请求,这些 ReadOnly 请求由这里设置的 CHANNEL_READONLYEVENT_SENT_KEY 参数值决定是否需要等待响应返回。
CODEC_KEY 参数值,默认值为 dubbo。你可以回顾 Codec2 接口中 @Adaptive 注解的参数,都是获取该 URL 中的 CODEC_KEY 参数值。
检测 SERVER_KEY 参数指定的扩展实现名称是否合法,默认值为 netty。你可以回顾 Transporter 接口中 @Adaptive 注解的参数,它决定了 Transport 层使用的网络库实现,默认使用 Netty 4 实现。
检测 CLIENT_KEY 参数指定的扩展实现名称是否合法。同 SERVER_KEY 参数的检查流程。
完成上述默认参数值的设置之后,我们就可以通过 Exchangers 门面类创建 ExchangeServer并封装成 DubboProtocolServer 返回。
private ProtocolServer createServer(URL url) {
url = URLBuilder.from(url)
// ReadOnly请求是否阻塞等待
.addParameterIfAbsent(CHANNEL_READONLYEVENT_SENT_KEY, Boolean.TRUE.toString())
// 心跳间隔
.addParameterIfAbsent(HEARTBEAT_KEY, String.valueOf(DEFAULT_HEARTBEAT))
.addParameter(CODEC_KEY, DubboCodec.NAME) // Codec2扩展实现
.build();
// 检测SERVER_KEY参数指定的Transporter扩展实现是否合法
String str = url.getParameter(SERVER_KEY, DEFAULT_REMOTING_SERVER);
if (str != null && str.length() > 0 && !ExtensionLoader.getExtensionLoader(Transporter.class).hasExtension(str)) {
throw new RpcException("...");
}
// 通过Exchangers门面类创建ExchangeServer对象
ExchangeServer server = Exchangers.bind(url, requestHandler);
... // 检测CLIENT_KEY参数指定的Transporter扩展实现是否合法(略)
// 将ExchangeServer封装成DubboProtocolServer返回
return new DubboProtocolServer(server);
}
在 createServer() 方法中还有几个细节需要展开分析一下。第一个是创建 ExchangeServer 时,使用的 Codec2 接口实现实际上是 DubboCountCodec对应的 SPI 配置文件如下:
Codec2 SPI 配置文件
DubboCountCodec 中维护了一个 DubboCodec 对象,编解码的能力都是 DubboCodec 提供的DubboCountCodec 只负责在解码过程中 ChannelBuffer 的 readerIndex 指针控制,具体实现如下:
public Object decode(Channel channel, ChannelBuffer buffer) throws IOException {
int save = buffer.readerIndex(); // 首先保存readerIndex指针位置
// 创建MultiMessage对象其中可以存储多条消息
MultiMessage result = MultiMessage.create();
do {
// 通过DubboCodec提供的解码能力解码一条消息
Object obj = codec.decode(channel, buffer);
// 如果可读字节数不足一条消息则会重置readerIndex指针
if (Codec2.DecodeResult.NEED_MORE_INPUT == obj) {
buffer.readerIndex(save);
break;
} else { // 将成功解码的消息添加到MultiMessage中暂存
result.addMessage(obj);
logMessageLength(obj, buffer.readerIndex() - save);
save = buffer.readerIndex();
}
} while (true);
if (result.isEmpty()) { // 一条消息也未解码出来则返回NEED_MORE_INPUT错误码
return Codec2.DecodeResult.NEED_MORE_INPUT;
}
if (result.size() == 1) { // 只解码出来一条消息,则直接返回该条消息
return result.get(0);
}
// 解码出多条消息的话会将MultiMessage返回
return result;
}
DubboCountCodec、DubboCodec 都实现了第 22 课时介绍的 Codec2 接口,其中 DubboCodec 是 ExchangeCodec 的子类。
DubboCountCodec 及 DubboCodec 继承关系图
我们知道 ExchangeCodec 只处理了 Dubbo 协议的请求头,而 DubboCodec 则是通过继承的方式,在 ExchangeCodec 基础之上,添加了解析 Dubbo 消息体的功能。在第 22 课时介绍 ExchangeCodec 实现的时候,我们重点分析了 encodeRequest() 方法,即 Request 请求的编码实现,其中会调用 encodeRequestData() 方法完成请求体的编码。
DubboCodec 中就覆盖了 encodeRequestData() 方法,按照 Dubbo 协议的格式编码 Request 请求体,具体实现如下:
protected void encodeRequestData(Channel channel, ObjectOutput out, Object data, String version) throws IOException {
// 请求体相关的内容都封装在了RpcInvocation
RpcInvocation inv = (RpcInvocation) data;
out.writeUTF(version); // 写入版本号
String serviceName = inv.getAttachment(INTERFACE_KEY);
if (serviceName == null) {
serviceName = inv.getAttachment(PATH_KEY);
}
// 写入服务名称
out.writeUTF(serviceName);
// 写入Service版本号
out.writeUTF(inv.getAttachment(VERSION_KEY));
// 写入方法名称
out.writeUTF(inv.getMethodName());
// 写入参数类型列表
out.writeUTF(inv.getParameterTypesDesc());
// 依次写入全部参数
Object[] args = inv.getArguments();
if (args != null) {
for (int i = 0; i < args.length; i++) {
out.writeObject(encodeInvocationArgument(channel, inv, i));
}
}
// 依次写入全部的附加信息
out.writeAttachments(inv.getObjectAttachments());
}
RpcInvocation 实现了上一课时介绍的 Invocation 接口如下图所示
RpcInvocation 继承关系图
下面是 RpcInvocation 中的核心字段通过读写这些字段即可实现 Invocation 接口的全部方法
targetServiceUniqueNameString类型要调用的唯一服务名称其实就是 ServiceKey interface/group:version 三部分构成的字符串
methodNameString类型调用的目标方法名称
serviceNameString类型调用的目标服务名称示例中就是org.apache.dubbo.demo.DemoService
parameterTypesClass<?>[]类型):记录了目标方法的全部参数类型。
parameterTypesDescString类型参数列表签名。
argumentsObject[]类型):具体参数值。
attachmentsMap类型此次调用的附加信息可以被序列化到请求中。
attributesMap类型此次调用的属性信息这些信息不能被发送出去。
invokerInvoker<?>类型):此次调用关联的 Invoker 对象。
returnTypeClass<?>类型):返回值的类型。
invokeModeInvokeMode类型此次调用的模式分为 SYNC、ASYNC 和 FUTURE 三类。
我们在上面的继承图中看到 RpcInvocation 的一个子类—— DecodeableRpcInvocation它是用来支持解码的其实现的 decode() 方法正好是 DubboCodec.encodeRequestData() 方法对应的解码操作,在 DubboCodec.decodeBody() 方法中就调用了这个方法,调用关系如下图所示:
decode() 方法调用栈
这个解码过程中有个细节,在 DubboCodec.decodeBody() 方法中有如下代码片段,其中会根据 DECODE_IN_IO_THREAD_KEY 这个参数决定是否在 DubboCodec 中进行解码DubboCodec 是在 IO 线程中调用的)。
// decode request.
Request req = new Request(id);
... // 省略Request中其他字段的设置
Object data;
DecodeableRpcInvocation inv;
// 这里会检查DECODE_IN_IO_THREAD_KEY参数
if (channel.getUrl().getParameter(DECODE_IN_IO_THREAD_KEY, DEFAULT_DECODE_IN_IO_THREAD)) {
inv = new DecodeableRpcInvocation(channel, req, is, proto);
inv.decode(); // 直接调用decode()方法在当前IO线程中解码
} else { // 这里只是读取数据不会调用decode()方法在当前IO线程中进行解码
inv = new DecodeableRpcInvocation(channel, req,
new UnsafeByteArrayInputStream(readMessageData(is)), proto);
}
data = inv;
req.setData(data); // 设置到Request请求的data字段
return req;
如果不在 DubboCodec 中解码,那会在哪里解码呢?你可以回顾第 20 课时介绍的 DecodeHandlerTransport 层),它的 received() 方法也是可以进行解码的另外DecodeableRpcInvocation 中有一个 hasDecoded 字段来判断当前是否已经完成解码,这样,三者配合就可以根据 DECODE_IN_IO_THREAD_KEY 参数决定执行解码操作的线程了。
如果你对线程模型不清楚,可以依次回顾一下 Exchangers、HeaderExchanger、Transporters 三个门面类的 bind() 方法,以及 Dispatcher 各实现提供的线程模型,搞清楚各个 ChannelHandler 是由哪个线程执行的,这些知识点在前面课时都介绍过了,不再重复。这里我们就直接以 AllDispatcher 实现为例给出结论。
IO 线程内执行的 ChannelHandler 实现依次有InternalEncoder、InternalDecoder两者底层都是调用 DubboCodec、IdleStateHandler、MultiMessageHandler、HeartbeatHandler 和 NettyServerHandler。
在非 IO 线程内执行的 ChannelHandler 实现依次有DecodeHandler、HeaderExchangeHandler 和 DubboProtocol$requestHandler。
在 DubboProtocol 中有一个 requestHandler 字段,它是一个实现了 ExchangeHandlerAdapter 抽象类的匿名内部类的实例,间接实现了 ExchangeHandler 接口,其核心是 reply() 方法,具体实现如下:
public CompletableFuture<Object> reply(ExchangeChannel channel, Object message) throws RemotingException {
... // 这里省略了检查message类型的逻辑通过前面Handler的处理这里收到的message必须是Invocation类型的对象
Invocation inv = (Invocation) message;
// 获取此次调用Invoker对象
Invoker<?> invoker = getInvoker(channel, inv);
... // 针对客户端回调的内容,在后面详细介绍,这里不再展开分析
// 将客户端的地址记录到RpcContext中
RpcContext.getContext().setRemoteAddress(channel.getRemoteAddress());
// 执行真正的调用
Result result = invoker.invoke(inv);
// 返回结果
return result.thenApply(Function.identity());
}
其中 getInvoker() 方法会先根据 Invocation 携带的信息构造 ServiceKey然后从 exporterMap 集合中查找对应的 DubboExporter 对象,并从中获取底层的 Invoker 对象返回,具体实现如下:
Invoker<?> getInvoker(Channel channel, Invocation inv) throws RemotingException {
... // 省略对客户端Callback以及stub的处理逻辑后面单独介绍
String serviceKey = serviceKey(port, path, (String) inv.getObjectAttachments().get(VERSION_KEY),
(String) inv.getObjectAttachments().get(GROUP_KEY));
DubboExporter<?> exporter = (DubboExporter<?>) exporterMap.get(serviceKey);
... // 查找不到相应的DubboExporter对象时会直接抛出异常这里省略了这个检测
return exporter.getInvoker(); // 获取exporter中获取Invoker对象
}
到这里,我们终于见到了对 Invoker 对象的调用,对 Invoker 实现的介绍和分析,在后面课时我们会深入介绍,这里就先专注于 DubboProtocol 的相关内容。
3. 序列化优化处理
下面我们回到 DubboProtocol.export() 方法继续分析,在完成 ProtocolServer 的启动之后export() 方法最后会调用 optimizeSerialization() 方法对指定的序列化算法进行优化。
这里先介绍一个基础知识,在使用某些序列化算法(例如, Kryo、FST 等)时,为了让其能发挥出最佳的性能,最好将那些需要被序列化的类提前注册到 Dubbo 系统中。例如,我们可以通过一个实现了 SerializationOptimizer 接口的优化器,并在配置中指定该优化器,如下示例代码:
public class SerializationOptimizerImpl implements SerializationOptimizer {
public Collection<Class> getSerializableClasses() {
List<Class> classes = new ArrayList<>();
classes.add(xxxx.class); // 添加需要被序列化的类
return classes;
}
}
在 DubboProtocol.optimizeSerialization() 方法中,就会获取该优化器中注册的类,通知底层的序列化算法进行优化,序列化的性能将会被大大提升。当然,在进行序列化的时候,难免会级联到很多 Java 内部的类例如数组、各种集合类型等Kryo、FST 等序列化算法已经自动将JDK 中的常用类进行了注册,所以无须重复注册它们。
下面我们回头来看 optimizeSerialization() 方法,分析序列化优化操作的具体实现细节:
private void optimizeSerialization(URL url) throws RpcException {
// 根据URL中的optimizer参数值确定SerializationOptimizer接口的实现类
String className = url.getParameter(OPTIMIZER_KEY, "");
Class clazz = Thread.currentThread().getContextClassLoader().loadClass(className);
// 创建SerializationOptimizer实现类的对象
SerializationOptimizer optimizer = (SerializationOptimizer) clazz.newInstance();
// 调用getSerializableClasses()方法获取需要注册的类
for (Class c : optimizer.getSerializableClasses()) {
SerializableClassRegistry.registerClass(c);
}
optimizers.add(className);
}
SerializableClassRegistry 底层维护了一个 static 的 MapREGISTRATIONS 字段registerClass() 方法就是将待优化的类写入该集合中暂存,在使用 Kryo、FST 等序列化算法时,会读取该集合中的类,完成注册操作,相关的调用关系如下图所示:
getRegisteredClasses() 方法的调用位置
按照 Dubbo 官方文档的说法即使不注册任何类进行优化Kryo 和 FST 的性能依然普遍优于Hessian2 和 Dubbo 序列化。
总结
本课时我们重点介绍了 DubboProtocol 发布一个 Dubbo 服务的核心流程。首先,我们介绍了 AbstractProtocol 这个抽象类为 Protocol 实现类提供的公共能力和字段,然后我们结合 Dubbo 协议对应的 DubboProtocol 实现,讲解了发布一个 Dubbo 服务的核心流程其中涉及整个服务端核心启动流程、RpcInvocation 实现、DubboProtocol.requestHandler 字段调用 Invoker 对象以及序列化相关的优化处理等内容。

View File

@@ -0,0 +1,370 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 从 Protocol 起手,看服务暴露和服务引用的全流程(下)
在上一课时,我们以 DubboProtocol 实现为基础,详细介绍了 Dubbo 服务发布的核心流程。在本课时,我们继续介绍 DubboProtocol 中服务引用相关的实现。
refer 流程
下面我们开始介绍 DubboProtocol 中引用服务的相关实现,其核心实现在 protocolBindingRefer() 方法中:
public <T> Invoker<T> protocolBindingRefer(Class<T> serviceType, URL url) throws RpcException {
optimizeSerialization(url); // 进行序列化优化,注册需要优化的类
// 创建DubboInvoker对象
DubboInvoker<T> invoker = new DubboInvoker<T>(serviceType, url, getClients(url), invokers);
// 将上面创建DubboInvoker对象添加到invoker集合之中
invokers.add(invoker);
return invoker;
}
关于 DubboInvoker 的具体实现我们先暂时不做深入分析。这里我们需要先关注的是getClients() 方法,它创建了底层发送请求和接收响应的 Client 集合,其核心分为了两个部分,一个是针对共享连接的处理,另一个是针对独享连接的处理,具体实现如下:
private ExchangeClient[] getClients(URL url) {
// 是否使用共享连接
boolean useShareConnect = false;
// CONNECTIONS_KEY参数值决定了后续建立连接的数量
int connections = url.getParameter(CONNECTIONS_KEY, 0);
List<ReferenceCountExchangeClient> shareClients = null;
if (connections == 0) { // 如果没有连接数的相关配置,默认使用共享连接的方式
useShareConnect = true;
// 确定建立共享连接的条数,默认只建立一条共享连接
String shareConnectionsStr = url.getParameter(SHARE_CONNECTIONS_KEY, (String) null);
connections = Integer.parseInt(StringUtils.isBlank(shareConnectionsStr) ? ConfigUtils.getProperty(SHARE_CONNECTIONS_KEY,
DEFAULT_SHARE_CONNECTIONS) : shareConnectionsStr);
// 创建公共ExchangeClient集合
shareClients = getSharedClient(url, connections);
}
// 整理要返回的ExchangeClient集合
ExchangeClient[] clients = new ExchangeClient[connections];
for (int i = 0; i < clients.length; i++) {
if (useShareConnect) {
clients[i] = shareClients.get(i);
} else {
// 不使用公共连接的情况下会创建单独的ExchangeClient实例
clients[i] = initClient(url);
}
}
return clients;
}
当使用独享连接的时候对每个 Service 建立固定数量的 Client每个 Client 维护一个底层连接如下图所示就是针对每个 Service 都启动了两个独享连接
Service 独享连接示意图
当使用共享连接的时候会区分不同的网络地址host:port一个地址只建立固定数量的共享连接如下图所示Provider 1 暴露了多个服务Consumer 引用了 Provider 1 中的多个服务共享连接是说 Consumer 调用 Provider 1 中的多个服务时是通过固定数量的共享 TCP 长连接进行数据传输这样就可以达到减少服务端连接数的目的
Service 共享连接示意图
那怎么去创建共享连接呢创建共享连接的实现细节是在 getSharedClient() 方法中它首先从 referenceClientMap 缓存Map`> 类型)中查询 Keyhost 和 port 拼接成的字符串)对应的共享 Client 集合,如果查找到的 Client 集合全部可用,则直接使用这些缓存的 Client否则要创建新的 Client 来补充替换缓存中不可用的 Client。示例代码如下
private List<ReferenceCountExchangeClient> getSharedClient(URL url, int connectNum) {
String key = url.getAddress(); // 获取对端的地址(host:port)
// 从referenceClientMap集合中获取与该地址连接的ReferenceCountExchangeClient集合
List<ReferenceCountExchangeClient> clients = referenceClientMap.get(key);
// checkClientCanUse()方法中会检测clients集合中的客户端是否全部可用
if (checkClientCanUse(clients)) {
batchClientRefIncr(clients); // 客户端全部可用时
return clients;
}
locks.putIfAbsent(key, new Object());
synchronized (locks.get(key)) { // 针对指定地址的客户端进行加锁,分区加锁可以提高并发度
clients = referenceClientMap.get(key);
if (checkClientCanUse(clients)) { // double check再次检测客户端是否全部可用
batchClientRefIncr(clients); // 增加应用Client的次数
return clients;
}
connectNum = Math.max(connectNum, 1); // 至少一个共享连接
// 如果当前Clients集合为空则直接通过initClient()方法初始化所有共享客户端
if (CollectionUtils.isEmpty(clients)) {
clients = buildReferenceCountExchangeClientList(url, connectNum);
referenceClientMap.put(key, clients);
} else { // 如果只有部分共享客户端不可用,则只需要处理这些不可用的客户端
for (int i = 0; i < clients.size(); i++) {
ReferenceCountExchangeClient referenceCountExchangeClient = clients.get(i);
if (referenceCountExchangeClient == null || referenceCountExchangeClient.isClosed()) {
clients.set(i, buildReferenceCountExchangeClient(url));
continue;
}
// 增加引用
referenceCountExchangeClient.incrementAndGetCount();
}
}
// 清理locks集合中的锁对象防止内存泄漏如果key对应的服务宕机或是下线
// 这里不进行清理的话这个用于加锁的Object对象是无法被GC的从而出现内存泄漏
locks.remove(key);
return clients;
}
}
这里使用的 ExchangeClient 实现是 ReferenceCountExchangeClient它是 ExchangeClient 的一个装饰器在原始 ExchangeClient 对象基础上添加了引用计数的功能
ReferenceCountExchangeClient 中除了持有被修饰的 ExchangeClient 对象外还有一个 referenceCount 字段AtomicInteger 类型用于记录该 Client 被应用的次数从下图中我们可以看到 ReferenceCountExchangeClient 的构造方法以及 incrementAndGetCount() 方法中会增加引用次数 close() 方法中则会减少引用次数
referenceCount 修改调用栈
这样对于同一个地址的共享连接就可以满足两个基本需求
当引用次数减到 0 的时候ExchangeClient 连接关闭
当引用次数未减到 0 的时候底层的 ExchangeClient 不能关闭
还有一个需要注意的细节是 ReferenceCountExchangeClient.close() 方法在关闭底层 ExchangeClient 对象之后会立即创建一个 LazyConnectExchangeClient 也有人称其为幽灵连接具体逻辑如下所示这里的 LazyConnectExchangeClient 主要用于异常情况的兜底
public void close(int timeout) {
// 引用次数减到0关闭底层的ExchangeClient具体操作有停掉心跳任务重连任务以及关闭底层Channel这些在前文介绍HeaderExchangeClient的时候已经详细分析过了这里不再赘述
if (referenceCount.decrementAndGet() <= 0) {
if (timeout == 0) {
client.close();
} else {
client.close(timeout);
}
// 创建LazyConnectExchangeClient并将client字段指向该对象
replaceWithLazyClient();
}
}
private void replaceWithLazyClient() {
// 在原有的URL之上添加一些LazyConnectExchangeClient特有的参数
URL lazyUrl = URLBuilder.from(url)
.addParameter(LAZY_CONNECT_INITIAL_STATE_KEY, Boolean.TRUE)
.addParameter(RECONNECT_KEY, Boolean.FALSE)
.addParameter(SEND_RECONNECT_KEY, Boolean.TRUE.toString())
.addParameter("warning", Boolean.TRUE.toString())
.addParameter(LazyConnectExchangeClient.REQUEST_WITH_WARNING_KEY, true)
.addParameter("_client_memo", "referencecounthandler.replacewithlazyclient")
.build();
// 如果当前client字段已经指向了LazyConnectExchangeClient则不需要再次创建LazyConnectExchangeClient兜底了
if (!(client instanceof LazyConnectExchangeClient) || client.isClosed()) {
// ChannelHandler依旧使用原始ExchangeClient使用的Handler即DubboProtocol中的requestHandler字段
client = new LazyConnectExchangeClient(lazyUrl, client.getExchangeHandler());
}
}
LazyConnectExchangeClient 也是 ExchangeClient 的装饰器它会在原有 ExchangeClient 对象的基础上添加懒加载的功能LazyConnectExchangeClient 在构造方法中不会创建底层持有连接的 Client而是在需要发送请求的时候才会调用 initClient() 方法进行 Client 的创建如下图调用关系所示
initClient() 方法的调用位置
initClient() 方法的具体实现如下
private void initClient() throws RemotingException {
if (client != null) { // 底层Client已经初始化过了这里不再初始化
return;
}
connectLock.lock();
try {
if (client != null) { return; } // double check
// 通过Exchangers门面类创建ExchangeClient对象
this.client = Exchangers.connect(url, requestHandler);
} finally {
connectLock.unlock();
}
}
在这些发送请求的方法中除了通过 initClient() 方法初始化底层 ExchangeClient 还会调用warning() 方法其会根据当前 URL 携带的参数决定是否打印 WARN 级别日志为了防止瞬间打印大量日志的情况发生这里有打印的频率限制默认每发送 5000 次请求打印 1 条日志你可以看到在前面展示的兜底场景中我们就开启了打印日志的选项
分析完 getSharedClient() 方法创建共享 Client 的核心流程之后我们回到 DubboProtocol 继续介绍创建独享 Client 的流程
创建独享 Client 的入口在DubboProtocol.initClient() 方法它首先会在 URL 中设置一些默认的参数然后根据 LAZY_CONNECT_KEY 参数决定是否使用 LazyConnectExchangeClient 进行封装实现懒加载功能如下代码所示
private ExchangeClient initClient(URL url) {
// 获取客户端扩展名并进行检查省略检测的逻辑
String str = url.getParameter(CLIENT_KEY, url.getParameter(SERVER_KEY, DEFAULT_REMOTING_CLIENT));
// 设置Codec2的扩展名
url = url.addParameter(CODEC_KEY, DubboCodec.NAME);
// 设置默认的心跳间隔
url = url.addParameterIfAbsent(HEARTBEAT_KEY, String.valueOf(DEFAULT_HEARTBEAT));
ExchangeClient client;
// 如果配置了延迟创建连接的特性则创建LazyConnectExchangeClient
if (url.getParameter(LAZY_CONNECT_KEY, false)) {
client = new LazyConnectExchangeClient(url, requestHandler);
} else { // 未使用延迟连接功能则直接创建HeaderExchangeClient
client = Exchangers.connect(url, requestHandler);
}
return client;
}
这里涉及的 LazyConnectExchangeClient 装饰器以及 Exchangers 门面类在前面已经深入分析过了就不再赘述了
DubboProtocol 中还剩下几个方法没有介绍这里你只需要简单了解一下它们的实现即可
batchClientRefIncr() 方法会遍历传入的集合将其中的每个 ReferenceCountExchangeClient 对象的引用加一
buildReferenceCountExchangeClient() 方法会调用上面介绍的 initClient() 创建 Client 对象然后再包装一层 ReferenceCountExchangeClient 进行修饰最后返回该方法主要用于创建共享 Client
destroy方法
DubboProtocol 销毁的时候会调用 destroy() 方法释放底层资源其中就涉及 export 流程中创建的 ProtocolServer 对象以及 refer 流程中创建的 Client
DubboProtocol.destroy() 方法首先会逐个关闭 serverMap 集合中的 ProtocolServer 对象相关代码片段如下
for (String key : new ArrayList<>(serverMap.keySet())) {
ProtocolServer protocolServer = serverMap.remove(key);
if (protocolServer == null) { continue;}
RemotingServer server = protocolServer.getRemotingServer();
// 在close()方法中发送ReadOnly请求、阻塞指定时间、关闭底层的定时任务、关闭相关线程池最终会断开所有连接关闭Server。这些逻辑在前文介绍HeaderExchangeServer、NettyServer等实现的时候已经详细分析过了这里不再展开
server.close(ConfigurationUtils.getServerShutdownTimeout());
}
ConfigurationUtils.getServerShutdownTimeout() 方法返回的阻塞时长默认是 10 秒,我们可以通过 dubbo.service.shutdown.wait 或是 dubbo.service.shutdown.wait.seconds 进行配置。
之后DubboProtocol.destroy() 方法会逐个关闭 referenceClientMap 集合中的 Client逻辑与上述关闭ProtocolServer的逻辑相同这里不再重复。只不过需要注意前面我们提到的 ReferenceCountExchangeClient 的存在,只有引用减到 0底层的 Client 才会真正销毁。
最后DubboProtocol.destroy() 方法会调用父类 AbstractProtocol 的 destroy() 方法,销毁全部 Invoker 对象,前面已经介绍过 AbstractProtocol.destroy() 方法的实现,这里也不再重复。
总结
本课时我们继续上一课时的话题,以 DubboProtocol 为例,介绍了 Dubbo 在 Protocol 层实现服务引用的核心流程。我们首先介绍了 DubboProtocol 初始化 Client 的核心逻辑分析了共享连接和独立连接的模型后续还讲解了ReferenceCountExchangeClient、LazyConnectExchangeClient 等装饰器的功能和实现,最后说明了 destroy() 方法释放底层资源的相关实现。
关于 DubboProtocol你若还有什么疑问或想法欢迎你留言跟我分享。下一课时我们将开始深入介绍 Dubbo 的“心脏”—— Invoker 接口的相关实现,这是我们的一篇加餐文章,记得按时来听课。

View File

@@ -0,0 +1,381 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker
在前面课时介绍 DubboProtocol 的时候我们看到,上层业务 Bean 会被封装成 Invoker 对象,然后传入 DubboProtocol.export() 方法中,该 Invoker 被封装成 DubboExporter并保存到 exporterMap 集合中缓存。
在 DubboProtocol 暴露的 ProtocolServer 收到请求时,经过一系列解码处理,最终会到达 DubboProtocol.requestHandler 这个 ExchangeHandler 对象中,该 ExchangeHandler 对象会从 exporterMap 集合中取出请求的 Invoker并调用其 invoke() 方法处理请求。
DubboProtocol.protocolBindingRefer() 方法则会将底层的 ExchangeClient 集合封装成 DubboInvoker然后由上层逻辑封装成代理对象这样业务层就可以像调用本地 Bean 一样,完成远程调用。
深入 Invoker
首先,我们来看 AbstractInvoker 这个抽象类,它继承了 Invoker 接口,继承关系如下图所示:
AbstractInvoker 继承关系示意图
从图中可以看到,最核心的 DubboInvoker 继承自AbstractInvoker 抽象类AbstractInvoker 的核心字段有如下几个。
typeClass<T> 类型):该 Invoker 对象封装的业务接口类型,例如 Demo 示例中的 DemoService 接口。
urlURL 类型):与当前 Invoker 关联的 URL 对象,其中包含了全部的配置信息。
attachmentMap 类型):当前 Invoker 关联的一些附加信息,这些附加信息可以来自关联的 URL。在 AbstractInvoker 的构造函数的某个重载中,会调用 convertAttachment() 方法,其中就会从关联的 URL 对象获取指定的 KV 值记录到 attachment 集合中。
availablevolatile boolean类型、destroyedAtomicBoolean 类型):这两个字段用来控制当前 Invoker 的状态。available 默认值为 truedestroyed 默认值为 false。在 destroy() 方法中会将 available 设置为 false将 destroyed 字段设置为 true。
在 AbstractInvoker 中实现了 Invoker 接口中的 invoke() 方法,这里有点模板方法模式的感觉,其中先对 URL 中的配置信息以及 RpcContext 中携带的附加信息进行处理,添加到 Invocation 中作为附加信息,然后调用 doInvoke() 方法发起远程调用(该方法由 AbstractInvoker 的子类具体实现),最后得到 AsyncRpcResult 对象返回。
public Result invoke(Invocation inv) throws RpcException {
// 首先将传入的Invocation转换为RpcInvocation
RpcInvocation invocation = (RpcInvocation) inv;
invocation.setInvoker(this);
// 将前文介绍的attachment集合添加为Invocation的附加信息
if (CollectionUtils.isNotEmptyMap(attachment)) {
invocation.addObjectAttachmentsIfAbsent(attachment);
}
// 将RpcContext的附加信息添加为Invocation的附加信息
Map<String, Object> contextAttachments = RpcContext.getContext().getObjectAttachments();
if (CollectionUtils.isNotEmptyMap(contextAttachments)) {
invocation.addObjectAttachments(contextAttachments);
}
// 设置此次调用的模式,异步还是同步
invocation.setInvokeMode(RpcUtils.getInvokeMode(url, invocation));
// 如果是异步调用给这次调用添加一个唯一ID
RpcUtils.attachInvocationIdIfAsync(getUrl(), invocation);
AsyncRpcResult asyncResult;
try { // 调用子类实现的doInvoke()方法
asyncResult = (AsyncRpcResult) doInvoke(invocation);
} catch (InvocationTargetException e) {// 省略异常处理的逻辑
} catch (RpcException e) { // 省略异常处理的逻辑
} catch (Throwable e) {
asyncResult = AsyncRpcResult.newDefaultAsyncResult(null, e, invocation);
}
RpcContext.getContext().setFuture(new FutureAdapter(asyncResult.getResponseFuture()));
return asyncResult;
}
接下来,需要深入介绍的第一个类是 RpcContext。
RpcContext
RpcContext 是线程级别的上下文信息,每个线程绑定一个 RpcContext 对象,底层依赖 ThreadLocal 实现。RpcContext 主要用于存储一个线程中一次请求的临时状态当线程处理新的请求Provider 端或是线程发起新的请求Consumer 端RpcContext 中存储的内容就会更新。
下面来看 RpcContext 中两个InternalThreadLocal的核心字段这两个字段的定义如下所示
// 在发起请求时会使用该RpcContext来存储上下文信息
private static final InternalThreadLocal<RpcContext> LOCAL = new InternalThreadLocal<RpcContext>() {
@Override
protected RpcContext initialValue() {
return new RpcContext();
}
};
// 在接收到响应的时候会使用该RpcContext来存储上下文信息
private static final InternalThreadLocal<RpcContext> SERVER_LOCAL = ...
JDK 提供的 ThreadLocal 底层实现大致如下:对于不同线程创建对应的 ThreadLocalMap用于存放线程绑定信息当用户调用ThreadLocal.get() 方法获取变量时,底层会先获取当前线程 Thread然后获取绑定到当前线程 Thread 的 ThreadLocalMap最后将当前 ThreadLocal 对象作为 Key 去 ThreadLocalMap 表中获取线程绑定的数据。ThreadLocal.set() 方法的逻辑与之类似,首先会获取绑定到当前线程的 ThreadLocalMap然后将 ThreadLocal 实例作为 Key、待存储的数据作为 Value 存储到 ThreadLocalMap 中。
Dubbo 的 InternalThreadLocal 与 JDK 提供的 ThreadLocal 功能类似,只是底层实现略有不同,其底层的 InternalThreadLocalMap 采用数组结构存储数据,直接通过 index 获取变量,相较于 Map 方式计算 hash 值的性能更好。
这里我们来介绍一下 dubbo-common 模块中的 InternalThread 这个类,它继承了 Thread 类Dubbo 的线程工厂 NamedInternalThreadFactory 创建的线程类其实都是 InternalThread 实例对象,你可以回顾前面第 19 课时介绍的 ThreadPool 接口实现,它们都是通过 NamedInternalThreadFactory 这个工厂类来创建线程的。
InternalThread 中主要提供了 setThreadLocalMap() 和 threadLocalMap() 两个方法,用于设置和获取 InternalThreadLocalMap。InternalThreadLocalMap 中的核心字段有如下四个。
indexedVariablesObject[] 类型):用于存储绑定到当前线程的数据。
NEXT_INDEXAtomicInteger 类型):自增索引,用于计算下次存储到 indexedVariables 数组中的位置,这是一个静态字段。
slowThreadLocalMapThreadLocal<InternalThreadLocalMap> 类型):当使用原生 Thread 的时候,会使用该 ThreadLocal 存储 InternalThreadLocalMap这是一个降级策略。
UNSETObject 类型):当一个与线程绑定的值被删除之后,会被设置为 UNSET 值。
在 InternalThreadLocalMap 中获取当前线程绑定的InternalThreadLocaMap的静态方法都会与 slowThreadLocalMap 字段配合实现降级,也就是说,如果当前线程为原生 Thread 类型,则根据 slowThreadLocalMap 获取InternalThreadLocalMap。这里我们以 getIfSet() 方法为例:
public static InternalThreadLocalMap getIfSet() {
Thread thread = Thread.currentThread(); // 获取当前线程
if (thread instanceof InternalThread) { // 判断当前线程的类型
// 如果是InternalThread类型直接获取InternalThreadLocalMap返回
return ((InternalThread) thread).threadLocalMap();
}
// 原生Thread则需要通过ThreadLocal获取InternalThreadLocalMap
return slowThreadLocalMap.get();
}
InternalThreadLocalMap 中的 get()、remove()、set() 等方法都有类似的降级操作,这里不再一一重复。
在拿到 InternalThreadLocalMap 对象之后,我们就可以调用其 setIndexedVariable() 方法和 indexedVariable() 方法读写这里我们得结合InternalThreadLocal进行讲解。在 InternalThreadLocal 的构造方法中,会使用 InternalThreadLocalMap.NEXT_INDEX 初始化其 index 字段int 类型),在 InternalThreadLocal.set() 方法中就会将传入的数据存储到 InternalThreadLocalMap.indexedVariables 集合中,具体的下标位置就是这里的 index 字段值:
public final void set(V value) {
if (value == null|| value == InternalThreadLocalMap.UNSET{
remove(); // 如果要存储的值为null或是UNSERT则直接清除
} else {
// 获取当前线程绑定的InternalThreadLocalMap
InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
// 将value存储到InternalThreadLocalMap.indexedVariables集合中
if (threadLocalMap.setIndexedVariable(index, value)) {
// 将当前InternalThreadLocal记录到待删除集合中
addToVariablesToRemove(threadLocalMap, this);
}
}
}
InternalThreadLocal 的静态变量 VARIABLES_TO_REMOVE_INDEX 是调用InternalThreadLocalMap 的 nextVariableIndex 方法得到的一个索引值,在 InternalThreadLocalMap 数组的对应位置保存的是 Set<InternalThreadLocal> 类型的集合,也就是上面提到的“待删除集合”,即绑定到当前线程所有的 InternalThreadLocal这样就可以方便管理对象及内存的释放。
接下来我们继续看 InternalThreadLocalMap.setIndexedVariable() 方法的实现:
public boolean setIndexedVariable(int index, Object value) {
Object[] lookup = indexedVariables;
if (index < lookup.length) { // 将value存储到index指定的位置
Object oldValue = lookup[index];
lookup[index] = value;
return oldValue == UNSET;
} else {
// 当index超过indexedVariables数组的长度时需要对indexedVariables数组进行扩容
expandIndexedVariableTableAndSet(index, value);
return true;
}
}
明确了设置 InternalThreadLocal 变量的流程之后我们再来分析读取 InternalThreadLocal 变量的流程入口在 InternalThreadLocal get() 方法
public final V get() {
// 获取当前线程绑定的InternalThreadLocalMap
InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
// 根据当前InternalThreadLocal对象的index字段从InternalThreadLocalMap中读取相应的数据
Object v = threadLocalMap.indexedVariable(index);
if (v != InternalThreadLocalMap.UNSET) {
return (V) v; // 如果非UNSET则表示读取到了有效数据直接返回
}
// 读取到UNSET值则会调用initialize()方法进行初始化其中首先会调用initialValue()方法进行初始化然后会调用前面介绍的setIndexedVariable()方法和addToVariablesToRemove()方法存储初始化得到的值
return initialize(threadLocalMap);
}
我们可以看到 RpcContext LOCAL SERVER_LOCAL 两个 InternalThreadLocal 类型的字段都实现了 initialValue() 方法它们的实现都是创建并返回 RpcContext 对象
理解了 InternalThreadLocal 的底层原理之后我们回到 RpcContext 继续分析RpcContext 作为调用的上下文信息可以记录非常多的信息下面介绍其中的一些核心字段
attachmentsMap 类型可用于记录调用上下文的附加信息这些信息会被添加到 Invocation 并传递到远端节点
valuesMap 类型用来记录上下文的键值对信息但是不会被传递到远端节点
methodNameparameterTypesarguments分别用来记录调用的方法名参数类型列表以及具体的参数列表与相关 Invocation 对象中的信息一致
localAddressremoteAddressInetSocketAddress 类型记录了自己和远端的地址
requestresponseObject 类型可用于记录底层关联的请求和响应
asyncContextAsyncContext 类型异步Context其中可以存储异步调用相关的 RpcContext 以及异步请求相关的 Future
DubboInvoker
通过前面对 DubboProtocol 的分析我们知道protocolBindingRefer() 方法会根据调用的业务接口类型以及 URL 创建底层的 ExchangeClient 集合然后封装成 DubboInvoker 对象返回DubboInvoker AbstractInvoker 的实现类在其 doInvoke() 方法中首先会选择此次调用使用 ExchangeClient 对象然后确定此次调用是否需要返回值最后调用 ExchangeClient.request() 方法发送请求对返回的 Future 进行简单封装并返回
protected Result doInvoke(final Invocation invocation) throws Throwable {
RpcInvocation inv = (RpcInvocation) invocation;
// 此次调用的方法名称
final String methodName = RpcUtils.getMethodName(invocation);
// 向Invocation中添加附加信息这里将URL的path和version添加到附加信息中
inv.setAttachment(PATH_KEY, getUrl().getPath());
inv.setAttachment(VERSION_KEY, version);
ExchangeClient currentClient; // 选择一个ExchangeClient实例
if (clients.length == 1) {
currentClient = clients[0];
} else {
currentClient = clients[index.getAndIncrement() % clients.length];
}
boolean isOneway = RpcUtils.isOneway(getUrl(), invocation);
// 根据调用的方法名称和配置计算此次调用的超时时间
int timeout = calculateTimeout(invocation, methodName);
if (isOneway) { // 不需要关注返回值的请求
boolean isSent = getUrl().getMethodParameter(methodName, Constants.SENT_KEY, false);
currentClient.send(inv, isSent);
return AsyncRpcResult.newDefaultAsyncResult(invocation);
} else { // 需要关注返回值的请求
// 获取处理响应的线程池对于同步请求会使用ThreadlessExecutorThreadlessExecutor的原理前面已经分析过了这里不再赘述对于异步请求则会使用共享的线程池ExecutorRepository接口的相关设计和实现在前面已经详细分析过了这里不再重复
ExecutorService executor = getCallbackExecutor(getUrl(), inv);
// 使用上面选出的ExchangeClient执行request()方法将请求发送出去
CompletableFuture<AppResponse> appResponseFuture =
currentClient.request(inv, timeout, executor).thenApply(obj -> (AppResponse) obj);
// 这里将AppResponse封装成AsyncRpcResult返回
AsyncRpcResult result = new AsyncRpcResult(appResponseFuture, inv);
result.setExecutor(executor);
return result;
}
}
在 DubboInvoker.invoke() 方法中有一些细节需要关注一下。首先是根据 URL 以及 Invocation 中的配置决定此次调用是否为oneway 调用方式。
public static boolean isOneway(URL url, Invocation inv) {
boolean isOneway;
if (Boolean.FALSE.toString().equals(inv.getAttachment(RETURN_KEY))) {
isOneway = true; // 首先关注的是Invocation中"return"这个附加属性
} else {
isOneway = !url.getMethodParameter(getMethodName(inv), RETURN_KEY, true); // 之后关注URL中调用方法对应的"return"配置
}
return isOneway;
}
oneway 指的是客户端发送消息后,不需要得到响应。所以,对于那些不关心服务端响应的请求,就比较适合使用 oneway 通信,如下图所示:
oneway 和 twoway 通信方式对比图
可以看到发送 oneway 请求的方式是send() 方法,而后面发送 twoway 请求的方式是 request() 方法。通过之前的分析我们知道request() 方法会相应地创建 DefaultFuture 对象以及检测超时的定时任务,而 send() 方法则不会创建这些东西,它是直接将 Invocation 包装成 oneway 类型的 Request 发送出去。
在服务端的 HeaderExchangeHandler.receive() 方法中,会针对 oneway 请求和 twoway 请求执行不同的分支处理twoway 请求由 handleRequest() 方法进行处理,其中会关注调用结果并形成 Response 返回给客户端oneway 请求则直接交给上层的 DubboProtocol.requestHandler完成方法调用之后不会返回任何 Response。
我们就结合如下示例代码来简单说明一下 HeaderExchangeHandler.request() 方法中的相关片段。
public void received(Channel channel, Object message) throws RemotingException {
final ExchangeChannel exchangeChannel = HeaderExchangeChannel.getOrAddChannel(channel);
if (message instanceof Request) {
if (request.isTwoWay()) {
handleRequest(exchangeChannel, request);
} else {
handler.received(exchangeChannel, request.getData());
}
} else ... // 省略其他分支的展示
}
总结
本课时我们重点介绍了 Dubbo 最核心的接口—— Invoker。首先我们介绍了 AbstractInvoker 抽象类提供的公共能力;然后分析了 RpcContext 的功能和涉及的组件例如InternalThreadLocal、InternalThreadLocalMap 等;最后我们说明了 DubboInvoker 对 doinvoke() 方法的实现,并区分了 oneway 和 twoway 两种类型的请求。
下一课时,我们将继续介绍 DubboInvoker 的实现。

View File

@@ -0,0 +1,505 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker
关于 DubboInvoker在发送完oneway 请求之后,会立即创建一个已完成状态的 AsyncRpcResult 对象(主要是其中的 responseFuture 是已完成状态)。这在上一课时我们已经讲解过了。
本课时我们将继续介绍 DubboInvoker 处理 twoway 请求和响应的相关实现,其中会涉及响应解码、同步/异步响应等相关内容;完成对 DubboInvoker 的分析之后,我们还会介绍 Dubbo 中与 Listener、Filter 相关的 Invoker 装饰器。
再探 DubboInvoker
那 DubboInvoker 对twoway 请求的处理又是怎样的呢接下来我们就来重点介绍下。首先DubboInvoker 会调用 getCallbackExecutor() 方法,根据不同的 InvokeMode 返回不同的线程池实现,代码如下:
protected ExecutorService getCallbackExecutor(URL url, Invocation inv) {
ExecutorService sharedExecutor = ExtensionLoader.getExtensionLoader(ExecutorRepository.class).getDefaultExtension().getExecutor(url);
if (InvokeMode.SYNC == RpcUtils.getInvokeMode(getUrl(), inv)) {
return new ThreadlessExecutor(sharedExecutor);
} else {
return sharedExecutor;
}
}
InvokeMode 有三个可选值,分别是 SYNC、ASYNC 和 FUTURE。这里对于 SYNC 模式返回的线程池是 ThreadlessExecutor至于其他两种异步模式会根据 URL 选择对应的共享线程池。
SYNC 表示同步模式,是 Dubbo 的默认调用模式,具体含义如下图所示,客户端发送请求之后,客户端线程会阻塞等待服务端返回响应。
SYNC 调用模式图
在拿到线程池之后DubboInvoker 就会调用 ExchangeClient.request() 方法,将 Invocation 包装成 Request 请求发送出去,同时会创建相应的 DefaultFuture 返回。注意,这里还加了一个回调,取出其中的 AppResponse 对象。AppResponse 表示的是服务端返回的具体响应,其中有三个字段。
resultObject 类型):响应结果,也就是服务端返回的结果值,注意,这是一个业务上的结果值。例如,在我们前面第 01 课时的 Demo 示例(即 dubbo-demo 模块中的 DemoProvider 端 DemoServiceImpl 返回的 “Hello Dubbo xxx” 这一串字符串。
exceptionThrowable 类型):服务端返回的异常信息。
attachmentsMap 类型):服务端返回的附加信息。
这里请求返回的 AppResponse 你可能不太熟悉,但是其子类 DecodeableRpcResult 你可能就有点眼熟了DecodeableRpcResult 表示的是一个响应,与其对应的是 DecodeableRpcInvocation它表示的是请求。在第 24 课时介绍 DubboCodec 对 Dubbo 请求体的编码流程中,我们已经详细介绍过 DecodeableRpcInvocation 了,你可以回顾一下 DubboCodec 的 decodeBody() 方法,就会发现 DecodeableRpcResult 的“身影”。
1. DecodeableRpcResult
DecodeableRpcResult 解码核心流程大致如下:
首先,确定当前使用的序列化方式,并对字节流进行解码。
然后,读取一个 byte 的标志位,其可选值有六种枚举,下面我们就以其中的 RESPONSE_VALUE_WITH_ATTACHMENTS 为例进行分析。
标志位为 RESPONSE_VALUE_WITH_ATTACHMENTS 时,会先通过 handleValue() 方法处理返回值,其中会根据 RpcInvocation 中记录的返回值类型读取返回值,并设置到 result 字段。
最后,再通过 handleAttachment() 方法读取返回的附加信息,并设置到 DecodeableRpcResult 的 attachments 字段中。
public Object decode(Channel channel, InputStream input) throws IOException {
// 反序列化
ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), serializationType)
.deserialize(channel.getUrl(), input);
byte flag = in.readByte(); // 读取一个byte的标志位
// 根据标志位判断当前结果中包含的信息,并调用不同的方法进行处理
switch (flag) {
case DubboCodec.RESPONSE_NULL_VALUE:
break;
case DubboCodec.RESPONSE_VALUE:
handleValue(in);
break;
case DubboCodec.RESPONSE_WITH_EXCEPTION:
handleException(in);
break;
case DubboCodec.RESPONSE_NULL_VALUE_WITH_ATTACHMENTS:
handleAttachment(in);
break;
case DubboCodec.RESPONSE_VALUE_WITH_ATTACHMENTS:
handleValue(in);
handleAttachment(in);
break;
case DubboCodec.RESPONSE_WITH_EXCEPTION_WITH_ATTACHMENTS:
default:
throw new IOException("..." );
}
if (in instanceof Cleanable) {
((Cleanable) in).cleanup();
}
return this;
}
decode() 方法中其他分支的代码这里就不再展示了,你若感兴趣的话可以参考 DecodeableRpcResult 源码进行分析。
2. AsyncRpcResult
在 DubboInvoker 中还有一个 AsyncRpcResult 类,它表示的是一个异步的、未完成的 RPC 调用,其中会记录对应 RPC 调用的信息(例如,关联的 RpcContext 和 Invocation 对象),包括以下几个核心字段。
responseFutureCompletableFuture<AppResponse> 类型):这个 responseFuture 字段与前文提到的 DefaultFuture 有紧密的联系,是 DefaultFuture 回调链上的一个 Future。后面 AsyncRpcResult 之上添加的回调,实际上都是添加到这个 Future 之上。
storedContext、storedServerContextRpcContext 类型):用于存储相关的 RpcContext 对象。我们知道 RpcContext 是与线程绑定的,而真正执行 AsyncRpcResult 上添加的回调方法的线程可能先后处理过多个不同的 AsyncRpcResult所以我们需要传递并保存当前的 RpcContext。
executorExecutor 类型):此次 RPC 调用关联的线程池。
invocationInvocation 类型):此次 RPC 调用关联的 Invocation 对象。
在 AsyncRpcResult 构造方法中,除了接收发送请求返回的 CompletableFuture<AppResponse> 对象,还会将当前的 RpcContext 保存到 storedContext 和 storedServerContext 中,具体实现如下:
public AsyncRpcResult(CompletableFuture<AppResponse> future, Invocation invocation) {
this.responseFuture = future;
this.invocation = invocation;
this.storedContext = RpcContext.getContext();
this.storedServerContext = RpcContext.getServerContext();
}
通过 whenCompleteWithContext() 方法,我们可以为 AsyncRpcResult 添加回调方法,而这个回调方法会被包装一层并注册到 responseFuture 上,具体实现如下:
public Result whenCompleteWithContext(BiConsumer<Result, Throwable> fn) {
// 在responseFuture之上注册回调
this.responseFuture = this.responseFuture.whenComplete((v, t) -> {
beforeContext.accept(v, t);
fn.accept(v, t);
afterContext.accept(v, t);
});
return this;
}
这里的 beforeContext 首先会将当前线程的 RpcContext 记录到 tmpContext 中,然后将构造函数中存储的 RpcContext 设置到当前线程中,为后面的回调执行做准备;而 afterContext 则会恢复线程原有的 RpcContext。具体实现如下
private RpcContext tmpContext;
private RpcContext tmpServerContext;
private BiConsumer<Result, Throwable> beforeContext = (appResponse, t) -> {
// 将当前线程的 RpcContext 记录到 tmpContext 中
tmpContext = RpcContext.getContext();
tmpServerContext = RpcContext.getServerContext();
// 将构造函数中存储的 RpcContext 设置到当前线程中
RpcContext.restoreContext(storedContext);
RpcContext.restoreServerContext(storedServerContext);
};
private BiConsumer<Result, Throwable> afterContext = (appResponse, t) -> {
// 将tmpContext中存储的RpcContext恢复到当前线程绑定的RpcContext
RpcContext.restoreContext(tmpContext);
RpcContext.restoreServerContext(tmpServerContext);
};
这样AsyncRpcResult 就可以处于不断地添加回调而不丢失 RpcContext 的状态。总之AsyncRpcResult 整个就是为异步请求设计的。
在前面的分析中我们看到RpcInvocation.InvokeMode 字段中可以指定调用为 SYNC 模式,也就是同步调用模式,那 AsyncRpcResult 这种异步设计是如何支持同步调用的呢? 在 AbstractProtocol.refer() 方法中Dubbo 会将 DubboProtocol.protocolBindingRefer() 方法返回的 Invoker 对象(即 DubboInvoker 对象)用 AsyncToSyncInvoker 封装一层。
AsyncToSyncInvoker 是 Invoker 的装饰器,负责将异步调用转换成同步调用,其 invoke() 方法的核心实现如下:
public Result invoke(Invocation invocation) throws RpcException {
Result asyncResult = invoker.invoke(invocation);
if (InvokeMode.SYNC == ((RpcInvocation) invocation).getInvokeMode()) {
// 调用get()方法,阻塞等待响应返回
asyncResult.get(Integer.MAX_VALUE, TimeUnit.MILLISECONDS);
}
return asyncResult;
}
其实 AsyncRpcResult.get() 方法底层调用的就是 responseFuture 字段的 get() 方法,对于同步请求来说,会先调用 ThreadlessExecutor.waitAndDrain() 方法阻塞等待响应返回,具体实现如下所示:
public Result get() throws InterruptedException, ExecutionException {
if (executor != null && executor instanceof ThreadlessExecutor) {
// 针对ThreadlessExecutor的特殊处理这里调用waitAndDrain()等待响应
ThreadlessExecutor threadlessExecutor = (ThreadlessExecutor) executor;
threadlessExecutor.waitAndDrain();
}
// 非ThreadlessExecutor线程池的场景中则直接调用Future(最底层是DefaultFuture)的get()方法阻塞
return responseFuture.get();
}
ThreadlessExecutor 针对同步请求的优化,我们在前面的第 20 课时已经详细介绍过了,这里不再重复。
最后要说明的是AsyncRpcResult 实现了 Result 接口,如下图所示:
AsyncRpcResult 继承关系图
AsyncRpcResult 对 Result 接口的实现例如getValue() 方法、recreate() 方法、getAttachments() 方法等,都会先调用 getAppResponse() 方法从 responseFuture 中拿到 AppResponse 对象,然后再调用其对应的方法。这里我们以 recreate() 方法为例,简单分析一下:
public Result getAppResponse() { // 省略异常处理的逻辑
if (responseFuture.isDone()) { // 检测responseFuture是否已完成
return responseFuture.get(); // 获取AppResponse
}
// 根据调用方法的返回值,生成默认值
return createDefaultValue(invocation);
}
public Object recreate() throws Throwable {
RpcInvocation rpcInvocation = (RpcInvocation) invocation;
if (InvokeMode.FUTURE == rpcInvocation.getInvokeMode()) {
return RpcContext.getContext().getFuture();
}
// 调用AppResponse.recreate()方法
return getAppResponse().recreate();
}
AppResponse.recreate() 方法实现比较简单,如下所示:
public Object recreate() throws Throwable {
if (exception != null) { // 存在异常则直接抛出异常
// 省略处理堆栈信息的逻辑
throw exception;
}
return result; // 正常返回无异常时直接返回result
}
这里我们注意到,在 recreate() 方法中AsyncRpcResult 会对 FUTURE 特殊处理。如果服务接口定义的返回参数是 CompletableFuture则属于 FUTURE 模式FUTURE 模式也属于 Dubbo 提供的一种异步调用方式只不过是服务端异步。FUTURE 模式下拿到的 CompletableFuture 对象其实是在 AbstractInvoker 中塞到 RpcContext 中的,在 AbstractInvoker.invoke() 方法中有这么一段代码:
RpcContext.getContext().setFuture(
new FutureAdapter(asyncResult.getResponseFuture()));
这里拿到的其实就是 AsyncRpcResult 中 responseFuture即前面介绍的 DefaultFuture。可见无论是 SYNC 模式、ASYNC 模式还是 FUTURE 模式,都是围绕 DefaultFuture 展开的。
其实,在 Dubbo 2.6.x 及之前的版本提供了一定的异步编程能力,但其异步方式存在如下一些问题:
Future 获取方式不够直接,业务需要从 RpcContext 中手动获取。
Future 接口无法实现自动回调,而自定义 ResponseFuture这是 Dubbo 2.6.x 中类)虽支持回调,但支持的异步场景有限,并且还不支持 Future 间的相互协调或组合等。
不支持 Provider 端异步。
Dubbo 2.6.x 及之前版本中使用的 Future 是在 Java 5 中引入的,所以存在以上一些功能设计上的问题;而在 Java 8 中引入的 CompletableFuture 进一步丰富了 Future 接口很好地解决了这些问题。Dubbo 在 2.7.0 版本已经升级了对 Java 8 的支持,同时基于 CompletableFuture 对当前的异步功能进行了增强,弥补了上述不足。
因为 CompletableFuture 实现了 CompletionStage 和 Future 接口,所以它还是可以像以前一样通过 get() 阻塞或者 isDone() 方法轮询的方式获得结果,这就保证了同步调用依旧可用。当然,在实际工作中,不是很建议用 get() 这样阻塞的方式来获取结果,因为这样就丢失了异步操作带来的性能提升。
另外CompletableFuture 提供了良好的回调方法例如whenComplete()、whenCompleteAsync() 等方法都可以在逻辑完成后,执行该方法中添加的 action 逻辑实现回调的逻辑。同时CompletableFuture 很好地支持了 Future 间的相互协调或组合例如thenApply()、thenApplyAsync() 等方法。
正是由于 CompletableFuture 的增强,我们可以更加流畅地使用回调,不必因为等待一个响应而阻塞着调用线程,而是通过前面介绍的方法告诉 CompletableFuture 完成当前逻辑之后,就去执行某个特定的函数。在 Demo 示例(即 dubbo-demo 模块中的 Demo )中,返回 CompletableFuture 的 sayHelloAsync() 方法就是使用的 FUTURE 模式。
好了DubboInvoker 涉及的同步调用、异步调用的原理和底层实现就介绍到这里了,我们可以通过一张流程图进行简单总结,如下所示:
DubboInvoker 核心流程图
在 Client 端发送请求时,首先会创建对应的 DefaultFuture其中记录了请求 ID 等信息),然后依赖 Netty 的异步发送特性将请求发送到 Server 端。需要说明的是,这整个发送过程是不会阻塞任何线程的。之后,将 DefaultFuture 返回给上层在这个返回过程中DefaultFuture 会被封装成 AsyncRpcResult同时也可以添加回调函数。
当 Client 端接收到响应结果的时候会交给关联的线程池ExecutorService或是业务线程使用 ThreadlessExecutor 场景)进行处理,得到 Server 返回的真正结果。拿到真正的返回结果后,会将其设置到 DefaultFuture 中,并调用 complete() 方法将其设置为完成状态。此时,就会触发前面注册在 DefaulFuture 上的回调函数,执行回调逻辑。
Invoker 装饰器
除了上面介绍的 DubboInvoker 实现之外Invoker 接口还有很多装饰器实现,这里重点介绍 Listener、Filter 相关的 Invoker 实现。
1. ListenerInvokerWrapper
在前面的第 23 课时中简单提到过 InvokerListener 接口,我们可以提供其实现来监听 refer 事件以及 destroy 事件,相应地要实现 referred() 方法以及 destroyed() 方法。
ProtocolListenerWrapper 是 Protocol 接口的实现之一,如下图所示:
ProtocolListenerWrapper 继承关系图
ProtocolListenerWrapper 本身是 Protocol 接口的装饰器,在其 export() 方法和 refer() 方法中,会分别在原有 Invoker 基础上封装一层 ListenerExporterWrapper 和 ListenerInvokerWrapper。
ListenerInvokerWrapper 是 Invoker 的装饰器,其构造方法参数列表中除了被修饰的 Invoker 外,还有 InvokerListener 列表,在构造方法内部会遍历整个 InvokerListener 列表,并调用每个 InvokerListener 的 referred() 方法,通知它们 Invoker 被引用的事件。核心逻辑如下:
public ListenerInvokerWrapper(Invoker<T> invoker, List<InvokerListener> listeners) {
this.invoker = invoker; // 底层被修饰的Invoker对象
this.listeners = listeners; // 监听器集合
if (CollectionUtils.isNotEmpty(listeners)) {
for (InvokerListener listener : listeners) {
if (listener != null) {// 在服务引用过程中触发全部InvokerListener监听器
listener.referred(invoker);
}
}
}
}
在 ListenerInvokerWrapper.destroy() 方法中,首先会调用被修饰 Invoker 对象的 destroy() 方法,之后循环调用全部 InvokerListener 的 destroyed() 方法,通知它们该 Invoker 被销毁的事件,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
与 InvokerListener 对应的是 ExporterListener 监听器,其实现类可以通过实现 exported() 方法和 unexported() 方法监听服务暴露事件以及取消暴露事件。
相应地,在 ProtocolListenerWrapper 的 export() 方法中也会在原有 Invoker 之上用 ListenerExporterWrapper 进行一层封装ListenerExporterWrapper 的构造方法中会循环调用全部 ExporterListener 的 exported() 方法,通知其服务暴露的事件,核心逻辑如下所示:
public ListenerExporterWrapper(Exporter<T> exporter, List<ExporterListener> listeners) {
this.exporter = exporter;
this.listeners = listeners;
if (CollectionUtils.isNotEmpty(listeners)) {
RuntimeException exception = null;
for (ExporterListener listener : listeners) {
if (listener != null) {
listener.exported(this);
}
}
}
}
ListenerExporterWrapper.unexported() 方法的逻辑与上述 exported() 方法的实现基本类似,这里不再赘述。
这里介绍的 ListenerInvokerWrapper 和 ListenerExporterWrapper 都是被 @SPI 注解修饰的,我们可以提供相应的扩展实现,然后配置 SPI 文件监听这些事件。
2. Filter 相关的 Invoker 装饰器
Filter 接口是 Dubbo 为用户提供的一个非常重要的扩展接口,将各个 Filter 串联成 Filter 链并与 Invoker 实例相关。构造 Filter 链的核心逻辑位于 ProtocolFilterWrapper.buildInvokerChain() 方法中ProtocolFilterWrapper 的 refer() 方法和 export() 方法都会调用该方法。
buildInvokerChain() 方法的核心逻辑如下:
首先会根据 URL 中携带的配置信息,确定当前激活的 Filter 扩展实现有哪些,形成 Filter 集合。
遍历 Filter 集合,将每个 Filter 实现封装成一个匿名 Invoker在这个匿名 Invoker 中,会调用 Filter 的 invoke() 方法执行 Filter 的逻辑,然后由 Filter 内部的逻辑决定是否将调用传递到下一个 Filter 执行。
buildInvokerChain() 方法的具体实现如下:
private static <T> Invoker<T> buildInvokerChain(final Invoker<T> invoker, String key, String group) {
Invoker<T> last = invoker;
// 根据 URL 中携带的配置信息,确定当前激活的 Filter 扩展实现有哪些,形成 Filter 集合
List<Filter> filters = ExtensionLoader.getExtensionLoader(Filter.class).getActivateExtension(invoker.getUrl(), key, group);
if (!filters.isEmpty()) {
for (int i = filters.size() - 1; i >= 0; i--) {
final Filter filter = filters.get(i);
final Invoker<T> next = last;
// 遍历 Filter 集合,将每个 Filter 实现封装成一个匿名 Invoker
last = new Invoker<T>() {
@Override
public Result invoke(Invocation invocation) throws RpcException {
Result asyncResult;
try {
// 调用 Filter 的 invoke() 方法执行 Filter 的逻辑,然后由 Filter 内部的逻辑决定是否将调用传递到下一个 Filter 执行
asyncResult = filter.invoke(next, invocation);
} catch (Exception e) {
... // 省略异常时监听器的逻辑
} finally {
}
return asyncResult.whenCompleteWithContext((r, t) -> {
... // 省略监听器的处理逻辑
});
}
};
}
}
return last;
}
在 Filter 接口内部还定义了一个 Listener 接口,有一些 Filter 实现会同时实现这个内部 Listener 接口,当 invoke() 方法执行正常结束时,会调用该 Listener 的 onResponse() 方法进行通知;当 invoke() 方法执行出现异常时,会调用该 Listener 的 onError() 方法进行通知。
另外,还有一个 ListenableFilter 抽象类,它继承了 Filter 接口,在原有 Filter 的基础上添加了一个 listeners 集合ConcurrentMap 集合)用来记录一次请求需要触发的监听器。需要注意的是,在执行 invoke() 调用之前,我们可以调用 addListener() 方法添加 Filter.Listener 实例进行监听,完成一次 invoke() 方法之后,这些添加的 Filter.Listener 实例就会立即从 listeners 集合中删除,也就是说,这些 Filter.Listener 实例不会在调用之间共享。
总结
本课时主要介绍的是 Dubbo 中 Invoker 接口的核心实现,这也是 Dubbo 最核心的实现之一。
紧接上一课时,我们分析了 DubboInvoker 对 twoway 请求的处理逻辑,其中展开介绍了涉及的 DecodeableRpcResult 以及 AsyncRpcResult 等核心类,深入讲解了 Dubbo 的同步、异步调用实现原理,说明了 Dubbo 在 2.7.x 版本之后的相关改进。最后,我们还介绍了 Invoker 接口的几个装饰器,其中涉及用于注册监听器的 ListenerInvokerWrapper 以及 Filter 相关的 Invoker 装饰器。
下一课时,我们将深入介绍 Dubbo RPC 层中代理的相关实现。

View File

@@ -0,0 +1,873 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 复杂问题简单化,代理帮你隐藏了多少底层细节?
在前面介绍 DubboProtocol 的相关实现时,我们知道 Protocol 这一层以及后面介绍的 Cluster 层暴露出来的接口都是 Dubbo 内部的一些概念,业务层无法直接使用。为了让业务逻辑能够无缝使用 Dubbo我们就需要将业务逻辑与 Dubbo 内部概念打通这就用到了动态生成代理对象的功能。Proxy 层在 Dubbo 架构中的位置如下所示(虽然在架构图中 Proxy 层与 Protocol 层距离很远,但 Proxy 的具体代码实现就位于 dubbo-rpc-api 模块中):
Dubbo 架构中 Proxy 层的位置图
在 Consumer 进行调用的时候Dubbo 会通过动态代理将业务接口实现对象转化为相应的 Invoker 对象,然后在 Cluster 层、Protocol 层都会使用 Invoker。在 Provider 暴露服务的时候,也会有 Invoker 对象与业务接口实现对象之间的转换,这同样也是通过动态代理实现的。
实现动态代理的常见方案有JDK 动态代理、CGLib 动态代理和 Javassist 动态代理。这些方案的应用都还是比较广泛的例如Hibernate 底层使用了 Javassist 和 CGLibSpring 使用了 CGLib 和 JDK 动态代理MyBatis 底层使用了 JDK 动态代理和 Javassist。
从性能方面看Javassist 与 CGLib 的实现方式相差无几,两者都比 JDK 动态代理性能要高具体高多少这就要看具体的机器、JDK 版本、测试基准的具体实现等条件了。
Dubbo 提供了两种方式来实现代理,分别是 JDK 动态代理和 Javassist。我们可以在 proxy 这个包内,看到相应工厂类,如下图所示:
ProxyFactory 核心实现的位置
了解了 Proxy 存在的必要性以及 Dubbo 提供的两种代理生成方式之后,下面我们就开始对 Proxy 层的实现进行深入分析。
ProxyFactory
关于 ProxyFactory 接口,我们在前面的第 23 课时中已经介绍过了这里做一下简单回顾。ProxyFactory 是一个扩展接口,其中定义了两个核心方法:一个是 getProxy() 方法,为 Invoker 对象创建代理对象;另一个是 getInvoker() 方法,将代理对象反向封装成 Invoker 对象。
@SPI("javassist")
public interface ProxyFactory {
// 为传入的Invoker对象创建代理对象
@Adaptive({PROXY_KEY})
<T> T getProxy(Invoker<T> invoker) throws RpcException;
@Adaptive({PROXY_KEY})
<T> T getProxy(Invoker<T> invoker, boolean generic) throws RpcException;
// 将传入的代理对象封装成Invoker对象
@Adaptive({PROXY_KEY})
<T> Invoker<T> getInvoker(T proxy, Class<T> type, URL url) throws RpcException;
}
看到 ProxyFactory 上的 @SPI 注解我们知道,其默认实现使用 Javassist 来创建代码对象。
AbstractProxyFactory 是代理工厂的抽象类,继承关系如下图所示:
AbstractProxyFactory 继承关系图
AbstractProxyFactory
AbstractProxyFactory 主要处理的是需要代理的接口,具体实现在 getProxy() 方法中:
public <T> T getProxy(Invoker<T> invoker, boolean generic) throws RpcException {
Set<Class<?>> interfaces = new HashSet<>();// 记录要代理的接口
// 获取URL中interfaces参数指定的接口
String config = invoker.getUrl().getParameter(INTERFACES);
if (config != null && config.length() > 0) {
// 按照逗号切分interfaces参数得到接口集合
String[] types = COMMA_SPLIT_PATTERN.split(config);
for (String type : types) { // 记录这些接口信息
interfaces.add(ReflectUtils.forName(type));
}
}
if (generic) { // 针对泛化接口的处理
if (!GenericService.class.isAssignableFrom(invoker.getInterface())) {
interfaces.add(GenericService.class);
}
// 从URL中获取interface参数指定的接口
String realInterface = invoker.getUrl().getParameter(Constants.INTERFACE);
interfaces.add(ReflectUtils.forName(realInterface));
}
// 获取Invoker中type字段指定的接口
interfaces.add(invoker.getInterface());
// 添加EchoService、Destroyable两个默认接口
interfaces.addAll(Arrays.asList(INTERNAL_INTERFACES));
// 调用抽象的getProxy()重载方法
return getProxy(invoker, interfaces.toArray(new Class<?>[0]));
}
AbstractProxyFactory 从多个地方获取需要代理的接口之后,会调用子类实现的 getProxy() 方法创建代理对象。
JavassistProxyFactory 对 getProxy() 方法的实现比较简单,直接委托给了 dubbo-common 模块中的 Proxy 工具类进行代理类的生成。下面我们就来深入分析 Proxy 生成代理类的全流程。
Proxy
在 dubbo-common 模块Proxy 中的 getProxy() 方法提供了动态创建代理类的核心实现。这个创建代理类的流程比较长,为了便于你更好地理解,这里我们将其拆开,一步步进行分析。
首先是查找 PROXY_CACHE_MAP 这个代理类缓存new WeakHashMap>() 类型),其中第一层 Key 是 ClassLoader 对象,第二层 Key 是上面整理得到的接口拼接而成的Value 是被缓存的代理类的 WeakReference弱引用
WeakReference弱引用的特性是WeakReference 引用的对象生命周期是两次 GC 之间,也就是说当垃圾收集器扫描到只具有弱引用的对象时,无论当前内存空间是否足够,都会回收该对象。(由于垃圾收集器是一个优先级很低的线程,不一定会很快发现那些只具有弱引用的对象。)
WeakReference 的特性决定了它特别适合用于数据可恢复的内存型缓存。查找缓存的结果有下面三个:
如果缓存中查找不到任务信息,则会在缓存中添加一个 PENDING_GENERATION_MARKER 占位符,当前线程后续创建生成代理类并最终替换占位符。
如果在缓存中查找到了 PENDING_GENERATION_MARKER 占位符,说明其他线程已经在生成相应的代理类了,当前线程会阻塞等待。
如果缓存中查找到完整代理类,则会直接返回,不会再执行后续动态代理类的生成。
下面是 Proxy.getProxy() 方法中对 PROXY_CACHE_MAP 缓存进行查询的相关代码片段:
public static Proxy getProxy(ClassLoader cl, Class<?>... ics) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < ics.length; i++) { // 循环处理每个接口类
String itf = ics[i].getName();
if (!ics[i].isInterface()) { // 传入的必须是接口类否则直接报错
throw new RuntimeException(itf + " is not a interface.");
}
// 加载接口类加载失败则直接报错
Class<?> tmp = Class.forName(itf, false, cl);
if (tmp != ics[i]) {
throw new IllegalArgumentException("...");
}
sb.append(itf).append(';'); // 将接口类的完整名称用分号连接起来
}
// 接口列表将会作为第二层集合的Key
String key = sb.toString();
final Map<String, Object> cache;
synchronized (PROXY_CACHE_MAP) { // 加锁同步
cache = PROXY_CACHE_MAP.computeIfAbsent(cl, k -> new HashMap<>());
}
Proxy proxy = null;
synchronized (cache) { // 加锁
do {
Object value = cache.get(key);
if (value instanceof Reference<?>) { // 获取到WeakReference
proxy = (Proxy) ((Reference<?>) value).get();
if (proxy != null) { // 查找到缓存的代理类
return proxy;
}
}
if (value == PENDING_GENERATION_MARKER) { // 获取到占位符
cache.wait(); // 阻塞等待其他线程生成好代理类,并添加到缓存中
} else { // 设置占位符,由当前线程生成代理类
cache.put(key, PENDING_GENERATION_MARKER);
break; // 退出当前循环
}
}
while (true);
}
... ... // 后续动态生成代理类的逻辑
}
完成缓存的查找之后,下面我们再来看代理类的生成过程。
第一步,调用 ClassGenerator.newInstance() 方法创建 ClassLoader 对应的 ClassPool。ClassGenerator 中封装了 Javassist 的基本操作,还定义了很多字段用来暂存代理类的信息,在其 toClass() 方法中会用这些暂存的信息来动态生成代理类。下面就来简单说明一下这些字段。
mClassNameString 类型):代理类的类名。
mSuperClassString 类型):代理类父类的名称。
mInterfacesSet<String> 类型):代理类实现的接口。
mFieldsList类型代理类中的字段。
mConstructorsList<String>类型):代理类中全部构造方法的信息,其中包括构造方法的具体实现。
mMethodsList<String>类型):代理类中全部方法的信息,其中包括方法的具体实现。
mDefaultConstructorboolean 类型):标识是否为代理类生成的默认构造方法。
在 ClassGenerator 的 toClass() 方法中,会根据上述字段用 Javassist 生成代理类,具体实现如下:
public Class<?> toClass(ClassLoader loader, ProtectionDomain pd) {
if (mCtc != null) {
mCtc.detach();
}
// 在代理类继承父类的时候会将该id作为后缀编号防止代理类重名
long id = CLASS_NAME_COUNTER.getAndIncrement();
CtClass ctcs = mSuperClass == null ? null : mPool.get(mSuperClass);
if (mClassName == null) { // 确定代理类的名称
mClassName = (mSuperClass == null || javassist.Modifier.isPublic(ctcs.getModifiers())
? ClassGenerator.class.getName() : mSuperClass + "$sc") + id;
}
mCtc = mPool.makeClass(mClassName); // 创建CtClass用来生成代理类
if (mSuperClass != null) { // 设置代理类的父类
mCtc.setSuperclass(ctcs);
}
// 设置代理类实现的接口默认会添加DC这个接口
mCtc.addInterface(mPool.get(DC.class.getName()));
if (mInterfaces != null) {
for (String cl : mInterfaces) {
mCtc.addInterface(mPool.get(cl));
}
}
if (mFields != null) { // 设置代理类的字段
for (String code : mFields) {
mCtc.addField(CtField.make(code, mCtc));
}
}
if (mMethods != null) { // 生成代理类的方法
for (String code : mMethods) {
if (code.charAt(0) == ':') {
mCtc.addMethod(CtNewMethod.copy(getCtMethod(mCopyMethods.get(code.substring(1))),
code.substring(1, code.indexOf('(')), mCtc, null));
} else {
mCtc.addMethod(CtNewMethod.make(code, mCtc));
}
}
}
if (mDefaultConstructor) { // 生成默认的构造方法
mCtc.addConstructor(CtNewConstructor.defaultConstructor(mCtc));
}
if (mConstructors != null) { // 生成构造方法
for (String code : mConstructors) {
if (code.charAt(0) == ':') {
mCtc.addConstructor(CtNewConstructor
.copy(getCtConstructor(mCopyConstructors.get(code.substring(1))), mCtc, null));
} else {
String[] sn = mCtc.getSimpleName().split("\\$+"); // inner class name include $.
mCtc.addConstructor(
CtNewConstructor.make(code.replaceFirst(SIMPLE_NAME_TAG, sn[sn.length - 1]), mCtc));
}
}
}
return mCtc.toClass(loader, pd);
}
第二步,从 PROXY_CLASS_COUNTER 字段AtomicLong类型中获取一个 id 值,作为代理类的后缀,这主要是为了避免类名重复发生冲突。
第三步,遍历全部接口,获取每个接口中定义的方法,对每个方法进行如下处理:
加入 worked 集合Set<String> 类型)中,用来判重。
将方法对应的 Method 对象添加到 methods 集合List<Method> 类型)中。
获取方法的参数类型以及返回类型,构建方法体以及 return 语句。
将构造好的方法添加到 ClassGenerator 中的 mMethods 集合中进行缓存。
相关代码片段如下所示:
long id = PROXY_CLASS_COUNTER.getAndIncrement();
String pkg = null;
ClassGenerator ccp = null, ccm = null;
ccp = ClassGenerator.newInstance(cl);
Set<String> worked = new HashSet<>()
List<Method> methods = new ArrayList>();
for (int i = 0; i < ics.length; i++) {
if (!Modifier.isPublic(ics[i].getModifiers())) {
String npkg = ics[i].getPackage().getName();
if (pkg == null) { // 如果接口不是public的则需要保证所有接口在一个包下
pkg = npkg;
} else {
if (!pkg.equals(npkg)) {
throw new IllegalArgumentException("non-public interfaces from different packages");
}
}
}
ccp.addInterface(ics[i]); // 向ClassGenerator中添加接口
for (Method method : ics[i].getMethods()) { // 遍历接口中的每个方法
String desc = ReflectUtils.getDesc(method);
// 跳过已经重复方法以及static方法
if (worked.contains(desc) || Modifier.isStatic(method.getModifiers())) {
continue;
}
if (ics[i].isInterface() && Modifier.isStatic(method.getModifiers())) {
continue;
}
worked.add(desc); // 将方法描述添加到worked这个Set集合中进行去重
int ix = methods.size();
Class<?> rt = method.getReturnType(); // 获取方法的返回值
Class<?>[] pts = method.getParameterTypes(); // 获取方法的参数列表
// 创建方法体
StringBuilder code = new StringBuilder("Object[] args = new Object[").append(pts.length).append("];");
for (int j = 0; j < pts.length; j++) {
code.append(" args[").append(j).append("] = ($w)$").append(j + 1).append(";");
}
code.append(" Object ret = handler.invoke(this, methods[").append(ix).append("], args);");
if (!Void.TYPE.equals(rt)) { // 生成return语句
code.append(" return ").append(asArgument(rt, "ret")).append(";");
}
// 将生成好的方法添加到ClassGenerator中缓存
methods.add(method);
ccp.addMethod(method.getName(), method.getModifiers(), rt, pts, method.getExceptionTypes(), code.toString());
}
}
这里我们以 Demo 示例(即 dubbo-demo 模块中的 Demo中的 sayHello() 方法为例,生成的方法如下所示:
public java.lang.String sayHello(java.lang.String arg0){
Object[] args = new Object[1];
args[0] = ($w)$1;
// 这里通过InvocationHandler.invoke()方法调用目标方法
Object ret = handler.invoke(this, methods[3], args);
return (java.lang.String)ret;
}
这里的方法调用其实是:委托 InvocationHandler 对象的 invoke() 方法去调用真正的实例方法。
第四步开始创建代理实例类ProxyInstance和代理类。这里我们先创建代理实例类需要向 ClassGenerator 中添加相应的信息,例如,类名、默认构造方法、字段、父类以及一个 newInstance() 方法,具体实现如下:
String pcn = pkg + ".proxy" + id; // 生成并设置代理类类名
ccp.setClassName(pcn);
// 添加字段一个是前面生成的methods集合另一个是InvocationHandler对象
ccp.addField("public static java.lang.reflect.Method[] methods;");
ccp.addField("private " + InvocationHandler.class.getName() + " handler;");
// 添加构造方法
ccp.addConstructor(Modifier.PUBLIC, new Class<?>[]{InvocationHandler.class}, new Class<?>[0], "handler=$1;");
ccp.addDefaultConstructor(); // 默认构造方法
Class<?> clazz = ccp.toClass();
clazz.getField("methods").set(null, methods.toArray(new Method[0]));
这里得到的代理实例类中每个方法的实现,都类似于上面提到的 sayHello() 方法的实现,即通过 InvocationHandler.invoke()方法调用目标方法。
接下来创建代理类,它实现了 Proxy 接口,并实现了 newInstance() 方法,该方法会直接返回上面代理实例类的对象,相关代码片段如下:
String fcn = Proxy.class.getName() + id;
ccm = ClassGenerator.newInstance(cl);
ccm.setClassName(fcn);
ccm.addDefaultConstructor(); // 默认构造方法
ccm.setSuperClass(Proxy.class); // 实现Proxy接口
// 实现newInstance()方法,返回上面创建的代理实例类的对象
ccm.addMethod("public Object newInstance(" + InvocationHandler.class.getName() + " h){ return new " + pcn + "($1); }");
Class<?> pc = ccm.toClass();
proxy = (Proxy) pc.newInstance();
生成的代理类如下所示:
package com.apache.dubbo.common.bytecode;
public class Proxy0 implements Proxy {
public void Proxy0() {}
public Object newInstance(InvocationHandler h){
return new proxy0(h);
}
}
第五步,也就是最后一步,在 finally 代码块中,会释放 ClassGenerator 的相关资源,将生成的代理类添加到 PROXY_CACHE_MAP 缓存中保存,同时会唤醒所有阻塞在 PROXY_CACHE_MAP 缓存上的线程,重新检测需要的代理类是否已经生成完毕。相关代码片段如下:
if (ccp != null) { // 释放ClassGenerator的相关资源
ccp.release();
}
if (ccm != null) {
ccm.release();
}
synchronized (cache) { // 加锁
if (proxy == null) {
cache.remove(key);
} else { // 填充PROXY_CACHE_MAP缓存
cache.put(key, new WeakReference<Proxy>(proxy));
}
cache.notifyAll(); // 唤醒所有阻塞在PROXY_CACHE_MAP上的线程
}
getProxy() 方法实现
分析完 Proxy 使用 Javassist 生成代理类的完整流程之后,我们再回头看一下 JavassistProxyFactory 工厂的 getProxy() 方法实现。这里首先通过前面分析的 getProxy() 方法获取 Proxy 对象,然后调用 newInstance() 方法获取目标类的代理对象,具体如下所示:
public <T> T getProxy(Invoker<T> invoker, Class<?>[] interfaces) {
return (T) Proxy.getProxy(interfaces).newInstance(new InvokerInvocationHandler(invoker));
}
相比之下JdkProxyFactory 对 getProxy() 方法的实现就简单很多,直接使用 JDK 自带的 java.lang.reflect.Proxy 生成代理对象,你可以参考前面第 8 课时中 JDK 动态代理的基本使用方式以及原理:
public <T> T getProxy(Invoker<T> invoker, Class<?>[] interfaces) {
return (T) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), interfaces, new InvokerInvocationHandler(invoker));
}
InvokerInvocationHandler
无论是 Javassist 还是 JDK 生成的代理类,都会将方法委托给 InvokerInvocationHandler 进行处理。InvokerInvocationHandler 中维护了一个 Invoker 对象,也是前面 getProxy() 方法传入的第一个参数,这个 Invoker 不是一个简单的 DubboInvoker 对象,而是在 DubboInvoker 之上经过一系列装饰器修饰的 Invoker 对象。
在 InvokerInvocationHandler 的 invoke() 方法中,首先会针对特殊的方法进行处理,比如 toString()、$destroy() 等方法。之后,对于业务方法,会创建相应的 RpcInvocation 对象调用 Invoker.invoke() 方法发起 RPC 调用,具体实现如下:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 对于Object中定义的方法直接调用Invoker对象的相应方法即可
if (method.getDeclaringClass() == Object.class) {
return method.invoke(invoker, args);
}
String methodName = method.getName();
Class<?>[] parameterTypes = method.getParameterTypes();
if (parameterTypes.length == 0) { // 对$destroy等方法的特殊处理
if ("$destroy".equals(methodName)) {
invoker.destroy();
return null;
}
}
... // 省略其他特殊处理的方法
// 创建RpcInvocation对象后面会作为远程RPC调用的参数
RpcInvocation rpcInvocation = new RpcInvocation(method, invoker.getInterface().getName(), args);
String serviceKey = invoker.getUrl().getServiceKey();
rpcInvocation.setTargetServiceUniqueName(serviceKey);
if (consumerModel != null) {
rpcInvocation.put(Constants.CONSUMER_MODEL, consumerModel);
rpcInvocation.put(Constants.METHOD_MODEL, consumerModel.getMethodModel(method));
}
// 调用invoke()方法发起远程调用拿到AsyncRpcResult之后调用recreate()方法获取响应结果(或是Future)
return invoker.invoke(rpcInvocation).recreate();
}
Wrapper
Invoker 是 Dubbo 的核心模型。在 Dubbo 中Provider 的业务层实现会被包装成一个 ProxyInvoker然后这个 ProxyInvoker 还会被 Filter、Listener 以及其他装饰器包装。ProxyFactory 的 getInvoker 方法就是将业务接口实现封装成 ProxyInvoker 入口。
我们先来看 JdkProxyFactory 中的实现。JdkProxyFactory 会创建一个匿名 AbstractProxyInvoker 的实现,其中的 doInvoke() 方法是通过 Java 原生的反射技术实现的,具体实现如下:
public <T> Invoker<T> getInvoker(T proxy, Class<T> type, URL url) {
return new AbstractProxyInvoker<T>(proxy, type, url) {
@Override
protected Object doInvoke(T proxy, String methodName,
Class<?>[] parameterTypes, Object[] arguments) throws Throwable {
// 使用反射方式查找methodName对应的方法并进行调用
Method method = proxy.getClass().getMethod(methodName, parameterTypes);
return method.invoke(proxy, arguments);
}
};
}
在前面两个课时中我们已经介绍了 Invoker 接口的一个重要实现分支—— AbstractInvoker 以及它的一个实现 DubboInvoker。AbstractProxyInvoker 是 Invoker 接口的另一个实现分支,继承关系如下图所示,其实现类都是 ProxyFactory 实现中的匿名内部类。
在 AbstractProxyInvoker 实现的 invoke() 方法中,会将 doInvoke() 方法返回的结果封装成 CompletableFuture 对象,然后再封装成 AsyncRpcResult 对象返回,具体实现如下:
public Result invoke(Invocation invocation) throws RpcException {
// 执行doInvoke()方法,调用业务实现
Object value = doInvoke(proxy, invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments());
// 将value值封装成CompletableFuture对象
CompletableFuture<Object> future = wrapWithFuture(value);
// 再次转换转换为CompletableFuture<AppResponse>类型
CompletableFuture<AppResponse> appResponseFuture = future.handle((obj, t) -> {
AppResponse result = new AppResponse();
if (t != null) {
if (t instanceof CompletionException) {
result.setException(t.getCause());
} else {
result.setException(t);
}
} else {
result.setValue(obj);
}
return result;
});
// 将CompletableFuture封装成AsyncRpcResult返回
return new AsyncRpcResult(appResponseFuture, invocation);
}
了解了 AbstractProxyInvoker 以及 JdkProxyFactory 返回的实现之后,我们再来看 JavassistProxyFactory.getInvoker() 方法返回的实现。首先该方法会通过 Wrapper 创建一个包装类,然后创建一个实现了 AbstractProxyInvoker 的匿名内部类,其 doInvoker() 方法会直接委托给 Wrapper 对象的 InvokeMethod() 方法,具体实现如下:
public <T> Invoker<T> getInvoker(T proxy, Class<T> type, URL url) {
// 通过Wrapper创建一个包装类对象
final Wrapper wrapper = Wrapper.getWrapper(proxy.getClass().getName().indexOf('$') < 0 ? proxy.getClass() : type);
// 创建一个实现了AbstractProxyInvoker的匿名内部类其doInvoker()方法会直接委托给Wrapper对象的InvokeMethod()方法
return new AbstractProxyInvoker<T>(proxy, type, url) {
@Override
protected Object doInvoke(T proxy, String methodName,
Class<?>[] parameterTypes, Object[] arguments) throws Throwable {
return wrapper.invokeMethod(proxy, methodName, parameterTypes, arguments);
}
};
}
Wrapper 类本身是抽象类,是对 Java 类的一种包装。Wrapper 会从 Java 类中的字段和方法抽象出相应 propertyName 和 methodName在需要调用一个字段或方法的时候会根据传入的方法名和参数进行匹配找到对应的字段和方法进行调用。
Wrapper.getWrapper() 方法会根据不同的 Java 对象,使用 Javassist 生成一个相应的 Wrapper 实现对象。下面我们就来一起分析下 getWrapper() 方法实现:
首先检测该 Java 类是否实现了 DC 这个标识接口,在前面介绍 Proxy 抽象类的时候,我们提到过这个接口;
检测 WRAPPER_MAP 集合Map, Wrapper> 类型)中是否缓存了对应的 Wrapper 对象,如果已缓存则直接返回,如果未缓存则调用 makeWrapper() 方法动态生成 Wrapper 实现类,以及相应的实例对象,并写入缓存中。
makeWrapper() 方法的实现非常长,但是逻辑并不复杂,该方法会遍历传入的 Class 对象的所有 public 字段和 public 方法,构建组装 Wrapper 实现类需要的 Java 代码。具体实现有如下三个步骤。
第一步public 字段会构造相应的 getPropertyValue() 方法和 setPropertyValue() 方法。例如有一个名为“name”的 public 字段,则会生成如下的代码:
// 生成的getPropertyValue()方法
public Object getPropertyValue(Object o, String n){
DemoServiceImpl w;
try{
w = ((DemoServiceImpl)$1);
}catch(Throwable e){
throw new IllegalArgumentException(e);
}
if( $2.equals(" if( $2.equals("name") ){
return ($w)w.name;
}
}
// 生成的setPropertyValue()方法
public void setPropertyValue(Object o, String n, Object v){
DemoServiceImpl w;
try{
w = ((DemoServiceImpl)$1);
}catch(Throwable e){
throw new IllegalArgumentException(e);
}
if( $2.equals("name") ){
w.name=(java.lang.String)$3; return;
}
}
第二步,处理 public 方法,这些 public 方法会添加到 invokeMethod 方法中。以 Demo 示例(即 dubbo-demo 模块中的 demo )中的 DemoServiceImpl 为例,生成的 invokeMethod() 方法实现如下:
public Object invokeMethod(Object o, String n, Class[] p, Object[] v) throws java.lang.reflect.InvocationTargetException {
org.apache.dubbo.demo.provider.DemoServiceImpl w;
try {
w = ((org.apache.dubbo.demo.provider.DemoServiceImpl) $1);
} catch (Throwable e) {
throw new IllegalArgumentException(e);
}
try {
// 省略getter/setter方法
if ("sayHello".equals($2) && $3.length == 1) {
return ($w) w.sayHello((java.lang.String) $4[0]);
}
if ("sayHelloAsync".equals($2) && $3.length == 1) {
return ($w) w.sayHelloAsync((java.lang.String) $4[0]);
}
} catch (Throwable e) {
throw new java.lang.reflect.InvocationTargetException(e);
}
throw new NoSuchMethodException("Not found method");
}
第三步,完成了上述 Wrapper 实现类相关信息的填充之后makeWrapper() 方法会通过 ClassGenerator 创建 Wrapper 实现类,具体原理与前面 Proxy 创建代理类的流程类似,这里就不再赘述。
总结
本课时主要介绍了 dubbo-rpc-api 模块中“代理”相关的内容。首先我们从 ProxyFactory.getProxy() 方法入手,详细介绍了 JDK 方式和 Javassist 方式创建动态代理类的底层原理,以及其中使用的 InvokerInvocationHandler 的实现。接下来我们又通过 ProxyFactory.getInvoker() 方法入手,重点讲解了 Wrapper 的生成过程和核心原理。
下面这张简图很好地展示了 Dubbo 中 Proxy 和 Wrapper 的重要性:
Proxy 和 Wrapper 远程调用简图
Consumer 端的 Proxy 底层屏蔽了复杂的网络交互、集群策略以及 Dubbo 内部的 Invoker 等概念提供给上层使用的是业务接口。Provider 端的 Wrapper 是将个性化的业务接口实现,统一转换成 Dubbo 内部的 Invoker 接口实现。正是由于 Proxy 和 Wrapper 这两个组件的存在Dubbo 才能实现内部接口和业务接口的无缝转换。
关于“代理”相关的内容,你若还有什么想法,欢迎你留言跟我分享。下一课时,我们会再做一个加餐,介绍 Dubbo 中支持的 HTTP 协议的相关内容。

View File

@@ -0,0 +1,598 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 加餐:深潜 Directory 实现,探秘服务目录玄机
从这一课时我们就进入“集群”模块了,今天我们分享的是一篇加餐文章,主题是:深潜 Directory 实现,探秘服务目录玄机。
在生产环境中,为了保证服务的可靠性、吞吐量以及容错能力,我们通常会在多个服务器上运行相同的服务端程序,然后以集群的形式对外提供服务。根据各项性能指标的要求不同,各个服务端集群中服务实例的个数也不尽相同,从几个实例到几百个实例不等。
对于客户端程序来说,就会出现几个问题:
客户端程序是否要感知每个服务端地址?
客户端程序的一次请求,到底调用哪个服务端程序呢?
请求失败之后的处理是重试,还会是抛出异常?
如果是重试,是再次请求该服务实例,还是尝试请求其他服务实例?
服务端集群如何做到负载均衡,负载均衡的标准是什么呢?
……
为了解决上述问题Dubbo 独立出了一个实现集群功能的模块—— dubbo-cluster。
dubbo-cluster 结构图
作为 dubbo-cluster 模块分析的第一课时,我们就首先来了解一下 dubbo-cluster 模块的架构以及最核心的 Cluster 接口。
Cluster 架构
dubbo-cluster 模块的主要功能是将多个 Provider 伪装成一个 Provider 供 Consumer 调用,其中涉及集群的容错处理、路由规则的处理以及负载均衡。下图展示了 dubbo-cluster 的核心组件:
Cluster 核心接口图
由图我们可以看出dubbo-cluster 主要包括以下四个核心接口:
Cluster 接口,是集群容错的接口,主要是在某些 Provider 节点发生故障时,让 Consumer 的调用请求能够发送到正常的 Provider 节点,从而保证整个系统的可用性。
Directory 接口,表示多个 Invoker 的集合,是后续路由规则、负载均衡策略以及集群容错的基础。
Router 接口,抽象的是路由器,请求经过 Router 的时候,会按照用户指定的规则匹配出符合条件的 Provider。
LoadBalance 接口是负载均衡接口Consumer 会按照指定的负载均衡策略,从 Provider 集合中选出一个最合适的 Provider 节点来处理请求。
Cluster 层的核心流程是这样的:当调用进入 Cluster 的时候Cluster 会创建一个 AbstractClusterInvoker 对象,在这个 AbstractClusterInvoker 中,首先会从 Directory 中获取当前 Invoker 集合;然后按照 Router 集合进行路由,得到符合条件的 Invoker 集合;接下来按照 LoadBalance 指定的负载均衡策略得到最终要调用的 Invoker 对象。
了解了 dubbo-cluster 模块的核心架构和基础组件之后,我们后续将会按照上面架构图的顺序介绍每个接口的定义以及相关实现。
Directory 接口详解
Directory 接口表示的是一个集合,该集合由多个 Invoker 构成,后续的路由处理、负载均衡、集群容错等一系列操作都是在 Directory 基础上实现的。
下面我们深入分析一下 Directory 的相关内容,首先是 Directory 接口中定义的方法:
public interface Directory<T> extends Node {
// 服务接口类型
Class<T> getInterface();
// list()方法会根据传入的Invocation请求过滤自身维护的Invoker集合返回符合条件的Invoker集合
List<Invoker<T>> list(Invocation invocation) throws RpcException;
// getAllInvokers()方法返回当前Directory对象维护的全部Invoker对象
List<Invoker<T>> getAllInvokers();
// Consumer端的URL
URL getConsumerUrl();
}
AbstractDirectory 是 Directory 接口的抽象实现,其中除了维护 Consumer 端的 URL 信息,还维护了一个 RouterChain 对象,用于记录当前使用的 Router 对象集合,也就是后面课时要介绍的路由规则。
AbstractDirectory 对 list() 方法的实现也比较简单,就是直接委托给了 doList() 方法doList() 是个抽象方法,由 AbstractDirectory 的子类具体实现。
Directory 接口有 RegistryDirectory 和 StaticDirectory 两个具体实现,如下图所示:
Directory 接口继承关系图
其中RegistryDirectory 实现中维护的 Invoker 集合会随着注册中心中维护的注册信息动态发生变化,这就依赖了 ZooKeeper 等注册中心的推送能力StaticDirectory 实现中维护的 Invoker 集合则是静态的,在 StaticDirectory 对象创建完成之后,不会再发生变化。
下面我们就来分别介绍 Directory 接口的这两个具体实现。
1. StaticDirectory
StaticDirectory 这个 Directory 实现比较简单在构造方法中StaticDirectory 会接收一个 Invoker 集合,并赋值到自身的 invokers 字段中,作为底层的 Invoker 集合。在 doList() 方法中StaticDirectory 会使用 RouterChain 中的 Router 从 invokers 集合中过滤出符合路由规则的 Invoker 对象集合,具体实现如下:
protected List<Invoker<T>> doList(Invocation invocation) throws RpcException {
List<Invoker<T>> finalInvokers = invokers;
if (routerChain != null) { // 通过RouterChain过滤出符合条件的Invoker集合
finalInvokers = routerChain.route(getConsumerUrl(), invocation);
}
return finalInvokers == null ? Collections.emptyList() : finalInvokers;
}
在创建 StaticDirectory 对象的时候,如果没有传入 RouterChain 对象,则会根据 URL 构造一个包含内置 Router 的 RouterChain 对象:
public void buildRouterChain() {
RouterChain<T> routerChain = RouterChain.buildChain(getUrl()); // 创建内置Router集合
// 将invokers与RouterChain关联
routerChain.setInvokers(invokers);
this.setRouterChain(routerChain); // 设置routerChain字段
}
2. RegistryDirectory
RegistryDirectory 是一个动态的 Directory 实现,实现了 NotifyListener 接口当注册中心的服务配置发生变化时RegistryDirectory 会收到变更通知然后RegistryDirectory 会根据注册中心推送的通知,动态增删底层 Invoker 集合。
下面我们先来看一下 RegistryDirectory 中的核心字段。
clusterCluster 类型):集群策略适配器,这里通过 Dubbo SPI 方式(即 ExtensionLoader.getAdaptiveExtension() 方法)动态创建适配器实例。
routerFactoryRouterFactory 类型):路由工厂适配器,也是通过 Dubbo SPI 动态创建的适配器实例。routerFactory 字段和 cluster 字段都是静态字段,多个 RegistryDirectory 对象通用。
serviceKeyString 类型):服务对应的 ServiceKey默认是 {interface}:[group]:[version] 三部分构成。
serviceTypeClass 类型服务接口类型例如org.apache.dubbo.demo.DemoService。
queryMapMap 类型Consumer URL 中 refer 参数解析后得到的全部 KV。
directoryUrlURL 类型):只保留 Consumer 属性的 URL也就是由 queryMap 集合重新生成的 URL。
multiGroupboolean类型是否引用多个服务组。
protocolProtocol 类型):使用的 Protocol 实现。
registryRegistry 类型):使用的注册中心实现。
invokersvolatile List 类型):动态更新的 Invoker 集合。
urlInvokerMapvolatile Map< String, Invoker> 类型Provider URL 与对应 Invoker 之间的映射,该集合会与 invokers 字段同时动态更新。
cachedInvokerUrlsvolatile Set类型当前缓存的所有 Provider 的 URL该集合会与 invokers 字段同时动态更新。
configuratorsvolatile List< Configurator>类型):动态更新的配置信息,配置的具体内容在后面的分析中会介绍到。
在 RegistryDirectory 的构造方法中,会根据传入的注册中心 URL 初始化上述核心字段,具体实现如下:
public RegistryDirectory(Class<T> serviceType, URL url) {
// 传入的url参数是注册中心的URL例如zookeeper://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService?...其中refer参数包含了Consumer信息例如refer=application=dubbo-demo-api-consumer&dubbo=2.0.2&interface=org.apache.dubbo.demo.DemoService&pid=13423&register.ip=192.168.124.3&side=consumer(URLDecode之后的值)
super(url);
shouldRegister = !ANY_VALUE.equals(url.getServiceInterface()) && url.getParameter(REGISTER_KEY, true);
shouldSimplified = url.getParameter(SIMPLIFIED_KEY, false);
this.serviceType = serviceType;
this.serviceKey = url.getServiceKey();
// 解析refer参数值得到其中Consumer的属性信息
this.queryMap = StringUtils.parseQueryString(url.getParameterAndDecoded(REFER_KEY));
// 将queryMap中的KV作为参数重新构造URL其中的protocol和path部分不变
this.overrideDirectoryUrl = this.directoryUrl = turnRegistryUrlToConsumerUrl(url);
String group = directoryUrl.getParameter(GROUP_KEY, "");
this.multiGroup = group != null && (ANY_VALUE.equals(group) || group.contains(","));
}
在完成初始化之后,我们来看 subscribe() 方法,该方法会在 Consumer 进行订阅的时候被调用,其中调用 Registry 的 subscribe() 完成订阅操作,同时还会将当前 RegistryDirectory 对象作为 NotifyListener 监听器添加到 Registry 中,具体实现如下:
public void subscribe(URL url) {
setConsumerUrl(url);
// 将当前RegistryDirectory对象作为ConfigurationListener记录到CONSUMER_CONFIGURATION_LISTENER中
CONSUMER_CONFIGURATION_LISTENER.addNotifyListener(this);
serviceConfigurationListener = new ReferenceConfigurationListener(this, url);
// 完成订阅操作,注册中心的相关操作在前文已经介绍过了,这里不再重复
registry.subscribe(url, this);
}
我们看到除了作为 NotifyListener 监听器之外RegistryDirectory 内部还有两个 ConfigurationListener 的内部类(继承关系如下图所示),为了保持连贯,这两个监听器的具体原理我们在后面的课时中会详细介绍,这里先不展开讲述。
RegistryDirectory 内部的 ConfigurationListener 实现
通过前面对 Registry 的介绍我们知道,在注册 NotifyListener 的时候,监听的是 providers、configurators 和 routers 三个目录,所以在这三个目录下发生变化的时候,就会触发 RegistryDirectory 的 notify() 方法。
在 RegistryDirectory.notify() 方法中,首先会按照 category 对发生变化的 URL 进行分类,分成 configurators、routers、providers 三类,并分别对不同类型的 URL 进行处理:
将 configurators 类型的 URL 转化为 Configurator保存到 configurators 字段中;
将 router 类型的 URL 转化为 Router并通过 routerChain.addRouters() 方法添加 routerChain 中保存;
将 provider 类型的 URL 转化为 Invoker 对象,并记录到 invokers 集合和 urlInvokerMap 集合中。
notify() 方法的具体实现如下:
public synchronized void notify(List<URL> urls) {
// 按照category进行分类分成configurators、routers、providers三类
Map<String, List<URL>> categoryUrls = urls.stream()
.filter(Objects::nonNull)
.filter(this::isValidCategory)
.filter(this::isNotCompatibleFor26x)
.collect(Collectors.groupingBy(this::judgeCategory));
// 获取configurators类型的URL并转换成Configurator对象
List<URL> configuratorURLs = categoryUrls.getOrDefault(CONFIGURATORS_CATEGORY, Collections.emptyList());
this.configurators = Configurator.toConfigurators(configuratorURLs).orElse(this.configurators);
// 获取routers类型的URL并转成Router对象添加到RouterChain中
List<URL> routerURLs = categoryUrls.getOrDefault(ROUTERS_CATEGORY, Collections.emptyList());
toRouters(routerURLs).ifPresent(this::addRouters);
// 获取providers类型的URL调用refreshOverrideAndInvoker()方法进行处理
List<URL> providerURLs = categoryUrls.getOrDefault(PROVIDERS_CATEGORY, Collections.emptyList());
... // 在Dubbo3.0中会触发AddressListener监听器但是现在AddressListener接口还没有实现所以省略这段代码
refreshOverrideAndInvoker(providerURLs);
}
我们这里首先来专注providers 类型 URL 的处理,具体实现位置在 refreshInvoker() 方法中,具体实现如下:
private void refreshInvoker(List<URL> invokerUrls) {
// 如果invokerUrls集合不为空长度为1并且协议为empty则表示该服务的所有Provider都下线了会销毁当前所有Provider对应的Invoker。
if (invokerUrls.size() == 1 && invokerUrls.get(0) != null
&& EMPTY_PROTOCOL.equals(invokerUrls.get(0).getProtocol())) {
this.forbidden = true; // forbidden标记设置为true后续请求将直接抛出异常
this.invokers = Collections.emptyList();
routerChain.setInvokers(this.invokers); // 清空RouterChain中的Invoker集合
destroyAllInvokers(); // 关闭所有Invoker对象
} else {
this.forbidden = false; // forbidden标记设置为falseRegistryDirectory可以正常处理后续请求
Map<String, Invoker<T>> oldUrlInvokerMap = this.urlInvokerMap; // 保存本地引用
if (invokerUrls == Collections.<URL>emptyList()) {
invokerUrls = new ArrayList<>();
}
if (invokerUrls.isEmpty() && this.cachedInvokerUrls != null) {
// 如果invokerUrls集合为空并且cachedInvokerUrls不为空则将使用cachedInvokerUrls缓存的数据
// 也就是说注册中心中的providers目录未发生变化invokerUrls则为空表示cachedInvokerUrls集合中缓存的URL为最新的值
invokerUrls.addAll(this.cachedInvokerUrls);
} else {
// 如果invokerUrls集合不为空则用invokerUrls集合更新cachedInvokerUrls集合
// 也就是说providers发生变化invokerUrls集合中会包含此时注册中心所有的服务提供者
this.cachedInvokerUrls = new HashSet<>();
this.cachedInvokerUrls.addAll(invokerUrls);//Cached invoker urls, convenient for comparison
}
if (invokerUrls.isEmpty()) {
return; // 如果invokerUrls集合为空即providers目录未发生变更则无须处理结束本次更新服务提供者Invoker操作。
}
// 将invokerUrls转换为对应的Invoker映射关系
Map<String, Invoker<T>> newUrlInvokerMap = toInvokers(invokerUrls);
if (CollectionUtils.isEmptyMap(newUrlInvokerMap)) {
return;
}
// 更新invokers字段和urlInvokerMap集合
List<Invoker<T>> newInvokers = Collections.unmodifiableList(new ArrayList<>(newUrlInvokerMap.values()));
routerChain.setInvokers(newInvokers);
// 针对multiGroup的特殊处理合并多个group的Invoker
this.invokers = multiGroup ? toMergeInvokerList(newInvokers) : newInvokers;
this.urlInvokerMap = newUrlInvokerMap;
// 比较新旧两组Invoker集合销毁掉已经下线的Invoker
destroyUnusedInvokers(oldUrlInvokerMap, newUrlInvokerMap);
}
}
通过对 refreshInvoker() 方法的介绍,我们可以看出,其最核心的逻辑是 Provider URL 转换成 Invoker 对象,也就是 toInvokers() 方法。下面我们就来深入 toInvokers() 方法内部,看看其具体的转换逻辑:
private Map<String, Invoker<T>> toInvokers(List<URL> urls) {
... // urls集合为空时直接返回空Map
Set<String> keys = new HashSet<>();
String queryProtocols = this.queryMap.get(PROTOCOL_KEY); // 获取Consumer端支持的协议即protocol参数指定的协议
for (URL providerUrl : urls) {
if (queryProtocols != null && queryProtocols.length() > 0) {
boolean accept = false;
String[] acceptProtocols = queryProtocols.split(",");
for (String acceptProtocol : acceptProtocols) { // 遍历所有Consumer端支持的协议
if (providerUrl.getProtocol().equals(acceptProtocol)) {
accept = true;
break;
}
}
if (!accept) {
continue; // 如果当前URL不支持Consumer端的协议也就无法执行后续转换成Invoker的逻辑
}
}
if (EMPTY_PROTOCOL.equals(providerUrl.getProtocol())) {
continue; // 跳过empty协议的URL
}
// 如果Consumer端不支持该URL的协议这里通过SPI方式检测是否有对应的Protocol扩展实现也会跳过该URL
if (!ExtensionLoader.getExtensionLoader(Protocol.class).hasExtension(providerUrl.getProtocol())) {
logger.error("...");
continue;
}
// 合并URL参数这个合并过程在本课时后面展开介绍
URL url = mergeUrl(providerUrl);
// 获取完整URL对应的字符串也就是在urlInvokerMap集合中的key
String key = url.toFullString();
if (keys.contains(key)) { // 跳过重复的URL
continue;
}
keys.add(key); // 记录key
// 匹配urlInvokerMap缓存中的Invoker对象如果命中缓存直接将Invoker添加到newUrlInvokerMap这个新集合中即可
// 如果未命中缓存则创建新的Invoker对象然后添加到newUrlInvokerMap这个新集合中
Map<String, Invoker<T>> localUrlInvokerMap = this.urlInvokerMap;
Invoker<T> invoker = localUrlInvokerMap == null ? null : localUrlInvokerMap.get(key);
if (invoker == null) {
try {
boolean enabled = true;
if (url.hasParameter(DISABLED_KEY)) { // 检测URL中的disable和enable参数决定是否能够创建Invoker对象
enabled = !url.getParameter(DISABLED_KEY, false);
} else {
enabled = url.getParameter(ENABLED_KEY, true);
}
if (enabled) { // 这里通过Protocol.refer()方法创建对应的Invoker对象
invoker = new InvokerDelegate<>(protocol.refer(serviceType, url), url, providerUrl);
}
} catch (Throwable t) {
logger.error("Failed to refer invoker for interface:" + serviceType + ",url:(" + url + ")" + t.getMessage(), t);
}
if (invoker != null) { // 将key和Invoker对象之间的映射关系记录到newUrlInvokerMap中
newUrlInvokerMap.put(key, invoker);
}
} else {// 缓存命中直接将urlInvokerMap中的Invoker转移到newUrlInvokerMap即可
newUrlInvokerMap.put(key, invoker);
}
}
keys.clear();
return newUrlInvokerMap;
}
toInvokers() 方法的代码虽然有点长,但核心逻辑就是调用 Protocol.refer() 方法创建 Invoker 对象,其他的逻辑都是在判断是否调用该方法。
在 toInvokers() 方法内部,我们可以看到调用了 mergeUrl() 方法对 URL 参数进行合并。在 mergeUrl() 方法中,会将注册中心中 configurators 目录下的 URLoverride 协议),以及服务治理控制台动态添加的配置与 Provider URL 进行合并,即覆盖 Provider URL 原有的一些信息,具体实现如下:
private URL mergeUrl(URL providerUrl) {
// 首先移除Provider URL中只在Provider端生效的属性例如threadname、threadpool、corethreads、threads、queues等参数。
// 然后用Consumer端的配置覆盖Provider URL的相应配置其中version、group、methods、timestamp等参数以Provider端的配置优先
// 最后合并Provider端和Consumer端配置的Filter以及Listener
providerUrl = ClusterUtils.mergeUrl(providerUrl, queryMap);
// 合并configurators类型的URLconfigurators类型的URL又分为三类
// 第一类是注册中心Configurators目录下新增的URL(override协议)
// 第二类是通过ConsumerConfigurationListener监听器(监听应用级别的配置)得到的动态配置
// 第三类是通过ReferenceConfigurationListener监听器(监听服务级别的配置)得到的动态配置
// 这里只需要先了解除了注册中心的configurators目录下有配置信息之外还有可以在服务治理控制台动态添加配置
// ConsumerConfigurationListener、ReferenceConfigurationListener监听器就是用来监听服务治理控制台的动态配置的
// 至于服务治理控制台的具体使用,在后面详细介绍
providerUrl = overrideWithConfigurator(providerUrl);
// 增加check=false即只有在调用时才检查Provider是否可用
providerUrl = providerUrl.addParameter(Constants.CHECK_KEY, String.valueOf(false));
// 重新复制overrideDirectoryUrlproviderUrl在经过第一步参数合并后包含override协议覆盖后的属性赋值给overrideDirectoryUrl。
this.overrideDirectoryUrl = this.overrideDirectoryUrl.addParametersIfAbsent(providerUrl.getParameters());
... // 省略对Dubbo低版本的兼容处理逻辑
return providerUrl;
}
完成 URL 到 Invoker 对象的转换toInvokers() 方法)之后,其实在 refreshInvoker() 方法的最后,还会根据 multiGroup 的配置决定是否调用 toMergeInvokerList() 方法将每个 group 中的 Invoker 合并成一个 Invoker。下面我们一起来看 toMergeInvokerList() 方法的具体实现:
private List<Invoker<T>> toMergeInvokerList(List<Invoker<T>> invokers) {
List<Invoker<T>> mergedInvokers = new ArrayList<>();
Map<String, List<Invoker<T>>> groupMap = new HashMap<>();
for (Invoker<T> invoker : invokers) { // 按照group将Invoker分组
String group = invoker.getUrl().getParameter(GROUP_KEY, "");
groupMap.computeIfAbsent(group, k -> new ArrayList<>());
groupMap.get(group).add(invoker);
}
if (groupMap.size() == 1) { // 如果只有一个group则直接使用该group分组对应的Invoker集合作为mergedInvokers
mergedInvokers.addAll(groupMap.values().iterator().next());
} else if (groupMap.size() > 1) { // 将每个group对应的Invoker集合合并成一个Invoker
for (List<Invoker<T>> groupList : groupMap.values()) {
// 这里使用到StaticDirectory以及Cluster合并每个group中的Invoker
StaticDirectory<T> staticDirectory = new StaticDirectory<>(groupList);
staticDirectory.buildRouterChain();
mergedInvokers.add(CLUSTER.join(staticDirectory));
}
} else {
mergedInvokers = invokers;
}
return mergedInvokers;
}
这里使用到了 Cluster 接口的相关功能,我们在后面课时还会继续深入分析 Cluster 接口及其实现,你现在可以将 Cluster 理解为一个黑盒,知道其 join() 方法会将多个 Invoker 对象转换成一个 Invoker 对象即可。
到此为止RegistryDirectory 处理一次完整的动态 Provider 发现流程就介绍完了。
最后我们再分析下RegistryDirectory 中另外一个核心方法—— doList() 方法,该方法是 AbstractDirectory 留给其子类实现的一个方法,也是通过 Directory 接口获取 Invoker 集合的核心所在,具体实现如下:
public List<Invoker<T>> doList(Invocation invocation) {
if (forbidden) { // 检测forbidden字段当该字段在refreshInvoker()过程中设置为true时表示无Provider可用直接抛出异常
throw new RpcException("...");
}
if (multiGroup) {
// multiGroup为true时的特殊处理在refreshInvoker()方法中针对multiGroup为true的场景已经使用Router进行了筛选所以这里直接返回接口
return this.invokers == null ? Collections.emptyList() : this.invokers;
}
List<Invoker<T>> invokers = null;
// 通过RouterChain.route()方法筛选Invoker集合最终得到符合路由条件的Invoker集合
invokers = routerChain.route(getConsumerUrl(), invocation);
return invokers == null ? Collections.emptyList() : invokers;
}
总结
在本课时,我们首先介绍了 dubbo-cluster 模块的整体架构,简单说明了 Cluster、Directory、Router、LoadBalance 四个核心接口的功能。接下来我们就深入介绍了 Directory 接口的定义以及 StaticDirectory、RegistryDirectory 两个类的核心实现,其中 RegistryDirectory 涉及动态查找 Provider URL 以及处理动态配置的相关逻辑,显得略微复杂了一点,希望你能耐心学习和理解。关于这部分内容,你若有不懂或不理解的地方,也欢迎你留言和我交流。

View File

@@ -0,0 +1,495 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 路由机制:请求到底怎么走,它说了算(上)
作为 dubbo-cluster 模块分析的第二课时,本课时我们就来介绍一下 dubbo-cluster 模块中涉及的另一个核心概念—— Router。
Router 的主要功能就是根据用户配置的路由规则以及请求携带的信息,过滤出符合条件的 Invoker 集合,供后续负载均衡逻辑使用。在上一课时介绍 RegistryDirectory 实现的时候,我们就已经看到了 RouterChain 这个 Router 链的存在,但是没有深入分析,下面我们就来深入 Router 进行分析。
RouterChain、RouterFactory 与 Router
首先我们来看 RouterChain 的核心字段。
invokersList`> 类型):当前 RouterChain 对象要过滤的 Invoker 集合。我们可以看到,在 StaticDirectory 中是通过 RouterChain.setInvokers() 方法进行设置的。
builtinRoutersList<Router> 类型):当前 RouterChain 激活的内置 Router 集合。
routersList<Router> 类型):当前 RouterChain 中真正要使用的 Router 集合,其中不仅包括了上面 builtinRouters 集合中全部的 Router 对象,还包括通过 addRouters() 方法添加的 Router 对象。
在 RouterChain 的构造函数中,会在传入的 URL 参数中查找 router 参数值,并根据该值获取确定激活的 RouterFactory之后通过 Dubbo SPI 机制加载这些激活的 RouterFactory 对象,由 RouterFactory 创建当前激活的内置 Router 实例,具体实现如下:
private RouterChain(URL url) {
// 通过ExtensionLoader加载激活的RouterFactory
List<RouterFactory> extensionFactories = ExtensionLoader.getExtensionLoader(RouterFactory.class)
.getActivateExtension(url, "router");
// 遍历所有RouterFactory调用其getRouter()方法创建相应的Router对象
List<Router> routers = extensionFactories.stream()
.map(factory -> factory.getRouter(url))
.collect(Collectors.toList());
initWithRouters(routers); // 初始化buildinRouters字段以及routers字段
}
public void initWithRouters(List<Router> builtinRouters) {
this.builtinRouters = builtinRouters;
this.routers = new ArrayList<>(builtinRouters);
this.sort(); // 这里会对routers集合进行排序
}
完成内置 Router 的初始化之后,在 Directory 实现中还可以通过 addRouter() 方法添加新的 Router 实例到 routers 字段中,具体实现如下:
public void addRouters(List<Router> routers) {
List<Router> newRouters = new ArrayList<>();
newRouters.addAll(builtinRouters); // 添加builtinRouters集合
newRouters.addAll(routers); // 添加传入的Router集合
CollectionUtils.sort(newRouters); // 重新排序
this.routers = newRouters;
}
RouterChain.route() 方法会遍历 routers 字段,逐个调用 Router 对象的 route() 方法,对 invokers 集合进行过滤,具体实现如下:
public List<Invoker<T>> route(URL url, Invocation invocation) {
List<Invoker<T>> finalInvokers = invokers;
for (Router router : routers) { // 遍历全部的Router对象
finalInvokers = router.route(finalInvokers, url, invocation);
}
return finalInvokers;
}
了解了 RouterChain 的大致逻辑之后,我们知道真正进行路由的是 routers 集合中的 Router 对象。接下来我们再来看 RouterFactory 这个工厂接口RouterFactory 接口是一个扩展接口,具体定义如下:
@SPI
public interface RouterFactory {
@Adaptive("protocol") // 动态生成的适配器会根据protocol参数选择扩展实现
Router getRouter(URL url);
}
RouterFactory 接口有很多实现类,如下图所示:
RouterFactory 继承关系图
下面我们就来深入介绍下每个 RouterFactory 实现类以及对应的 Router 实现对象。Router 决定了一次 Dubbo 调用的目标服务Router 接口的每个实现类代表了一个路由规则,当 Consumer 访问 Provider 时Dubbo 根据路由规则筛选出合适的 Provider 列表之后通过负载均衡算法再次进行筛选。Router 接口的继承关系如下图所示:
Router 继承关系图
接下来我们就开始介绍 RouterFactory 以及 Router 的具体实现。
ConditionRouterFactory&ConditionRouter
首先来看 ConditionRouterFactory 实现,其扩展名为 condition在其 getRouter() 方法中会创建 ConditionRouter 对象,如下所示:
public Router getRouter(URL url) {
return new ConditionRouter(url);
}
ConditionRouter 是基于条件表达式的路由实现类,下面就是一条基于条件表达式的路由规则:
host = 192.168.0.100 => host = 192.168.0.150
在上述规则中,=>之前的为 Consumer 匹配的条件,该条件中的所有参数会与 Consumer 的 URL 进行对比,当 Consumer 满足匹配条件时,会对该 Consumer 的此次调用执行 => 后面的过滤规则。
=> 之后为 Provider 地址列表的过滤条件,该条件中的所有参数会和 Provider 的 URL 进行对比Consumer 最终只拿到过滤后的地址列表。
如果 Consumer 匹配条件为空,表示 => 之后的过滤条件对所有 Consumer 生效,例如:=> host != 192.168.0.150,含义是所有 Consumer 都不能请求 192.168.0.150 这个 Provider 节点。
如果 Provider 过滤条件为空,表示禁止访问所有 Provider例如host = 192.168.0.100 =>,含义是 192.168.0.100 这个 Consumer 不能访问任何 Provider 节点。
ConditionRouter 的核心字段有如下几个。
urlURL 类型):路由规则的 URL可以从 rule 参数中获取具体的路由规则。
ROUTE_PATTERNPattern 类型):用于切分路由规则的正则表达式。
priorityint 类型):路由规则的优先级,用于排序,该字段值越大,优先级越高,默认值为 0。
forceboolean 类型):当路由结果为空时,是否强制执行。如果不强制执行,则路由结果为空的路由规则将会自动失效;如果强制执行,则直接返回空的路由结果。
whenConditionMap 类型Consumer 匹配的条件集合,通过解析条件表达式 rule 的 => 之前半部分,可以得到该集合中的内容。
thenConditionMap 类型Provider 匹配的条件集合,通过解析条件表达式 rule 的 => 之后半部分,可以得到该集合中的内容。
在 ConditionRouter 的构造方法中,会根据 URL 中携带的相应参数初始化 priority、force、enable 等字段,然后从 URL 的 rule 参数中获取路由规则进行解析,具体的解析逻辑是在 init() 方法中实现的,如下所示:
public void init(String rule) {
// 将路由规则中的"consumer."和"provider."字符串清理掉
rule = rule.replace("consumer.", "").replace("provider.", "");
// 按照"=>"字符串进行分割得到whenRule和thenRule两部分
int i = rule.indexOf("=>");
String whenRule = i < 0 ? null : rule.substring(0, i).trim();
String thenRule = i < 0 ? rule.trim() : rule.substring(i + 2).trim();
// 解析whenRule和thenRule得到whenCondition和thenCondition两个条件集合
Map<String, MatchPair> when = StringUtils.isBlank(whenRule) || "true".equals(whenRule) ? new HashMap<String, MatchPair>() : parseRule(whenRule);
Map<String, MatchPair> then = StringUtils.isBlank(thenRule) || "false".equals(thenRule) ? null : parseRule(thenRule);
this.whenCondition = when;
this.thenCondition = then;
}
whenCondition 和 thenCondition 两个集合中Key 是条件表达式中指定的参数名称(例如 host = 192.168.0.150 这个表达式中的 host。ConditionRouter 支持三类参数:
服务调用信息例如method、argument 等;
URL 本身的字段例如protocol、host、port 等;
URL 上的所有参数例如application 等。
Value 是 MatchPair 对象,包含两个 Set 类型的集合—— matches 和 mismatches。在使用 MatchPair 进行过滤的时候,会按照下面四条规则执行。
当 mismatches 集合为空的时候,会逐个遍历 matches 集合中的匹配条件,匹配成功任意一条即会返回 true。这里具体的匹配逻辑以及后续 mismatches 集合中条件的匹配逻辑,都是在 UrlUtils.isMatchGlobPattern() 方法中实现,其中完成了如下操作:如果匹配条件以 “$” 符号开头,则从 URL 中获取相应的参数值进行匹配;当遇到 “” 通配符的时候,会处理”“通配符在匹配条件开头、中间以及末尾三种情况。
当 matches 集合为空的时候,会逐个遍历 mismatches 集合中的匹配条件,匹配成功任意一条即会返回 false。
当 matches 集合和 mismatches 集合同时不为空时,会优先匹配 mismatches 集合中的条件,成功匹配任意一条规则,就会返回 false若 mismatches 中的条件全部匹配失败,才会开始匹配 matches 集合,成功匹配任意一条规则,就会返回 true。
当上述三个步骤都没有成功匹配时,直接返回 false。
上述流程具体实现在 MatchPair 的 isMatch() 方法中,比较简单,这里就不再展示。
了解了每个 MatchPair 的匹配流程之后我们来看parseRule() 方法是如何解析一条完整的条件表达式,生成对应 MatchPair 的,具体实现如下:
private static Map<String, MatchPair> parseRule(String rule) throws ParseException {
Map<String, MatchPair> condition = new HashMap<String, MatchPair>();
MatchPair pair = null;
Set<String> values = null;
// 首先按照ROUTE_PATTERN指定的正则表达式匹配整个条件表达式
final Matcher matcher = ROUTE_PATTERN.matcher(rule);
while (matcher.find()) { // 遍历匹配的结果
// 每个匹配结果有两部分(分组),第一部分是分隔符,第二部分是内容
String separator = matcher.group(1);
String content = matcher.group(2);
if (StringUtils.isEmpty(separator)) { // ---(1) 没有分隔符content即为参数名称
pair = new MatchPair();
// 初始化MatchPair对象并将其与对应的Key(即content)记录到condition集合中
condition.put(content, pair);
}
else if ("&".equals(separator)) { // ---(4)
// &分隔符表示多个表达式,会创建多个MatchPair对象
if (condition.get(content) == null) {
pair = new MatchPair();
condition.put(content, pair);
} else {
pair = condition.get(content);
}
}else if ("=".equals(separator)) { // ---(2)
// =以及!=两个分隔符表示KV的分界线
if (pair == null) {
throw new ParseException("..."");
}
values = pair.matches;
values.add(content);
}else if ("!=".equals(separator)) { // ---(5)
if (pair == null) {
throw new ParseException("...");
}
values = pair.mismatches;
values.add(content);
}else if (",".equals(separator)) { // ---(3)
// 逗号分隔符表示有多个Value值
if (values == null || values.isEmpty()) {
throw new ParseException("...");
}
values.add(content);
} else {
throw new ParseException("...");
}
}
return condition;
}
介绍完 parseRule() 方法的实现之后,我们可以再通过下面这个条件表达式示例的解析流程,更深入地体会 parseRule() 方法的工作原理:
host = 2.2.2.2,1.1.1.1,3.3.3.3 & method !=get => host = 1.2.3.4
经过 ROUTE_PATTERN 正则表达式的分组之后,我们得到如下分组:
Rule 分组示意图
我们先来看 => 之前的 Consumer 匹配规则的处理。
分组 1 中separator 为空字符串content 为 host 字符串。此时会进入上面示例代码展示的 parseRule() 方法中1处的分支创建 MatchPair 对象,并以 host 为 Key 记录到 condition 集合中。
分组 2 中separator 为 “=” 空字符串content 为 “2.2.2.2” 字符串。处理该分组时,会进入 parseRule() 方法中2 处的分支,在 MatchPair 的 matches 集合中添加 “2.2.2.2” 字符串。
分组 3 中separator 为 “,” 字符串content 为 “3.3.3.3” 字符串。处理该分组时,会进入 parseRule() 方法中3处的分支继续向 MatchPair 的 matches 集合中添加 “3.3.3.3” 字符串。
分组 4 中separator 为 “&” 字符串content 为 “method” 字符串。处理该分组时,会进入 parseRule() 方法中4处的分支创建新的 MatchPair 对象,并以 method 为 Key 记录到 condition 集合中。
分组 5 中separator 为 “!=” 字符串content 为 “get” 字符串。处理该分组时,会进入 parseRule() 方法中5处的分支向步骤 4 新建的 MatchPair 对象中的 mismatches 集合添加 “get” 字符串。
最后,我们得到的 whenCondition 集合如下图所示:
whenCondition 集合示意图
同理parseRule() 方法解析上述表达式 => 之后的规则得到的 thenCondition 集合,如下图所示:
thenCondition 集合示意图
了解了 ConditionRouter 解析规则的流程以及 MatchPair 内部的匹配原则之后ConditionRouter 中最后一个需要介绍的内容就是它的 route() 方法了。
ConditionRouter.route() 方法首先会尝试前面创建的 whenCondition 集合,判断此次发起调用的 Consumer 是否符合表达式中 => 之前的 Consumer 过滤条件,若不符合,直接返回整个 invokers 集合;若符合,则通过 thenCondition 集合对 invokers 集合进行过滤,得到符合 Provider 过滤条件的 Invoker 集合然后返回给上层调用方。ConditionRouter.route() 方法的核心实现如下:
public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation)
throws RpcException {
... // 通过enable字段判断当前ConditionRouter对象是否可用
... // 当前invokers集合为空则直接返回
if (!matchWhen(url, invocation)) { // 匹配发起请求的Consumer是否符合表达式中=>之前的过滤条件
return invokers;
}
List<Invoker<T>> result = new ArrayList<Invoker<T>>();
if (thenCondition == null) { // 判断=>之后是否存在Provider过滤条件若不存在则直接返回空集合表示无Provider可用
return result;
}
for (Invoker<T> invoker : invokers) { // 逐个判断Invoker是否符合表达式中=>之后的过滤条件
if (matchThen(invoker.getUrl(), url)) {
result.add(invoker); // 记录符合条件的Invoker
}
}
if (!result.isEmpty()) {
return result;
} else if (force) { // 在无Invoker符合条件时根据force决定是返回空集合还是返回全部Invoker
return result;
}
return invokers;
}
ScriptRouterFactory&ScriptRouter
ScriptRouterFactory 的扩展名为 script其 getRouter() 方法中会创建一个 ScriptRouter 对象并返回。
ScriptRouter 支持 JDK 脚本引擎的所有脚本例如JavaScript、JRuby、Groovy 等,通过 type=javascript 参数设置脚本类型,缺省为 javascript。下面我们就定义一个 route() 函数进行 host 过滤:
function route(invokers, invocation, context){
var result = new java.util.ArrayList(invokers.size());
var targetHost = new java.util.ArrayList();
targetHost.add("10.134.108.2");
for (var i = 0; i < invokers.length; i) { // 遍历Invoker集合
// 判断Invoker的host是否符合条件
if(targetHost.contains(invokers[i].getUrl().getHost())){
result.add(invokers[i]);
}
}
return result;
}
route(invokers, invocation, context) // 立即执行route()函数
我们可以将上面这段代码进行编码并作为 rule 参数的值添加到 URL 在这个 URL 传入 ScriptRouter 的构造函数时即可被 ScriptRouter 解析
ScriptRouter 的核心字段有如下几个
urlURL 类型路由规则的 URL可以从 rule 参数中获取具体的路由规则
priorityint 类型路由规则的优先级用于排序该字段值越大优先级越高默认值为 0
ENGINESConcurrentHashMap 类型这是一个 static 集合其中的 Key 是脚本语言的名称Value 是对应的 ScriptEngine 对象这里会按照脚本语言的类型复用 ScriptEngine 对象
engineScriptEngine 类型当前 ScriptRouter 使用的 ScriptEngine 对象
ruleString 类型当前 ScriptRouter 使用的具体脚本内容
functionCompiledScript 类型根据 rule 这个具体脚本内容编译得到
ScriptRouter 的构造函数中首先会初始化 url 字段以及 priority 字段用于排序然后根据 URL 中的 type 参数初始化 enginerule function 三个核心字段 具体实现如下
public ScriptRouter(URL url) {
this.url = url;
this.priority = url.getParameter(PRIORITY_KEY, SCRIPT_ROUTER_DEFAULT_PRIORITY);
// 根据URL中的type参数值从ENGINES集合中获取对应的ScriptEngine对象
engine = getEngine(url);
// 获取URL中的rule参数值即为具体的脚本
rule = getRule(url);
Compilable compilable = (Compilable) engine;
// 编译rule字段中的脚本得到function字段
function = compilable.compile(rule);
}
接下来看 ScriptRouter route() 方法的实现其中首先会创建调用 function 函数所需的入参也就是 Bindings 对象然后调用 function 函数得到过滤后的 Invoker 集合最后通过 getRoutedInvokers() 方法整理 Invoker 集合得到最终的返回值
public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException {
// 创建Bindings对象作为function函数的入参
Bindings bindings = createBindings(invokers, invocation);
if (function == null) {
return invokers;
}
// 调用function函数并在getRoutedInvokers()方法中整理得到的Invoker集合
return getRoutedInvokers(function.eval(bindings));
}
private <T> Bindings createBindings(List<Invoker<T>> invokers, Invocation invocation) {
Bindings bindings = engine.createBindings();
// 与前面的javascript的示例脚本结合我们可以看到这里在Bindings中为脚本中的route()函数提供了invokers、Invocation、context三个参数
bindings.put("invokers", new ArrayList<>(invokers));
bindings.put("invocation", invocation);
bindings.put("context", RpcContext.getContext());
return bindings;
}
总结
本课时重点介绍了 Router 接口的相关内容。首先我们介绍了 RouterChain 的核心实现以及构建过程,然后讲解了 RouterFactory 接口和 Router 接口中核心方法的功能。接下来我们还深入分析了ConditionRouter 对条件路由功能的实现以及ScriptRouter 对脚本路由功能的实现。

View File

@@ -0,0 +1,371 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 加餐:初探 Dubbo 动态配置的那些事儿
在前面第 31 课时中我们详细讲解了 RegistryDirectory 相关的内容,作为一个 NotifyListener 监听器RegistryDirectory 会同时监听注册中心的 providers、routers 和 configurators 三个目录。通过 RegistryDirectory 处理 configurators 目录的逻辑,我们了解到 configurators 目录中动态添加的 URL 会覆盖 providers 目录下注册的 Provider URLDubbo 还会按照 configurators 目录下的最新配置,重新创建 Invoker 对象(同时会销毁原来的 Invoker 对象)。
在老版本的 Dubbo 中,我们可以通过服务治理控制台向注册中心的 configurators 目录写入动态配置的 URL。在 Dubbo 2.7.x 版本中,动态配置信息除了可以写入注册中心的 configurators 目录之外,还可以写入外部的配置中心,这部分内容我们将在后面的课时详细介绍,今天这一课时我们重点来看写入注册中心的动态配置。
首先,我们需要了解一下 configurators 目录中 URL 都有哪些协议以及这些协议的含义,然后还要知道 Dubbo 是如何解析这些 URL 得到 Configurator 对象的,以及 Configurator 是如何与已有的 Provider URL 共同作用得到实现动态更新配置的效果。
基础协议
首先,我们需要了解写入注册中心 configurators 中的动态配置有 override 和 absent 两种协议。下面是一个 override 协议的示例:
override://0.0.0.0/org.apache.dubbo.demo.DemoService?category=configurators&dynamic=false&enabled=true&application=dubbo-demo-api-consumer&timeout=1000
那这个 URL 中各个部分的含义是怎样的呢?下面我们就一个一个来分析下。
override表示采用覆盖方式。Dubbo 支持 override 和 absent 两种协议,我们也可以通过 SPI 的方式进行扩展。
0.0.0.0,表示对所有 IP 生效。如果只想覆盖某个特定 IP 的 Provider 配置,可以使用该 Provider 的具体 IP。
org.apache.dubbo.demo.DemoService表示只对指定服务生效。
category=configurators表示该 URL 为动态配置类型。
dynamic=false表示该 URL 为持久数据,即使注册该 URL 的节点退出,该 URL 依旧会保存在注册中心。
enabled=true表示该 URL 的覆盖规则已生效。
application=dubbo-demo-api-consumer表示只对指定应用生效。如果不指定则默认表示对所有应用都生效。
timeout=1000表示将满足以上条件 Provider URL 中的 timeout 参数值覆盖为 1000。如果想覆盖其他配置可以直接以参数的形式添加到 override URL 之上。
在 Dubbo 的官网中,还提供了一些简单示例,我们这里也简单解读一下。
禁用某个 Provider通常用于临时剔除某个 Provider 节点:
override://10.20.153.10/com.foo.BarService?category=configurators&dynamic=false&disabled=true
调整某个 Provider 的权重为 200
override://10.20.153.10/com.foo.BarService?category=configurators&dynamic=false&weight=200
调整负载均衡策略为 LeastActiveLoadBalance负载均衡的内容会在下一课时详细介绍
override://10.20.153.10/com.foo.BarService?category=configurators&dynamic=false&loadbalance=leastactive
服务降级通常用于临时屏蔽某个出错的非关键服务mock 机制的具体实现我们会在后面的课时详细介绍):
override://0.0.0.0/com.foo.BarService?category=configurators&dynamic=false&application=foo&mock=force:return+null
Configurator
当我们在注册中心的 configurators 目录中添加 override或 absent协议的 URL 时Registry 会收到注册中心的通知,回调注册在其上的 NotifyListener其中就包括 RegistryDirectory。我们在第 31 课时中已经详细分析了 RegistryDirectory.notify() 处理 providers、configurators 和 routers 目录变更的流程,其中 configurators 目录下 URL 会被解析成 Configurator 对象。
Configurator 接口抽象了一条配置信息,同时提供了将配置 URL 解析成 Configurator 对象的工具方法。Configurator 接口具体定义如下:
public interface Configurator extends Comparable<Configurator> {
// 获取该Configurator对象对应的配置URL例如前文介绍的override协议URL
URL getUrl();
// configure()方法接收的参数是原始URL返回经过Configurator修改后的URL
URL configure(URL url);
// toConfigurators()工具方法可以将多个配置URL对象解析成相应的Configurator对象
static Optional<List<Configurator>> toConfigurators(List<URL> urls) {
// 创建ConfiguratorFactory适配器
ConfiguratorFactory configuratorFactory = ExtensionLoader.getExtensionLoader(ConfiguratorFactory.class)
.getAdaptiveExtension();
List<Configurator> configurators = new ArrayList<>(urls.size()); // 记录解析的结果
for (URL url : urls) {
// 遇到empty协议直接清空configurators集合结束解析返回空集合
if (EMPTY_PROTOCOL.equals(url.getProtocol())) {
configurators.clear();
break;
}
Map<String, String> override = new HashMap<>(url.getParameters());
override.remove(ANYHOST_KEY);
if (override.size() == 0) { // 如果该配置URL没有携带任何参数则跳过该URL
configurators.clear();
continue;
}
// 通过ConfiguratorFactory适配器选择合适ConfiguratorFactory扩展并创建Configurator对象
configurators.add(configuratorFactory.getConfigurator(url));
}
Collections.sort(configurators); // 排序
return Optional.of(configurators);
}
// 排序首先按照ip进行排序所有ip的优先级都高于0.0.0.0当ip相同时会按照priority参数值进行排序
default int compareTo(Configurator o) {
if (o == null) {
return -1;
}
int ipCompare = getUrl().getHost().compareTo(o.getUrl().getHost());
if (ipCompare == 0) {
int i = getUrl().getParameter(PRIORITY_KEY, 0);
int j = o.getUrl().getParameter(PRIORITY_KEY, 0);
return Integer.compare(i, j);
} else {
return ipCompare;
}
}
ConfiguratorFactory 接口是一个扩展接口Dubbo 提供了两个实现类,如下图所示:
ConfiguratorFactory 继承关系图
其中OverrideConfiguratorFactory 对应的扩展名为 override创建的 Configurator 实现是 OverrideConfiguratorAbsentConfiguratorFactory 对应的扩展名是 absent创建的 Configurator 实现类是 AbsentConfigurator。
Configurator 接口的继承关系如下图所示:
Configurator 继承关系图
其中AbstractConfigurator 中维护了一个 configuratorUrl 字段,记录了完整的配置 URL。AbstractConfigurator 是一个模板类,其核心实现是 configure() 方法,具体实现如下:
public URL configure(URL url) {
// 这里会根据配置URL的enabled参数以及host决定该URL是否可用同时还会根据原始URL是否为空以及原始URL的host是否为空决定当前是否执行后续覆盖逻辑
if (!configuratorUrl.getParameter(ENABLED_KEY, true) || configuratorUrl.getHost() == null || url == null || url.getHost() == null) {
return url;
}
// 针对2.7.0之后版本这里添加了一个configVersion参数作为区分
String apiVersion = configuratorUrl.getParameter(CONFIG_VERSION_KEY);
if (StringUtils.isNotEmpty(apiVersion)) { // 对2.7.0之后版本的配置处理
String currentSide = url.getParameter(SIDE_KEY);
String configuratorSide = configuratorUrl.getParameter(SIDE_KEY);
// 根据配置URL中的side参数以及原始URL中的side参数值进行匹配
if (currentSide.equals(configuratorSide) && CONSUMER.equals(configuratorSide) && 0 == configuratorUrl.getPort()) {
url = configureIfMatch(NetUtils.getLocalHost(), url);
} else if (currentSide.equals(configuratorSide) && PROVIDER.equals(configuratorSide) && url.getPort() == configuratorUrl.getPort()) {
url = configureIfMatch(url.getHost(), url);
}
} else { // 2.7.0版本之前对配置的处理
url = configureDeprecated(url);
}
return url;
}
这里我们需要关注下configureDeprecated() 方法对历史版本的兼容,其实这也是对注册中心 configurators 目录下配置 URL 的处理,具体实现如下:
private URL configureDeprecated(URL url) {
// 如果配置URL中的端口不为空则是针对Provider的需要判断原始URL的端口两者端口相同才能执行configureIfMatch()方法中的配置方法
if (configuratorUrl.getPort() != 0) {
if (url.getPort() == configuratorUrl.getPort()) {
return configureIfMatch(url.getHost(), url);
}
} else {
// 如果没有指定端口则该配置URL要么是针对Consumer的要么是针对任意URL的即host为0.0.0.0
// 如果原始URL属于Consumer则使用Consumer的host进行匹配
if (url.getParameter(SIDE_KEY, PROVIDER).equals(CONSUMER)) {
return configureIfMatch(NetUtils.getLocalHost(), url);
} else if (url.getParameter(SIDE_KEY, CONSUMER).equals(PROVIDER)) {
// 如果是Provider URL则用0.0.0.0来配置
return configureIfMatch(ANYHOST_VALUE, url);
}
}
return url;
}
configureIfMatch() 方法会排除匹配 URL 中不可动态修改的参数,并调用 Configurator 子类的 doConfigurator() 方法重写原始 URL具体实现如下
private URL configureIfMatch(String host, URL url) {
if (ANYHOST_VALUE.equals(configuratorUrl.getHost()) || host.equals(configuratorUrl.getHost())) { // 匹配host
String providers = configuratorUrl.getParameter(OVERRIDE_PROVIDERS_KEY);
if (StringUtils.isEmpty(providers) || providers.contains(url.getAddress()) || providers.contains(ANYHOST_VALUE)) {
String configApplication = configuratorUrl.getParameter(APPLICATION_KEY,
configuratorUrl.getUsername());
String currentApplication = url.getParameter(APPLICATION_KEY, url.getUsername());
if (configApplication == null || ANY_VALUE.equals(configApplication)
|| configApplication.equals(currentApplication)) { // 匹配application
// 排除不能动态修改的属性其中包括category、check、dynamic、enabled还有以~开头的属性
Set<String> conditionKeys = new HashSet<String>();
conditionKeys.add(CATEGORY_KEY);
conditionKeys.add(Constants.CHECK_KEY);
conditionKeys.add(DYNAMIC_KEY);
conditionKeys.add(ENABLED_KEY);
conditionKeys.add(GROUP_KEY);
conditionKeys.add(VERSION_KEY);
conditionKeys.add(APPLICATION_KEY);
conditionKeys.add(SIDE_KEY);
conditionKeys.add(CONFIG_VERSION_KEY);
conditionKeys.add(COMPATIBLE_CONFIG_KEY);
conditionKeys.add(INTERFACES);
for (Map.Entry<String, String> entry : configuratorUrl.getParameters().entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (key.startsWith("~") || APPLICATION_KEY.equals(key) || SIDE_KEY.equals(key)) {
conditionKeys.add(key);
// 如果配置URL与原URL中以~开头的参数值不相同则不使用该配置URL重写原URL
if (value != null && !ANY_VALUE.equals(value)
&& !value.equals(url.getParameter(key.startsWith("~") ? key.substring(1) : key))) {
return url;
}
}
}
// 移除配置URL不支持动态配置的参数之后调用Configurator子类的doConfigure方法重新生成URL
return doConfigure(url, configuratorUrl.removeParameters(conditionKeys));
}
}
}
return url;
}
我们再反过来仔细审视一下 AbstractConfigurator.configure() 方法中针对 2.7.0 版本之后动态配置的处理,其中会根据 side 参数明确判断配置 URL 和原始 URL 属于 Consumer 端还是 Provider 端,判断逻辑也更加清晰。匹配之后的具体替换过程同样是调用 configureIfMatch() 方法实现的,这里不再重复。
Configurator 的两个子类实现非常简单。在 OverrideConfigurator 的 doConfigure() 方法中,会直接用配置 URL 中剩余的全部参数,覆盖原始 URL 中的相应参数,具体实现如下:
public URL doConfigure(URL currentUrl, URL configUrl) {
// 直接调用addParameters()方法,进行覆盖
return currentUrl.addParameters(configUrl.getParameters());
}
在 AbsentConfigurator 的 doConfigure() 方法中,会尝试用配置 URL 中的参数添加到原始 URL 中,如果原始 URL 中已经有了该参数是不会被覆盖的,具体实现如下:
public URL doConfigure(URL currentUrl, URL configUrl) {
// 直接调用addParametersIfAbsent()方法尝试添加参数
return currentUrl.addParametersIfAbsent(configUrl.getParameters());
}
到这里Dubbo 2.7.0 版本之前的动态配置核心实现就介绍完了,其中我们也简单涉及了 Dubbo 2.7.0 版本之后一些逻辑,只不过没有全面介绍 Dubbo 2.7.0 之后的配置格式以及核心处理逻辑,不用担心,这些内容我们将会在后面的“配置中心”章节继续深入分析。
总结
本课时我们主要介绍了 Dubbo 中配置相关的实现。我们首先通过示例分析了 configurators 目录中涉及的 override 协议 URL、absent 协议 URL 的格式以及各个参数的含义,然后还详细讲解了 Dubbo 解析 configurator URL 得到的 Configurator 对象,以及 Configurator 覆盖 Provider URL 各个参数的具体实现。

View File

@@ -0,0 +1,452 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上)
在前面的课时中,我们已经详细介绍了 dubbo-cluster 模块中的 Directory 和 Router 两个核心接口以及核心实现,同时也介绍了这两个接口相关的周边知识。本课时我们继续按照下图的顺序介绍 LoadBalance 的相关内容。
LoadBalance 核心接口图
LoadBalance负载均衡的职责是将网络请求或者其他形式的负载“均摊”到不同的服务节点上从而避免服务集群中部分节点压力过大、资源紧张而另一部分节点比较空闲的情况。
通过合理的负载均衡算法,我们希望可以让每个服务节点获取到适合自己处理能力的负载,实现处理能力和流量的合理分配。常用的负载均衡可分为软件负载均衡(比如,日常工作中使用的 Nginx和硬件负载均衡主要有 F5、Array、NetScaler 等,不过开发工程师在实践中很少直接接触到)。
常见的 RPC 框架中都有负载均衡的概念和相应的实现Dubbo 也不例外。Dubbo 需要对 Consumer 的调用请求进行分配,避免少数 Provider 节点负载过大,而剩余的其他 Provider 节点处于空闲的状态。因为当 Provider 负载过大时,就会导致一部分请求超时、丢失等一系列问题发生,造成线上故障。
Dubbo 提供了 5 种负载均衡实现,分别是:
基于 Hash 一致性的 ConsistentHashLoadBalance
基于权重随机算法的 RandomLoadBalance
基于最少活跃调用数算法的 LeastActiveLoadBalance
基于加权轮询算法的 RoundRobinLoadBalance
基于最短响应时间的 ShortestResponseLoadBalance 。
LoadBalance 接口
上述 Dubbo 提供的负载均衡实现,都是 LoadBalance 接口的实现类,如下图所示:
LoadBalance 继承关系图
LoadBalance 是一个扩展接口,默认使用的扩展实现是 RandomLoadBalance其定义如下所示其中的 @Adaptive 注解参数为 loadbalance即动态生成的适配器会按照 URL 中的 loadbalance 参数值选择扩展实现类。
@SPI(RandomLoadBalance.NAME)
public interface LoadBalance {
@Adaptive("loadbalance")
<T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException;
}
LoadBalance 接口中 select() 方法的核心功能是根据传入的 URL 和 Invocation以及自身的负载均衡算法从 Invoker 集合中选择一个 Invoker 返回。
AbstractLoadBalance 抽象类并没有真正实现 select() 方法,只是对 Invoker 集合为空或是只包含一个 Invoker 对象的特殊情况进行了处理,具体实现如下:
public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
if (CollectionUtils.isEmpty(invokers)) {
return null; // Invoker集合为空直接返回null
}
if (invokers.size() == 1) { // Invoker集合只包含一个Invoker则直接返回该Invoker对象
return invokers.get(0);
}
// Invoker集合包含多个Invoker对象时交给doSelect()方法处理,这是个抽象方法,留给子类具体实现
return doSelect(invokers, url, invocation);
}
另外AbstractLoadBalance 还提供了一个 getWeight() 方法,该方法用于计算 Provider 权重,具体实现如下:
int getWeight(Invoker<?> invoker, Invocation invocation) {
int weight;
URL url = invoker.getUrl();
if (REGISTRY_SERVICE_REFERENCE_PATH.equals(url.getServiceInterface())) {
// 如果是RegistryService接口的话直接获取权重即可
weight = url.getParameter(REGISTRY_KEY + "." + WEIGHT_KEY, DEFAULT_WEIGHT);
} else {
weight = url.getMethodParameter(invocation.getMethodName(), WEIGHT_KEY, DEFAULT_WEIGHT);
if (weight > 0) {
// 获取服务提供者的启动时间戳
long timestamp = invoker.getUrl().getParameter(TIMESTAMP_KEY, 0L);
if (timestamp > 0L) {
// 计算Provider运行时长
long uptime = System.currentTimeMillis() - timestamp;
if (uptime < 0) {
return 1;
}
// 计算Provider预热时长
int warmup = invoker.getUrl().getParameter(WARMUP_KEY, DEFAULT_WARMUP);
// 如果Provider运行时间小于预热时间则该Provider节点可能还在预热阶段需要重新计算服务权重(降低其权重)
if (uptime > 0 && uptime < warmup) {
weight = calculateWarmupWeight((int)uptime, warmup, weight);
}
}
}
}
return Math.max(weight, 0);
}
calculateWarmupWeight() 方法的目的是对还在预热状态的 Provider 节点进行降权避免 Provider 一启动就有大量请求涌进来服务预热是一个优化手段这是由 JVM 本身的一些特性决定的例如JIT 等方面的优化我们一般会在服务启动之后让其在小流量状态下运行一段时间然后再逐步放大流量
static int calculateWarmupWeight(int uptime, int warmup, int weight) {
// 计算权重随着服务运行时间uptime增大权重ww的值会慢慢接近配置值weight
int ww = (int) ( uptime / ((float) warmup / weight));
return ww < 1 ? 1 : (Math.min(ww, weight));
}
了解了 LoadBalance 接口的定义以及 AbstractLoadBalance 提供的公共能力之后下面我们开始逐个介绍 LoadBalance 接口的具体实现
ConsistentHashLoadBalance
ConsistentHashLoadBalance 底层使用一致性 Hash 算法实现负载均衡为了让你更好地理解这部分内容我们先来简单介绍一下一致性 Hash 算法相关的知识点
1. 一致性 Hash 简析
一致性 Hash 负载均衡可以让参数相同的请求每次都路由到相同的服务节点上这种负载均衡策略可以在某些 Provider 节点下线的时候让这些节点上的流量平摊到其他 Provider 不会引起流量的剧烈波动
下面我们通过一个示例简单介绍一致性 Hash 算法的原理
假设现在有 123 三个 Provider 节点对外提供服务 100 个请求同时到达如果想让请求尽可能均匀地分布到这三个 Provider 节点上我们可能想到的最简单的方法就是 Hash 取模 hash(请求参数) % 3如果参与 Hash 计算的是请求的全部参数那么参数相同的请求将会落到同一个 Provider 节点上不过此时如果突然有一个 Provider 节点出现宕机的情况那我们就需要对 2 取模即请求会重新分配到相应的 Provider 之上在极端情况下甚至会出现所有请求的处理节点都发生了变化这就会造成比较大的波动
为了避免因一个 Provider 节点宕机而导致大量请求的处理节点发生变化的情况我们可以考虑使用一致性 Hash 算法一致性 Hash 算法的原理也是取模算法 Hash 取模的不同之处在于Hash 取模是对 Provider 节点数量取模而一致性 Hash 算法是对 2^32 取模
一致性 Hash 算法需要同时对 Provider 地址以及请求参数进行取模
hash(Provider地址) % 2^32
hash(请求参数) % 2^32
Provider 地址和请求经过对 2^32 取模得到的结果值都会落到一个 Hash 环上如下图所示
一致性 Hash 节点均匀分布图
我们按顺时针的方向依次将请求分发到对应的 Provider这样当某台 Provider 节点宕机或增加新的 Provider 节点时只会影响这个 Provider 节点对应的请求
在理想情况下一致性 Hash 算法会将这三个 Provider 节点均匀地分布到 Hash 环上请求也可以均匀地分发给这三个 Provider 节点但在实际情况中这三个 Provider 节点地址取模之后的值可能差距不大这样会导致大量的请求落到一个 Provider 节点上如下图所示
一致性 Hash 节点非均匀分布图
这就出现了数据倾斜的问题所谓数据倾斜是指由于节点不够分散导致大量请求落到了同一个节点上而其他节点只会接收到少量请求的情况
为了解决一致性 Hash 算法中出现的数据倾斜问题又演化出了 Hash 槽的概念
Hash 槽解决数据倾斜的思路是既然问题是由 Provider 节点在 Hash 环上分布不均匀造成的那么可以虚拟出 n P1P2P3 Provider 节点 让多组 Provider 节点相对均匀地分布在 Hash 环上如下图所示相同阴影的节点均为同一个 Provider 节点比如 P1-1P1-2……P1-99 表示的都是 P1 这个 Provider 节点引入 Provider 虚拟节点之后 Provider 在圆环上分散开来以避免数据倾斜问题
数据倾斜解决示意图
2. ConsistentHashSelector 实现分析
了解了一致性 Hash 算法的基本原理之后我们再来看一下 ConsistentHashLoadBalance 一致性 Hash 负载均衡的具体实现首先来看 doSelect() 方法的实现其中会根据 ServiceKey methodName 选择一个 ConsistentHashSelector 对象核心算法都委托给 ConsistentHashSelector 对象完成
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
// 获取调用的方法名称
String methodName = RpcUtils.getMethodName(invocation);
// 将ServiceKey和方法拼接起来构成一个key
String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
// 注意这是为了在invokers列表发生变化时都会重新生成ConsistentHashSelector对象
int invokersHashCode = invokers.hashCode();
// 根据key获取对应的ConsistentHashSelector对象selectors是一个ConcurrentMap<String, ConsistentHashSelector>集合
ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
if (selector == null || selector.identityHashCode != invokersHashCode) { // 未查找到ConsistentHashSelector对象则进行创建
selectors.put(key, new ConsistentHashSelector<T>(invokers, methodName, invokersHashCode));
selector = (ConsistentHashSelector<T>) selectors.get(key);
}
// 通过ConsistentHashSelector对象选择一个Invoker对象
return selector.select(invocation);
}
下面我们来看 ConsistentHashSelector其核心字段如下所示。
virtualInvokersTreeMap`> 类型):用于记录虚拟 Invoker 对象的 Hash 环。这里使用 TreeMap 实现 Hash 环,并将虚拟的 Invoker 对象分布在 Hash 环上。
replicaNumberint 类型):虚拟 Invoker 个数。
identityHashCodeint 类型Invoker 集合的 HashCode 值。
argumentIndexint[] 类型):需要参与 Hash 计算的参数索引。例如argumentIndex = [0, 1, 2] 时,表示调用的目标方法的前三个参数要参与 Hash 计算。
接下来看 ConsistentHashSelector 的构造方法,其中的主要任务是:
构建 Hash 槽;
确认参与一致性 Hash 计算的参数,默认是第一个参数。
这些操作的目的就是为了让 Invoker 尽可能均匀地分布在 Hash 环上,具体实现如下:
ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
// 初始化virtualInvokers字段也就是虚拟Hash槽
this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
// 记录Invoker集合的hashCode用该hashCode值来判断Provider列表是否发生了变化
this.identityHashCode = identityHashCode;
URL url = invokers.get(0).getUrl();
// 从hash.nodes参数中获取虚拟节点的个数
this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160);
// 获取参与Hash计算的参数下标值默认对第一个参数进行Hash运算
String[] index = COMMA_SPLIT_PATTERN.split(url.getMethodParameter(methodName, HASH_ARGUMENTS, "0"));
argumentIndex = new int[index.length];
for (int i = 0; i < index.length; i++) {
argumentIndex[i] = Integer.parseInt(index[i]);
}
// 构建虚拟Hash槽默认replicaNumber=160相当于在Hash槽上放160个槽位
// 外层轮询40次内层轮询4次共40*4=160次也就是同一节点虚拟出160个槽位
for (Invoker<T> invoker : invokers) {
String address = invoker.getUrl().getAddress();
for (int i = 0; i < replicaNumber / 4; i++) {
// 对address + i进行md5运算得到一个长度为16的字节数组
byte[] digest = md5(address + i);
// 对digest部分字节进行4次Hash运算得到4个不同的long型正整数
for (int h = 0; h < 4; h++) {
// h = 0 digest 中下标为 0~3 4 个字节进行位运算
// h = 1 digest 中下标为 4~7 4 个字节进行位运算
// h = 2 h = 3时过程同上
long m = hash(digest, h);
virtualInvokers.put(m, invoker);
}
}
}
}
最后请求会通过 ConsistentHashSelector.select() 方法选择合适的 Invoker 对象其中会先对请求参数进行 md5 以及 Hash 运算得到一个 Hash 然后再通过这个 Hash 值到 TreeMap 中查找目标 Invoker具体实现如下
public Invoker<T> select(Invocation invocation) {
// 将参与一致性Hash的参数拼接到一起
String key = toKey(invocation.getArguments());
// 计算key的Hash值
byte[] digest = md5(key);
// 匹配Invoker对象
return selectForKey(hash(digest, 0));
}
private Invoker<T> selectForKey(long hash) {
// 从virtualInvokers集合TreeMap是按照Key排序的中查找第一个节点值大于或等于传入Hash值的Invoker对象
Map.Entry<Long, Invoker<T>> entry = virtualInvokers.ceilingEntry(hash);
// 如果Hash值大于Hash环中的所有Invoker则回到Hash环的开头返回第一个Invoker对象
if (entry == null) {
entry = virtualInvokers.firstEntry();
}
return entry.getValue();
}
RandomLoadBalance
RandomLoadBalance 使用的负载均衡算法是加权随机算法。RandomLoadBalance 是一个简单、高效的负载均衡实现,它也是 Dubbo 默认使用的 LoadBalance 实现。
这里我们通过一个示例来说明加权随机算法的核心思想。假设我们有三个 Provider 节点 A、B、C它们对应的权重分别为 5、2、3权重总和为 10。现在把这些权重值放到一维坐标轴上[0, 5) 区间属于节点 A[5, 7) 区间属于节点 B[7, 10) 区间属于节点 C如下图所示
权重坐标轴示意图
下面我们通过随机数生成器在 [0, 10) 这个范围内生成一个随机数,然后计算这个随机数会落到哪个区间中。例如,随机生成 4就会落到 Provider A 对应的区间中,此时 RandomLoadBalance 就会返回 Provider A 这个节点。
接下来我们再来看 RandomLoadBalance 中 doSelect() 方法的实现,其核心逻辑分为三个关键点:
计算每个 Invoker 对应的权重值以及总权重值;
当各个 Invoker 权重值不相等时,计算随机数应该落在哪个 Invoker 区间中,返回对应的 Invoker 对象;
当各个 Invoker 权重值相同时,随机返回一个 Invoker 即可。
RandomLoadBalance 经过多次请求后,能够将调用请求按照权重值均匀地分配到各个 Provider 节点上。下面是 RandomLoadBalance 的核心实现:
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
int length = invokers.size();
boolean sameWeight = true;
// 计算每个Invoker对象对应的权重并填充到weights[]数组中
int[] weights = new int[length];
// 计算第一个Invoker的权重
int firstWeight = getWeight(invokers.get(0), invocation);
weights[0] = firstWeight;
// totalWeight用于记录总权重值
int totalWeight = firstWeight;
for (int i = 1; i < length; i++) {
// 计算每个Invoker的权重以及总权重totalWeight
int weight = getWeight(invokers.get(i), invocation);
weights[i] = weight;
// Sum
totalWeight += weight;
// 检测每个Provider的权重是否相同
if (sameWeight && weight != firstWeight) {
sameWeight = false;
}
}
// 各个Invoker权重值不相等时计算随机数落在哪个区间上
if (totalWeight > 0 && !sameWeight) {
// 随机获取一个[0, totalWeight) 区间内的数字
int offset = ThreadLocalRandom.current().nextInt(totalWeight);
// 循环让offset数减去Invoker的权重值当offset小于0时返回相应的Invoker
for (int i = 0; i < length; i++) {
offset -= weights[i];
if (offset < 0) {
return invokers.get(i);
}
}
}
// 各个Invoker权重值相同时随机返回一个Invoker即可
return invokers.get(ThreadLocalRandom.current().nextInt(length));
}
总结
本课时我们重点介绍了 Dubbo Cluster 层中负载均衡相关的内容首先我们介绍了 LoadBalance 接口的定义以及 AbstractLoadBalance 抽象类提供的公共能力然后我们还详细讲解了 ConsistentHashLoadBalance 的核心实现其中还简单说明了一致性 Hash 算法的基础知识点最后我们又一块儿分析了 RandomLoadBalance 的基本原理和核心实现

View File

@@ -0,0 +1,412 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下)
在上一课时我们了解了 LoadBalance 接口定义以及 AbstractLoadBalance 抽象类的内容,还详细介绍了 ConsistentHashLoadBalance 以及 RandomLoadBalance 这两个实现类的核心原理和大致实现。本课时我们将继续介绍 LoadBalance 的剩余三个实现。
LeastActiveLoadBalance
LeastActiveLoadBalance 使用的是最小活跃数负载均衡算法。它认为当前活跃请求数越小的 Provider 节点,剩余的处理能力越多,处理请求的效率也就越高,那么该 Provider 在单位时间内就可以处理更多的请求,所以我们应该优先将请求分配给该 Provider 节点。
LeastActiveLoadBalance 需要配合 ActiveLimitFilter 使用ActiveLimitFilter 会记录每个接口方法的活跃请求数,在 LeastActiveLoadBalance 进行负载均衡时,只会从活跃请求数最少的 Invoker 集合里挑选 Invoker。
在 LeastActiveLoadBalance 的实现中,首先会选出所有活跃请求数最小的 Invoker 对象,之后的逻辑与 RandomLoadBalance 完全一样,即按照这些 Invoker 对象的权重挑选最终的 Invoker 对象。下面是 LeastActiveLoadBalance.doSelect() 方法的具体实现:
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
// 初始化Invoker数量
int length = invokers.size();
// 记录最小的活跃请求数
int leastActive = -1;
// 记录活跃请求数最小的Invoker集合的个数
int leastCount = 0;
// 记录活跃请求数最小的Invoker在invokers数组中的下标位置
int[] leastIndexes = new int[length];
// 记录活跃请求数最小的Invoker集合中每个Invoker的权重值
int[] weights = new int[length];
// 记录活跃请求数最小的Invoker集合中所有Invoker的权重值之和
int totalWeight = 0;
// 记录活跃请求数最小的Invoker集合中第一个Invoker的权重值
int firstWeight = 0;
// 活跃请求数最小的集合中所有Invoker的权重值是否相同
boolean sameWeight = true;
for (int i = 0; i < length; i++) { // 遍历所有Invoker获取活跃请求数最小的Invoker集合
Invoker<T> invoker = invokers.get(i);
// 获取该Invoker的活跃请求数
int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive();
// 获取该Invoker的权重
int afterWarmup = getWeight(invoker, invocation);
weights[i] = afterWarmup;
// 比较活跃请求数
if (leastActive == -1 || active < leastActive) {
// 当前的Invoker是第一个活跃请求数最小的Invoker则记录如下信息
leastActive = active; // 重新记录最小的活跃请求数
leastCount = 1; // 重新记录活跃请求数最小的Invoker集合个数
leastIndexes[0] = i; // 重新记录Invoker
totalWeight = afterWarmup; // 重新记录总权重值
firstWeight = afterWarmup; // 该Invoker作为第一个Invoker记录其权重值
sameWeight = true; // 重新记录是否权重值相等
} else if (active == leastActive) {
// 当前Invoker属于活跃请求数最小的Invoker集合
leastIndexes[leastCount++] = i; // 记录该Invoker的下标
totalWeight += afterWarmup; // 更新总权重
if (sameWeight && afterWarmup != firstWeight) {
sameWeight = false; // 更新权重值是否相等
}
}
}
// 如果只有一个活跃请求数最小的Invoker对象直接返回即可
if (leastCount == 1) {
return invokers.get(leastIndexes[0]);
}
// 下面按照RandomLoadBalance的逻辑从活跃请求数最小的Invoker集合中随机选择一个Invoker对象返回
if (!sameWeight && totalWeight > 0) {
int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight);
for (int i = 0; i < leastCount; i++) {
int leastIndex = leastIndexes[i];
offsetWeight -= weights[leastIndex];
if (offsetWeight < 0) {
return invokers.get(leastIndex);
}
}
}
return invokers.get(leastIndexes[ThreadLocalRandom.current().nextInt(leastCount)]);
}
ActiveLimitFilter 以及底层的 RpcStatus 记录活跃请求数的具体原理在前面的[ 30 课时]中我们已经详细分析过了这里不再重复如果有不清楚的地方你可以回顾之前课时相关的内容
RoundRobinLoadBalance
RoundRobinLoadBalance 实现的是加权轮询负载均衡算法
轮询指的是将请求轮流分配给每个 Provider例如 ABC 三个 Provider 节点按照普通轮询的方式我们会将第一个请求分配给 Provider A将第二个请求分配给 Provider B第三个请求分配给 Provider C第四个请求再次分配给 Provider A如此循环往复
轮询是一种无状态负载均衡算法实现简单适用于集群中所有 Provider 节点性能相近的场景 但现实情况中就很难保证这一点了因为很容易出现集群中性能最好和最差的 Provider 节点处理同样流量的情况这就可能导致性能差的 Provider 节点各方面资源非常紧张甚至无法及时响应了但是性能好的 Provider 节点的各方面资源使用还较为空闲这时我们可以通过加权轮询的方式降低分配到性能较差的 Provider 节点的流量
加权之后分配给每个 Provider 节点的流量比会接近或等于它们的权重比例如Provider 节点 ABC 权重比为 5:1:1那么在 7 次请求中节点 A 将收到 5 次请求节点 B 会收到 1 次请求节点 C 则会收到 1 次请求
Dubbo 2.6.4 版本及之前RoundRobinLoadBalance 的实现存在一些问题例如选择 Invoker 的性能问题负载均衡时不够平滑等 Dubbo 2.6.5 版本之后这些问题都得到了修复所以这里我们就来介绍最新的 RoundRobinLoadBalance 实现
每个 Provider 节点有两个权重一个权重是配置的 weight该值在负载均衡的过程中不会变化另一个权重是 currentWeight该值会在负载均衡的过程中动态调整初始值为 0
当有新的请求进来时RoundRobinLoadBalance 会遍历 Invoker 列表并用对应的 currentWeight 加上其配置的权重遍历完成后再找到最大的 currentWeight将其减去权重总和然后返回相应的 Invoker 对象
下面我们通过一个示例说明 RoundRobinLoadBalance 的执行流程这里我们依旧假设 ABC 三个节点的权重比例为 5:1:1
处理第一个请求currentWeight 数组中的权重与配置的 weight 相加即从 [0, 0, 0] 变为 [5, 1, 1]接下来从中选择权重最大的 Invoker 作为结果即节点 A最后将节点 A currentWeight 值减去 totalWeight 最终得到 currentWeight 数组为 [-2, 1, 1]
处理第二个请求currentWeight 数组中的权重与配置的 weight 相加即从 [-2, 1, 1] 变为 [3, 2, 2]接下来从中选择权重最大的 Invoker 作为结果即节点 A最后将节点 A currentWeight 值减去 totalWeight 最终得到 currentWeight 数组为 [-4, 2, 2]
处理第三个请求currentWeight 数组中的权重与配置的 weight 相加即从 [-4, 2, 2] 变为 [1, 3, 3]接下来从中选择权重最大的 Invoker 作为结果即节点 B最后将节点 B currentWeight 值减去 totalWeight 最终得到 currentWeight 数组为 [1, -4, 3]
处理第四个请求currentWeight 数组中的权重与配置的 weight 相加即从 [1, -4, 3] 变为 [6, -3, 4]接下来从中选择权重最大的 Invoker 作为结果即节点 A最后将节点 A currentWeight 值减去 totalWeight 最终得到 currentWeight 数组为 [-1, -3, 4]
处理第五个请求currentWeight 数组中的权重与配置的 weight 相加即从 [-1, -3, 4] 变为 [4, -2, 5]接下来从中选择权重最大的 Invoker 作为结果即节点 C最后将节点 C currentWeight 值减去 totalWeight 最终得到 currentWeight 数组为 [4, -2, -2]
处理第六个请求currentWeight 数组中的权重与配置的 weight 相加即从 [4, -2, -2] 变为 [9, -1, -1]接下来从中选择权重最大的 Invoker 作为结果即节点 A最后将节点 A currentWeight 值减去 totalWeight 最终得到 currentWeight 数组为 [2, -1, -1]
处理第七个请求currentWeight 数组中的权重与配置的 weight 相加即从 [2, -1, -1] 变为 [7, 0, 0]接下来从中选择权重最大的 Invoker 作为结果即节点 A最后将节点 A currentWeight 值减去 totalWeight 最终得到 currentWeight 数组为 [0, 0, 0]
到此为止一个轮询的周期就结束了
而在 Dubbo 2.6.4 版本中上面示例的一次轮询结果是 [A, A, A, A, A, B, C]也就是说前 5 个请求会全部都落到 A 这个节点上这将会使节点 A 在短时间内接收大量的请求压力陡增而节点 B 和节点 C 此时没有收到任何请求处于完全空闲的状态这种瞬间分配不平衡的情况也就是前面提到的不平滑问题
RoundRobinLoadBalance 我们为每个 Invoker 对象创建了一个对应的 WeightedRoundRobin 对象用来记录配置的权重weight 字段以及随每次负载均衡算法执行变化的 current 权重current 字段
了解了 WeightedRoundRobin 这个内部类后我们再来看 RoundRobinLoadBalance.doSelect() 方法的具体实现
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
// 获取整个Invoker列表对应的WeightedRoundRobin映射表如果为空则创建一个新的WeightedRoundRobin映射表
ConcurrentMap<String, WeightedRoundRobin> map = methodWeightMap.computeIfAbsent(key, k -> new ConcurrentHashMap<>());
int totalWeight = 0;
long maxCurrent = Long.MIN_VALUE;
long now = System.currentTimeMillis(); // 获取当前时间
Invoker<T> selectedInvoker = null;
WeightedRoundRobin selectedWRR = null;
for (Invoker<T> invoker : invokers) {
String identifyString = invoker.getUrl().toIdentityString();
int weight = getWeight(invoker, invocation);
// 检测当前Invoker是否有相应的WeightedRoundRobin对象没有则进行创建
WeightedRoundRobin weightedRoundRobin = map.computeIfAbsent(identifyString, k -> {
WeightedRoundRobin wrr = new WeightedRoundRobin();
wrr.setWeight(weight);
return wrr;
});
// 检测Invoker权重是否发生了变化若发生变化则更新WeightedRoundRobin的weight字段
if (weight != weightedRoundRobin.getWeight()) {
weightedRoundRobin.setWeight(weight);
}
// 让currentWeight加上配置的Weight
long cur = weightedRoundRobin.increaseCurrent();
// 设置lastUpdate字段
weightedRoundRobin.setLastUpdate(now);
// 寻找具有最大currentWeight的Invoker以及Invoker对应的WeightedRoundRobin
if (cur > maxCurrent) {
maxCurrent = cur;
selectedInvoker = invoker;
selectedWRR = weightedRoundRobin;
}
totalWeight += weight; // 计算权重总和
}
if (invokers.size() != map.size()) {
map.entrySet().removeIf(item -> now - item.getValue().getLastUpdate() > RECYCLE_PERIOD);
}
if (selectedInvoker != null) {
// 用currentWeight减去totalWeight
selectedWRR.sel(totalWeight);
// 返回选中的Invoker对象
return selectedInvoker;
}
return invokers.get(0);
}
ShortestResponseLoadBalance
ShortestResponseLoadBalance 是Dubbo 2.7 版本之后新增加的一个 LoadBalance 实现类。它实现了最短响应时间的负载均衡算法,也就是从多个 Provider 节点中选出调用成功的且响应时间最短的 Provider 节点,不过满足该条件的 Provider 节点可能有多个,所以还要再使用随机算法进行一次选择,得到最终要调用的 Provider 节点。
了解了 ShortestResponseLoadBalance 的核心原理之后,我们一起来看 ShortestResponseLoadBalance.doSelect() 方法的核心实现,如下所示:
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
// 记录Invoker集合的数量
int length = invokers.size();
// 用于记录所有Invoker集合中最短响应时间
long shortestResponse = Long.MAX_VALUE;
// 具有相同最短响应时间的Invoker个数
int shortestCount = 0;
// 存放所有最短响应时间的Invoker的下标
int[] shortestIndexes = new int[length];
// 存储每个Invoker的权重
int[] weights = new int[length];
// 存储权重总和
int totalWeight = 0;
// 记录第一个Invoker对象的权重
int firstWeight = 0;
// 最短响应时间Invoker集合中的Invoker权重是否相同
boolean sameWeight = true;
for (int i = 0; i < length; i++) {
Invoker<T> invoker = invokers.get(i);
RpcStatus rpcStatus = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName());
// 获取调用成功的平均时间,具体计算方式是:调用成功的请求数总数对应的总耗时 / 调用成功的请求数总数 = 成功调用的平均时间
// RpcStatus 的内容在前面课时已经介绍过了,这里不再重复
long succeededAverageElapsed = rpcStatus.getSucceededAverageElapsed();
// 获取的是该Provider当前的活跃请求数也就是当前正在处理的请求数
int active = rpcStatus.getActive();
// 计算一个处理新请求的预估值也就是如果当前请求发给这个Provider大概耗时多久处理完成
long estimateResponse = succeededAverageElapsed * active;
// 计算该Invoker的权重主要是处理预热
int afterWarmup = getWeight(invoker, invocation);
weights[i] = afterWarmup;
if (estimateResponse < shortestResponse) {
// 第一次找到Invoker集合中最短响应耗时的Invoker对象记录其相关信息
shortestResponse = estimateResponse;
shortestCount = 1;
shortestIndexes[0] = i;
totalWeight = afterWarmup;
firstWeight = afterWarmup;
sameWeight = true;
} else if (estimateResponse == shortestResponse) {
// 出现多个耗时最短的Invoker对象
shortestIndexes[shortestCount++] = i;
totalWeight += afterWarmup;
if (sameWeight && i > 0
&& afterWarmup != firstWeight) {
sameWeight = false;
}
}
}
if (shortestCount == 1) {
return invokers.get(shortestIndexes[0]);
}
// 如果耗时最短的所有Invoker对象的权重不相同则通过加权随机负载均衡的方式选择一个Invoker返回
if (!sameWeight && totalWeight > 0) {
int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight);
for (int i = 0; i < shortestCount; i++) {
int shortestIndex = shortestIndexes[i];
offsetWeight -= weights[shortestIndex];
if (offsetWeight < 0) {
return invokers.get(shortestIndex);
}
}
}
// 如果耗时最短的所有Invoker对象的权重相同则随机返回一个
return invokers.get(shortestIndexes[ThreadLocalRandom.current().nextInt(shortestCount)]);
}
总结
今天我们紧接上一课时介绍了 LoadBalance 接口的剩余三个实现
我们首先介绍了 LeastActiveLoadBalance 实现它使用最小活跃数负载均衡算法选择当前请求最少的 Provider 节点处理最新的请求接下来介绍了 RoundRobinLoadBalance 实现它使用加权轮询负载均衡算法弥补了单纯的轮询负载均衡算法导致的问题同时随着 Dubbo 版本的升级也将其自身不够平滑的问题优化掉了最后介绍了 ShortestResponseLoadBalance 实现它会从响应时间最短的 Provider 节点中选择一个 Provider 节点来处理新请求

View File

@@ -0,0 +1,581 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 集群容错:一个好汉三个帮(上)
你好,我是杨四正,今天我和你分享的主题是集群容错:一个好汉三个帮(上篇)。
在前面的课时中,我们已经对 Directory、Router、LoadBalance 等概念进行了深入的剖析,本课时将重点分析 Cluster 接口的相关内容。
Cluster 接口提供了我们常说的集群容错功能。
集群中的单个节点有一定概率出现一些问题,例如,磁盘损坏、系统崩溃等,导致节点无法对外提供服务,因此在分布式 RPC 框架中,必须要重视这种情况。为了避免单点故障,我们的 Provider 通常至少会部署在两台服务器上,以集群的形式对外提供服务,对于一些负载比较高的服务,则需要部署更多 Provider 来抗住流量。
在 Dubbo 中,通过 Cluster 这个接口把一组可供调用的 Provider 信息组合成为一个统一的 Invoker 供调用方进行调用。经过 Router 过滤、LoadBalance 选址之后,选中一个具体 Provider 进行调用,如果调用失败,则会按照集群的容错策略进行容错处理。
Dubbo 默认内置了若干容错策略,并且每种容错策略都有自己独特的应用场景,我们可以通过配置选择不同的容错策略。如果这些内置容错策略不能满足需求,我们还可以通过自定义容错策略进行配置。
了解了上述背景知识之后,下面我们就正式开始介绍 Cluster 接口。
Cluster 接口与容错机制
Cluster 的工作流程大致可以分为两步(如下图所示):①创建 Cluster Invoker 实例(在 Consumer 初始化时Cluster 实现类会创建一个 Cluster Invoker 实例,即下图中的 merge 操作);②使用 Cluster Invoker 实例(在 Consumer 服务消费者发起远程调用请求的时候Cluster Invoker 会依赖前面课时介绍的 Directory、Router、LoadBalance 等组件得到最终要调用的 Invoker 对象)。
Cluster 核心流程图
Cluster Invoker 获取 Invoker 的流程大致可描述为如下:
通过 Directory 获取 Invoker 列表,以 RegistryDirectory 为例,会感知注册中心的动态变化,实时获取当前 Provider 对应的 Invoker 集合;
调用 Router 的 route() 方法进行路由,过滤掉不符合路由规则的 Invoker 对象;
通过 LoadBalance 从 Invoker 列表中选择一个 Invoker
ClusterInvoker 会将参数传给 LoadBalance 选择出的 Invoker 实例的 invoke 方法,进行真正的远程调用。
这个过程是一个正常流程没有涉及容错处理。Dubbo 中常见的容错方式有如下几个。
Failover Cluster失败自动切换。它是 Dubbo 的默认容错机制,在请求一个 Provider 节点失败的时候,自动切换其他 Provider 节点,默认执行 3 次,适合幂等操作。当然,重试次数越多,在故障容错的时候带给 Provider 的压力就越大,在极端情况下甚至可能造成雪崩式的问题。
Failback Cluster失败自动恢复。失败后记录到队列中通过定时器重试。
Failfast Cluster快速失败。请求失败后返回异常不进行任何重试。
Failsafe Cluster失败安全。请求失败后忽略异常不进行任何重试。
Forking Cluster并行调用多个 Provider 节点,只要有一个成功就返回。
Broadcast Cluster广播多个 Provider 节点,只要有一个节点失败就失败。
Available Cluster遍历所有的 Provider 节点,找到每一个可用的节点,就直接调用。如果没有可用的 Provider 节点,则直接抛出异常。
Mergeable Cluster请求多个 Provider 节点并将得到的结果进行合并。
下面我们再来看 Cluster 接口。Cluster 接口是一个扩展接口,通过 @SPI 注解的参数我们知道其使用的默认实现是 FailoverCluster它只定义了一个 join() 方法,在其上添加了 @Adaptive 注解,会动态生成适配器类,其中会优先根据 Directory.getUrl() 方法返回的 URL 中的 cluster 参数值选择扩展实现,若无 cluster 参数则使用默认的 FailoverCluster 实现。Cluster 接口的具体定义如下所示:
@SPI(FailoverCluster.NAME)
public interface Cluster {
@Adaptive
<T> Invoker<T> join(Directory<T> directory) throws RpcException;
}
Cluster 接口的实现类如下图所示,分别对应前面提到的多种容错策略:
Cluster 接口继承关系
在每个 Cluster 接口实现中,都会创建对应的 Invoker 对象,这些都继承自 AbstractClusterInvoker 抽象类,如下图所示:
AbstractClusterInvoker 继承关系图
通过上面两张继承关系图我们可以看出Cluster 接口和 Invoker 接口都会有相应的抽象实现类,这些抽象实现类都实现了一些公共能力。下面我们就来深入介绍 AbstractClusterInvoker 和 AbstractCluster 这两个抽象类。
AbstractClusterInvoker
了解了 Cluster Invoker 的继承关系之后,我们首先来看 AbstractClusterInvoker它有两点核心功能一个是实现的 Invoker 接口,对 Invoker.invoke() 方法进行通用的抽象实现;另一个是实现通用的负载均衡算法。
在 AbstractClusterInvoker.invoke() 方法中,会通过 Directory 获取 Invoker 列表,然后通过 SPI 初始化 LoadBalance最后调用 doInvoke() 方法执行子类的逻辑。在 Directory.list() 方法返回 Invoker 集合之前,已经使用 Router 进行了一次筛选,你可以回顾前面[第 31 课时]对 RegistryDirectory 的分析。
public Result invoke(final Invocation invocation) throws RpcException {
// 检测当前Invoker是否已销毁
checkWhetherDestroyed();
// 将RpcContext中的attachment添加到Invocation中
Map<String, Object> contextAttachments = RpcContext.getContext().getObjectAttachments();
if (contextAttachments != null && contextAttachments.size() != 0) {
((RpcInvocation) invocation).addObjectAttachments(contextAttachments);
}
// 通过Directory获取Invoker对象列表通过对RegistryDirectory的介绍我们知道其中已经调用了Router进行过滤
List<Invoker<T>> invokers = list(invocation);
// 通过SPI加载LoadBalance
LoadBalance loadbalance = initLoadBalance(invokers, invocation);
RpcUtils.attachInvocationIdIfAsync(getUrl(), invocation);
// 调用doInvoke()方法,该方法是个抽象方法
return doInvoke(invocation, invokers, loadbalance);
}
protected List<Invoker<T>> list(Invocation invocation) throws RpcException {
return directory.list(invocation); // 调用Directory.list()方法
}
下面我们来看一下 AbstractClusterInvoker 是如何按照不同的 LoadBalance 算法从 Invoker 集合中选取最终 Invoker 对象的。
AbstractClusterInvoker 并没有简单粗暴地使用 LoadBalance.select() 方法完成负载均衡,而是做了进一步的封装,具体实现在 select() 方法中。在 select() 方法中会根据配置决定是否开启粘滞连接特性,如果开启了,则需要将上次使用的 Invoker 缓存起来,只要 Provider 节点可用就直接调用,不会再进行负载均衡。如果调用失败,才会重新进行负载均衡,并且排除已经重试过的 Provider 节点。
// 第一个参数是此次使用的LoadBalance实现第二个参数Invocation是此次服务调用的上下文信息
// 第三个参数是待选择的Invoker集合第四个参数用来记录负载均衡已经选出来、尝试过的Invoker集合
protected Invoker<T> select(LoadBalance loadbalance, Invocation invocation, List<Invoker<T>> invokers, List<Invoker<T>> selected) throws RpcException {
if (CollectionUtils.isEmpty(invokers)) {
return null;
}
// 获取调用方法名
String methodName = invocation == null ? StringUtils.EMPTY_STRING : invocation.getMethodName();
// 获取sticky配置sticky表示粘滞连接所谓粘滞连接是指Consumer会尽可能地
// 调用同一个Provider节点除非这个Provider无法提供服务
boolean sticky = invokers.get(0).getUrl()
.getMethodParameter(methodName, CLUSTER_STICKY_KEY, DEFAULT_CLUSTER_STICKY);
// 检测invokers列表是否包含sticky Invoker如果不包含
// 说明stickyInvoker代表的服务提供者挂了此时需要将其置空
if (stickyInvoker != null && !invokers.contains(stickyInvoker)) {
stickyInvoker = null;
}
// 如果开启了粘滞连接特性需要先判断这个Provider节点是否已经重试过了
if (sticky && stickyInvoker != null // 表示粘滞连接
&& (selected == null || !selected.contains(stickyInvoker)) // 表示stickyInvoker未重试过
) {
// 检测当前stickyInvoker是否可用如果可用直接返回stickyInvoker
if (availablecheck && stickyInvoker.isAvailable()) {
return stickyInvoker;
}
}
// 执行到这里说明前面的stickyInvoker为空或者不可用
// 这里会继续调用doSelect选择新的Invoker对象
Invoker<T> invoker = doSelect(loadbalance, invocation, invokers, selected);
if (sticky) { // 是否开启粘滞更新stickyInvoker字段
stickyInvoker = invoker;
}
return invoker;
}
doSelect() 方法主要做了两件事:
一是通过 LoadBalance 选择 Invoker 对象;
二是如果选出来的 Invoker 不稳定或不可用,会调用 reselect() 方法进行重选。
private Invoker<T> doSelect(LoadBalance loadbalance, Invocation invocation,
List<Invoker<T>> invokers, List<Invoker<T>> selected) throws RpcException {
// 判断是否需要进行负载均衡Invoker集合为空直接返回null
if (CollectionUtils.isEmpty(invokers)) {
return null;
}
if (invokers.size() == 1) { // 只有一个Invoker对象直接返回即可
return invokers.get(0);
}
// 通过LoadBalance实现选择Invoker对象
Invoker<T> invoker = loadbalance.select(invokers, getUrl(), invocation);
// 如果LoadBalance选出的Invoker对象已经尝试过请求了或不可用则需要调用reselect()方法重选
if ((selected != null && selected.contains(invoker)) // Invoker已经尝试调用过了但是失败了
|| (!invoker.isAvailable() && getUrl() != null && availablecheck) // Invoker不可用
) {
try {
// 调用reselect()方法重选
Invoker<T> rInvoker = reselect(loadbalance, invocation, invokers, selected, availablecheck);
// 如果重选的Invoker对象不为空则直接返回这个 rInvoker
if (rInvoker != null) {
invoker = rInvoker;
} else {
int index = invokers.indexOf(invoker);
try {
// 如果重选的Invoker对象为空则返回该Invoker的下一个Invoker对象
invoker = invokers.get((index + 1) % invokers.size());
} catch (Exception e) {
logger.warn("...");
}
}
} catch (Throwable t) {
logger.error("...");
}
}
return invoker;
}
reselect() 方法会重新进行一次负载均衡,首先对未尝试过的可用 Invokers 进行负载均衡,如果已经全部重试过了,则将尝试过的 Provider 节点过滤掉,然后在可用的 Provider 节点中重新进行负载均衡。
private Invoker<T> reselect(LoadBalance loadbalance, Invocation invocation,
List<Invoker<T>> invokers, List<Invoker<T>> selected, boolean availablecheck) throws RpcException {
// 用于记录要重新进行负载均衡的Invoker集合
List<Invoker<T>> reselectInvokers = new ArrayList<>(
invokers.size() > 1 ? (invokers.size() - 1) : invokers.size());
// 将不在selected集合中的Invoker过滤出来进行负载均衡
for (Invoker<T> invoker : invokers) {
if (availablecheck && !invoker.isAvailable()) {
continue;
}
if (selected == null || !selected.contains(invoker)) {
reselectInvokers.add(invoker);
}
}
// reselectInvokers不为空时才需要通过负载均衡组件进行选择
if (!reselectInvokers.isEmpty()) {
return loadbalance.select(reselectInvokers, getUrl(), invocation);
}
// 只能对selected集合中可用的Invoker再次进行负载均衡
if (selected != null) {
for (Invoker<T> invoker : selected) {
if ((invoker.isAvailable()) // available first
&& !reselectInvokers.contains(invoker)) {
reselectInvokers.add(invoker);
}
}
}
if (!reselectInvokers.isEmpty()) {
return loadbalance.select(reselectInvokers, getUrl(), invocation);
}
return null;
}
AbstractCluster
常用的 ClusterInvoker 实现都继承了 AbstractClusterInvoker 类型,对应的 Cluster 扩展实现都继承了 AbstractCluster 抽象类。AbstractCluster 抽象类的核心逻辑是在 ClusterInvoker 外层包装一层 ClusterInterceptor从而实现类似切面的效果。
下面是 ClusterInterceptor 接口的定义:
@SPI
public interface ClusterInterceptor {
// 前置拦截方法
void before(AbstractClusterInvoker<?> clusterInvoker, Invocation invocation);
// 后置拦截方法
void after(AbstractClusterInvoker<?> clusterInvoker, Invocation invocation);
// 调用ClusterInvoker的invoke()方法完成请求
default Result intercept(AbstractClusterInvoker<?> clusterInvoker, Invocation invocation) throws RpcException {
return clusterInvoker.invoke(invocation);
}
// 这个Listener用来监听请求的正常结果以及异常
interface Listener {
void onMessage(Result appResponse, AbstractClusterInvoker<?> clusterInvoker, Invocation invocation);
void onError(Throwable t, AbstractClusterInvoker<?> clusterInvoker, Invocation invocation);
}
}
在 AbstractCluster 抽象类的 join() 方法中,首先会调用 doJoin() 方法获取最终要调用的 Invoker 对象doJoin() 是个抽象方法,由 AbstractCluster 子类根据具体的策略进行实现。之后AbstractCluster.join() 方法会调用 buildClusterInterceptors() 方法加载 ClusterInterceptor 扩展实现类,对 Invoker 对象进行包装。具体实现如下:
private <T> Invoker<T> buildClusterInterceptors(AbstractClusterInvoker<T> clusterInvoker, String key) {
AbstractClusterInvoker<T> last = clusterInvoker;
// 通过SPI方式加载ClusterInterceptor扩展实现
List<ClusterInterceptor> interceptors = ExtensionLoader.getExtensionLoader(ClusterInterceptor.class).getActivateExtension(clusterInvoker.getUrl(), key);
if (!interceptors.isEmpty()) {
for (int i = interceptors.size() - 1; i >= 0; i--) {
// 将InterceptorInvokerNode收尾连接到一起形成调用链
final ClusterInterceptor interceptor = interceptors.get(i);
final AbstractClusterInvoker<T> next = last;
last = new InterceptorInvokerNode<>(clusterInvoker, interceptor, next);
}
}
return last;
}
@Override
public <T> Invoker<T> join(Directory<T> directory) throws RpcException {
// 扩展名称由reference.interceptor参数确定
return buildClusterInterceptors(doJoin(directory), directory.getUrl().getParameter(REFERENCE_INTERCEPTOR_KEY));
}
InterceptorInvokerNode 会将底层的 AbstractClusterInvoker 对象以及关联的 ClusterInterceptor 对象封装到一起,还会维护一个 next 引用,指向下一个 InterceptorInvokerNode 对象。
在 InterceptorInvokerNode.invoke() 方法中,会先调用 ClusterInterceptor 的前置逻辑,然后执行 intercept() 方法调用 AbstractClusterInvoker 的 invoke() 方法完成远程调用,最后执行 ClusterInterceptor 的后置逻辑。具体实现如下:
public Result invoke(Invocation invocation) throws RpcException {
Result asyncResult;
try {
interceptor.before(next, invocation); // 前置逻辑
// 执行invoke()方法完成远程调用
asyncResult = interceptor.intercept(next, invocation);
} catch (Exception e) {
if (interceptor instanceof ClusterInterceptor.Listener) {
// 出现异常时会触发监听器的onError()方法
ClusterInterceptor.Listener listener = (ClusterInterceptor.Listener) interceptor;
listener.onError(e, clusterInvoker, invocation);
}
throw e;
} finally {
// 执行后置逻辑
interceptor.after(next, invocation);
}
return asyncResult.whenCompleteWithContext((r, t) -> {
if (interceptor instanceof ClusterInterceptor.Listener) {
ClusterInterceptor.Listener listener = (ClusterInterceptor.Listener) interceptor;
if (t == null) {
// 正常返回时会调用onMessage()方法触发监听器
listener.onMessage(r, clusterInvoker, invocation);
} else {
listener.onError(t, clusterInvoker, invocation);
}
}
});
}
Dubbo 提供了两个 ClusterInterceptor 实现类,分别是 ConsumerContextClusterInterceptor 和 ZoneAwareClusterInterceptor如下图所示
ClusterInterceptor 继承关系图
在 ConsumerContextClusterInterceptor 的 before() 方法中,会在 RpcContext 中设置当前 Consumer 地址、此次调用的 Invoker 等信息,同时还会删除之前与当前线程绑定的 Server Context。在 after() 方法中,会删除本地 RpcContext 的信息。ConsumerContextClusterInterceptor 的具体实现如下:
public void before(AbstractClusterInvoker<?> invoker, Invocation invocation) {
// 获取当前线程绑定的RpcContext
RpcContext context = RpcContext.getContext();
// 设置Invoker、Consumer地址等信息 context.setInvocation(invocation).setLocalAddress(NetUtils.getLocalHost(), 0);
if (invocation instanceof RpcInvocation) {
((RpcInvocation) invocation).setInvoker(invoker);
}
RpcContext.removeServerContext();
}
public void after(AbstractClusterInvoker<?> clusterInvoker, Invocation invocation) {
RpcContext.removeContext(true); // 删除本地RpcContext的信息
}
ConsumerContextClusterInterceptor 同时继承了 ClusterInterceptor.Listener 接口,在其 onMessage() 方法中,会获取响应中的 attachments 并设置到 RpcContext 中的 SERVER_LOCAL 之中,具体实现如下:
public void onMessage(Result appResponse, AbstractClusterInvoker<?> invoker, Invocation invocation) {
// 从AppResponse中获取attachment并设置到SERVER_LOCAL这个RpcContext中 RpcContext.getServerContext().setObjectAttachments(appResponse.getObjectAttachments());
}
介绍完 ConsumerContextClusterInterceptor我们再来看 ZoneAwareClusterInterceptor。
在 ZoneAwareClusterInterceptor 的 before() 方法中,会从 RpcContext 中获取多注册中心相关的参数并设置到 Invocation 中(主要是 registry_zone 参数和 registry_zone_force 参数,这两个参数的具体含义,在后面分析 ZoneAwareClusterInvoker 时详细介绍ZoneAwareClusterInterceptor 的 after() 方法为空实现。ZoneAwareClusterInterceptor 的具体实现如下:
public void before(AbstractClusterInvoker<?> clusterInvoker, Invocation invocation) {
RpcContext rpcContext = RpcContext.getContext();
// 从RpcContext中获取registry_zone参数和registry_zone_force参数
String zone = (String) rpcContext.getAttachment(REGISTRY_ZONE);
String force = (String) rpcContext.getAttachment(REGISTRY_ZONE_FORCE);
// 检测用户是否提供了ZoneDetector接口的扩展实现
ExtensionLoader<ZoneDetector> loader = ExtensionLoader.getExtensionLoader(ZoneDetector.class);
if (StringUtils.isEmpty(zone) && loader.hasExtension("default")) {
ZoneDetector detector = loader.getExtension("default");
zone = detector.getZoneOfCurrentRequest(invocation);
force = detector.isZoneForcingEnabled(invocation, zone);
}
// 将registry_zone参数和registry_zone_force参数设置到Invocation中
if (StringUtils.isNotEmpty(zone)) {
invocation.setAttachment(REGISTRY_ZONE, zone);
}
if (StringUtils.isNotEmpty(force)) {
invocation.setAttachment(REGISTRY_ZONE_FORCE, force);
}
}
需要注意的是ZoneAwareClusterInterceptor 没有实现 ClusterInterceptor.Listener 接口,也就是不提供监听响应的功能。
总结
本课时我们主要介绍的是 Dubbo Cluster 层中容错机制相关的内容。首先,我们了解了集群容错机制的作用。然后,我们介绍了 Cluster 接口的定义以及其各个实现类的核心功能。之后,我们深入讲解了 AbstractClusterInvoker 的实现,其核心是实现了一套通用的负载均衡算法。最后,我们还分析了 AbstractCluster 抽象实现类以及其中涉及的 ClusterInterceptor 接口的内容。

View File

@@ -0,0 +1,952 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 集群容错:一个好汉三个帮(下)
你好,我是杨四正,今天我和你分享的主题是集群容错:一个好汉三个帮(下篇)。
在上一课时,我们介绍了 Dubbo Cluster 层中集群容错机制的基础知识,还说明了 Cluster 接口的定义以及其各个实现类的核心功能。同时,我们还分析了 AbstractClusterInvoker 抽象类以及 AbstractCluster 抽象实现类的核心实现。
那接下来在本课时,我们将介绍 Cluster 接口的全部实现类,以及相关的 Cluster Invoker 实现类。
FailoverClusterInvoker
通过前面对 Cluster 接口的介绍我们知道Cluster 默认的扩展实现是 FailoverCluster其 doJoin() 方法中会创建一个 FailoverClusterInvoker 对象并返回,具体实现如下:
public <T> AbstractClusterInvoker<T> doJoin(Directory<T> directory) throws RpcException {
return new FailoverClusterInvoker<>(directory);
}
FailoverClusterInvoker 会在调用失败的时候,自动切换 Invoker 进行重试。下面来看 FailoverClusterInvoker 的核心实现:
public Result doInvoke(Invocation invocation, final List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
List<Invoker<T>> copyInvokers = invokers;
// 检查copyInvokers集合是否为空如果为空会抛出异常
checkInvokers(copyInvokers, invocation);
String methodName = RpcUtils.getMethodName(invocation);
// 参数重试次数默认重试2次总共执行3次
int len = getUrl().getMethodParameter(methodName, RETRIES_KEY, DEFAULT_RETRIES) + 1;
if (len <= 0) {
len = 1;
}
RpcException le = null;
// 记录已经尝试调用过的Invoker对象
List<Invoker<T>> invoked = new ArrayList<Invoker<T>>(copyInvokers.size());
Set<String> providers = new HashSet<String>(len);
for (int i = 0; i < len; i++) {
// 第一次传进来的invokers已经check过了第二次则是重试需要重新获取最新的服务列表
if (i > 0) {
checkWhetherDestroyed();
// 这里会重新调用Directory.list()方法获取Invoker列表
copyInvokers = list(invocation);
// 检查copyInvokers集合是否为空如果为空会抛出异常
checkInvokers(copyInvokers, invocation);
}
// 通过LoadBalance选择Invoker对象这里传入的invoked集合
// 就是前面介绍AbstractClusterInvoker.select()方法中的selected集合
Invoker<T> invoker = select(loadbalance, invocation, copyInvokers, invoked);
// 记录此次要尝试调用的Invoker对象下一次重试时就会过滤这个服务
invoked.add(invoker);
RpcContext.getContext().setInvokers((List) invoked);
try {
// 调用目标Invoker对象的invoke()方法,完成远程调用
Result result = invoker.invoke(invocation);
// 经过尝试之后终于成功这里会打印一个警告日志将尝试过来的Provider地址打印出来
if (le != null && logger.isWarnEnabled()) {
logger.warn("...");
}
return result;
} catch (RpcException e) {
if (e.isBiz()) { // biz exception.
throw e;
}
le = e;
} catch (Throwable e) { // 抛出异常,表示此次尝试失败,会进行重试
le = new RpcException(e.getMessage(), e);
} finally {
// 记录尝试过的Provider地址会在上面的警告日志中打印出来
providers.add(invoker.getUrl().getAddress());
}
}
// 达到重试次数上限之后会抛出异常其中会携带调用的方法名、尝试过的Provider节点的地址(providers集合)、全部的Provider个数(copyInvokers集合)以及Directory信息
throw new RpcException(le.getCode(), "...");
}
FailbackClusterInvoker
FailbackCluster 是 Cluster 接口的另一个扩展实现,扩展名是 failback其 doJoin() 方法中创建的 Invoker 对象是 FailbackClusterInvoker 类型,具体实现如下:
public <T> AbstractClusterInvoker<T> doJoin(Directory<T> directory) throws RpcException {
return new FailbackClusterInvoker<>(directory);
}
FailbackClusterInvoker 在请求失败之后,返回一个空结果给 Consumer同时还会添加一个定时任务对失败的请求进行重试。下面来看 FailbackClusterInvoker 的具体实现:
protected Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
Invoker<T> invoker = null;
try {
// 检测Invoker集合是否为空
checkInvokers(invokers, invocation);
// 调用select()方法得到此次尝试的Invoker对象
invoker = select(loadbalance, invocation, invokers, null);
// 调用invoke()方法完成远程调用
return invoker.invoke(invocation);
} catch (Throwable e) {
// 请求失败之后,会添加一个定时任务进行重试
addFailed(loadbalance, invocation, invokers, invoker);
return AsyncRpcResult.newDefaultAsyncResult(null, null, invocation); // 请求失败时,会返回一个空结果
}
}
在 doInvoke() 方法中,请求失败时会调用 addFailed() 方法添加定时任务进行重试,默认每隔 5 秒执行一次,总共重试 3 次,具体实现如下:
private void addFailed(LoadBalance loadbalance, Invocation invocation, List<Invoker<T>> invokers, Invoker<T> lastInvoker) {
if (failTimer == null) {
synchronized (this) {
if (failTimer == null) { // Double Check防止并发问题
// 初始化时间轮这个时间轮有32个槽每个槽代表1秒
failTimer = new HashedWheelTimer(
new NamedThreadFactory("failback-cluster-timer", true),
1,
TimeUnit.SECONDS, 32, failbackTasks);
}
}
}
// 创建一个定时任务
RetryTimerTask retryTimerTask = new RetryTimerTask(loadbalance, invocation, invokers, lastInvoker, retries, RETRY_FAILED_PERIOD);
try {
// 将定时任务添加到时间轮中
failTimer.newTimeout(retryTimerTask, RETRY_FAILED_PERIOD, TimeUnit.SECONDS);
} catch (Throwable e) {
logger.error("...");
}
}
在 RetryTimerTask 定时任务中,会重新调用 select() 方法筛选合适的 Invoker 对象,并尝试进行请求。如果请求再次失败且重试次数未达到上限,则调用 rePut() 方法再次添加定时任务等待进行重试如果请求成功也不会返回任何结果。RetryTimerTask 的核心实现如下:
public void run(Timeout timeout) {
try {
// 重新选择Invoker对象注意这里会将上次重试失败的Invoker作为selected集合传入
Invoker<T> retryInvoker = select(loadbalance, invocation, invokers, Collections.singletonList(lastInvoker));
lastInvoker = retryInvoker;
retryInvoker.invoke(invocation); // 请求对应的Provider节点
} catch (Throwable e) {
if ((++retryTimes) >= retries) { // 重试次数达到上限,输出警告日志
logger.error("...");
} else {
rePut(timeout); // 重试次数未达到上限,则重新添加定时任务,等待重试
}
}
}
private void rePut(Timeout timeout) {
if (timeout == null) { // 边界检查
return;
}
Timer timer = timeout.timer();
if (timer.isStop() || timeout.isCancelled()) { // 检查时间轮状态、检查定时任务状态
return;
}
// 重新添加定时任务
timer.newTimeout(timeout.task(), tick, TimeUnit.SECONDS);
}
FailfastClusterInvoker
FailfastCluster 的扩展名是 failfast在其 doJoin() 方法中会创建 FailfastClusterInvoker 对象,具体实现如下:
public <T> AbstractClusterInvoker<T> doJoin(Directory<T> directory) throws RpcException {
return new FailfastClusterInvoker<>(directory);
}
FailfastClusterInvoker 只会进行一次请求,请求失败之后会立即抛出异常,这种策略适合非幂等的操作,具体实现如下:
public Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
checkInvokers(invokers, invocation);
// 调用select()得到此次要调用的Invoker对象
Invoker<T> invoker = select(loadbalance, invocation, invokers, null);
try {
return invoker.invoke(invocation); // 发起请求
} catch (Throwable e) {
// 请求失败,直接抛出异常
if (e instanceof RpcException && ((RpcException) e).isBiz()) {
throw (RpcException) e;
}
throw new RpcException("...");
}
}
FailsafeClusterInvoker
FailsafeCluster 的扩展名是 failsafe在其 doJoin() 方法中会创建 FailsafeClusterInvoker 对象,具体实现如下:
public <T> AbstractClusterInvoker<T> doJoin(Directory<T> directory) throws RpcException {
return new FailsafeClusterInvoker<>(directory);
}
FailsafeClusterInvoker 只会进行一次请求,请求失败之后会返回一个空结果,具体实现如下:
public Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
try {
// 检测Invoker集合是否为空
checkInvokers(invokers, invocation);
// 调用select()得到此次要调用的Invoker对象
Invoker<T> invoker = select(loadbalance, invocation, invokers, null);
// 发起请求
return invoker.invoke(invocation);
} catch (Throwable e) {
// 请求失败之后,会打印一行日志并返回空结果
logger.error("...");
return AsyncRpcResult.newDefaultAsyncResult(null, null, invocation);
}
}
ForkingClusterInvoker
ForkingCluster 的扩展名称为 forking在其 doJoin() 方法中,会创建一个 ForkingClusterInvoker 对象,具体实现如下:
public <T> AbstractClusterInvoker<T> doJoin(Directory<T> directory) throws RpcException {
return new ForkingClusterInvoker<>(directory);
}
ForkingClusterInvoker 中会维护一个线程池executor 字段,通过 Executors.newCachedThreadPool() 方法创建的线程池),并发调用多个 Provider 节点,只要有一个 Provider 节点成功返回了结果ForkingClusterInvoker 的 doInvoke() 方法就会立即结束运行。
ForkingClusterInvoker 主要是为了应对一些实时性要求较高的读操作,因为没有并发控制的多线程写入,可能会导致数据不一致。
ForkingClusterInvoker.doInvoke() 方法首先从 Invoker 集合中选出指定个数forks 参数决定)的 Invoker 对象,然后通过 executor 线程池并发调用这些 Invoker并将请求结果存储在 ref 阻塞队列中,则当前线程会阻塞在 ref 队列上,等待第一个请求结果返回。下面是 ForkingClusterInvoker 的具体实现:
public Result doInvoke(final Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
try {
// 检查Invoker集合是否为空
checkInvokers(invokers, invocation);
final List<Invoker<T>> selected;
// 从URL中获取forks参数作为并发请求的上限默认值为2
final int forks = getUrl().getParameter(FORKS_KEY, DEFAULT_FORKS);
final int timeout = getUrl().getParameter(TIMEOUT_KEY, DEFAULT_TIMEOUT);
if (forks <= 0 || forks >= invokers.size()) {
// 如果forks为负数或是大于Invoker集合的长度会直接并发调用全部Invoker
selected = invokers;
} else {
// 按照forks指定的并发度选择此次并发调用的Invoker对象
selected = new ArrayList<>(forks);
while (selected.size() < forks) {
Invoker<T> invoker = select(loadbalance, invocation, invokers, selected);
if (!selected.contains(invoker)) {
selected.add(invoker); // 避免重复选择
}
}
}
RpcContext.getContext().setInvokers((List) selected);
// 记录失败的请求个数
final AtomicInteger count = new AtomicInteger();
// 用于记录请求结果
final BlockingQueue<Object> ref = new LinkedBlockingQueue<>();
for (final Invoker<T> invoker : selected) { // 遍历 selected 列表
executor.execute(() -> { // 为每个Invoker创建一个任务并提交到线程池中
try {
// 发起请求
Result result = invoker.invoke(invocation);
// 将请求结果写到ref队列中
ref.offer(result);
} catch (Throwable e) {
int value = count.incrementAndGet();
if (value >= selected.size()) {
// 如果失败的请求个数超过了并发请求的个数则向ref队列中写入异常
ref.offer(e);
}
}
});
}
try {
// 当前线程会阻塞等待任意一个请求结果的出现
Object ret = ref.poll(timeout, TimeUnit.MILLISECONDS);
if (ret instanceof Throwable) { // 如果结果类型为Throwable则抛出异常
Throwable e = (Throwable) ret;
throw new RpcException("...");
}
return (Result) ret; // 返回结果
} catch (InterruptedException e) {
throw new RpcException("...");
}
} finally {
// 清除上下文信息
RpcContext.getContext().clearAttachments();
}
}
BroadcastClusterInvoker
BroadcastCluster 这个 Cluster 实现类的扩展名为 broadcast在其 doJoin() 方法中创建的是 BroadcastClusterInvoker 类型的 Invoker 对象,具体实现如下:
public <T> AbstractClusterInvoker<T> doJoin(Directory<T> directory) throws RpcException {
return new BroadcastClusterInvoker<>(directory);
}
在 BroadcastClusterInvoker 中,会逐个调用每个 Provider 节点,其中任意一个 Provider 节点报错都会在全部调用结束之后抛出异常。BroadcastClusterInvoker通常用于通知类的操作例如通知所有 Provider 节点更新本地缓存。
下面来看 BroadcastClusterInvoker 的具体实现:
public Result doInvoke(final Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
// 检测Invoker集合是否为空
checkInvokers(invokers, invocation);
RpcContext.getContext().setInvokers((List) invokers);
RpcException exception = null; // 用于记录失败请求的相关异常信息
Result result = null;
// 遍历所有Invoker对象
for (Invoker<T> invoker : invokers) {
try {
// 发起请求
result = invoker.invoke(invocation);
} catch (RpcException e) {
exception = e;
logger.warn(e.getMessage(), e);
} catch (Throwable e) {
exception = new RpcException(e.getMessage(), e);
logger.warn(e.getMessage(), e);
}
}
if (exception != null) { // 出现任何异常,都会在这里抛出
throw exception;
}
return result;
}
AvailableClusterInvoker
AvailableCluster 这个 Cluster 实现类的扩展名为 available在其 join() 方法中创建的是 AvailableClusterInvoker 类型的 Invoker 对象,具体实现如下:
public <T> Invoker<T> join(Directory<T> directory) throws RpcException {
return new AvailableClusterInvoker<>(directory);
}
在 AvailableClusterInvoker 的 doInvoke() 方法中,会遍历整个 Invoker 集合,逐个调用对应的 Provider 节点,当遇到第一个可用的 Provider 节点时,就尝试访问该 Provider 节点,成功则返回结果;如果访问失败,则抛出异常终止遍历。
下面是 AvailableClusterInvoker 的具体实现:
public Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
for (Invoker<T> invoker : invokers) { // 遍历整个Invoker集合
if (invoker.isAvailable()) { // 检测该Invoker是否可用
// 发起请求,调用失败时的异常会直接抛出
return invoker.invoke(invocation);
}
}
// 没有找到可用的Invoker也会抛出异常
throw new RpcException("No provider available in " + invokers);
}
MergeableClusterInvoker
MergeableCluster 这个 Cluster 实现类的扩展名为 mergeable在其 doJoin() 方法中创建的是 MergeableClusterInvoker 类型的 Invoker 对象,具体实现如下:
public <T> AbstractClusterInvoker<T> doJoin(Directory<T> directory) throws RpcException {
return new MergeableClusterInvoker<T>(directory);
}
MergeableClusterInvoker 会对多个 Provider 节点返回结果合并。如果请求的方法没有配置 Merger 合并器(即没有指定 merger 参数),则不会进行结果合并,而是直接将第一个可用的 Invoker 结果返回。下面来看 MergeableClusterInvoker 的具体实现:
protected Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
checkInvokers(invokers, invocation);
String merger = getUrl().getMethodParameter(invocation.getMethodName(), MERGER_KEY);
// 判断要调用的目标方法是否有合并器,如果没有,则不会进行合并,
// 找到第一个可用的Invoker直接调用并返回结果
if (ConfigUtils.isEmpty(merger)) {
for (final Invoker<T> invoker : invokers) {
if (invoker.isAvailable()) {
try {
return invoker.invoke(invocation);
} catch (RpcException e) {
if (e.isNoInvokerAvailableAfterFilter()) {
log.debug("No available provider for service" + getUrl().getServiceKey() + " on group " + invoker.getUrl().getParameter(GROUP_KEY) + ", will continue to try another group.");
} else {
throw e;
}
}
}
}
return invokers.iterator().next().invoke(invocation);
}
// 确定目标方法的返回值类型
Class<?> returnType;
try {
returnType = getInterface().getMethod(
invocation.getMethodName(), invocation.getParameterTypes()).getReturnType();
} catch (NoSuchMethodException e) {
returnType = null;
}
// 调用每个Invoker对象(异步方式)将请求结果记录到results集合中
Map<String, Result> results = new HashMap<>();
for (final Invoker<T> invoker : invokers) {
RpcInvocation subInvocation = new RpcInvocation(invocation, invoker);
subInvocation.setAttachment(ASYNC_KEY, "true");
results.put(invoker.getUrl().getServiceKey(), invoker.invoke(subInvocation));
}
Object result = null;
List<Result> resultList = new ArrayList<Result>(results.size());
// 等待结果返回
for (Map.Entry<String, Result> entry : results.entrySet()) {
Result asyncResult = entry.getValue();
try {
Result r = asyncResult.get();
if (r.hasException()) {
log.error("Invoke " + getGroupDescFromServiceKey(entry.getKey()) +
" failed: " + r.getException().getMessage(),
r.getException());
} else {
resultList.add(r);
}
} catch (Exception e) {
throw new RpcException("Failed to invoke service " + entry.getKey() + ": " + e.getMessage(), e);
}
}
if (resultList.isEmpty()) {
return AsyncRpcResult.newDefaultAsyncResult(invocation);
} else if (resultList.size() == 1) {
return resultList.iterator().next();
}
if (returnType == void.class) {
return AsyncRpcResult.newDefaultAsyncResult(invocation);
}
// merger如果以"."开头,后面为方法名,这个方法名是远程目标方法的返回类型中的方法
// 得到每个Provider节点返回的结果对象之后会遍历每个返回对象调用merger参数指定的方法
if (merger.startsWith(".")) {
merger = merger.substring(1);
Method method;
try {
method = returnType.getMethod(merger, returnType);
} catch (NoSuchMethodException e) {
throw new RpcException("Can not merge result because missing method [ " + merger + " ] in class [ " +
returnType.getName() + " ]");
}
if (!Modifier.isPublic(method.getModifiers())) {
method.setAccessible(true);
}
// resultList集合保存了所有的返回对象method是Method对象也就是merger指定的方法
// result是最后返回调用方的结果
result = resultList.remove(0).getValue();
try {
if (method.getReturnType() != void.class
&& method.getReturnType().isAssignableFrom(result.getClass())) {
for (Result r : resultList) { // 反射调用
result = method.invoke(result, r.getValue());
}
} else {
for (Result r : resultList) { // 反射调用
method.invoke(result, r.getValue());
}
}
} catch (Exception e) {
throw new RpcException("Can not merge result: " + e.getMessage(), e);
}
} else {
Merger resultMerger;
if (ConfigUtils.isDefault(merger)) {
// merger参数为true或者default表示使用默认的Merger扩展实现完成合并
// 在后面课时中会介绍Merger接口
resultMerger = MergerFactory.getMerger(returnType);
} else {
//merger参数指定了Merger的扩展名称则使用SPI查找对应的Merger扩展实现对象
resultMerger = ExtensionLoader.getExtensionLoader(Merger.class).getExtension(merger);
}
if (resultMerger != null) {
List<Object> rets = new ArrayList<Object>(resultList.size());
for (Result r : resultList) {
rets.add(r.getValue());
}
// 执行合并操作
result = resultMerger.merge(
rets.toArray((Object[]) Array.newInstance(returnType, 0)));
} else {
throw new RpcException("There is no merger to merge result.");
}
}
return AsyncRpcResult.newDefaultAsyncResult(result, invocation);
}
ZoneAwareClusterInvoker
ZoneAwareCluster 这个 Cluster 实现类的扩展名为 zone-aware在其 doJoin() 方法中创建的是 ZoneAwareClusterInvoker 类型的 Invoker 对象,具体实现如下:
protected <T> AbstractClusterInvoker<T> doJoin(Directory<T> directory) throws RpcException {
return new ZoneAwareClusterInvoker<T>(directory);
}
在 Dubbo 中使用多个注册中心的架构如下图所示:
双注册中心结构图
Consumer 可以使用 ZoneAwareClusterInvoker 先在多个注册中心之间进行选择,选定注册中心之后,再选择 Provider 节点,如下图所示:
ZoneAwareClusterInvoker 在多注册中心之间进行选择的策略有以下四种。
找到preferred 属性为 true 的注册中心,它是优先级最高的注册中心,只有该中心无可用 Provider 节点时,才会回落到其他注册中心。
根据请求中的 zone key 做匹配,优先派发到相同 zone 的注册中心。
根据权重(也就是注册中心配置的 weight 属性)进行轮询。
如果上面的策略都未命中,则选择第一个可用的 Provider 节点。
下面来看 ZoneAwareClusterInvoker 的具体实现:
public Result doInvoke(Invocation invocation, final List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
// 首先找到preferred属性为true的注册中心它是优先级最高的注册中心只有该中心无可用 Provider 节点时,才会回落到其他注册中心
for (Invoker<T> invoker : invokers) {
MockClusterInvoker<T> mockClusterInvoker = (MockClusterInvoker<T>) invoker;
if (mockClusterInvoker.isAvailable() && mockClusterInvoker.getRegistryUrl()
.getParameter(REGISTRY_KEY + "." + PREFERRED_KEY, false)) {
return mockClusterInvoker.invoke(invocation);
}
}
// 根据请求中的registry_zone做匹配优先派发到相同zone的注册中心
String zone = (String) invocation.getAttachment(REGISTRY_ZONE);
if (StringUtils.isNotEmpty(zone)) {
for (Invoker<T> invoker : invokers) {
MockClusterInvoker<T> mockClusterInvoker = (MockClusterInvoker<T>) invoker;
if (mockClusterInvoker.isAvailable() && zone.equals(mockClusterInvoker.getRegistryUrl().getParameter(REGISTRY_KEY + "." + ZONE_KEY))) {
return mockClusterInvoker.invoke(invocation);
}
}
String force = (String) invocation.getAttachment(REGISTRY_ZONE_FORCE);
if (StringUtils.isNotEmpty(force) && "true".equalsIgnoreCase(force)) {
throw new IllegalStateException("...");
}
}
// 根据权重也就是注册中心配置的weight属性进行轮询
Invoker<T> balancedInvoker = select(loadbalance, invocation, invokers, null);
if (balancedInvoker.isAvailable()) {
return balancedInvoker.invoke(invocation);
}
// 选择第一个可用的 Provider 节点
for (Invoker<T> invoker : invokers) {
MockClusterInvoker<T> mockClusterInvoker = (MockClusterInvoker<T>) invoker;
if (mockClusterInvoker.isAvailable()) {
return mockClusterInvoker.invoke(invocation);
}
}
throw new RpcException("No provider available in " + invokers);
}
总结
本课时我们重点介绍了 Dubbo 中 Cluster 接口的各个实现类的原理以及相关 Invoker 的实现原理。这里重点分析的 Cluster 实现有Failover Cluster、Failback Cluster、Failfast Cluster、Failsafe Cluster、Forking Cluster、Broadcast Cluster、Available Cluster 和 Mergeable Cluster。除此之外我们还分析了多注册中心的 ZoneAware Cluster 实现。

View File

@@ -0,0 +1,401 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 加餐多个返回值不用怕Merger 合并器来帮忙
你好,我是杨四正,今天我和你分享的主题是 Merger 合并器。
在上一课时中,我们分析 MergeableClusterInvoker 的具体实现时讲解过这样的内容MergeableClusterInvoker 中会读取 URL 中的 merger 参数值,如果 merger 参数以 “.” 开头,则表示 “.” 后的内容是一个方法名这个方法名是远程目标方法的返回类型中的一个方法MergeableClusterInvoker 在拿到所有 Invoker 返回的结果对象之后,会遍历每个返回结果,并调用 merger 参数指定的方法,合并这些结果值。
其实,除了上述指定 Merger 方法名称的合并方式之外Dubbo 内部还提供了很多默认的 Merger 实现,这也就是本课时将要分析的内容。本课时将详细介绍 MergerFactory 工厂类、Merger 接口以及针对 Java 中常见数据类型的 Merger 实现。
MergerFactory
在 MergeableClusterInvoker 使用默认 Merger 实现的时候,会通过 MergerFactory 以及服务接口返回值类型returnType选择合适的 Merger 实现。
在 MergerFactory 中维护了一个 ConcurrentHashMap 集合(即 MERGER_CACHE 字段),用来缓存服务接口返回值类型与 Merger 实例之间的映射关系。
MergerFactory.getMerger() 方法会根据传入的 returnType 类型,从 MERGER_CACHE 缓存中查找相应的 Merger 实现,下面我们来看该方法的具体实现:
public static <T> Merger<T> getMerger(Class<T> returnType) {
if (returnType == null) { // returnType为空直接抛出异常
throw new IllegalArgumentException("returnType is null");
}
Merger result;
if (returnType.isArray()) { // returnType为数组类型
// 获取数组中元素的类型
Class type = returnType.getComponentType();
// 获取元素类型对应的Merger实现
result = MERGER_CACHE.get(type);
if (result == null) {
loadMergers();
result = MERGER_CACHE.get(type);
}
// 如果Dubbo没有提供元素类型对应的Merger实现则返回ArrayMerger
if (result == null && !type.isPrimitive()) {
result = ArrayMerger.INSTANCE;
}
} else {
// 如果returnType不是数组类型则直接从MERGER_CACHE缓存查找对应的Merger实例
result = MERGER_CACHE.get(returnType);
if (result == null) {
loadMergers();
result = MERGER_CACHE.get(returnType);
}
}
return result;
}
loadMergers() 方法会通过 Dubbo SPI 方式加载 Merger 接口全部扩展实现的名称,并填充到 MERGER_CACHE 集合中,具体实现如下:
static void loadMergers() {
// 获取Merger接口的所有扩展名称
Set<String> names = ExtensionLoader.getExtensionLoader(Merger.class)
.getSupportedExtensions();
for (String name : names) { // 遍历所有Merger扩展实现
Merger m = ExtensionLoader.getExtensionLoader(Merger.class).getExtension(name);
// 将Merger实例与对应returnType的映射关系记录到MERGER_CACHE集合中
MERGER_CACHE.putIfAbsent(ReflectUtils.getGenericClass(m.getClass()), m);
}
}
ArrayMerger
在 Dubbo 中提供了处理不同类型返回值的 Merger 实现,其中不仅有处理 boolean[]、byte[]、char[]、double[]、float[]、int[]、long[]、short[] 等基础类型数组的 Merger 实现,还有处理 List、Set、Map 等集合类的 Merger 实现,具体继承关系如下图所示:
Merger 继承关系图
我们首先来看 ArrayMerger 实现:当服务接口的返回值为数组的时候,会使用 ArrayMerger 将多个数组合并成一个数组也就是将二维数组拍平成一维数组。ArrayMerger.merge() 方法的具体实现如下:
public Object[] merge(Object[]... items) {
if (ArrayUtils.isEmpty(items)) {
// 传入的结果集合为空,则直接返回空数组
return new Object[0];
}
int i = 0;
// 查找第一个不为null的结果
while (i < items.length && items[i] == null) {
i++;
}
// 所有items数组中全部结果都为null则直接返回空数组
if (i == items.length) {
return new Object[0];
}
Class<?> type = items[i].getClass().getComponentType();
int totalLen = 0;
for (; i < items.length; i++) {
if (items[i] == null) { // 忽略为null的结果
continue;
}
Class<?> itemType = items[i].getClass().getComponentType();
if (itemType != type) { // 保证类型相同
throw new IllegalArgumentException("Arguments' types are different");
}
totalLen += items[i].length;
}
if (totalLen == 0) { // 确定最终数组的长度
return new Object[0];
}
Object result = Array.newInstance(type, totalLen);
int index = 0;
// 遍历全部的结果数组将items二维数组中的每个元素都加到result中形成一维数组
for (Object[] array : items) {
if (array != null) {
for (int j = 0; j < array.length; j++) {
Array.set(result, index++, array[j]);
}
}
}
return (Object[]) result;
}
其他基础数据类型数组的 Merger 实现 ArrayMerger 的实现非常类似都是将相应类型的二维数组拍平成同类型的一维数组这里以 IntArrayMerger 为例进行分析
public int[] merge(int[]... items) {
if (ArrayUtils.isEmpty(items)) {
// 检测传入的多个int[]不能为空
return new int[0];
}
// 直接使用Stream的API将多个int[]数组拍平成一个int[]数组
return Arrays.stream(items).filter(Objects::nonNull)
.flatMapToInt(Arrays::stream)
.toArray();
}
剩余的其他基础类型的 Merger 实现类例如FloatArrayMergerIntArrayMergerLongArrayMergerBooleanArrayMergerByteArrayMergerCharArrayMergerDoubleArrayMerger 这里就不再赘述你若感兴趣的话可以参考源码进行学习
MapMerger
SetMergerListMerger MapMerger 是针对 Set List Map 返回值的 Merger 实现它们会将多个 Set ListMap集合合并成一个 Set ListMap集合核心原理与 ArrayMerger 的实现类似这里我们先来看 MapMerger 的核心实现
public Map<?, ?> merge(Map<?, ?>... items) {
if (ArrayUtils.isEmpty(items)) {
// 空结果集时这就返回空Map
return Collections.emptyMap();
}
// 将items中所有Map集合中的KV添加到result这一个Map集合中
Map<Object, Object> result = new HashMap<Object, Object>();
Stream.of(items).filter(Objects::nonNull).forEach(result::putAll);
return result;
}
接下来再看 SetMerger 和 ListMerger 的核心实现:
public Set<Object> merge(Set<?>... items) {
if (ArrayUtils.isEmpty(items)) {
// 空结果集时这就返回空Set集合
return Collections.emptySet();
}
// 创建一个新的HashSet集合传入的所有Set集合都添加到result中
Set<Object> result = new HashSet<Object>();
Stream.of(items).filter(Objects::nonNull).forEach(result::addAll);
return result;
}
public List<Object> merge(List<?>... items) {
if (ArrayUtils.isEmpty(items)) {
// 空结果集时这就返回空Set集合
return Collections.emptyList();
}
// 通过Stream API将传入的所有List集合拍平成一个List集合并返回
return Stream.of(items).filter(Objects::nonNull)
.flatMap(Collection::stream)
.collect(Collectors.toList());
}
自定义 Merger 扩展实现
介绍完 Dubbo 自带的 Merger 实现之后,下面我们还可以尝试动手写一个自己的 Merger 实现,这里我们以 dubbo-demo-xml 中的 Provider 和 Consumer 为例进行修改。
首先我们在 dubbo-demo-xml-provider 示例模块中发布两个服务,分别属于 groupA 和 groupB相应的 dubbo-provider.xml 配置如下:
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.3.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application metadata-type="remote" name="demo-provider"/>
<dubbo:metadata-report address="zookeeper://127.0.0.1:2181"/>
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
<dubbo:protocol name="dubbo"/>
<!-- 配置两个Spring Bean -->
<bean id="demoService" class="org.apache.dubbo.demo.provider.DemoServiceImpl"/>
<bean id="demoServiceB" class="org.apache.dubbo.demo.provider.DemoServiceImpl"/>
<!-- 将demoService和demoServiceB两个Spring Bean作为服务发布出去分别属于groupA和groupB-->
<dubbo:service interface="org.apache.dubbo.demo.DemoService" ref="demoService" group="groupA"/>
<dubbo:service interface="org.apache.dubbo.demo.DemoService" ref="demoServiceB" group="groupB"/>
</beans>
接下来,在 dubbo-demo-xml-consumer 示例模块中进行服务引用dubbo-consumer.xml 配置文件的具体内容如下:
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.3.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-consumer"/>
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
<!-- 引用DemoService这里指定了group为*即可以引用任何group的Provider同时merger设置为true即需要对结果进行合并-->
<dubbo:reference id="demoService" check="false" interface="org.apache.dubbo.demo.DemoService" group="*" merger="true"/>
</beans>
然后,在 dubbo-demo-xml-consumer 示例模块的 /resources/META-INF/dubbo 目录下,添加一个名为 org.apache.dubbo.rpc.cluster.Merger 的 Dubbo SPI 配置文件,其内容如下:
String=org.apache.dubbo.demo.consumer.StringMerger
StringMerger 实现了前面介绍的 Merger 接口,它会将多个 Provider 节点返回的 String 结果值拼接起来,具体实现如下:
public class StringMerger implements Merger<String> {
@Override
public String merge(String... items) {
if (ArrayUtils.isEmpty(items)) { // 检测空返回值
return "";
}
String result = "";
for (String item : items) { // 通过竖线将多个Provider的返回值拼接起来
result += item + "|";
}
return result;
}
}
最后,我们依次启动 Zookeeper、dubbo-demo-xml-provider 示例模块和 dubbo-demo-xml-consumer 示例模块。在控制台中我们会看到如下输出:
result: Hello world, response from provider: 172.17.108.179:20880|Hello world, response from provider: 172.17.108.179:20880|
总结
本课时我们重点介绍了 MergeableCluster 中涉及的 Merger 合并器相关的知识点。
首先,我们介绍了 MergerFactory 工厂类的核心功能,它可以配合远程方法调用的返回值,选择对应的 Merger 实现,完成结果的合并。
然后,我们深入分析了 Dubbo 自带的 Merger 实现类,涉及 Java 中各个基础类型数组的 Merger 合并器实现例如IntArrayMerger、LongArrayMerger 等,它们都是将多个特定类型的一维数组拍平成相同类型的一维数组。
除了这些基础类型数组的 Merger 实现Dubbo 还提供了 List、Set、Map 等集合类的 Merger 实现,它们的核心是将多个集合中的元素整理到一个同类型的集合中。
最后,我们还以 StringMerger 为例,介绍了如何自定义 Merger 合并器。

View File

@@ -0,0 +1,447 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
40 加餐模拟远程调用Mock 机制帮你搞定
你好我是杨四正今天我和你分享的主题是Dubbo 中的 Mock 机制。
Mock 机制是 RPC 框架中非常常见、也非常有用的功能不仅可以用来实现服务降级还可以用来在测试中模拟调用的各种异常情况。Dubbo 中的 Mock 机制是在 Consumer 这一端实现的,具体来说就是在 Cluster 这一层实现的。
在前面第 38 课时中,我们深入介绍了 Dubbo 提供的多种 Cluster 实现以及相关的 Cluster Invoker 实现,其中的 ZoneAwareClusterInvoker 就涉及了 MockClusterInvoker 的相关内容。本课时我们就来介绍 Dubbo 中 Mock 机制的全链路流程,不仅包括与 Cluster 接口相关的 MockClusterWrapper 和 MockClusterInvoker我们还会回顾前面课程的 Router 和 Protocol 接口,分析它们与 Mock 机制相关的实现。
MockClusterWrapper
Cluster 接口有两条继承线(如下图所示):一条线是 AbstractCluster 抽象类,这条继承线涉及的全部 Cluster 实现类我们已经在[第 37 课时]中深入分析过了;另一条线是 MockClusterWrapper 这条线。
Cluster 继承关系图
MockClusterWrapper 是 Cluster 对象的包装类,我们在之前[第 4 课时]介绍 Dubbo SPI 机制时已经分析过 Wrapper 的功能MockClusterWrapper 类会对 Cluster 进行包装。下面是 MockClusterWrapper 的具体实现,其中会在 Cluster Invoker 对象的基础上使用 MockClusterInvoker 进行包装:
public class MockClusterWrapper implements Cluster {
private Cluster cluster;
// Wrapper类都会有一个拷贝构造函数
public MockClusterWrapper(Cluster cluster) {
this.cluster = cluster;
}
@Override
public <T> Invoker<T> join(Directory<T> directory) throws RpcException {
// 用MockClusterInvoker进行包装
return new MockClusterInvoker<T>(directory,
this.cluster.join(directory));
}
}
MockClusterInvoker
MockClusterInvoker 是 Dubbo Mock 机制的核心,它主要是通过 invoke()、doMockInvoke() 和 selectMockInvoker() 这三个核心方法来实现 Mock 机制的。
下面我们就来逐个介绍这三个方法的具体实现。
首先来看 MockClusterInvoker 的 invoke() 方法,它会先判断是否需要开启 Mock 机制。如果在 mock 参数中配置的是 force 模式,则会直接调用 doMockInvoke() 方法进行 mock。如果在 mock 参数中配置的是 fail 模式,则会正常调用 Invoker 发起请求,在请求失败的时候,会调动 doMockInvoke() 方法进行 mock。下面是 MockClusterInvoker 的 invoke() 方法的具体实现:
public Result invoke(Invocation invocation) throws RpcException {
Result result = null;
// 从URL中获取方法对应的mock配置
String value = getUrl().getMethodParameter(invocation.getMethodName(), MOCK_KEY, Boolean.FALSE.toString()).trim();
if (value.length() == 0 || "false".equalsIgnoreCase(value)) {
// 若mock参数未配置或是配置为false则不会开启Mock机制直接调用底层的Invoker
result = this.invoker.invoke(invocation);
} else if (value.startsWith("force")) {
//force:direct mock
// 若mock参数配置为force则表示强制mock直接调用doMockInvoke()方法
result = doMockInvoke(invocation, null);
} else {
// 如果mock配置的不是force那配置的就是fail会继续调用Invoker对象的invoke()方法进行请求
try {
result = this.invoker.invoke(invocation);
} catch (RpcException e) {
if (e.isBiz()) { // 如果是业务异常,会直接抛出
throw e;
}
// 如果是非业务异常会调用doMockInvoke()方法返回mock结果
result = doMockInvoke(invocation, e);
}
}
return result;
}
在 doMockInvoke() 方法中,首先调用 selectMockInvoker() 方法获取 MockInvoker 对象,并调用其 invoke() 方法进行 mock 操作。doMockInvoke() 方法的具体实现如下:
private Result doMockInvoke(Invocation invocation, RpcException e) {
Result result = null;
Invoker<T> minvoker;
// 调用selectMockInvoker()方法过滤得到MockInvoker
List<Invoker<T>> mockInvokers = selectMockInvoker(invocation);
if (CollectionUtils.isEmpty(mockInvokers)) {
// 如果selectMockInvoker()方法未返回MockInvoker对象则创建一个MockInvoker
minvoker = (Invoker<T>) new MockInvoker(getUrl(), directory.getInterface());
} else {
minvoker = mockInvokers.get(0);
}
try {
// 调用MockInvoker.invoke()方法进行mock
result = minvoker.invoke(invocation);
} catch (RpcException me) {
if (me.isBiz()) { // 如果是业务异常则在Result中设置该异常
result = AsyncRpcResult.newDefaultAsyncResult(me.getCause(), invocation);
} else {
throw new RpcException(...);
}
} catch (Throwable me) {
throw new RpcException(...);
}
return result;
}
selectMockInvoker() 方法中并没有进行 MockInvoker 的选择或是创建,它仅仅是将 Invocation 附属信息中的 invocation.need.mock 属性设置为 true然后交给 Directory 中的 Router 集合进行处理。selectMockInvoker() 方法的具体实现如下:
private List<Invoker<T>> selectMockInvoker(Invocation invocation) {
List<Invoker<T>> invokers = null;
if (invocation instanceof RpcInvocation) {
// 将Invocation附属信息中的invocation.need.mock属性设置为true
((RpcInvocation) invocation).setAttachment(INVOCATION_NEED_MOCK, Boolean.TRUE.toString());
invokers = directory.list(invocation);
}
return invokers;
}
MockInvokersSelector
在[第 32 课时]和[第 33 课时]中,我们介绍了 Router 接口多个实现类,但当时并没有深入介绍 Mock 相关的 Router 实现类—— MockInvokersSelector它的继承关系如下图所示
MockInvokersSelector 继承关系图
MockInvokersSelector 是 Dubbo Mock 机制相关的 Router 实现,在未开启 Mock 机制的时候,会返回正常的 Invoker 对象集合;在开启 Mock 机制之后,会返回 MockInvoker 对象集合。MockInvokersSelector 的具体实现如下:
public <T> List<Invoker<T>> route(final List<Invoker<T>> invokers,
URL url, final Invocation invocation) throws RpcException {
if (CollectionUtils.isEmpty(invokers)) {
return invokers;
}
if (invocation.getObjectAttachments() == null) {
// attachments为null会过滤掉MockInvoker只返回正常的Invoker对象
return getNormalInvokers(invokers);
} else {
String value = (String) invocation.getObjectAttachments().get(INVOCATION_NEED_MOCK);
if (value == null) {
// invocation.need.mock为null会过滤掉MockInvoker只返回正常的Invoker对象
return getNormalInvokers(invokers);
} else if (Boolean.TRUE.toString().equalsIgnoreCase(value)) {
// invocation.need.mock为true会过滤掉MockInvoker只返回正常的Invoker对象
return getMockedInvokers(invokers);
}
}
// invocation.need.mock为false则会将MockInvoker和正常的Invoker一起返回
return invokers;
}
在 getMockedInvokers() 方法中,会根据 URL 的 Protocol 进行过滤,只返回 Protocol 为 mock 的 Invoker 对象,而 getNormalInvokers() 方法只会返回 Protocol 不为 mock 的 Invoker 对象。这两个方法的具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
MockProtocol & MockInvoker
介绍完 Mock 功能在 Cluster 层的相关实现之后,我们还要来看一下 Dubbo 在 RPC 层对 Mock 机制的支持,这里涉及 MockProtocol 和 MockInvoker 两个类。
首先来看 MockProtocol它是 Protocol 接口的扩展实现,扩展名称为 mock。MockProtocol 只能通过 refer() 方法创建 MockInvoker不能通过 export() 方法暴露服务,具体实现如下:
final public class MockProtocol extends AbstractProtocol {
public int getDefaultPort() { return 0;}
public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException {
// 直接抛出异常,无法暴露服务
throw new UnsupportedOperationException();
}
public <T> Invoker<T> protocolBindingRefer(Class<T> type, URL url) throws RpcException {
// 直接创建MockInvoker对象
return new MockInvoker<>(url, type);
}
}
下面我们再来看 MockInvoker 是如何解析各类 mock 配置的,以及如何根据不同 mock 配置进行不同处理的。这里我们重点来看 MockInvoker.invoke() 方法,其中针对 mock 参数进行的分类处理具体有下面三条分支。
mock 参数以 return 开头:直接返回 mock 参数指定的固定值例如empty、null、true、false、json 等。mock 参数中指定的固定返回值将会由 parseMockValue() 方法进行解析。
mock 参数以 throw 开头:直接抛出异常。如果在 mock 参数中没有指定异常类型,则抛出 RpcException否则抛出指定的 Exception 类型。
mock 参数为 true 或 default 时,会查找服务接口对应的 Mock 实现;如果是其他值,则直接作为服务接口的 Mock 实现。拿到 Mock 实现之后,转换成 Invoker 进行调用。
MockInvoker.invoke() 方法的具体实现如下所示:
public Result invoke(Invocation invocation) throws RpcException {
if (invocation instanceof RpcInvocation) {
((RpcInvocation) invocation).setInvoker(this);
}
// 获取mock值(会从URL中的methodName.mock参数或mock参数获取)
String mock = null;
if (getUrl().hasMethodParameter(invocation.getMethodName())) {
mock = getUrl().getParameter(invocation.getMethodName() + "." + MOCK_KEY);
}
if (StringUtils.isBlank(mock)) {
mock = getUrl().getParameter(MOCK_KEY);
}
if (StringUtils.isBlank(mock)) { // 没有配置mock值直接抛出异常
throw new RpcException(new IllegalAccessException("mock can not be null. url :" + url));
}
// mock值进行处理去除"force:"、"fail:"前缀等
mock = normalizeMock(URL.decode(mock));
if (mock.startsWith(RETURN_PREFIX)) { // mock值以return开头
mock = mock.substring(RETURN_PREFIX.length()).trim();
try {
// 获取响应结果的类型
Type[] returnTypes = RpcUtils.getReturnTypes(invocation);
// 根据结果类型对mock值中结果值进行转换
Object value = parseMockValue(mock, returnTypes);
// 将固定的mock值设置到Result中
return AsyncRpcResult.newDefaultAsyncResult(value, invocation);
} catch (Exception ew) {
throw new RpcException("mock return invoke error. method :" + invocation.getMethodName()
+ ", mock:" + mock + ", url: " + url, ew);
}
} else if (mock.startsWith(THROW_PREFIX)) { // mock值以throw开头
mock = mock.substring(THROW_PREFIX.length()).trim();
if (StringUtils.isBlank(mock)) { // 未指定异常类型直接抛出RpcException
throw new RpcException("mocked exception for service degradation.");
} else { // 抛出自定义异常
Throwable t = getThrowable(mock);
throw new RpcException(RpcException.BIZ_EXCEPTION, t);
}
} else { // 执行mockService得到mock结果
try {
Invoker<T> invoker = getInvoker(mock);
return invoker.invoke(invocation);
} catch (Throwable t) {
throw new RpcException("Failed to create mock implementation class " + mock, t);
}
}
}
针对 return 和 throw 的处理逻辑比较简单,但 getInvoker() 方法略微复杂些,其中会处理 MOCK_MAP 缓存的读写、Mock 实现类的查找、生成和调用 Invoker具体实现如下
private Invoker<T> getInvoker(String mockService) {
// 尝试从MOCK_MAP集合中获取对应的Invoker对象
Invoker<T> invoker = (Invoker<T>) MOCK_MAP.get(mockService);
if (invoker != null) {
return invoker;
}
// 根据serviceType查找mock的实现类
Class<T> serviceType = (Class<T>) ReflectUtils.forName(url.getServiceInterface());
T mockObject = (T) getMockObject(mockService, serviceType);
// 创建Invoker对象
invoker = PROXY_FACTORY.getInvoker(mockObject, serviceType, url);
if (MOCK_MAP.size() < 10000) { // 写入缓存
MOCK_MAP.put(mockService, invoker);
}
return invoker;
}
getMockObject() 方法中会检查 mockService 参数是否为 true default如果是的话则在服务接口后添加 Mock 字符串作为服务接口的 Mock 实现如果不是的话则直接将 mockService 实现作为服务接口的 Mock 实现getMockObject() 方法的具体实现如下
public static Object getMockObject(String mockService, Class serviceType) {
if (ConfigUtils.isDefault(mockService)) {
// 如果mock为true或default值会在服务接口后添加Mock字符串得到对应的实现类名称并进行实例化
mockService = serviceType.getName() + "Mock";
}
Class<?> mockClass = ReflectUtils.forName(mockService);
if (!serviceType.isAssignableFrom(mockClass)) {
// 检查mockClass是否继承serviceType接口
throw new IllegalStateException("...");
}
return mockClass.newInstance();
}
总结
本课时我们重点介绍了 Dubbo 中 Mock 机制涉及的全部内容。
首先,我们介绍了 Cluster 接口的 MockClusterWrapper 实现类,它负责创建 MockClusterInvoker 对象,是 Dubbo Mock 机制的入口。
接下来,我们介绍了 MockClusterInvoker 这个 Cluster 层的 Invoker 实现,它是 Dubbo Mock 机制的核心,会根据配置决定请求是否启动了 Mock 机制以及在何种情况下才会触发 Mock。
随后,我们又讲解了 MockInvokersSelector 这个 Router 接口实现,它会在路由规则这个层面决定是否返回 MockInvoker 对象。
最后,我们分析了 Protocol 层与 Mock 相关的实现—— MockProtocol以及 MockInvoker 这个真正进行 Mock 操作的 Invoker 实现。在 MockInvoker 中会解析各类 Mock 配置,并根据不同 Mock 配置进行不同的 Mock 操作。

View File

@@ -0,0 +1,746 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
41 加餐:一键通关服务发布全流程
在前面的课时中,我们已经将整个 Dubbo 的核心实现进行了分析。接下来的两个课时,我们将串联 Dubbo 中的这些核心实现,分析 Dubbo服务发布和服务引用的全流程帮助你将之前课时介绍的独立知识点联系起来形成一个完整整体。
本课时我们就先来重点关注 Provider 节点发布服务的过程,在这个过程中会使用到之前介绍的很多 Dubbo 核心组件。我们从 DubboBootstrap 这个入口类开始介绍,分析 Provider URL 的组装以及服务发布流程,其中会详细介绍本地发布和远程发布的核心流程。
DubboBootstrap 入口
在[第 01 课时]dubbo-demo-api-provider 示例的 Provider 实现中我们可以看到,整个 Provider 节点的启动入口是 DubboBootstrap.start() 方法,在该方法中会执行一些初始化操作,以及一些状态控制字段的更新,具体实现如下:
public DubboBootstrap start() {
if (started.compareAndSet(false, true)) { // CAS操作保证启动一次
ready.set(false); // 用于判断当前节点是否已经启动完毕在后面的Dubbo QoS中会使用到该字段
// 初始化一些基础组件,例如,配置中心相关组件、事件监听、元数据相关组件,这些组件在后面将会进行介绍
initialize();
// 重点:发布服务
exportServices();
if (!isOnlyRegisterProvider() || hasExportedServices()) {
// 用于暴露本地元数据服务,后面介绍元数据的时候会深入介绍该部分的内容
exportMetadataService();
// 用于将服务实例注册到专用于服务发现的注册中心
registerServiceInstance();
}
// 处理Consumer的ReferenceConfig
referServices();
if (asyncExportingFutures.size() > 0) {
// 异步发布服务会启动一个线程监听发布是否完成完成之后会将ready设置为true
new Thread(() -> {
this.awaitFinish();
ready.set(true);
}).start();
} else { // 同步发布服务成功之后会将ready设置为true
ready.set(true);
}
}
return this;
}
不仅是直接通过 API 启动 Provider 的方式会使用到 DubboBootstrap在 Spring 与 Dubbo 集成的时候也是使用 DubboBootstrap 作为服务发布入口的,具体逻辑在 DubboBootstrapApplicationListener 这个 Spring Context 监听器中,如下所示:
public class DubboBootstrapApplicationListener extends OneTimeExecutionApplicationContextEventListener
implements Ordered {
private final DubboBootstrap dubboBootstrap;
public DubboBootstrapApplicationListener() {
// 初始化DubboBootstrap对象
this.dubboBootstrap = DubboBootstrap.getInstance();
}
@Override
public void onApplicationContextEvent(ApplicationContextEvent event) {
// 监听ContextRefreshedEvent事件和ContextClosedEvent事件
if (event instanceof ContextRefreshedEvent) {
onContextRefreshedEvent((ContextRefreshedEvent) event);
} else if (event instanceof ContextClosedEvent) {
onContextClosedEvent((ContextClosedEvent) event);
}
}
private void onContextRefreshedEvent(ContextRefreshedEvent event) {
dubboBootstrap.start(); // 启动DubboBootstrap
}
private void onContextClosedEvent(ContextClosedEvent event) {
dubboBootstrap.stop();
}
@Override
public int getOrder() {
return LOWEST_PRECEDENCE;
}
}
这里我们重点关注的是exportServices() 方法,它是服务发布核心逻辑的入口,其中每一个服务接口都会转换为对应的 ServiceConfig 实例,然后通过代理的方式转换成 Invoker最终转换成 Exporter 进行发布。服务发布流程中涉及的核心对象转换,如下图所示:
服务发布核心流程图
exportServices() 方法的具体实现如下:
private void exportServices() {
// 从配置管理器中获取到所有的要暴露的服务配置一个接口类对应一个ServiceConfigBase实例
configManager.getServices().forEach(sc -> {
ServiceConfig serviceConfig = (ServiceConfig) sc;
serviceConfig.setBootstrap(this);
if (exportAsync) { // 异步模式,获取一个线程池来异步执行服务发布逻辑
ExecutorService executor = executorRepository.getServiceExporterExecutor();
Future<?> future = executor.submit(() -> {
sc.export();
exportedServices.add(sc);
});
// 记录异步发布的Future
asyncExportingFutures.add(future);
} else {// 同步发布
sc.export();
exportedServices.add(sc);
}
});
}
ServiceConfig
在 ServiceConfig.export() 方法中,服务发布的第一步是检查参数,第二步会根据当前配置决定是延迟发布还是立即调用 doExport() 方法进行发布,第三步会通过 exported() 方法回调相关监听器,具体实现如下:
public synchronized void export() {
if (!shouldExport()) {
return;
}
if (bootstrap == null) {
bootstrap = DubboBootstrap.getInstance();
bootstrap.init();
}
// 检查并更新各项配置
checkAndUpdateSubConfigs();
... // 初始化元数据相关服务
if (shouldDelay()) { // 延迟发布
DELAY_EXPORT_EXECUTOR.schedule(this::doExport, getDelay(), TimeUnit.MILLISECONDS);
} else { // 立即发布
doExport();
}
exported(); // 回调监听器
}
在 checkAndUpdateSubConfigs() 方法中,会去检查各项配置是否合理,并补齐一些缺省的配置信息,这个方法非常冗长,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
完成配置的检查之后,再来看 doExport() 方法,其中首先调用 loadRegistries() 方法加载注册中心信息,即将 RegistryConfig 配置解析成 registryUrl。无论是使用 XML、Annotation还是 API 配置方式,都可以配置多个注册中心地址,一个服务接口可以同时注册在多个不同的注册中心。
RegistryConfig 是 Dubbo 的多个配置对象之一,可以通过解析 XML、Annotation 中注册中心相关的配置得到,对应的配置如下(当然,也可以直接通过 API 创建得到):
<dubbo:registry address="zookeeper://127.0.0.1:2181" protocol="zookeeper" port="2181" />
RegistryUrl 的格式大致如下(为了方便查看,这里将每个 URL 参数单独放在一行中展示):
// path是Zookeeper的地址
registry://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService?
application=dubbo-demo-api-provider
&dubbo=2.0.2
&pid=9405
&registry=zookeeper // 使用的注册中心是Zookeeper
&timestamp=1600307343086
加载注册中心信息得到 RegistryUrl 之后,会遍历所有的 ProtocolConfig依次调用 doExportUrlsFor1Protocol(protocolConfig, registryURLs) 在每个注册中心发布服务。一个服务接口可以以多种协议进行发布,每种协议都对应一个 ProtocolConfig例如我们在 Demo 示例中,只使用了 dubbo 协议,对应的配置是:<dubbo:protocol name="dubbo" />。
组装服务 URL
doExportUrlsFor1Protocol() 方法的代码非常长,这里我们分成两个部分进行介绍:一部分是组装服务的 URL另一部分就是后面紧接着介绍的服务发布。
组装服务的 URL核心步骤有如下 7 步。
获取此次发布使用的协议,默认使用 dubbo 协议。
设置服务 URL 中的参数,这里会从 MetricsConfig、ApplicationConfig、ModuleConfig、ProviderConfig、ProtocolConfig 中获取配置信息,并作为参数添加到 URL 中。这里调用的 appendParameters() 方法会将 AbstractConfig 中的配置信息存储到 Map 集合中,后续在构造 URL 的时候,会将该集合中的 KV 作为 URL 的参数。
解析指定方法的 MethodConfig 配置以及方法参数的 ArgumentConfig 配置,得到的配置信息也是记录到 Map 集合中,后续作为 URL 参数。
根据此次调用是泛化调用还是普通调用,向 Map 集合中添加不同的键值对。
获取 token 配置,并添加到 Map 集合中,默认随机生成 UUID。
获取 host、port 值,并开始组装服务的 URL。
根据 Configurator 覆盖或新增 URL 参数。
下面是 doExportUrlsFor1Protocol() 方法组装 URL 的核心实现:
private void doExportUrlsFor1Protocol(ProtocolConfig protocolConfig, List<URL> registryURLs) {
String name = protocolConfig.getName(); // 获取协议名称
if (StringUtils.isEmpty(name)) { // 默认使用Dubbo协议
name = DUBBO;
}
Map<String, String> map = new HashMap<String, String>(); // 记录URL的参数
map.put(SIDE_KEY, PROVIDER_SIDE); // side参数
// 添加URL参数例如Dubbo版本、时间戳、当前PID等
ServiceConfig.appendRuntimeParameters(map);
// 下面会从各个Config获取参数例如application、interface参数等
AbstractConfig.appendParameters(map, getMetrics());
AbstractConfig.appendParameters(map, getApplication());
AbstractConfig.appendParameters(map, getModule());
AbstractConfig.appendParameters(map, provider);
AbstractConfig.appendParameters(map, protocolConfig);
AbstractConfig.appendParameters(map, this);
MetadataReportConfig metadataReportConfig = getMetadataReportConfig();
if (metadataReportConfig != null && metadataReportConfig.isValid()) {
map.putIfAbsent(METADATA_KEY, REMOTE_METADATA_STORAGE_TYPE);
}
if (CollectionUtils.isNotEmpty(getMethods())) { // 从MethodConfig中获取URL参数
for (MethodConfig method : getMethods()) {
AbstractConfig.appendParameters(map, method, method.getName());
String retryKey = method.getName() + ".retry";
if (map.containsKey(retryKey)) {
String retryValue = map.remove(retryKey);
if ("false".equals(retryValue)) {
map.put(method.getName() + ".retries", "0");
}
}
List<ArgumentConfig> arguments = method.getArguments();
if (CollectionUtils.isNotEmpty(arguments)) {
for (ArgumentConfig argument : arguments) { // 从ArgumentConfig中获取URL参数
... ...
}
}
}
}
if (ProtocolUtils.isGeneric(generic)) { // 根据generic是否为true向map中添加不同的信息
map.put(GENERIC_KEY, generic);
map.put(METHODS_KEY, ANY_VALUE);
} else {
String revision = Version.getVersion(interfaceClass, version);
if (revision != null && revision.length() > 0) {
map.put(REVISION_KEY, revision);
}
String[] methods = Wrapper.getWrapper(interfaceClass).getMethodNames();
if (methods.length == 0) {
map.put(METHODS_KEY, ANY_VALUE);
} else {
// 添加method参数
map.put(METHODS_KEY, StringUtils.join(new HashSet<String>(Arrays.asList(methods)), ","));
}
}
// 添加token到map集合中默认随机生成UUID
if(ConfigUtils.isEmpty(token) && provider != null) {
token = provider.getToken();
}
if (!ConfigUtils.isEmpty(token)) {
if (ConfigUtils.isDefault(token)) {
map.put(TOKEN_KEY, UUID.randomUUID().toString());
} else {
map.put(TOKEN_KEY, token);
}
}
// 将map数据放入serviceMetadata中这与元数据相关后面再详细介绍其作用
serviceMetadata.getAttachments().putAll(map);
// 获取host、port值
String host = findConfigedHosts(protocolConfig, registryURLs, map);
Integer port = findConfigedPorts(protocolConfig, name, map);
// 根据上面获取的host、port以及前文获取的map集合组装URL
URL url = new URL(name, host, port, getContextPath(protocolConfig).map(p -> p + "/" + path).orElse(path), map);
// 通过Configurator覆盖或添加新的参数
if (ExtensionLoader.getExtensionLoader(ConfiguratorFactory.class)
.hasExtension(url.getProtocol())) {
url = ExtensionLoader.getExtensionLoader(ConfiguratorFactory.class)
.getExtension(url.getProtocol()).getConfigurator(url).configure(url);
}
... ...
}
经过上述准备操作之后,得到的服务 URL 如下所示(为了方便查看,这里将每个 URL 参数单独放在一行中展示):
dubbo://172.17.108.185:20880/org.apache.dubbo.demo.DemoService?
anyhost=true
&application=dubbo-demo-api-provider
&bind.ip=172.17.108.185
&bind.port=20880
&default=true
&deprecated=false
&dubbo=2.0.2
&dynamic=true
&generic=false
&interface=org.apache.dubbo.demo.DemoService
&methods=sayHello,sayHelloAsync
&pid=3918
&release=
&side=provider
&timestamp=1600437404483
服务发布入口
完成了服务 URL 的组装之后doExportUrlsFor1Protocol() 方法开始执行服务发布。服务发布可以分为远程发布和本地发布,具体发布方式与服务 URL 中的 scope 参数有关。
scope 参数有三个可选值,分别是 none、remote 和 local分别代表不发布、发布到本地和发布到远端注册中心从下面介绍的 doExportUrlsFor1Protocol() 方法代码中可以看到:
发布到本地的条件是 scope != remote
发布到注册中心的条件是 scope != local。
scope 参数的默认值为 null也就是说默认会同时在本地和注册中心发布该服务。下面来看 doExportUrlsFor1Protocol() 方法中发布服务的具体实现:
private void doExportUrlsFor1Protocol(ProtocolConfig protocolConfig, List<URL> registryURLs) {
... ...// 省略组装服务URL的过程
// 从URL中获取scope参数其中可选值有none、remote、local三个
// 分别代表不发布、发布到本地以及发布到远端,具体含义在下面一一介绍
String scope = url.getParameter(SCOPE_KEY);
if (!SCOPE_NONE.equalsIgnoreCase(scope)) { // scope不为none才进行发布
if (!SCOPE_REMOTE.equalsIgnoreCase(scope)) {// 发布到本地
exportLocal(url);
}
if (!SCOPE_LOCAL.equalsIgnoreCase(scope)) { // 发布到远端的注册中心
if (CollectionUtils.isNotEmpty(registryURLs)) { // 当前配置了至少一个注册中心
for (URL registryURL : registryURLs) { // 向每个注册中心发布服务
// injvm协议只在exportLocal()中有用,不会将服务发布到注册中心
// 所以这里忽略injvm协议
if (LOCAL_PROTOCOL.equalsIgnoreCase(url.getProtocol())){
continue;
}
// 设置服务URL的dynamic参数
url = url.addParameterIfAbsent(DYNAMIC_KEY, registryURL.getParameter(DYNAMIC_KEY));
// 创建monitorUrl并作为monitor参数添加到服务URL中
URL monitorUrl = ConfigValidationUtils.loadMonitor(this, registryURL);
if (monitorUrl != null) {
url = url.addParameterAndEncoded(MONITOR_KEY, monitorUrl.toFullString());
}
// 设置服务URL的proxy参数即生成动态代理方式(jdk或是javassist)作为参数添加到RegistryURL中
String proxy = url.getParameter(PROXY_KEY);
if (StringUtils.isNotEmpty(proxy)) {
registryURL = registryURL.addParameter(PROXY_KEY, proxy);
}
// 为服务实现类的对象创建相应的InvokergetInvoker()方法的第三个参数中会将服务URL作为export参数添加到RegistryURL中
// 这里的PROXY_FACTORY是ProxyFactory接口的适配器
Invoker<?> invoker = PROXY_FACTORY.getInvoker(ref, (Class) interfaceClass, registryURL.addParameterAndEncoded(EXPORT_KEY, url.toFullString()));
// DelegateProviderMetaDataInvoker是个装饰类将当前ServiceConfig和Invoker关联起来而已invoke()方法透传给底层Invoker对象
DelegateProviderMetaDataInvoker wrapperInvoker = new DelegateProviderMetaDataInvoker(invoker, this);
// 调用Protocol实现进行发布
// 这里的PROTOCOL是Protocol接口的适配器
Exporter<?> exporter = PROTOCOL.export(wrapperInvoker);
exporters.add(exporter);
}
} else {
// 不存在注册中心仅发布服务不会将服务信息发布到注册中心。Consumer没法在注册中心找到该服务的信息但是可以直连
// 具体的发布过程与上面的过程类似,只不过不会发布到注册中心
Invoker<?> invoker = PROXY_FACTORY.getInvoker(ref, (Class) interfaceClass, url);
DelegateProviderMetaDataInvoker wrapperInvoker = new DelegateProviderMetaDataInvoker(invoker, this);
Exporter<?> exporter = PROTOCOL.export(wrapperInvoker);
exporters.add(exporter);
}
// 元数据相关操作
WritableMetadataService metadataService = WritableMetadataService.getExtension(url.getParameter(METADATA_KEY, DEFAULT_METADATA_STORAGE_TYPE));
if (metadataService != null) {
metadataService.publishServiceDefinition(url);
}
}
}
this.urls.add(url);
}
本地发布
了解了本地发布、远程发布的入口逻辑之后,下面我们开始深入本地发布的逻辑。
在 exportLocal() 方法中,会将 Protocol 替换成 injvm 协议,将 host 设置成 127.0.0.1,将 port 设置为 0得到新的 LocalURL大致如下
injvm://127.0.0.1/org.apache.dubbo.demo.DemoService?anyhost=true
&application=dubbo-demo-api-provider
&bind.ip=172.17.108.185
&bind.port=20880
&default=true
&deprecated=false
&dubbo=2.0.2
&dynamic=true
&generic=false
&interface=org.apache.dubbo.demo.DemoService
&methods=sayHello,sayHelloAsync
&pid=4249
&release=
&side=provider
&timestamp=1600440074214
之后,会通过 ProxyFactory 接口适配器找到对应的 ProxyFactory 实现(默认使用 JavassistProxyFactory并调用 getInvoker() 方法创建 Invoker 对象;最后,通过 Protocol 接口的适配器查找到 InjvmProtocol 实现,并调用 export() 方法进行发布。 exportLocal() 方法的具体实现如下:
private void exportLocal(URL url) {
URL local = URLBuilder.from(url) // 创建新URL
.setProtocol(LOCAL_PROTOCOL)
.setHost(LOCALHOST_VALUE)
.setPort(0)
.build();
// 本地发布
Exporter<?> exporter = PROTOCOL.export(
PROXY_FACTORY.getInvoker(ref, (Class) interfaceClass, local));
exporters.add(exporter);
}
InjvmProtocol 的相关实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
远程发布
介绍完本地发布之后,我们再来看远程发布的核心逻辑,远程服务发布的流程相较本地发布流程,要复杂得多。
在 doExportUrlsFor1Protocol() 方法中,远程发布服务时,会遍历全部 RegistryURL并根据 RegistryURL 选择对应的 Protocol 扩展实现进行发布。我们知道 RegistryURL 是 “registry://” 协议,所以这里使用的是 RegistryProtocol 实现。
下面来看 RegistryProtocol.export() 方法的核心流程:
public <T> Exporter<T> export(final Invoker<T> originInvoker) throws RpcException {
// 将"registry://"协议转换成"zookeeper://"协议
URL registryUrl = getRegistryUrl(originInvoker);
// 获取export参数其中存储了一个"dubbo://"协议的ProviderURL
URL providerUrl = getProviderUrl(originInvoker);
// 获取要监听的配置目录这里会在ProviderURL的基础上添加category=configurators参数并封装成对OverrideListener记录到overrideListeners集合中
final URL overrideSubscribeUrl = getSubscribedOverrideUrl(providerUrl);
final OverrideListener overrideSubscribeListener = new OverrideListener(overrideSubscribeUrl, originInvoker);
overrideListeners.put(overrideSubscribeUrl, overrideSubscribeListener);
// 初始化时会检测一次Override配置重写ProviderURL
providerUrl = overrideUrlWithConfig(providerUrl, overrideSubscribeListener);
// 导出服务底层会通过执行DubboProtocol.export()方法启动对应的Server
final ExporterChangeableWrapper<T> exporter = doLocalExport(originInvoker, providerUrl);
// 根据RegistryURL获取对应的注册中心Registry对象其中会依赖之前课时介绍的RegistryFactory
final Registry registry = getRegistry(originInvoker);
// 获取将要发布到注册中心上的Provider URL其中会删除一些多余的参数信息
final URL registeredProviderUrl = getUrlToRegistry(providerUrl, registryUrl);
// 根据register参数值决定是否注册服务
boolean register = providerUrl.getParameter(REGISTER_KEY, true);
if (register) { // 调用Registry.register()方法将registeredProviderUrl发布到注册中心
register(registryUrl, registeredProviderUrl);
}
// 将Provider相关信息记录到的ProviderModel中
registerStatedUrl(registryUrl, registeredProviderUrl, register);
// 向注册中心进行订阅override数据主要是监听该服务的configurators节点
registry.subscribe(overrideSubscribeUrl, overrideSubscribeListener);
exporter.setRegisterUrl(registeredProviderUrl);
exporter.setSubscribeUrl(overrideSubscribeUrl);
// 触发RegistryProtocolListener监听器
notifyExport(exporter);
return new DestroyableExporter<>(exporter);
}
我们可以看到,远程发布流程大致可分为下面 5 个步骤。
准备 URL比如 ProviderURL、RegistryURL 和 OverrideSubscribeUrl。
发布 Dubbo 服务。在 doLocalExport() 方法中调用 DubboProtocol.export() 方法启动 Provider 端底层 Server。
注册 Dubbo 服务。在 register() 方法中,调用 ZookeeperRegistry.register() 方法向 Zookeeper 注册服务。
订阅 Provider 端的 Override 配置。调用 ZookeeperRegistry.subscribe() 方法订阅注册中心 configurators 节点下的配置变更。
触发 RegistryProtocolListener 监听器。
远程发布的详细流程如下图所示:
服务发布详细流程图
总结
本课时我们重点介绍了 Dubbo 服务发布的核心流程。
首先我们介绍了 DubboBootstrap 这个入口门面类中与服务发布相关的方法,重点是 start() 和 exportServices() 两个方法;然后详细介绍了 ServiceConfig 类的三个核心步骤:检查参数、立即(或延迟)执行 doExport() 方法进行发布、回调服务发布的相关监听器。
接下来我们分析了doExportUrlsFor1Protocol() 方法,它是发布一个服务的入口,也是规定服务发布流程的地方,其中涉及 Provider URL 的组装、本地服务发布流程以及远程服务发布流程,对于这些步骤,我们都进行了详细的分析。

View File

@@ -0,0 +1,594 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
42 加餐:服务引用流程全解析
Dubbo 作为一个 RPC 框架,暴露给用户最基本的功能就是服务发布和服务引用。在上一课时,我们已经分析了服务发布的核心流程。那么在本课时,我们就接着深入分析服务引用的核心流程。
Dubbo 支持两种方式引用远程的服务:
服务直连的方式,仅适合在调试服务的时候使用;
基于注册中心引用服务,这是生产环境中使用的服务引用方式。
DubboBootstrap 入口
在上一课时介绍服务发布的时候,我们介绍了 DubboBootstrap.start() 方法的核心流程,其中除了会调用 exportServices() 方法完成服务发布之外,还会调用 referServices() 方法完成服务引用,这里就不再贴出 DubboBootstrap.start() 方法的具体代码,你若感兴趣的话可以参考源码进行学习。
在 DubboBootstrap.referServices() 方法中,会从 ConfigManager 中获取所有 ReferenceConfig 列表,并根据 ReferenceConfig 获取对应的代理对象,入口逻辑如下:
private void referServices() {
if (cache == null) { // 初始ReferenceConfigCache
cache = ReferenceConfigCache.getCache();
}
configManager.getReferences().forEach(rc -> {
// 遍历ReferenceConfig列表
ReferenceConfig referenceConfig = (ReferenceConfig) rc;
referenceConfig.setBootstrap(this);
if (rc.shouldInit()) { // 检测ReferenceConfig是否已经初始化
if (referAsync) { // 异步
CompletableFuture<Object> future = ScheduledCompletableFuture.submit(
executorRepository.getServiceExporterExecutor(),
() -> cache.get(rc)
);
asyncReferringFutures.add(future);
} else { // 同步
cache.get(rc);
}
}
});
}
这里的 ReferenceConfig 是哪里来的呢?在[第 01 课时]dubbo-demo-api-consumer 示例中,我们可以看到构造 ReferenceConfig 对象的逻辑,这些新建的 ReferenceConfig 对象会通过 DubboBootstrap.reference() 方法添加到 ConfigManager 中进行管理,如下所示:
public DubboBootstrap reference(ReferenceConfig<?> referenceConfig) {
configManager.addReference(referenceConfig);
return this;
}
ReferenceConfigCache
服务引用的核心实现在 ReferenceConfig 之中,一个 ReferenceConfig 对象对应一个服务接口,每个 ReferenceConfig 对象中都封装了与注册中心的网络连接,以及与 Provider 的网络连接,这是一个非常重要的对象。
为了避免底层连接泄漏造成性能问题,从 Dubbo 2.4.0 版本开始Dubbo 提供了 ReferenceConfigCache 用于缓存 ReferenceConfig 实例。
在 dubbo-demo-api-consumer 示例中,我们可以看到 ReferenceConfigCache 的基本使用方式:
ReferenceConfig<DemoService> reference = new ReferenceConfig<>();
reference.setInterface(DemoService.class);
...
// 这一步在DubboBootstrap.start()方法中完成
ReferenceConfigCache cache = ReferenceConfigCache.getCache();
...
DemoService demoService = ReferenceConfigCache.getCache().get(reference);
在 ReferenceConfigCache 中维护了一个静态的 MapCACHE_HOLDER字段其中 Key 是由 Group、服务接口和 version 构成Value 是一个 ReferenceConfigCache 对象。在 ReferenceConfigCache 中可以传入一个 KeyGenerator 用来修改缓存 Key 的生成逻辑KeyGenerator 接口的定义如下:
public interface KeyGenerator {
String generateKey(ReferenceConfigBase<?> referenceConfig);
}
默认的 KeyGenerator 实现是 ReferenceConfigCache 中的匿名内部类,其对象由 DEFAULT_KEY_GENERATOR 这个静态字段引用,具体实现如下:
public static final KeyGenerator DEFAULT_KEY_GENERATOR = referenceConfig -> {
String iName = referenceConfig.getInterface();
if (StringUtils.isBlank(iName)) { // 获取服务接口名称
Class<?> clazz = referenceConfig.getInterfaceClass();
iName = clazz.getName();
}
if (StringUtils.isBlank(iName)) {
throw new IllegalArgumentException("No interface info in ReferenceConfig" + referenceConfig);
}
// Key的格式是group/interface:version
StringBuilder ret = new StringBuilder();
if (!StringUtils.isBlank(referenceConfig.getGroup())) {
ret.append(referenceConfig.getGroup()).append("/");
}
ret.append(iName);
if (!StringUtils.isBlank(referenceConfig.getVersion())) {
ret.append(":").append(referenceConfig.getVersion());
}
return ret.toString();
};
在 ReferenceConfigCache 实例对象中,会维护下面两个 Map 集合。
proxiesConcurrentMap, ConcurrentMap>类型):该集合用来存储服务接口的全部代理对象,其中第一层 Key 是服务接口的类型,第二层 Key 是上面介绍的 KeyGenerator 为不同服务提供方生成的 KeyValue 是服务的代理对象。
referredReferencesConcurrentMap> 类型):该集合用来存储已经被处理的 ReferenceConfig 对象。
我们回到 DubboBootstrap.referServices() 方法中,看一下其中与 ReferenceConfigCache 相关的逻辑。
首先是 ReferenceConfigCache.getCache() 这个静态方法,会在 CACHE_HOLDER 集合中添加一个 Key 为“*DEFAULT*”的 ReferenceConfigCache 对象(使用默认的 KeyGenerator 实现),它将作为默认的 ReferenceConfigCache 对象。
接下来,无论是同步服务引用还是异步服务引用,都会调用 ReferenceConfigCache.get() 方法,创建并缓存代理对象。下面就是 ReferenceConfigCache.get() 方法的核心实现:
public <T> T get(ReferenceConfigBase<T> referenceConfig) {
// 生成服务提供方对应的Key
String key = generator.generateKey(referenceConfig);
// 获取接口类型
Class<?> type = referenceConfig.getInterfaceClass();
// 获取该接口对应代理对象集合
proxies.computeIfAbsent(type, _t -> new ConcurrentHashMap<>());
ConcurrentMap<String, Object> proxiesOfType = proxies.get(type);
// 根据Key获取服务提供方对应的代理对象
proxiesOfType.computeIfAbsent(key, _k -> {
// 服务引用
Object proxy = referenceConfig.get();
// 将ReferenceConfig记录到referredReferences集合
referredReferences.put(key, referenceConfig);
return proxy;
});
return (T) proxiesOfType.get(key);
}
ReferenceConfig
通过前面的介绍我们知道ReferenceConfig 是服务引用的真正入口,其中会创建相关的代理对象。下面先来看 ReferenceConfig.get() 方法:
public synchronized T get() {
if (destroyed) { // 检测当前ReferenceConfig状态
throw new IllegalStateException("...");
}
if (ref == null) {// ref指向了服务的代理对象
init(); // 初始化ref字段
}
return ref;
}
在 ReferenceConfig.init() 方法中,首先会对服务引用的配置进行处理,以保证配置的正确性。这里的具体实现其实本身并不复杂,但由于涉及很多的配置解析和处理逻辑,代码就显得非常长,我们就不再一一展示,你若感兴趣的话可以参考源码进行学习。
ReferenceConfig.init() 方法的核心逻辑是调用 createProxy() 方法,调用之前会从配置中获取 createProxy() 方法需要的参数:
public synchronized void init() {
if (initialized) { // 检测ReferenceConfig的初始化状态
return;
}
if (bootstrap == null) { // 检测DubboBootstrap的初始化状态
bootstrap = DubboBootstrap.getInstance();
bootstrap.init();
}
... // 省略其他配置的检查
Map<String, String> map = new HashMap<String, String>();
map.put(SIDE_KEY, CONSUMER_SIDE); // 添加side参数
// 添加Dubbo版本、release参数、timestamp参数、pid参数
ReferenceConfigBase.appendRuntimeParameters(map);
// 添加interface参数
map.put(INTERFACE_KEY, interfaceName);
... // 省略其他参数的处理
String hostToRegistry = ConfigUtils.getSystemProperty(DUBBO_IP_TO_REGISTRY);
if (StringUtils.isEmpty(hostToRegistry)) {
hostToRegistry = NetUtils.getLocalHost();
} else if (isInvalidLocalHost(hostToRegistry)) {
throw new IllegalArgumentException("...");
}
// 添加ip参数
map.put(REGISTER_IP_KEY, hostToRegistry);
// 调用createProxy()方法
ref = createProxy(map);
...// 省略其他代码
initialized = true;
// 触发ReferenceConfigInitializedEvent事件
dispatch(new ReferenceConfigInitializedEvent(this, invoker));
}
ReferenceConfig.createProxy() 方法中处理了多种服务引用的场景,例如,直连单个/多个Provider、单个/多个注册中心。下面是 createProxy() 方法的核心流程,大致可以梳理出这么 5 个步骤。
根据传入的参数集合判断协议是否为 injvm 协议,如果是,直接通过 InjvmProtocol 引用服务。
构造 urls 集合。Dubbo 支持直连 Provider和依赖注册中心两种服务引用方式。如果是直连服务的模式我们可以通过 url 参数指定一个或者多个 Provider 地址,会被解析并填充到 urls 集合;如果通过注册中心的方式进行服务引用,则会调用 AbstractInterfaceConfig.loadRegistries() 方法加载所有注册中心。
如果 urls 集合中只记录了一个 URL通过 Protocol 适配器选择合适的 Protocol 扩展实现创建 Invoker 对象。如果是直连 Provider 的场景,则 URL 为 dubbo 协议,这里就会使用 DubboProtocol 这个实现;如果依赖注册中心,则使用 RegistryProtocol 这个实现。
如果 urls 集合中有多个注册中心,则使用 ZoneAwareCluster 作为 Cluster 的默认实现,生成对应的 Invoker 对象;如果 urls 集合中记录的是多个直连服务的地址,则使用 Cluster 适配器选择合适的扩展实现生成 Invoker 对象。
通过 ProxyFactory 适配器选择合适的 ProxyFactory 扩展实现,将 Invoker 包装成服务接口的代理对象。
通过上面的流程我们可以看出createProxy() 方法中有两个核心:一是通过 Protocol 适配器选择合适的 Protocol 扩展实现创建 Invoker 对象;二是通过 ProxyFactory 适配器选择合适的 ProxyFactory 创建代理对象。
下面我们来看 createProxy() 方法的具体实现:
private T createProxy(Map<String, String> map) {
if (shouldJvmRefer(map)) { // 根据url的协议、scope以及injvm等参数检测是否需要本地引用
// 创建injvm协议的URL
URL url = new URL(LOCAL_PROTOCOL, LOCALHOST_VALUE, 0, interfaceClass.getName()).addParameters(map);
// 通过Protocol的适配器选择对应的Protocol实现创建Invoker对象
invoker = REF_PROTOCOL.refer(interfaceClass, url);
if (logger.isInfoEnabled()) {
logger.info("Using injvm service " + interfaceClass.getName());
}
} else {
urls.clear();
if (url != null && url.length() > 0) {
String[] us = SEMICOLON_SPLIT_PATTERN.split(url); // 配置多个URL的时候会用分号进行切分
if (us != null && us.length > 0) { // url不为空表明用户可能想进行点对点调用
for (String u : us) {
URL url = URL.valueOf(u);
if (StringUtils.isEmpty(url.getPath())) {
url = url.setPath(interfaceName); // 设置接口完全限定名为URL Path
}
if (UrlUtils.isRegistry(url)) { // 检测URL协议是否为registry若是说明用户想使用指定的注册中心
// 这里会将map中的参数整理成一个参数添加到refer参数中
urls.add(url.addParameterAndEncoded(REFER_KEY, StringUtils.toQueryString(map)));
} else {
// 将map中的参数添加到url中
urls.add(ClusterUtils.mergeUrl(url, map));
}
}
}
} else {
if (!LOCAL_PROTOCOL.equalsIgnoreCase(getProtocol())) {
checkRegistry();
// 加载注册中心的地址RegistryURL
List<URL> us = ConfigValidationUtils.loadRegistries(this, false);
if (CollectionUtils.isNotEmpty(us)) {
for (URL u : us) {
URL monitorUrl = ConfigValidationUtils.loadMonitor(this, u);
if (monitorUrl != null) {
map.put(MONITOR_KEY, URL.encode(monitorUrl.toFullString()));
}
// 将map中的参数整理成refer参数添加到RegistryURL中
urls.add(u.addParameterAndEncoded(REFER_KEY, StringUtils.toQueryString(map)));
}
}
if (urls.isEmpty()) { // 既不是服务直连,也没有配置注册中心,抛出异常
throw new IllegalStateException("...");
}
}
}
if (urls.size() == 1) {
// 在单注册中心或是直连单个服务提供方的时候通过Protocol的适配器选择对应的Protocol实现创建Invoker对象
invoker = REF_PROTOCOL.refer(interfaceClass, urls.get(0));
} else {
// 多注册中心或是直连多个服务提供方的时候会根据每个URL创建Invoker对象
List<Invoker<?>> invokers = new ArrayList<Invoker<?>>();
URL registryURL = null;
for (URL url : urls) {
invokers.add(REF_PROTOCOL.refer(interfaceClass, url));
if (UrlUtils.isRegistry(url)) { // 确定是多注册中心还是直连多个Provider
registryURL = url;
}
}
if (registryURL != null) {
// 多注册中心的场景中会使用ZoneAwareCluster作为Cluster默认实现多注册中心之间的选择
URL u = registryURL.addParameterIfAbsent(CLUSTER_KEY, ZoneAwareCluster.NAME);
invoker = CLUSTER.join(new StaticDirectory(u, invokers));
} else {
// 多个Provider直连的场景中使用Cluster适配器选择合适的扩展实现
invoker = CLUSTER.join(new StaticDirectory(invokers));
}
}
}
if (shouldCheck() && !invoker.isAvailable()) {
// 根据check配置决定是否检测Provider的可用性
invoker.destroy();
throw new IllegalStateException("...");
}
...// 元数据处理相关的逻辑
// 通过ProxyFactory适配器选择合适的ProxyFactory扩展实现创建代理对象
return (T) PROXY_FACTORY.getProxy(invoker, ProtocolUtils.isGeneric(generic));
}
RegistryProtocol
在直连 Provider 的场景中,会使用 DubboProtocol.refer() 方法完成服务引用DubboProtocol.refer() 方法的具体实现在前面[第 25 课时]中已经详细介绍过了这里我们重点来看存在注册中心的场景中Dubbo Consumer 是如何通过 RegistryProtocol 完成服务引用的。
在 RegistryProtocol.refer() 方法中,会先根据 URL 获取注册中心的 URL再调用 doRefer 方法生成 Invoker在 refer() 方法中会使用 MergeableCluster 处理多 group 引用的场景。
public <T> Invoker<T> refer(Class<T> type, URL url) throws RpcException {
url = getRegistryUrl(url); // 从URL中获取注册中心的URL
// 获取Registry实例这里的RegistryFactory对象是通过Dubbo SPI的自动装载机制注入的
Registry registry = registryFactory.getRegistry(url);
if (RegistryService.class.equals(type)) {
return proxyFactory.getInvoker((T) registry, type, url);
}
// 从注册中心URL的refer参数中获取此次服务引用的一些参数其中就包括group
Map<String, String> qs = StringUtils.parseQueryString(url.getParameterAndDecoded(REFER_KEY));
String group = qs.get(GROUP_KEY);
if (group != null && group.length() > 0) {
if ((COMMA_SPLIT_PATTERN.split(group)).length > 1 || "*".equals(group)) {
// 如果此次可以引用多个group的服务则Cluser实现使用MergeableCluster实现
// 这里的getMergeableCluster()方法就会通过Dubbo SPI方式找到MergeableCluster实例
return doRefer(getMergeableCluster(), registry, type, url);
}
}
// 如果没有group参数或是只指定了一个group则通过Cluster适配器选择Cluster实现
return doRefer(cluster, registry, type, url);
}
在 doRefer() 方法中,首先会根据 URL 初始化 RegistryDirectory 实例,然后生成 Subscribe URL 并进行注册,之后会通过 Registry 订阅服务,最后通过 Cluster 将多个 Invoker 合并成一个 Invoker 返回给上层,具体实现如下:
private <T> Invoker<T> doRefer(Cluster cluster, Registry registry, Class<T> type, URL url) {
// 创建RegistryDirectory实例
RegistryDirectory<T> directory = new RegistryDirectory<T>(type, url);
directory.setRegistry(registry);
directory.setProtocol(protocol);
// 生成SubscribeUrl协议为consumer具体的参数是RegistryURL中refer参数指定的参数
Map<String, String> parameters = new HashMap<String, String>(directory.getConsumerUrl().getParameters());
URL subscribeUrl = new URL(CONSUMER_PROTOCOL, parameters.remove(REGISTER_IP_KEY), 0, type.getName(), parameters);
if (directory.isShouldRegister()) {
directory.setRegisteredConsumerUrl(subscribeUrl); // 在SubscribeUrl中添加category=consumers和check=false参数
registry.register(directory.getRegisteredConsumerUrl()); // 服务注册在Zookeeper的consumers节点下添加该Consumer对应的节点
}
directory.buildRouterChain(subscribeUrl); // 根据SubscribeUrl创建服务路由
// 订阅服务toSubscribeUrl()方法会将SubscribeUrl中category参数修改为"providers,configurators,routers"
// RegistryDirectory的subscribe()在前面详细分析过了其中会通过Registry订阅服务同时还会添加相应的监听器
directory.subscribe(toSubscribeUrl(subscribeUrl));
// 注册中心中可能包含多个Provider相应地也就有多个Invoker
// 这里通过前面选择的Cluster将多个Invoker对象封装成一个Invoker对象
Invoker<T> invoker = cluster.join(directory);
// 根据URL中的registry.protocol.listener参数加载相应的监听器实现
List<RegistryProtocolListener> listeners = findRegistryProtocolListeners(url);
if (CollectionUtils.isEmpty(listeners)) {
return invoker;
}
// 为了方便在监听器中回调这里将此次引用使用到的Directory对象、Cluster对象、Invoker对象以及SubscribeUrl
// 封装到一个RegistryInvokerWrapper中传递给监听器
RegistryInvokerWrapper<T> registryInvokerWrapper = new RegistryInvokerWrapper<>(directory, cluster, invoker, subscribeUrl);
for (RegistryProtocolListener listener : listeners) {
listener.onRefer(this, registryInvokerWrapper);
}
return registryInvokerWrapper;
}
这里涉及的 RegistryDirectory、Router 接口、Cluster 接口及其相关的扩展实现,我们都已经在前面的课时详细分析过了,这里不再重复。
总结
本课时,我们重点介绍了 Dubbo 服务引用的整个流程。
首先,我们介绍了 DubboBootStrap 这个入口门面类与服务引用相关的方法,其中涉及 referServices()、reference() 等核心方法。
接下来,我们分析了 ReferenceConfigCache 这个 ReferenceConfig 对象缓存,以及 ReferenceConfig 实现服务引用的核心流程。
最后,我们还讲解了 RegistryProtocol 从注册中心引用服务的核心实现。

View File

@@ -0,0 +1,119 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
43 服务自省设计方案:新版本新方案
随着微服务架构的不断发展和普及RPC 框架成为微服务架构中不可或缺的重要角色Dubbo 作为 Java 生态中一款成熟的 RPC 框架也在随着技术的更新换代不断发展壮大。当然,传统的 Dubbo 架构也面临着新思想、新生态和新技术带来的挑战。
在微服务架构中,服务是基本单位,而 Dubbo 架构中服务的基本单位是 Java 接口,这种架构上的差别就会带来一系列挑战。从 2.7.5 版本开始Dubbo 引入了服务自省架构,来应对微服务架构带来的挑战。具体都有哪些挑战呢?下面我们就来详细说明一下。
注册中心面临的挑战
在开始介绍注册中心面临的挑战之前,我们先来回顾一下前面课时介绍过的 Dubbo 传统架构以及这个架构中最核心的组件:
Dubbo 核心架构图
结合上面这张架构图,我们可以一起回顾一下这些核心组件的功能。
Registry注册中心。 负责服务地址的注册与查找,服务的 Provider 和 Consumer 只在启动时与注册中心交互。注册中心通过长连接感知 Provider 的存在,在 Provider 出现宕机的时候,注册中心会立即推送相关事件通知 Consumer。
Provider服务提供者。 在它启动的时候,会向 Registry 进行注册操作,将自己服务的地址和相关配置信息封装成 URL 添加到 ZooKeeper 中。
Consumer服务消费者。 在它启动的时候,会向 Registry 进行订阅操作。订阅操作会从 ZooKeeper 中获取 Provider 注册的 URL并在 ZooKeeper 中添加相应的监听器。获取到 Provider URL 之后Consumer 会根据 URL 中相应的参数选择 LoadBalance、Router、Cluster 实现,创建相应的 Invoker 对象,然后封装服务接口的代理对象,返回给上层业务。上层业务调用该代理对象的方法,就会执行远程调用。
Monitor监控中心。 用于统计服务的调用次数和调用时间。Provider 和 Consumer 在运行过程中,会在内存中统计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。监控中心在上面的架构图中并不是必要角色,监控中心宕机不会影响 Provider、Consumer 以及 Registry 的功能,只会丢失监控数据而已。
通过前面对整个 Dubbo 实现体系的介绍我们知道URL 是贯穿整个 Dubbo 注册与发现的核心。Provider URL 注册到 ZooKeeper 上的大致格式如下:
dubbo://192.168.0.100:20880/org.apache.dubbo.demo.DemoService?anyhost=true&application=demo-provider&deprecated=false&dubbo=2.0.2&dynamic=true&generic=false&group=groupA&interface=org.apache.dubbo.demo.DemoService&metadata-type=remote&methods=sayHello,sayHelloAsync&pid=59975&release=&side=provider&timestamp=1601390276192
其中包括 Provider 的 IP、Port、服务接口的完整名称、Dubbo 协议的版本号、分组信息、进程 ID 等。
我们常用的注册中心比如ZooKeeper、Nacos 或 etcd 等,都是中心化的基础设施。注册中心基本都是以内存作为核心存储,其内存使用量与服务接口的数量以及 Provider 节点的个数是成正比的,一个 Dubbo Provider 节点可以注册多个服务接口。随着业务发展,服务接口的数量会越来越多,为了支撑整个系统的流量增长,部署的 Dubbo Provider 节点和 Dubbo Consumer 节点也会不断增加,这就导致注册中心的内存压力越来越大。
在生产环境中为了避免单点故障在搭建注册中心的时候都会使用高可用方案。这些高可用方案的本质就是底层的一致性协议例如ZooKeeper 使用的是 Zab 协议etcd 使用的是 Raft 协议。当注册数据频繁发生变化的时候,注册中心集群的内部节点用于同步数据的网络开销也会增大。
从注册中心的外部看Dubbo Provider 和 Dubbo Consumer 都可以算作注册中心的客户端,都会与注册中心集群之间维护长连接,这也会造成一部分网络开销和资源消耗。
在使用类似 ZooKeeper 的注册中心实现方案时,注册中心会主动将注册数据的变化推送到客户端。假设一个 Dubbo Consumer 订阅了 N 个服务接口,每个服务接口由 M 个 Provider 节点组成的集群提供服务,在 Provider 节点进行机器迁移的时候,就会涉及 M * N 个 URL 的更新,这些变更事件都会通知到每个 Dubbo Consumer 节点,这就造成了注册中心在处理通知方面的压力。
总之,在超大规模的微服务落地实践中,从内存、网络开销、通知等多个角度看,注册中心以及整个 Dubbo 传统架构都受到了不少的挑战和压力。
Dubbo 的改进方案
Dubbo 从 2.7.0 版本开始增加了简化 URL的特性从 URL 中抽出的数据会被存放至元数据中心。但是这次优化只是缩短了 URL 的长度,从内存使用量以及降低通知频繁度的角度降低了注册中心的压力,并没有减少注册中心 URL 的数量,所以注册中心所承受的压力还是比较明显的。
Dubbo 2.7.5 版本引入了服务自省架构进一步降低了注册中心的压力。在此次优化中Dubbo 修改成应用为粒度的服务注册与发现模型,最大化地减少了 Dubbo 服务元信息注册数量,其核心流程如下图所示:
服务自省架构图
上图展示了引入服务自省之后的 Dubbo 服务注册与发现的核心流程Dubbo 会按照顺序执行这些操作(当其中一个操作失败时,后续操作不会执行)。
我们首先来看 Provider 侧的执行流程:
1.发布所有业务接口中定义的服务接口,具体过程与[第 41 课时]中介绍的发布流程相同;
2.发布 MetadataService 接口,该接口的发布由 Dubbo 框架自主完成;
3.将 Service Instance 注册到注册中心;
4.建立所有的 Service ID 与 Service Name 的映射,并同步到配置中心。
接下来我们再来看Consumer 侧的执行流程:
5.注册当前 Consumer 的 Service InstanceDubbo 允许 Consumer 不进行服务注册,所以这一步操作是可选的;
6.从配置中心获取 Service ID 与 Service Name 的映射关系;
7.根据 Service ID 从注册中心获取 Service Instance 集合;
8.随机选择一个 Service Instance从中获取 MetadataService 的元数据,这里会发起 MetadataService 的调用,获取该 Service Instance 所暴露的业务接口的 URL 列表,从该 URL 列表中可以过滤出当前订阅的 Service 的 URL
9.根据步骤 8 中获取的业务接口 URL 发起远程调用。
至于上图中涉及的一些新概念,为方便你理解,这里我们对它们的具体实现进行一个简单的介绍。
Service Name服务名称例如在一个电商系统中有用户服务、商品服务、库存服务等。
Service Instance服务实例表示单个 Dubbo 应用进程,多个 Service Instance 构成一个服务集群,拥有相同的 Service Name。
Service ID唯一标识一个 Dubbo 服务,由 ${protocol}:${interface}:${version}:${group} 四部分构成。
在有的场景中,我们会在线上部署两组不同配置的服务节点,来验证某些配置是否生效。例如,共有 100 个服务节点,平均分成 A、B 两组A 组服务节点超时时间(即 timeout设置为 3000 msB 组的超时时间(即 timeout设置为 2000 ms这样的话该服务就有了两组不同的元数据。
按照前面介绍的优化方案,在订阅服务的时候,会得到 100 个 ServiceInstance因为每个 ServiceInstance 发布的服务元数据都有可能不一样,所以我们需要调用每个 ServiceInstance 的 MetadataService 服务获取元数据。
为了减少 MetadataService 服务的调用次数Dubbo 提出了服务修订版本的优化方案,其核心思想是:将每个 ServiceInstance 发布的服务 URL 计算一个 hash 值(也就是 revision 值),并随 ServiceInstance 一起发布到注册中心;在 Consumer 端进行订阅的时候,对于 revision 值相同的 ServiceInstance不再调用 MetadataService 服务,直接共用一份 URL 即可。下图展示了 Dubbo 服务修订的核心逻辑:
引入 Dubbo 服务修订的 Consumer 端交互图
通过该流程图,我们可以看到 Dubbo Consumer 端实现服务修订的流程如下。
Consumer 端通过服务发现 API 从注册中心获取 Provider 端的 ServiceInstance 列表。
注册中心返回 100 台服务实例,其中 revision 为 1 的 ServiceInstance 编号是 0~49revision 为 2 的 ServiceInstance 编号是 50~99。
Consumer 端在这 100 台服务实例中随机选择一台,例如,选择到编号为 68 的 ServiceInstance。
Consumer 端调用 ServiceInstance 68 暴露的 MetadataService 服务,获得其发布的 Dubbo 服务 URL 列表,并在本地内存中建立 revision 为 2 的服务 URL 列表缓存。
Consumer 端再从剩余的 99 台服务实例中随机选择一台,例如,选中了 ServiceInstance 30发现其 revision 值为 1且本地缓存中没有 revision 为 1 的服务 URL 列表缓存。此时Consumer 会如步骤 4 一样发起 MetadataService 调用,从 ServiceInstance 30 获取服务 URL 列表,并更新缓存。
由于此时的本地缓存已经覆盖了当前场景中全部的 revision 值,后续再次随机选择的 ServiceInstance 的 revision 不是 1 就是 2都会落到本地缓存中不会再次发起 MetadataService 服务调用。后续其他 ServiceInstance 的处理都会复用本地缓存的这两个 URL 列表,并根据 ServiceInstance 替换相应的参数例如host、port 等),这样即可得到 ServiceInstance 发布的完整的服务 URL 列表。
一般情况下revision 的数量不会很多,那么 Consumer 端发起的 MetadataService 服务调用次数也是有限的,不会随着 ServiceInstance 的扩容而增长。这样就避免了同一服务的不同版本导致的元数据膨胀。
总结
在本课时,我们重点介绍了 Dubbo 的服务自省架构的相关内容。
首先,我们一起复习了 Dubbo 的传统架构以及传统架构中基础组建的核心功能和交互流程。然后分析了 Dubbo 传统架构在超大规模微服务落地实践中面临的各项挑战和压力。最后,我们重点讲解了 Dubbo 2.7.5 版本之后引入的服务自省方案,服务自省方案可以很好地应对 Dubbo 面临的诸多挑战,并缓解基于 Dubbo 实现的、超大规模的微服务系统压力。在此基础上,我们还特别介绍了 Dubbo 服务修订方案是如何避免元数据膨胀的具体原理。

View File

@@ -0,0 +1,337 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
45 加餐:深入服务自省方案中的服务发布订阅(上)
在前面[第 43 课时]中介绍 Dubbo 的服务自省方案时,我们可以看到除了需要元数据方案的支持之外,还需要服务发布订阅功能的支持,这样才能构成完整的服务自省架构。
本课时我们就来讲解一下 Dubbo 中服务实例的发布与订阅功能的具体实现:首先说明 ServiceDiscovery 接口的核心定义,然后再重点介绍以 ZooKeeper 为注册中心的 ZookeeperServiceDiscovery 实现,这其中还会涉及相关事件监听的实现。
ServiceDiscovery 接口
ServiceDiscovery 主要封装了针对 ServiceInstance 的发布和订阅操作,你可以暂时将其理解成一个 ServiceInstance 的注册中心。ServiceDiscovery 接口的定义如下所示:
@SPI("zookeeper")
public interface ServiceDiscovery extends Prioritized {
// 初始化当前ServiceDiscovery实例传入的是注册中心的URL
void initialize(URL registryURL) throws Exception;
// 销毁当前ServiceDiscovery实例
void destroy() throws Exception;
// 发布传入的ServiceInstance实例
void register(ServiceInstance serviceInstance) throws RuntimeException;
// 更新传入的ServiceInstance实例
void update(ServiceInstance serviceInstance) throws RuntimeException;
// 注销传入的ServiceInstance实例
void unregister(ServiceInstance serviceInstance) throws RuntimeException;
// 查询全部Service Name
Set<String> getServices();
// 分页查询时默认每页的条数
default int getDefaultPageSize() {
return 100;
}
// 根据ServiceName分页查询ServiceInstance
default List<ServiceInstance> getInstances(String serviceName) throws NullPointerException {
List<ServiceInstance> allInstances = new LinkedList<>();
int offset = 0;
int pageSize = getDefaultPageSize();
// 分页查询ServiceInstance
Page<ServiceInstance> page = getInstances(serviceName, offset, pageSize);
allInstances.addAll(page.getData());
while (page.hasNext()) {
offset += page.getDataSize();
page = getInstances(serviceName, offset, pageSize);
allInstances.addAll(page.getData());
}
return unmodifiableList(allInstances);
}
default Page<ServiceInstance> getInstances(String serviceName, int offset, int pageSize) throws NullPointerException,
IllegalArgumentException {
return getInstances(serviceName, offset, pageSize, false);
}
default Page<ServiceInstance> getInstances(String serviceName, int offset, int pageSize, boolean healthyOnly) throws
NullPointerException, IllegalArgumentException, UnsupportedOperationException {
throw new UnsupportedOperationException("Current implementation does not support pagination query method.");
}
default Map<String, Page<ServiceInstance>> getInstances(Iterable<String> serviceNames, int offset, int requestSize) throws
NullPointerException, IllegalArgumentException {
Map<String, Page<ServiceInstance>> instances = new LinkedHashMap<>();
for (String serviceName : serviceNames) {
instances.put(serviceName, getInstances(serviceName, offset, requestSize));
}
return unmodifiableMap(instances);
}
// 添加ServiceInstance监听器
default void addServiceInstancesChangedListener(ServiceInstancesChangedListener listener)
throws NullPointerException, IllegalArgumentException {
}
// 触发ServiceInstancesChangedEvent事件
default void dispatchServiceInstancesChangedEvent(String serviceName) {
dispatchServiceInstancesChangedEvent(serviceName, getInstances(serviceName));
}
default void dispatchServiceInstancesChangedEvent(String serviceName, String... otherServiceNames) {
dispatchServiceInstancesChangedEvent(serviceName, getInstances(serviceName));
if (otherServiceNames != null) {
Stream.of(otherServiceNames)
.filter(StringUtils::isNotEmpty)
.forEach(this::dispatchServiceInstancesChangedEvent);
}
}
default void dispatchServiceInstancesChangedEvent(String serviceName, Collection<ServiceInstance> serviceInstances) {
dispatchServiceInstancesChangedEvent(new ServiceInstancesChangedEvent(serviceName, serviceInstances));
}
default void dispatchServiceInstancesChangedEvent(ServiceInstancesChangedEvent event) {
getDefaultExtension().dispatch(event);
}
}
ServiceDiscovery 接口被 @SPI 注解修饰,是一个扩展点,针对不同的注册中心,有不同的 ServiceDiscovery 实现,如下图所示:
ServiceDiscovery 继承关系图
在 Dubbo 创建 ServiceDiscovery 对象的时候,会通过 ServiceDiscoveryFactory 工厂类进行创建。ServiceDiscoveryFactory 接口也是一个扩展接口Dubbo 只提供了一个默认实现—— DefaultServiceDiscoveryFactory其继承关系如下图所示
ServiceDiscoveryFactory 继承关系图
在 AbstractServiceDiscoveryFactory 中维护了一个 ConcurrentMap 类型的集合discoveries 字段)来缓存 ServiceDiscovery 对象,并提供了一个 createDiscovery() 抽象方法来创建 ServiceDiscovery 实例。
public ServiceDiscovery getServiceDiscovery(URL registryURL) {
String key = registryURL.toServiceStringWithoutResolving();
return discoveries.computeIfAbsent(key, k -> createDiscovery(registryURL));
}
在 DefaultServiceDiscoveryFactory 中会实现 createDiscovery() 方法,使用 Dubbo SPI 机制获取对应的 ServiceDiscovery 对象,具体实现如下:
protected ServiceDiscovery createDiscovery(URL registryURL) {
String protocol = registryURL.getProtocol();
ExtensionLoader<ServiceDiscovery> loader = getExtensionLoader(ServiceDiscovery.class);
return loader.getExtension(protocol);
}
ZookeeperServiceDiscovery 实现分析
Dubbo 提供了多个 ServiceDiscovery 用来接入多种注册中心,下面我们以 ZookeeperServiceDiscovery 为例介绍 Dubbo 是如何接入 ZooKeeper 作为注册中心,实现服务实例发布和订阅的。
在 ZookeeperServiceDiscovery 中封装了一个 Apache Curator 中的 ServiceDiscovery 对象来实现与 ZooKeeper 的交互。在 initialize() 方法中会初始化 CuratorFramework 以及 Curator ServiceDiscovery 对象,如下所示:
public void initialize(URL registryURL) throws Exception {
... // 省略初始化EventDispatcher的相关逻辑
// 初始化CuratorFramework
this.curatorFramework = buildCuratorFramework(registryURL);
// 确定rootPath默认是"/services"
this.rootPath = ROOT_PATH.getParameterValue(registryURL);
// 初始化Curator ServiceDiscovery并启动
this.serviceDiscovery = buildServiceDiscovery(curatorFramework, rootPath);
this.serviceDiscovery.start();
}
在 ZookeeperServiceDiscovery 中的方法基本都是调用 Curator ServiceDiscovery 对象的相应方法实现例如register()、update() 、unregister() 方法都会调用 Curator ServiceDiscovery 对象的相应方法完成 ServiceInstance 的添加、更新和删除。这里我们以 register() 方法为例:
public void register(ServiceInstance serviceInstance) throws RuntimeException {
doInServiceRegistry(serviceDiscovery -> {
serviceDiscovery.registerService(build(serviceInstance));
});
}
// 在build()方法中会将Dubbo中的ServiceInstance对象转换成Curator中的ServiceInstance对象
public static org.apache.curator.x.discovery.ServiceInstance<ZookeeperInstance> build(ServiceInstance serviceInstance) {
ServiceInstanceBuilder builder = null;
// 获取Service Name
String serviceName = serviceInstance.getServiceName();
String host = serviceInstance.getHost();
int port = serviceInstance.getPort();
// 获取元数据
Map<String, String> metadata = serviceInstance.getMetadata();
// 生成的id格式是"host:ip"
String id = generateId(host, port);
// ZookeeperInstance是Curator ServiceInstance的payload
ZookeeperInstance zookeeperInstance = new ZookeeperInstance(null, serviceName, metadata);
builder = builder().id(id).name(serviceName).address(host).port(port)
.payload(zookeeperInstance);
return builder.build();
}
除了上述服务实例发布的功能之外,在服务实例订阅的时候,还会用到 ZookeeperServiceDiscovery 查询服务实例的信息,这些方法都是直接依赖 Apache Curator 实现的例如getServices() 方法会调用 Curator ServiceDiscovery 的 queryForNames() 方法查询 Service NamegetInstances() 方法会通过 Curator ServiceDiscovery 的 queryForInstances() 方法查询 Service Instance。
EventListener 接口
ZookeeperServiceDiscovery 除了实现了 ServiceDiscovery 接口之外,还实现了 EventListener 接口,如下图所示:
ZookeeperServiceDiscovery 继承关系图
也就是说ZookeeperServiceDiscovery 本身也是 EventListener 实现,可以作为 EventListener 监听某些事件。下面我们先来看 Dubbo 中 EventListener 接口的定义其中关注三个方法onEvent() 方法、getPriority() 方法和 findEventType() 工具方法。
@SPI
@FunctionalInterface
public interface EventListener<E extends Event> extends java.util.EventListener, Prioritized {
// 当发生该EventListener对象关注的事件时该EventListener的onEvent()方法会被调用
void onEvent(E event);
// 当前EventListener对象被调用的优先级
default int getPriority() {
return MIN_PRIORITY;
}
// 获取传入的EventListener对象监听何种Event事件
static Class<? extends Event> findEventType(EventListener<?> listener) {
return findEventType(listener.getClass());
}
static Class<? extends Event> findEventType(Class<?> listenerClass) {
Class<? extends Event> eventType = null;
// 检测传入listenerClass是否为Dubbo的EventListener接口实现
if (listenerClass != null && EventListener.class.isAssignableFrom(listenerClass)) {
eventType = findParameterizedTypes(listenerClass)
.stream()
.map(EventListener::findEventType) // 获取listenerClass中定义的Event泛型
.filter(Objects::nonNull)
.findAny()
// 获取listenerClass父类中定义的Event泛型
.orElse((Class) findEventType(listenerClass.getSuperclass()));
}
return eventType;
}
... // findEventType()方法用来过滤传入的parameterizedType是否为Event或Event子类(这里省略该方法的实现)
}
Dubbo 中有很多 EventListener 接口的实现,如下图所示:
EventListener 继承关系图
我们先来重点关注 ZookeeperServiceDiscovery 这个实现,在其 onEvent() 方法(以及 addServiceInstancesChangedListener() 方法)中会调用 registerServiceWatcher() 方法重新注册:
public void onEvent(ServiceInstancesChangedEvent event) {
// 发生ServiceInstancesChangedEvent事件的Service Name
String serviceName = event.getServiceName();
// 重新注册监听器
registerServiceWatcher(serviceName);
}
protected void registerServiceWatcher(String serviceName) {
// 构造要监听的path
String path = buildServicePath(serviceName);
// 创建监听器ZookeeperServiceDiscoveryChangeWatcher并记录到watcherCaches缓存中
CuratorWatcher watcher = watcherCaches.computeIfAbsent(path, key ->
new ZookeeperServiceDiscoveryChangeWatcher(this, serviceName));
// 在path上添加上面构造的ZookeeperServiceDiscoveryChangeWatcher监听器
// 来监听子节点的变化
curatorFramework.getChildren().usingWatcher(watcher).forPath(path);
}
ZookeeperServiceDiscoveryChangeWatcher 是 ZookeeperServiceDiscovery 配套的 CuratorWatcher 实现,其中 process() 方法实现会关注 NodeChildrenChanged 事件和 NodeDataChanged 事件,并调用关联的 ZookeeperServiceDiscovery 对象的 dispatchServiceInstancesChangedEvent() 方法,具体实现如下:
public void process(WatchedEvent event) throws Exception {
// 获取监听到的事件类型
Watcher.Event.EventType eventType = event.getType();
// 这里只关注NodeChildrenChanged和NodeDataChanged两种事件类型
if (NodeChildrenChanged.equals(eventType) || NodeDataChanged.equals(eventType)) {
// 调用dispatchServiceInstancesChangedEvent()方法分发ServiceInstancesChangedEvent事件
zookeeperServiceDiscovery.dispatchServiceInstancesChangedEvent(serviceName);
}
}
通过上面的分析我们可以知道ZookeeperServiceDiscoveryChangeWatcher 的核心就是将 ZooKeeper 中的事件转换成了 Dubbo 内部的 ServiceInstancesChangedEvent 事件。
EventDispatcher 接口
通过上面对 ZookeeperServiceDiscovery 实现的分析我们知道,它并没有对 dispatchServiceInstancesChangedEvent() 方法进行覆盖,那么在 ZookeeperServiceDiscoveryChangeWatcher 中调用的 dispatchServiceInstancesChangedEvent() 方法就是 ServiceDiscovery 接口中的默认实现。在该默认实现中,会通过 Dubbo SPI 获取 EventDispatcher 的默认实现,并分发 ServiceInstancesChangedEvent 事件,具体实现如下:
default void dispatchServiceInstancesChangedEvent(ServiceInstancesChangedEvent event) {
EventDispatcher.getDefaultExtension().dispatch(event);
}
下面我们来看 EventDispatcher 接口的具体定义:
@SPI("direct")
public interface EventDispatcher extends Listenable<EventListener<?>> {
// 该线程池用于串行调用被触发的EventListener也就是direct模式
Executor DIRECT_EXECUTOR = Runnable::run;
// 将被触发的事件分发给相应的EventListener对象
void dispatch(Event event);
// 获取direct模式中使用的线程池
default Executor getExecutor() {
return DIRECT_EXECUTOR;
}
// 工具方法用于获取EventDispatcher接口的默认实现
static EventDispatcher getDefaultExtension() {
return ExtensionLoader.getExtensionLoader(EventDispatcher.class).getDefaultExtension();
}
}
EventDispatcher 接口被 @SPI 注解修饰是一个扩展点Dubbo 提供了两个具体实现——ParallelEventDispatcher 和 DirectEventDispatcher如下图所示
EventDispatcher 继承关系图
在 AbstractEventDispatcher 中维护了两个核心字段。
listenersCacheConcurrentMap, List> 类型):用于记录监听各类型事件的 EventListener 集合。在 AbstractEventDispatcher 初始化时,会加载全部 EventListener 实现并调用 addEventListener() 方法添加到 listenersCache 集合中。
executorExecutor 类型):该线程池在 AbstractEventDispatcher 的构造函数中初始化。在 AbstractEventDispatcher 收到相应事件时,由该线程池来触发对应的 EventListener 集合。
AbstractEventDispatcher 中的 addEventListener()、removeEventListener()、getAllEventListeners() 方法都是通过操作 listenersCache 集合实现的,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
AbstractEventDispatcher 中另一个要关注的方法是 dispatch() 方法,该方法会从 listenersCache 集合中过滤出符合条件的 EventListener 对象,并按照串行或是并行模式进行通知,具体实现如下:
public void dispatch(Event event) {
// 获取通知EventListener的线程池默认为串行模式也就是direct实现
Executor executor = getExecutor();
executor.execute(() -> {
sortedListeners(entry -> entry.getKey().isAssignableFrom(event.getClass()))
.forEach(listener -> {
if (listener instanceof ConditionalEventListener) { // 针对ConditionalEventListener的特殊处理
ConditionalEventListener predicateEventListener = (ConditionalEventListener) listener;
if (!predicateEventListener.accept(event)) {
return;
}
}
// 通知EventListener
listener.onEvent(event);
});
});
}
// 这里的sortedListeners方法会对listenerCache进行过滤和排序
protected Stream<EventListener> sortedListeners(Predicate<Map.Entry<Class<? extends Event>, List<EventListener>>> predicate) {
return listenersCache
.entrySet()
.stream()
.filter(predicate)
.map(Map.Entry::getValue)
.flatMap(Collection::stream)
.sorted();
}
AbstractEventDispatcher 已经实现了 EventDispatcher 分发 Event 事件、通知 EventListener 的核心逻辑,然后在 ParallelEventDispatcher 和 DirectEventDispatcher 确定是并行通知模式还是串行通知模式即可。
在 ParallelEventDispatcher 中通知 EventListener 的线程池是 ForkJoinPool也就是并行模式在 DirectEventDispatcher 中使用的是 EventDispatcher.DIRECT_EXECUTOR 线程池,也就是串行模式。这两个 EventDispatcher 的具体实现比较简单,这里就不再展示。
我们回到 ZookeeperServiceDiscovery在其构造方法中会获取默认的 EventDispatcher 实现对象,并调用 addEventListener() 方法将 ZookeeperServiceDiscovery 对象添加到 listenersCache 集合中监听 ServiceInstancesChangedEvent 事件。ZookeeperServiceDiscovery 直接继承了 ServiceDiscovery 接口中 dispatchServiceInstancesChangedEvent() 方法的默认实现,并没有进行覆盖,在该方法中,会获取默认的 EventDispatcher 实现并调用 dispatch() 方法分发 ServiceInstancesChangedEvent 事件。
总结
在本课时,我们重点介绍了 Dubbo 服务自省方案中服务实例发布和订阅的基础。
首先,我们说明了 ServiceDiscovery 接口的核心定义,其中定义了服务实例发布和订阅的核心方法。接下来我们分析了以 ZooKeeper 作为注册中心的 ZookeeperServiceDiscovery 实现,其中还讲解了在 ZookeeperServiceDiscovery 上添加监听器的相关实现以及 ZookeeperServiceDiscovery 处理 ServiceInstancesChangedEvent 事件的机制。
下一课时,我们将继续介绍 Dubbo 服务自省方案中的服务实例发布以及订阅实现,记得按时来听课。

View File

@@ -0,0 +1,621 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
46 加餐:深入服务自省方案中的服务发布订阅(下)
在课程第二部分13~15 课时)中介绍 Dubbo 传统框架中的注册中心部分实现时,我们提到了 Registry、RegistryFactory 等与注册中心交互的接口。为了将 ServiceDiscovery 接口的功能与 Registry 融合Dubbo 提供了一个 ServiceDiscoveryRegistry 实现,继承关系如下所示:
ServiceDiscoveryRegistry 、ServiceDiscoveryRegistryFactory 继承关系图
由图我们可以看到ServiceDiscoveryRegistryFactory扩展名称是 service-discovery-registry是 ServiceDiscoveryRegistry 对应的工厂类,继承了 AbstractRegistryFactory 提供的公共能力。
ServiceDiscoveryRegistry 是一个面向服务实例ServiceInstance的注册中心实现其底层依赖前面两个课时介绍的 ServiceDiscovery、WritableMetadataService 等组件。
ServiceDiscoveryRegistry 中的核心字段有如下几个。
serviceDiscoveryServiceDiscovery 类型):用于 ServiceInstance 的发布和订阅。
subscribedServicesSet 类型):记录了当前订阅的服务名称。
serviceNameMappingServiceNameMapping 类型):用于 Service ID 与 Service Name 之间的转换。
writableMetadataServiceWritableMetadataService 类型):用于发布和查询元数据。
registeredListenersSet 类型):记录了注册的 ServiceInstancesChangedListener 的唯一标识。
subscribedURLsSynthesizersList 类型):将 ServiceInstance 的信息与元数据进行合并,得到订阅服务的完整 URL。
在 ServiceDiscoveryRegistry 的构造方法中,会初始化上述字段:
public ServiceDiscoveryRegistry(URL registryURL) {
// 初始化父类其中包括FailbackRegistry中的时间轮和重试定时任务以及AbstractRegistry中的本地文件缓存等
super(registryURL);
// 初始化ServiceDiscovery对象
this.serviceDiscovery = createServiceDiscovery(registryURL);
// 从registryURL中解析出subscribed-services参数并按照逗号切分得到subscribedServices集合
this.subscribedServices = parseServices(registryURL.getParameter(SUBSCRIBED_SERVICE_NAMES_KEY));
// 获取DefaultServiceNameMapping对象
this.serviceNameMapping = ServiceNameMapping.getDefaultExtension();
// 初始化WritableMetadataService对象
String metadataStorageType = getMetadataStorageType(registryURL);
this.writableMetadataService = WritableMetadataService.getExtension(metadataStorageType);
// 获取目前支持的全部SubscribedURLsSynthesizer实现并初始化
this.subscribedURLsSynthesizers = initSubscribedURLsSynthesizers();
}
在 createServiceDiscovery() 方法中,不仅会加载 ServiceDiscovery 的相应实现,还会在外层添加 EventPublishingServiceDiscovery 装饰器,在 register()、initialize() 等方法前后触发相应的事件,具体实现如下:
protected ServiceDiscovery createServiceDiscovery(URL registryURL) {
// 根据registryURL获取对应的ServiceDiscovery实现
ServiceDiscovery originalServiceDiscovery = getServiceDiscovery(registryURL);
// ServiceDiscovery外层添加一层EventPublishingServiceDiscovery修饰器
// EventPublishingServiceDiscovery会在register()、initialize()等方法前后触发相应的事件,
// 例如在register()方法的前后分别会触发ServiceInstancePreRegisteredEvent和ServiceInstanceRegisteredEvent
ServiceDiscovery serviceDiscovery = enhanceEventPublishing(originalServiceDiscovery);
execute(() -> { // 初始化ServiceDiscovery
serviceDiscovery.initialize(registryURL.addParameter(INTERFACE_KEY, ServiceDiscovery.class.getName())
.removeParameter(REGISTRY_TYPE_KEY));
});
return serviceDiscovery;
}
Registry 接口的核心是服务发布和订阅ServiceDiscoveryRegistry 既然实现了 Registry 接口,必然也要实现了服务注册和发布的功能。
服务注册
在 ServiceDiscoveryRegistry 的 register() 中,首先会检测待发布 URL 中的 side 参数,然后调用父类的 register() 方法。我们知道 FailbackRegistry.register() 方法会回调子类的 doRegister() 方法,而 ServiceDiscoveryRegistry.doRegister() 方法直接依赖 WritableMetadataService 的 exportURL() 方法,完成元数据的发布。
public final void register(URL url) {
if (!shouldRegister(url)) { // 检测URL中的side参数是否为provider
return;
}
super.register(url);
}
@Override
public void doRegister(URL url) {
// 将元数据发布到MetadataService
if (writableMetadataService.exportURL(url)) {
... // 输出INFO日志
} else {
... // 输出WARN日志
}
}
ServiceDiscoveryRegistry.unregister() 方法的实现逻辑也是类似的,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
服务订阅
接下来看 ServiceDiscoveryRegistry.subscribe() 方法的实现,其中也是首先会检测待发布 URL 中的 side 参数,然后调用父类的 subscribe() 方法。我们知道 FailbackRegistry.subscribe() 方法会回调子类的 doSubscribe() 方法。在 ServiceDiscoveryRegistry 的 doSubscribe() 方法中,会执行如下完整的订阅流程:
调用 WriteMetadataService.subscribeURL() 方法在 subscribedServiceURLs 集合中记录当前订阅的 URL
通过订阅的 URL 获取 Service Name
根据 Service Name 获取 ServiceInstance 集合;
根据 ServiceInstance 调用相应的 MetadataService 服务,获取元数据,其中涉及历史数据的清理和缓存更新等操作;
将 ServiceInstance 信息以及对应的元数据信息进行合并,得到完整的 URL
触发 NotifyListener 监听器;
添加 ServiceInstancesChangedListener 监听器。
下面来看 ServiceDiscoveryRegistry.doSubscribe() 方法的具体实现:
protected void subscribeURLs(URL url, NotifyListener listener) {
// 记录该订阅的URL
writableMetadataService.subscribeURL(url);
// 获取订阅的Service Name
Set<String> serviceNames = getServices(url);
if (CollectionUtils.isEmpty(serviceNames)) {
throw new IllegalStateException("...");
}
// 执行后续的订阅操作
serviceNames.forEach(serviceName -> subscribeURLs(url, listener, serviceName));
}
我们这就展开一步步来解析上面的这个流程。
1. 获取 Service Name
首先来看 getServices() 方法的具体实现:它会首先根据 subscribeURL 的 provided-by 参数值获取订阅的 Service Name 集合,如果获取失败,则根据 Service ID 获取对应的 Service Name 集合;如果此时依旧获取失败,则尝试从 registryURL 中的 subscribed-services 参数值获取 Service Name 集合。下面来看 getServices() 方法的具体实现:
protected Set<String> getServices(URL subscribedURL) {
Set<String> subscribedServices = new LinkedHashSet<>();
// 首先尝试从subscribeURL中获取provided-by参数值其中封装了全部Service Name
String serviceNames = subscribedURL.getParameter(PROVIDED_BY);
if (StringUtils.isNotEmpty(serviceNames)) {
// 解析provided-by参数值得到全部的Service Name集合
subscribedServices = parseServices(serviceNames);
}
if (isEmpty(subscribedServices)) {
// 如果没有指定provided-by参数则尝试通过subscribedURL构造Service ID
// 然后通过ServiceNameMapping的get()方法查找Service Name
subscribedServices = findMappedServices(subscribedURL);
if (isEmpty(subscribedServices)) {
// 如果subscribedServices依旧为空则返回registryURL中的subscribed-services参数值
subscribedServices = getSubscribedServices();
}
}
return subscribedServices;
}
2. 查找 Service Instance
接下来看 subscribeURLs(url, listener, serviceName) 这个重载的具体实现,其中会根据 Service Name 从 ServiceDiscovery 中查找对应的 ServiceInstance 集合以及注册ServiceInstancesChangedListener 监听。
protected void subscribeURLs(URL url, NotifyListener listener, String serviceName) {
// 根据Service Name获取ServiceInstance对象
List<ServiceInstance> serviceInstances = serviceDiscovery.getInstances(serviceName);
// 调用另一个subscribeURLs()方法重载
subscribeURLs(url, listener, serviceName, serviceInstances);
// 添加ServiceInstancesChangedListener监听器
registerServiceInstancesChangedListener(url, new ServiceInstancesChangedListener(serviceName) {
@Override
public void onEvent(ServiceInstancesChangedEvent event) {
subscribeURLs(url, listener, event.getServiceName(), new ArrayList<>(event.getServiceInstances()));
}
});
}
在 subscribeURLs(url, listener, serviceName, serviceInstances) 这个重载中,主要是根据前面获取的 ServiceInstance 实例集合,构造对应的、完整的 subscribedURL 集合,并触发传入的 NotifyListener 监听器,如下所示:
protected void subscribeURLs(URL subscribedURL, NotifyListener listener, String serviceName,
Collection<ServiceInstance> serviceInstances) {
List<URL> subscribedURLs = new LinkedList<>();
// 尝试通过MetadataService获取subscribedURL集合
subscribedURLs.addAll(getExportedURLs(subscribedURL, serviceInstances));
if (subscribedURLs.isEmpty()) { // 如果上面的尝试失败
// 尝试通过SubscribedURLsSynthesizer获取subscribedURL集合
subscribedURLs.addAll(synthesizeSubscribedURLs(subscribedURL, serviceInstances));
}
// 触发NotifyListener监听器
listener.notify(subscribedURLs);
}
这里构造完整 subscribedURL 可以分为两个分支。
第一个分支:结合传入的 subscribedURL 以及从元数据中获取每个 ServiceInstance 的对应参数,组装成每个 ServiceInstance 对应的完整 subscribeURL。该部分实现在 getExportedURLs() 方法中,也是订阅操作的核心。
第二个分支:当上述操作无法获得完整的 subscribeURL 集合时,会使用 SubscribedURLsSynthesizer基于 subscribedURL 拼凑出每个 ServiceInstance 对应的完整的 subscribedURL。该部分实现在 synthesizeSubscribedURLs() 方法中,目前主要针对 rest 协议。
3. getExportedURLs() 方法核心实现
getExportedURLs() 方法主要围绕 serviceRevisionExportedURLsCache 这个集合展开的,它是一个 Map> 类型的集合,其中第一层 Key 是 Service Name第二层 Key 是 Revision最终的 Value 值是 Service Name 对应的最新的 URL 集合。
1清理过期 URL
在 getExportedURLs() 方法中,首先会调用 expungeStaleRevisionExportedURLs() 方法销毁全部已过期的 URL 信息,具体实现如下:
private void expungeStaleRevisionExportedURLs(List<ServiceInstance> serviceInstances) {
// 从第一个ServiceInstance即可获取Service Name
String serviceName = serviceInstances.get(0).getServiceName();
// 获取该Service Name当前在serviceRevisionExportedURLsCache中对应的URL集合
Map<String, List<URL>> revisionExportedURLsMap = serviceRevisionExportedURLsCache
.computeIfAbsent(serviceName, s -> new LinkedHashMap());
if (revisionExportedURLsMap.isEmpty()) { // 没有缓存任何URL则无须后续清理操作直接返回即可
return;
}
// 获取Service Name在serviceRevisionExportedURLsCache中缓存的修订版本
Set<String> existedRevisions = revisionExportedURLsMap.keySet();
// 从ServiceInstance中获取当前最新的修订版本
Set<String> currentRevisions = serviceInstances.stream()
.map(ServiceInstanceMetadataUtils::getExportedServicesRevision)
.collect(Collectors.toSet());
// 获取要删除的陈旧修订版本staleRevisions = existedRevisions(copy) - currentRevisions
Set<String> staleRevisions = new HashSet<>(existedRevisions);
staleRevisions.removeAll(currentRevisions);
// 从revisionExportedURLsMap中删除staleRevisions集合中所有Key对应的URL集合
staleRevisions.forEach(revisionExportedURLsMap::remove);
}
我们看到这里是通过 ServiceInstanceMetadataUtils 工具类从每个 ServiceInstance 的 metadata 集合中获取最新的修订版本Key 为 dubbo.exported-services.revision那么该修订版本的信息是在哪里写入的呢我们来看一个新接口—— ServiceInstanceCustomizer具体定义如下
@SPI
public interface ServiceInstanceCustomizer extends Prioritized {
void customize(ServiceInstance serviceInstance);
}
关于 ServiceInstanceCustomizer 接口,这里需要关注三个点:①该接口被 @SPI 注解修饰,是一个扩展点;②该接口继承了 Prioritized 接口;③该接口中定义的 customize() 方法可以用来自定义 ServiceInstance 信息,其中就包括控制 metadata 集合中的数据。
也就说ServiceInstanceCustomizer 的多个实现可以按序调用,实现 ServiceInstance 的自定义。下图展示了 ServiceInstanceCustomizer 接口的所有实现类:
ServiceInstanceCustomizer 继承关系图
我们首先来看 ServiceInstanceMetadataCustomizer 这个抽象类,它主要是对 ServiceInstance 中 metadata 这个 KV 集合进行自定义修改,这部分逻辑在 customize() 方法中,如下所示:
public final void customize(ServiceInstance serviceInstance) {
// 获取ServiceInstance对象的metadata字段
Map<String, String> metadata = serviceInstance.getMetadata();
// 生成要添加到metadata集合的KV值
String propertyName = resolveMetadataPropertyName(serviceInstance);
String propertyValue = resolveMetadataPropertyValue(serviceInstance);
// 判断待添加的KV值是否为空
if (!isBlank(propertyName) && !isBlank(propertyValue)) {
String existedValue = metadata.get(propertyName);
boolean put = existedValue == null || isOverride();
if (put) { // 是否覆盖原值
metadata.put(propertyName, propertyValue);
}
}
}
生成 KV 值的 resolveMetadataPropertyName()、resolveMetadataPropertyValue() 方法以及 isOverride() 方法都是抽象方法,在 ServiceInstanceMetadataCustomizer 子类中实现。
在 ExportedServicesRevisionMetadataCustomizer 这个实现中resolveMetadataPropertyName() 方法返回 “dubbo.exported-services.revision” 固定字符串resolveMetadataPropertyValue() 方法会通过 WritableMetadataService 获取当前 ServiceInstance 对象发布的全部 URL然后计算 revision 值。具体实现如下:
protected String resolveMetadataPropertyValue(ServiceInstance serviceInstance) {
// 从ServiceInstance对象的metadata集合中获取当前ServiceInstance存储元数据的方式local还是remote
String metadataStorageType = getMetadataStorageType(serviceInstance);
// 获取相应的WritableMetadataService对象并获取当前ServiceInstance发布的全部元数据
WritableMetadataService writableMetadataService = getExtension(metadataStorageType);
SortedSet<String> exportedURLs = writableMetadataService.getExportedURLs();
// 计算整个exportedURLs集合的revision值
URLRevisionResolver resolver = new URLRevisionResolver();
return resolver.resolve(exportedURLs);
}
这里需要说明下计算 revision 值的核心实现:首先获取每个服务接口的方法签名以及对应 URL 参数集合,然后计算 hashCode 并加和返回,如果通过上述方式没有拿到 revision 值,则返回 “N/A” 占位符字符串。URLRevisionResolver.resolve() 方法的具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
在 SubscribedServicesRevisionMetadataCustomizer 这个实现中resolveMetadataPropertyName() 方法返回的是 “dubbo.subscribed-services.revision” 固定字符串resolveMetadataPropertyValue() 方法会通过 WritableMetadataService 获取当前 ServiceInstance 对象引用的全部 URL然后计算 revision 值并返回。具体实现如下:
protected String resolveMetadataPropertyValue(ServiceInstance serviceInstance) {
String metadataStorageType = getMetadataStorageType(serviceInstance);
WritableMetadataService writableMetadataService = getExtension(metadataStorageType);
// 获取subscribedServiceURLs集合
SortedSet<String> subscribedURLs = writableMetadataService.getSubscribedURLs();
URLRevisionResolver resolver = new URLRevisionResolver();
// 计算revision值
return resolver.resolve(subscribedURLs);
}
在 MetadataServiceURLParamsMetadataCustomizer 这个实现中resolveMetadataPropertyName() 方法返回 “dubbo.metadata-service.url-params” 固定字符串resolveMetadataPropertyValue() 方法返回 MetadataService 服务 URL 的参数。
对于 RefreshServiceMetadataCustomizer 这个实现,我们首先关注其执行顺序, 它覆盖了 getPriority() 方法,具体实现如下:
public int getPriority() {
return MIN_PRIORITY; // 执行优先级最低
}
这就保证了 RefreshServiceMetadataCustomizer 在前面介绍的 ServiceInstanceMetadataCustomizer 实现之后执行ServiceInstanceMetadataCustomizer 的优先级为 NORMAL_PRIORITY
customize() 方法的实现中RefreshServiceMetadataCustomizer 会分别获取该 ServiceInstance 发布服务的 URL revision 以及引用服务的 URL revision并更新到元数据中心。具体实现如下
public void customize(ServiceInstance serviceInstance) {
String metadataStoredType = getMetadataStorageType(serviceInstance);
WritableMetadataService writableMetadataService = getExtension(metadataStoredType);
// 从ServiceInstance.metadata集合中获取两个revision并调用refreshMetadata()方法进行更新
writableMetadataService.refreshMetadata(getExportedServicesRevision(serviceInstance),
getSubscribedServicesRevision(serviceInstance));
}
在 WritableMetadataService 接口的实现中,只有 RemoteWritableMetadataService 实现了 refreshMetadata() 方法,其中会判断两个 revision 值是否发生变化,如果发生了变化,则将相应的 URL 集合更新到元数据中心。如下所示:
public boolean refreshMetadata(String exportedRevision, String subscribedRevision) {
boolean result = true;
// 比较当前ServiceInstance的exportedRevision是否发生变化
if (!StringUtils.isEmpty(exportedRevision) && !exportedRevision.equals(this.exportedRevision)) {
// 发生变化的话会更新exportedRevision字段同时将exportedServiceURLs集合中的URL更新到元数据中心
this.exportedRevision = exportedRevision;
boolean executeResult = saveServiceMetadata();
if (!executeResult) {
result = false;
}
}
// 比较当前ServiceInstance的subscribedRevision是否发生变化
if (!StringUtils.isEmpty(subscribedRevision) && !subscribedRevision.equals(this.subscribedRevision)
&& CollectionUtils.isNotEmpty(writableMetadataService.getSubscribedURLs())) {
// 发生变化的话会更新subscribedRevision字段同时将subscribedServiceURLs集合中的URL更新到元数据中心
this.subscribedRevision = subscribedRevision;
SubscriberMetadataIdentifier metadataIdentifier = new SubscriberMetadataIdentifier();
metadataIdentifier.setApplication(serviceName());
metadataIdentifier.setRevision(subscribedRevision);
boolean executeResult = throwableAction(getMetadataReport()::saveSubscribedData, metadataIdentifier,
writableMetadataService.getSubscribedURLs());
if (!executeResult) {
result = false;
}
}
return result;
}
在 EventListener 接口的实现中有一个名为 CustomizableServiceInstanceListener 的实现,它会监听 ServiceInstancePreRegisteredEvent在其 onEvent() 方法中,加载全部 ServiceInstanceCustomizer 实现,并调用全部 customize() 方法完成 ServiceInstance 的自定义。具体实现如下:
public void onEvent(ServiceInstancePreRegisteredEvent event) {
// 加载全部ServiceInstanceCustomizer实现
ExtensionLoader<ServiceInstanceCustomizer> loader =
ExtensionLoader.getExtensionLoader(ServiceInstanceCustomizer.class);
// 按序实现ServiceInstance自定义
loader.getSupportedExtensionInstances().forEach(customizer -> {
customizer.customize(event.getServiceInstance());
});
}
2更新 Revision 缓存
介绍完 ServiceInstanceMetadataCustomizer 的内容之后,下面我们回到 ServiceDiscoveryRegistry 继续分析。
在清理完过期的修订版本 URL 之后,接下来会检测所有 ServiceInstance 的 revision 值是否已经存在于 serviceRevisionExportedURLsCache 缓存中,如果某个 ServiceInstance 的 revision 值没有在该缓存中,则会调用该 ServiceInstance 发布的 MetadataService 接口进行查询,这部分逻辑在 initializeRevisionExportedURLs() 方法中实现。具体实现如下:
private List<URL> initializeRevisionExportedURLs(ServiceInstance serviceInstance) {
if (serviceInstance == null) { // 判空
return emptyList();
}
// 获取Service Name
String serviceName = serviceInstance.getServiceName();
// 获取该ServiceInstance.metadata中携带的revision值
String revision = getExportedServicesRevision(serviceInstance);
// 从serviceRevisionExportedURLsCache集合中获取该revision值对应的URL集合
Map<String, List<URL>> revisionExportedURLsMap = getRevisionExportedURLsMap(serviceName);
List<URL> revisionExportedURLs = revisionExportedURLsMap.get(revision);
if (revisionExportedURLs == null) { // serviceRevisionExportedURLsCache缓存没有命中
// 调用该ServiceInstance对应的MetadataService服务获取其发布的URL集合
revisionExportedURLs = getExportedURLs(serviceInstance);
if (revisionExportedURLs != null) { // 调用MetadataService服务成功之后更新到serviceRevisionExportedURLsCache缓存中
revisionExportedURLsMap.put(revision, revisionExportedURLs);
}
} else { // 命中serviceRevisionExportedURLsCache缓存
... // 打印日志
}
return revisionExportedURLs;
}
3请求 MetadataService 服务
这里我们可以看到,请求某个 ServiceInstance 的 MetadataService 接口的实现是在 getExportedURLs() 方法中实现的,与我们前面整个课程介绍的请求普通业务接口的原理类似。具体实现如下:
private List<URL> getExportedURLs(ServiceInstance providerServiceInstance) {
List<URL> exportedURLs = null;
// 获取指定ServiceInstance实例存储元数据的类型
String metadataStorageType = getMetadataStorageType(providerServiceInstance);
try {
// 创建MetadataService接口的本地代理
MetadataService metadataService = MetadataServiceProxyFactory.getExtension(metadataStorageType)
.getProxy(providerServiceInstance);
if (metadataService != null) {
// 通过本地代理请求该ServiceInstance的MetadataService服务
SortedSet<String> urls = metadataService.getExportedURLs();
exportedURLs = toURLs(urls);
}
} catch (Throwable e) {
exportedURLs = null; // 置空exportedURLs
}
return exportedURLs;
}
这里涉及一个新的接口——MetadataServiceProxyFactory它是用来创建 MetadataService 本地代理的工厂类,继承关系如下所示:
MetadataServiceProxyFactory 继承关系图
在 BaseMetadataServiceProxyFactory 中提供了缓存 MetadataService 本地代理的公共功能,其中维护了一个 proxies 集合HashMap 类型Key 是 Service Name 与一个 ServiceInstance 的 revision 值的组合Value 是该 ServiceInstance 对应的 MetadataService 服务的本地代理对象。创建 MetadataService 本地代理的功能是在 createProxy() 抽象方法中实现的,这个方法由 BaseMetadataServiceProxyFactory 的子类具体实现。
下面来看 BaseMetadataServiceProxyFactory 的两个实现——DefaultMetadataServiceProxyFactory 和 RemoteMetadataServiceProxyFactory。
DefaultMetadataServiceProxyFactory 在其 createProxy() 方法中,会先通过 MetadataServiceURLBuilder 获取 MetadataService 接口的 URL然后通过 Protocol 接口引用指定 ServiceInstance 发布的 MetadataService 服务,得到对应的 Invoker 对象,最后通过 ProxyFactory 在 Invoker 对象的基础上创建 MetadataService 本地代理。
protected MetadataService createProxy(ServiceInstance serviceInstance) {
MetadataServiceURLBuilder builder = null;
ExtensionLoader<MetadataServiceURLBuilder> loader
= ExtensionLoader.getExtensionLoader(MetadataServiceURLBuilder.class);
Map<String, String> metadata = serviceInstance.getMetadata();
// 在使用Spring Cloud的时候metadata集合中会包含METADATA_SERVICE_URLS_PROPERTY_NAME整个Key
String dubboURLsJSON = metadata.get(METADATA_SERVICE_URLS_PROPERTY_NAME);
if (StringUtils.isNotEmpty(dubboURLsJSON)) {
builder = loader.getExtension(SpringCloudMetadataServiceURLBuilder.NAME);
} else {
builder = loader.getExtension(StandardMetadataServiceURLBuilder.NAME);
}
// 构造MetadataService服务对应的URL集合
List<URL> urls = builder.build(serviceInstance);
// 引用服务创建Invoker注意即使MetadataService接口使用了多种协议这里也只会使用第一种协议
Invoker<MetadataService> invoker = protocol.refer(MetadataService.class, urls.get(0));
// 创建MetadataService的本地代理对象
return proxyFactory.getProxy(invoker);
}
这里我们来看 MetadataServiceURLBuilder 接口中创建 MetadataService 服务对应的 URL 的逻辑,下图展示了 MetadataServiceURLBuilder 接口的实现:
MetadataServiceURLBuilder 继承关系图
其中SpringCloudMetadataServiceURLBuilder 是兼容 Spring Cloud 的实现,这里就不深入分析了。我们重点来看 StandardMetadataServiceURLBuilder 的实现,其中会根据 ServiceInstance.metadata 携带的 URL 参数、Service Name、ServiceInstance 的 host 等信息构造 MetadataService 服务对应 URL如下所示
public List<URL> build(ServiceInstance serviceInstance) {
// 从metadata集合中获取"dubbo.metadata-service.url-params"这个Key对应的Value值
// 这个Key是在MetadataServiceURLParamsMetadataCustomizer中写入的
Map<String, Map<String, String>> paramsMap = getMetadataServiceURLsParams(serviceInstance);
List<URL> urls = new ArrayList<>(paramsMap.size());
// 获取Service Name
String serviceName = serviceInstance.getServiceName();
// 获取ServiceInstance监听的host
String host = serviceInstance.getHost();
// MetadataService接口可能被发布成多种协议遍历paramsMap集合为每种协议都生成对应的URL
for (Map.Entry<String, Map<String, String>> entry : paramsMap.entrySet()) {
String protocol = entry.getKey();
Map<String, String> params = entry.getValue();
int port = Integer.parseInt(params.get(PORT_KEY));
URLBuilder urlBuilder = new URLBuilder()
.setHost(host)
.setPort(port)
.setProtocol(protocol)
.setPath(MetadataService.class.getName());
params.forEach((name, value) -> urlBuilder.addParameter(name, valueOf(value)));
urlBuilder.addParameter(GROUP_KEY, serviceName);
urls.add(urlBuilder.build());
}
return urls;
}
接下来我们看 RemoteMetadataServiceProxyFactory 这个实现类,其中的 createProxy() 方法会直接创建一个 RemoteMetadataServiceProxy 对象并返回。在前面第 44 课时介绍 MetadataService 接口的时候,我们重点介绍的是 WritableMetadataService 这个子接口下的实现,并没有提及 RemoteMetadataServiceProxy 这个实现。下图是 RemoteMetadataServiceProxy 在继承体系中的位置:
RemoteMetadataServiceProxy 继承关系图
RemoteMetadataServiceProxy 作为 RemoteWritableMetadataService 的本地代理,其 getExportedURLs()、getServiceDefinition() 等方法的实现,完全依赖于 MetadataReport 进行实现。这里以 getExportedURLs() 方法为例:
public SortedSet<String> getExportedURLs(String serviceInterface, String group, String version, String protocol) {
// 通过getMetadataReport()方法获取MetadataReport实现对象并通过其getExportedURLs()方法进行查询查询条件封装成ServiceMetadataIdentifier传入其中包括服务接口、group、version以及revision等一系列信息以ZookeeperMetadataReport实现为例真正有用的信息是revision和protocol
return toSortedStrings(getMetadataReport().getExportedURLs(
new ServiceMetadataIdentifier(serviceInterface, group, version, PROVIDER_SIDE, revision, protocol)));
}
到此为止serviceRevisionExportedURLsCache 缓存中各个修订版本的 URL 已经更新到最新数据。
4生成 SubcribedURL
在拿到最新修订版本的 URL 集合之后,接下来会调用 cloneExportedURLs() 方法,结合模板 URL也就是 subscribedURL以及各个 ServiceInstance 发布出来的元数据,生成要订阅服务的最终 subscribedURL 集合。
private List<URL> cloneExportedURLs(URL subscribedURL, Collection<ServiceInstance> serviceInstances) {
if (isEmpty(serviceInstances)) {
return emptyList();
}
List<URL> clonedExportedURLs = new LinkedList<>();
serviceInstances.forEach(serviceInstance -> {
// 获取该ServiceInstance的host
String host = serviceInstance.getHost();
// 获取该ServiceInstance的模板URL集合getTemplateExportedURLs()方法会根据Service Name以及当前ServiceInstance的revision
// 从serviceRevisionExportedURLsCache缓存中获取对应的URL集合另外还会根据subscribedURL的protocol、group、version等参数进行过滤
getTemplateExportedURLs(subscribedURL, serviceInstance)
.stream()
// 删除timestamp、pid等参数
.map(templateURL -> templateURL.removeParameter(TIMESTAMP_KEY))
.map(templateURL -> templateURL.removeParameter(PID_KEY))
.map(templateURL -> {
// 从ServiceInstance.metadata集合中获取该protocol对应的端口号
String protocol = templateURL.getProtocol();
int port = getProtocolPort(serviceInstance, protocol);
if (Objects.equals(templateURL.getHost(), host)
&& Objects.equals(templateURL.getPort(), port)) { // use templateURL if equals
return templateURL;
}
// 覆盖host、port参数
URLBuilder clonedURLBuilder = from(templateURL)
.setHost(host)
.setPort(port);
return clonedURLBuilder.build();
})
.forEach(clonedExportedURLs::add); // 记录新生成的URL
});
return clonedExportedURLs;
}
在 getProtocolPort() 方法中会从 ServiceInstance.metadata 集合中获取 endpoints 列表Key 为 dubbo.endpoints具体实现如下
public static Integer getProtocolPort(ServiceInstance serviceInstance, String protocol) {
Map<String, String> metadata = serviceInstance.getMetadata();
// 从metadata集合中进行查询
String rawEndpoints = metadata.get("dubbo.endpoints");
if (StringUtils.isNotEmpty(rawEndpoints)) {
// 将JSON格式的数据进行反序列化这里的Endpoint是ServiceDiscoveryRegistry的内部类只有port和protocol两个字段
List<Endpoint> endpoints = JSON.parseArray(rawEndpoints, Endpoint.class);
for (Endpoint endpoint : endpoints) {
// 根据Protocol获取对应的port
if (endpoint.getProtocol().equals(protocol)) {
return endpoint.getPort();
}
}
}
return null;
}
在 ServiceInstance.metadata 集合中设置 Endpoint 集合的 ServiceInstanceCustomizer 接口的另一个实现—— ProtocolPortsMetadataCustomizer主要是为了将不同 Protocol 监听的不同端口通知到 Consumer 端。ProtocolPortsMetadataCustomizer.customize() 方法的具体实现如下:
public void customize(ServiceInstance serviceInstance) {
// 获取WritableMetadataService
String metadataStoredType = getMetadataStorageType(serviceInstance);
WritableMetadataService writableMetadataService = getExtension(metadataStoredType);
Map<String, Integer> protocols = new HashMap<>();
// 先获取将当前ServiceInstance发布的各种Protocol对应的URL
writableMetadataService.getExportedURLs()
.stream().map(URL::valueOf)
// 过滤掉MetadataService接口
.filter(url -> !MetadataService.class.getName().equals(url.getServiceInterface()))
.forEach(url -> {
// 记录Protocol与port之间的映射关系
protocols.put(url.getProtocol(), url.getPort());
});
// 将protocols这个Map中的映射关系转换成Endpoint对象然后再序列化成JSON字符串并设置到该ServiceInstance的metadata集合中
setEndpoints(serviceInstance, protocols);
}
到此为止,整个 getExportedURLs() 方法的核心流程就介绍完了。
4. SubscribedURLsSynthesizer
最后,我们再来看看 synthesizeSubscribedURLs() 方法的相关实现,其中使用到 SubscribedURLsSynthesizer 这个接口,具体定义如下:
@SPI
public interface SubscribedURLsSynthesizer extends Prioritized {
// 是否支持该类型的URL
boolean supports(URL subscribedURL);
// 根据subscribedURL以及ServiceInstance的信息合成完整subscribedURL集合
List<URL> synthesize(URL subscribedURL, Collection<ServiceInstance> serviceInstances);
}
目前 Dubbo 只提供了 rest 协议的实现—— RestProtocolSubscribedURLsSynthesizer其中会根据 subscribedURL 中的服务接口以及 ServiceInstance 的 host、port、Service Name 等合成完整的 URL具体实现如下
public List<URL> synthesize(URL subscribedURL, Collection<ServiceInstance> serviceInstances) {
// 获取Protocol
String protocol = subscribedURL.getParameter(PROTOCOL_KEY);
return serviceInstances.stream().map(serviceInstance -> {
URLBuilder urlBuilder = new URLBuilder()
.setProtocol(protocol)
// 使用ServiceInstance的host、port
.setHost(serviceInstance.getHost())
.setPort(serviceInstance.getPort())
// 设置业务接口
.setPath(subscribedURL.getServiceInterface())
.addParameter(SIDE_KEY, PROVIDER)
// 设置Service Name
.addParameter(APPLICATION_KEY, serviceInstance.getServiceName())
.addParameter(REGISTER_KEY, TRUE.toString());
return urlBuilder.build();
}).collect(Collectors.toList());
}
到这里,关于整个 ServiceDiscoveryRegistry 的内容,我们就介绍完了。
总结
本课时我们重点介绍了 Dubbo 服务自省架构中服务发布、服务订阅功能与传统 Dubbo 架构中Registry 接口的兼容实现,也就是 ServiceDiscoveryRegistry 的核心实现。
首先我们讲解了 ServiceDiscoveryRegistry 对服务注册的核心实现,然后详细介绍了 ServiceDiscoveryRegistry 对服务订阅功能的实现,其中涉及 Service Instance 和 Service Name 的查询、MetadataService 服务调用等操作,最终得到 SubcribedURL。
下一课时,我们将开始介绍 Dubbo 服务自省架构中配置中心的相关内容,记得按时来听课。

View File

@@ -0,0 +1,392 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
47 配置中心设计与实现:集中化配置 and 本地化配置,我都要(上)
从 2.7.0 版本开始Dubbo 正式支持配置中心,在服务自省架构中也依赖配置中心完成 Service ID 与 Service Name 的映射。配置中心在 Dubbo 中主要承担两个职责:
外部化配置;
服务治理,负责服务治理规则的存储与通知。
外部化配置目的之一是实现配置的集中式管理。 目前已经有很多成熟的专业配置管理系统(例如,携程开源的 Apollo、阿里开源的 Nacos 等Dubbo 配置中心的目的不是再“造一次轮子”,而是保证 Dubbo 能与这些成熟的配置管理系统正常工作。
Dubbo 可以同时支持多种配置来源。在 Dubbo 初始化过程中,会从多个来源获取配置,并按照固定的优先级将这些配置整合起来,实现高优先级的配置覆盖低优先级配置的效果。这些配置的汇总结果将会参与形成 URL以及后续的服务发布和服务引用。
Dubbo 目前支持下面四种配置来源,优先级由 1 到 4 逐级降低:
System Properties即 -D 参数;
外部化配置,也就是本课时要介绍的配置中心;
API 接口、注解、XML 配置等编程方式收到的配置,最终得到 ServiceConfig、ReferenceConfig 等对象;
本地 dubbo.properties 配置文件。
Configuration
Configuration 接口是 Dubbo 中所有配置的基础接口,其中定义了根据指定 Key 获取对应配置值的相关方法,如下图所示:
Configuration 接口核心方法
从上图中我们可以看到Configuration 针对不同的 boolean、int、String 返回值都有对应的 get() 方法,同时还提供了带有默认值的 get() 方法。这些 get*() 方法底层首先调用 getInternalProperty() 方法获取配置值,然后调用 convert() 方法将获取到的配置值转换成返回值的类型之后返回。getInternalProperty() 是一个抽象方法,由 Configuration 接口的子类具体实现。
下图展示了 Dubbo 中提供的 Configuration 接口实现包括SystemConfiguration、EnvironmentConfiguration、InmemoryConfiguration、PropertiesConfiguration、CompositeConfiguration、ConfigConfigurationAdapter 和 DynamicConfiguration。下面我们将结合具体代码逐个介绍其实现。
Configuration 继承关系图
SystemConfiguration & EnvironmentConfiguration
SystemConfiguration 是从 Java Properties 配置(也就是 -D 配置参数中获取相应的配置项EnvironmentConfiguration 是从使用环境变量中获取相应的配置。两者的 getInternalProperty() 方法实现如下:
public class SystemConfiguration implements Configuration {
public Object getInternalProperty(String key) {
return System.getProperty(key); // 读取-D配置参数
}
}
public class EnvironmentConfiguration implements Configuration {
public Object getInternalProperty(String key) {
String value = System.getenv(key);
if (StringUtils.isEmpty(value)) {
// 读取环境变量中获取相应的配置
value = System.getenv(StringUtils.toOSStyleKey(key));
}
return value;
}
}
InmemoryConfiguration
InmemoryConfiguration 会在内存中维护一个 Map 集合store 字段),其 getInternalProperty() 方法的实现就是从 store 集合中获取对应配置值:
public class InmemoryConfiguration implements Configuration {
private Map<String, String> store = new LinkedHashMap<>();
@Override
public Object getInternalProperty(String key) {
return store.get(key);
}
// 省略addProperty()等写入store集合的方法
}
PropertiesConfiguration
PropertiesConfiguration 涉及 OrderedPropertiesProvider其接口的定义如下
@SPI
public interface OrderedPropertiesProvider {
// 用于排序
int priority();
// 获取Properties配置
Properties initProperties();
}
在 PropertiesConfiguration 的构造方法中,会加载 OrderedPropertiesProvider 接口的全部扩展实现,并按照 priority() 方法进行排序。然后,加载默认的 dubbo.properties.file 配置文件。最后,用 OrderedPropertiesProvider 中提供的配置覆盖 dubbo.properties.file 文件中的配置。PropertiesConfiguration 的构造方法的具体实现如下:
public PropertiesConfiguration() {
// 获取OrderedPropertiesProvider接口的全部扩展名称
ExtensionLoader<OrderedPropertiesProvider> propertiesProviderExtensionLoader = ExtensionLoader.getExtensionLoader(OrderedPropertiesProvider.class);
Set<String> propertiesProviderNames = propertiesProviderExtensionLoader.getSupportedExtensions();
if (propertiesProviderNames == null || propertiesProviderNames.isEmpty()) {
return;
}
// 加载OrderedPropertiesProvider接口的全部扩展实现
List<OrderedPropertiesProvider> orderedPropertiesProviders = new ArrayList<>();
for (String propertiesProviderName : propertiesProviderNames) {
orderedPropertiesProviders.add(propertiesProviderExtensionLoader.getExtension(propertiesProviderName));
}
// 排序OrderedPropertiesProvider接口的扩展实现
orderedPropertiesProviders.sort((OrderedPropertiesProvider a, OrderedPropertiesProvider b) -> {
return b.priority() - a.priority();
});
// 加载默认的dubbo.properties.file配置文件加载后的结果记录在ConfigUtils.PROPERTIES这个static字段中
Properties properties = ConfigUtils.getProperties();
// 使用OrderedPropertiesProvider扩展实现按序覆盖dubbo.properties.file配置文件中的默认配置
for (OrderedPropertiesProvider orderedPropertiesProvider :
orderedPropertiesProviders) {
properties.putAll(orderedPropertiesProvider.initProperties());
}
// 更新ConfigUtils.PROPERTIES字段
ConfigUtils.setProperties(properties);
}
在 PropertiesConfiguration.getInternalProperty() 方法中,直接从 ConfigUtils.PROPERTIES 这个 Properties 中获取覆盖后的配置信息。
public Object getInternalProperty(String key) {
return ConfigUtils.getProperty(key);
}
CompositeConfiguration
CompositeConfiguration 是一个复合的 Configuration 对象,其核心就是将多个 Configuration 对象组合起来,对外表现为一个 Configuration 对象。
CompositeConfiguration 组合的 Configuration 对象都保存在 configList 字段中LinkedList<Configuration> 集合CompositeConfiguration 提供了 addConfiguration() 方法用于向 configList 集合中添加 Configuration 对象,如下所示:
public void addConfiguration(Configuration configuration) {
if (configList.contains(configuration)) {
return; // 不会重复添加同一个Configuration对象
}
this.configList.add(configuration);
}
在 CompositeConfiguration 中维护了一个 prefix 字段和 id 字段,两者可以作为 Key 的前缀进行查询,在 getProperty() 方法中的相关代码如下:
public Object getProperty(String key, Object defaultValue) {
Object value = null;
if (StringUtils.isNotEmpty(prefix)) { // 检查prefix
if (StringUtils.isNotEmpty(id)) { // 检查id
// prefix和id都作为前缀然后拼接key进行查询
value = getInternalProperty(prefix + id + "." + key);
}
if (value == null) {
// 只把prefix作为前缀拼接key进行查询
value = getInternalProperty(prefix + key);
}
} else {
// 若prefix为空则直接用key进行查询
value = getInternalProperty(key);
}
return value != null ? value : defaultValue;
}
在 getInternalProperty() 方法中,会按序遍历 configList 集合中的全部 Configuration 查询对应的 Key返回第一个成功查询到的 Value 值,如下示例代码:
public Object getInternalProperty(String key) {
Configuration firstMatchingConfiguration = null;
for (Configuration config : configList) { // 遍历所有Configuration对象
try {
if (config.containsKey(key)) { // 得到第一个包含指定Key的Configuration对象
firstMatchingConfiguration = config;
break;
}
} catch (Exception e) {
logger.error("...");
}
}
if (firstMatchingConfiguration != null) { // 通过该Configuration查询Key并返回配置值
return firstMatchingConfiguration.getProperty(key);
} else {
return null;
}
}
ConfigConfigurationAdapter
Dubbo 通过 AbstractConfig 类来抽象实例对应的配置,如下图所示:
AbstractConfig 继承关系图
这些 AbstractConfig 实现基本都对应一个固定的配置,也定义了配置对应的字段以及 getter/setter() 方法。例如RegistryConfig 这个实现类就对应了注册中心的相关配置,其中包含了 address、protocol、port、timeout 等一系列与注册中心相关的字段以及对应的 getter/setter() 方法,来接收用户通过 XML、Annotation 或是 API 方式传入的注册中心配置。
ConfigConfigurationAdapter 是 AbstractConfig 与 Configuration 之间的适配器,它会将 AbstractConfig 对象转换成 Configuration 对象。在 ConfigConfigurationAdapter 的构造方法中会获取 AbstractConfig 对象的全部字段,并转换成一个 Map 集合返回,该 Map 集合将会被 ConfigConfigurationAdapter 的 metaData 字段引用。相关示例代码如下:
public ConfigConfigurationAdapter(AbstractConfig config) {
// 获取该AbstractConfig对象中的全部字段与字段值的映射
Map<String, String> configMetadata = config.getMetaData();
metaData = new HashMap<>(configMetadata.size());
// 根据AbstractConfig配置的prefix和id修改metaData集合中Key的名称
for (Map.Entry<String, String> entry : configMetadata.entrySet()) {
String prefix = config.getPrefix().endsWith(".") ? config.getPrefix() : config.getPrefix() + ".";
String id = StringUtils.isEmpty(config.getId()) ? "" : config.getId() + ".";
metaData.put(prefix + id + entry.getKey(), entry.getValue());
}
}
在 ConfigConfigurationAdapter 的 getInternalProperty() 方法实现中,直接从 metaData 集合中获取配置值即可,如下所示:
public Object getInternalProperty(String key) {
return metaData.get(key);
}
DynamicConfiguration
DynamicConfiguration 是对 Dubbo 中动态配置的抽象,其核心方法有下面三类。
getProperties()/ getConfig() / getProperty() 方法:从配置中心获取指定的配置,在使用时,可以指定一个超时时间。
addListener()/ removeListener() 方法:添加或删除对指定配置的监听器。
publishConfig() 方法:发布一条配置信息。
在上述三类方法中,每个方法都用多个重载,其中,都会包含一个带有 group 参数的重载,也就是说配置中心的配置可以按照 group 进行分组。
与 Dubbo 中很多接口类似DynamicConfiguration 接口本身不被 @SPI 注解修饰(即不是一个扩展接口),而是在 DynamicConfigurationFactory 上添加了 @SPI 注解,使其成为一个扩展接口。
在 DynamicConfiguration 中提供了 getDynamicConfiguration() 静态方法,该方法会从传入的配置中心 URL 参数中,解析出协议类型并获取对应的 DynamicConfigurationFactory 实现,如下所示:
static DynamicConfiguration getDynamicConfiguration(URL connectionURL) {
String protocol = connectionURL.getProtocol();
DynamicConfigurationFactory factory = getDynamicConfigurationFactory(protocol);
return factory.getDynamicConfiguration(connectionURL);
}
DynamicConfigurationFactory 接口的定义如下:
@SPI("nop")
public interface DynamicConfigurationFactory {
DynamicConfiguration getDynamicConfiguration(URL url);
static DynamicConfigurationFactory getDynamicConfigurationFactory(String name) {
// 根据扩展名称获取DynamicConfigurationFactory实现
Class<DynamicConfigurationFactory> factoryClass = DynamicConfigurationFactory.class;
ExtensionLoader<DynamicConfigurationFactory> loader = getExtensionLoader(factoryClass);
return loader.getOrDefaultExtension(name);
}
}
DynamicConfigurationFactory 接口的继承关系以及 DynamicConfiguration 接口对应的继承关系如下:
DynamicConfigurationFactory 继承关系图
DynamicConfiguration 继承关系图
我们先来看 AbstractDynamicConfigurationFactory 的实现,其中会维护一个 dynamicConfigurations 集合Map 类型),在 getDynamicConfiguration() 方法中会填充该集合实现缓存DynamicConfiguration 对象的效果。同时AbstractDynamicConfigurationFactory 提供了一个 createDynamicConfiguration() 方法给子类实现来创建DynamicConfiguration 对象。
以 ZookeeperDynamicConfigurationFactory 实现为例,其 createDynamicConfiguration() 方法创建的就是 ZookeeperDynamicConfiguration 对象:
protected DynamicConfiguration createDynamicConfiguration(URL url) {
// 这里创建ZookeeperDynamicConfiguration使用的ZookeeperTransporter就是前文在Transport层中针对Zookeeper的实现
return new ZookeeperDynamicConfiguration(url, zookeeperTransporter);
}
接下来我们再以 ZookeeperDynamicConfiguration 为例,分析 DynamicConfiguration 接口的具体实现。
首先来看 ZookeeperDynamicConfiguration 的核心字段。
executorExecutor 类型):用于执行监听器的线程池。
rootPathString 类型):以 Zookeeper 作为配置中心时,配置也是以 ZNode 形式存储的rootPath 记录了所有配置节点的根路径。
zkClientZookeeperClient 类型):与 Zookeeper 集群交互的客户端。
initializedLatchCountDownLatch 类型):阻塞等待 ZookeeperDynamicConfiguration 相关的监听器注册完成。
cacheListenerCacheListener 类型):用于监听配置变化的监听器。
urlURL 类型):配置中心对应的 URL 对象。
在 ZookeeperDynamicConfiguration 的构造函数中,会初始化上述核心字段,具体实现如下:
ZookeeperDynamicConfiguration(URL url, ZookeeperTransporter zookeeperTransporter) {
this.url = url;
// 根据URL中的config.namespace参数(默认值为dubbo)确定配置中心ZNode的根路径
rootPath = PATH_SEPARATOR + url.getParameter(CONFIG_NAMESPACE_KEY, DEFAULT_GROUP) + "/config";
// 初始化initializedLatch以及cacheListener
// 在cacheListener注册成功之后会调用cacheListener.countDown()方法
initializedLatch = new CountDownLatch(1);
this.cacheListener = new CacheListener(rootPath, initializedLatch);
// 初始化executor字段用于执行监听器的逻辑
this.executor = Executors.newFixedThreadPool(1, new NamedThreadFactory(this.getClass().getSimpleName(), true));
// 初始化Zookeeper客户端
zkClient = zookeeperTransporter.connect(url);
// 在rootPath上添加cacheListener监听器
zkClient.addDataListener(rootPath, cacheListener, executor);
try {
// 从URL中获取当前线程阻塞等待Zookeeper监听器注册成功的时长上限
long timeout = url.getParameter("init.timeout", 5000);
// 阻塞当前线程,等待监听器注册完成
boolean isCountDown = this.initializedLatch.await(timeout, TimeUnit.MILLISECONDS);
if (!isCountDown) {
throw new IllegalStateException("...");
}
} catch (InterruptedException e) {
logger.warn("...");
}
}
在上述初始化过程中ZookeeperDynamicConfiguration 会创建 CacheListener 监听器。在前面[第 15 课时]中,我们介绍了 dubbo-remoting-zookeeper 对外提供了 StateListener、DataListener 和 ChildListener 三种类型的监听器。这里的 CacheListener 就是 DataListener 监听器的具体实现。
在 CacheListener 中维护了一个 Map 集合keyListeners 字段)用于记录所有添加的 ConfigurationListener 监听器,其中 Key 是配置信息在 Zookeeper 中存储的 pathValue 为该 path 上的监听器集合。当某个配置项发生变化的时候CacheListener 会从 keyListeners 中获取该配置对应的 ConfigurationListener 监听器集合,并逐个进行通知。该逻辑是在 CacheListener 的 dataChanged() 方法中实现的:
public void dataChanged(String path, Object value, EventType eventType) {
if (eventType == null) {
return;
}
if (eventType == EventType.INITIALIZED) {
// 在收到INITIALIZED事件的时候表示CacheListener已经成功注册会释放阻塞在initializedLatch上的主线程
initializedLatch.countDown();
return;
}
if (path == null || (value == null && eventType != EventType.NodeDeleted)) {
return;
}
if (path.split("/").length >= MIN_PATH_DEPTH) { // 对path层数进行过滤
String key = pathToKey(path); // 将path中的"/"替换成"."
ConfigChangeType changeType;
switch (eventType) { // 将Zookeeper中不同的事件转换成不同的ConfigChangedEvent事件
case NodeCreated:
changeType = ConfigChangeType.ADDED;
break;
case NodeDeleted:
changeType = ConfigChangeType.DELETED;
break;
case NodeDataChanged:
changeType = ConfigChangeType.MODIFIED;
break;
default:
return;
}
// 使用ConfigChangedEvent封装触发事件的Key、Value、配置group以及事件类型
ConfigChangedEvent configChangeEvent = new ConfigChangedEvent(key, getGroup(path), (String) value, changeType);
// 从keyListeners集合中获取对应的ConfigurationListener集合然后逐一进行通知
Set<ConfigurationListener> listeners = keyListeners.get(path);
if (CollectionUtils.isNotEmpty(listeners)) {
listeners.forEach(listener -> listener.process(configChangeEvent));
}
}
}
CacheListener 中调用的监听器都是 ConfigurationListener 接口实现,如下图所示,这里涉及[第 33 课时]介绍的 TagRouter、AppRouter 和 ServiceRouter它们主要是监听路由配置的变化还涉及 RegistryDirectory 和 RegistryProtocol 中的四个内部类AbstractConfiguratorListener 的子类),它们主要监听 Provider 和 Consumer 的配置变化。
ConfigurationListener 继承关系图
这些 ConfigurationListener 实现在前面的课程中已经详细介绍过了这里就不再重复。ZookeeperDynamicConfiguration 中还提供了 addListener()、removeListener() 两个方法用来增删 ConfigurationListener 监听器,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
介绍完 ZookeeperDynamicConfiguration 的初始化过程之后,我们再来看 ZookeeperDynamicConfiguration 中读取配置、写入配置的相关操作。相关方法的实现如下:
public Object getInternalProperty(String key) {
// 直接从Zookeeper中读取对应的Key
return zkClient.getContent(key);
}
public boolean publishConfig(String key, String group, String content) {
// getPathKey()方法中会添加rootPath和group两部分信息到Key中
String path = getPathKey(group, key);
// 在Zookeeper中创建对应ZNode节点用来存储配置信息
zkClient.create(path, content, false);
return true;
}
总结
本课时我们重点介绍了 Dubbo 配置中心中的多种配置接口。首先,我们讲解了 Configuration 这个顶层接口的核心方法,然后介绍了 Configuration 接口的相关实现,这些实现可以从环境变量、-D 启动参数、Properties文件以及其他配置文件或注解处读取配置信息。最后我们还着重介绍了 DynamicConfiguration 这个动态配置接口的定义,并分析了以 Zookeeper 为动态配置中心的 ZookeeperDynamicConfiguration 实现。
下一课时,我们将深入介绍 Dubbo 动态配置中心启动的核心流程,记得按时来听课。

View File

@@ -0,0 +1,304 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
48 配置中心设计与实现:集中化配置 and 本地化配置,我都要(下)
在上一课时,我们详细分析了 Configuration 接口以及 DynamicConfiguration 接口的实现,其中 DynamicConfiguration 接口实现是动态配置中心的基础。那 Dubbo 中的动态配置中心是如何启动的呢?我们将在本课时详细介绍。
基础配置类
在 DubboBootstrap 初始化的过程中,会调用 ApplicationModel.initFrameworkExts() 方法初始化所有 FrameworkExt 接口实现,继承关系如下图所示:
FrameworkExt 继承关系图
相关代码片段如下:
public static void initFrameworkExts() {
Set<FrameworkExt> exts = ExtensionLoader.getExtensionLoader(FrameworkExt.class).getSupportedExtensionInstances();
for (FrameworkExt ext : exts) {
ext.initialize();
}
}
ConfigManager 用于管理当前 Dubbo 节点中全部 AbstractConfig 对象,其中就包括 ConfigCenterConfig 这个实现的对象,我们通过 XML、Annotation 或是 API 方式添加的配置中心的相关信息(例如,配置中心的地址、端口、协议等),会转换成 ConfigCenterConfig 对象。
在 Environment 中维护了上一课时介绍的多个 Configuration 对象,具体含义如下。
propertiesConfigurationPropertiesConfiguration 类型):全部 OrderedPropertiesProvider 实现提供的配置以及环境变量或是 -D 参数中指定配置文件的相关配置信息。
systemConfigurationSystemConfiguration 类型):-D 参数配置直接添加的配置信息。
environmentConfigurationEnvironmentConfiguration 类型):环境变量中直接添加的配置信息。
externalConfiguration、appExternalConfigurationInmemoryConfiguration 类型):使用 Spring 框架且将 include-spring-env 配置为 true 时,会自动从 Spring Environment 中读取配置。默认依次读取 key 为 dubbo.properties 和 application.dubbo.properties 到这里两个 InmemoryConfiguration 对象中。
globalConfigurationCompositeConfiguration 类型):用于组合上述各个配置来源。
dynamicConfigurationCompositeDynamicConfiguration 类型):用于组合当前全部的配置中心对应的 DynamicConfiguration。
configCenterFirstboolean 类型):用于标识配置中心的配置是否为最高优先级。
在 Environment 的构造方法中会初始化上述 Configuration 对象,在 initialize() 方法中会将从 Spring Environment 中读取到的配置填充到 externalConfiguration 以及 appExternalConfiguration 中。相关的实现片段如下:
public Environment() {
// 创建上述Configuration对象
this.propertiesConfiguration = new PropertiesConfiguration();
this.systemConfiguration = new SystemConfiguration();
this.environmentConfiguration = new EnvironmentConfiguration();
this.externalConfiguration = new InmemoryConfiguration();
this.appExternalConfiguration = new InmemoryConfiguration();
}
public void initialize() throws IllegalStateException {
// 读取对应配置填充上述Configuration对象
ConfigManager configManager = ApplicationModel.getConfigManager();
Optional<Collection<ConfigCenterConfig>> defaultConfigs = configManager.getDefaultConfigCenter();
defaultConfigs.ifPresent(configs -> {
for (ConfigCenterConfig config : configs) {
this.setExternalConfigMap(config.getExternalConfiguration());
this.setAppExternalConfigMap(config.getAppExternalConfiguration());
}
});
this.externalConfiguration.setProperties(externalConfigurationMap);
this.appExternalConfiguration.setProperties(appExternalConfigurationMap);
}
启动配置中心
完成了 Environment 的初始化之后DubboBootstrap 接下来会调用 startConfigCenter() 方法启动一个或多个配置中心客户端,核心操作有两个:一个是调用 ConfigCenterConfig.refresh() 方法刷新配置中心的相关配置;另一个是通过 prepareEnvironment() 方法根据 ConfigCenterConfig 中的配置创建 DynamicConfiguration 对象。
private void startConfigCenter() {
Collection<ConfigCenterConfig> configCenters = configManager.getConfigCenters();
if (CollectionUtils.isEmpty(configCenters)) { // 未指定配置中心
... ... // 省略该部分逻辑
} else {
for (ConfigCenterConfig configCenterConfig : configCenters) { // 可能配置了多个配置中心
configCenterConfig.refresh(); // 刷新配置
// 检查配置中心的配置是否合法 ConfigValidationUtils.validateConfigCenterConfig(configCenterConfig);
}
}
if (CollectionUtils.isNotEmpty(configCenters)) {
// 创建CompositeDynamicConfiguration对象用于组装多个DynamicConfiguration对象
CompositeDynamicConfiguration compositeDynamicConfiguration = new CompositeDynamicConfiguration();
for (ConfigCenterConfig configCenter : configCenters) {
// 根据ConfigCenterConfig创建相应的DynamicConfig对象并添加到CompositeDynamicConfiguration中
compositeDynamicConfiguration.addConfiguration(prepareEnvironment(configCenter));
}
// 将CompositeDynamicConfiguration记录到Environment中的dynamicConfiguration字段
environment.setDynamicConfiguration(compositeDynamicConfiguration);
}
configManager.refreshAll(); // 刷新所有AbstractConfig配置
}
1. 刷新配置中心的配置
首先来看 ConfigCenterConfig.refresh() 方法,该方法会组合 Environment 对象中全部已初始化的 Configuration然后遍历 ConfigCenterConfig 中全部字段的 setter 方法,并从 Environment 中获取对应字段的最终值。具体实现如下:
public void refresh() {
// 获取Environment对象
Environment env = ApplicationModel.getEnvironment();
// 将当前已初始化的所有Configuration合并返回
CompositeConfiguration compositeConfiguration = env.getPrefixedConfiguration(this);
Method[] methods = getClass().getMethods();
for (Method method : methods) {
if (MethodUtils.isSetter(method)) { // 获取ConfigCenterConfig中各个字段的setter方法
// 根据配置中心的相关配置以及Environment中的各个Configuration获取该字段的最终值
String value = StringUtils.trim(compositeConfiguration.getString(extractPropertyName(getClass(), method)));
// 调用setter方法更新ConfigCenterConfig的相应字段
if (StringUtils.isNotEmpty(value) && ClassUtils.isTypeMatch(method.getParameterTypes()[0], value)) {
method.invoke(this, ClassUtils.convertPrimitive(method.getParameterTypes()[0], value));
}
} else if (isParametersSetter(method)) { // 设置parameters字段与设置其他字段的逻辑基本类似但是实现有所不同
String value = StringUtils.trim(compositeConfiguration.getString(extractPropertyName(getClass(), method)));
if (StringUtils.isNotEmpty(value)) {
// 获取当前已有的parameters字段
Map<String, String> map = invokeGetParameters(getClass(), this);
map = map == null ? new HashMap<>() : map;
// 覆盖parameters集合
map.putAll(convert(StringUtils.parseParameters(value), ""));
// 设置parameters字段
invokeSetParameters(getClass(), this, map);
}
}
}
}
这里我们关注一下 Environment.getPrefixedConfiguration() 方法,该方法会将 Environment 中已有的 Configuration 对象以及当前的 ConfigCenterConfig 按照顺序合并,得到一个 CompositeConfiguration 对象,用于确定配置中心的最终配置信息。具体实现如下:
public synchronized CompositeConfiguration getPrefixedConfiguration(AbstractConfig config) {
// 创建CompositeConfiguration对象这里的prefix和id是根据ConfigCenterConfig确定的
CompositeConfiguration prefixedConfiguration = new CompositeConfiguration(config.getPrefix(), config.getId());
// 将ConfigCenterConfig封装成ConfigConfigurationAdapter
Configuration configuration = new ConfigConfigurationAdapter(config);
if (this.isConfigCenterFirst()) { // 根据配置确定ConfigCenterConfig配置的位置
// The sequence would be: SystemConfiguration -> AppExternalConfiguration -> ExternalConfiguration -> AbstractConfig -> PropertiesConfiguration
// 按序组合已有Configuration对象以及ConfigCenterConfig
prefixedConfiguration.addConfiguration(systemConfiguration);
prefixedConfiguration.addConfiguration(environmentConfiguration);
prefixedConfiguration.addConfiguration(appExternalConfiguration);
prefixedConfiguration.addConfiguration(externalConfiguration);
prefixedConfiguration.addConfiguration(configuration);
prefixedConfiguration.addConfiguration(propertiesConfiguration);
} else {
// 配置优先级如下SystemConfiguration -> AbstractConfig -> AppExternalConfiguration -> ExternalConfiguration -> PropertiesConfiguration
prefixedConfiguration.addConfiguration(systemConfiguration);
prefixedConfiguration.addConfiguration(environmentConfiguration);
prefixedConfiguration.addConfiguration(configuration);
prefixedConfiguration.addConfiguration(appExternalConfiguration);
prefixedConfiguration.addConfiguration(externalConfiguration);
prefixedConfiguration.addConfiguration(propertiesConfiguration);
}
return prefixedConfiguration;
}
2. 创建 DynamicConfiguration 对象
通过 ConfigCenterConfig.refresh() 方法确定了所有配置中心的最终配置之后,接下来就会对每个配置中心执行 prepareEnvironment() 方法,得到对应的 DynamicConfiguration 对象。具体实现如下:
private DynamicConfiguration prepareEnvironment(ConfigCenterConfig configCenter) {
if (configCenter.isValid()) { // 检查ConfigCenterConfig是否合法
if (!configCenter.checkOrUpdateInited()) {
return null; // 检查ConfigCenterConfig是否已初始化这里不能重复初始化
}
// 根据ConfigCenterConfig中的各个字段拼接出配置中心的URL创建对应的DynamicConfiguration对象
DynamicConfiguration dynamicConfiguration = getDynamicConfiguration(configCenter.toUrl());
// 从配置中心获取externalConfiguration和appExternalConfiguration并进行覆盖
String configContent = dynamicConfiguration.getProperties(configCenter.getConfigFile(), configCenter.getGroup());
String appGroup = getApplication().getName();
String appConfigContent = null;
if (isNotEmpty(appGroup)) {
appConfigContent = dynamicConfiguration.getProperties
(isNotEmpty(configCenter.getAppConfigFile()) ? configCenter.getAppConfigFile() : configCenter.getConfigFile(),
appGroup
);
}
try {
// 更新Environment
environment.setConfigCenterFirst(configCenter.isHighestPriority());
environment.updateExternalConfigurationMap(parseProperties(configContent));
environment.updateAppExternalConfigurationMap(parseProperties(appConfigContent));
} catch (IOException e) {
throw new IllegalStateException("Failed to parse configurations from Config Center.", e);
}
return dynamicConfiguration; // 返回通过该ConfigCenterConfig创建的DynamicConfiguration对象
}
return null;
}
完成 DynamicConfiguration 的创建之后DubboBootstrap 会将多个配置中心对应的 DynamicConfiguration 对象封装成一个 CompositeDynamicConfiguration 对象,并记录到 Environment.dynamicConfiguration 字段中,等待后续使用。另外,还会调用全部 AbstractConfig 的 refresh() 方法(即根据最新的配置更新各个 AbstractConfig 对象的字段)。这些逻辑都在 DubboBootstrap.startConfigCenter() 方法中,前面已经展示过了,这里不再重复。
配置中心初始化的后续流程
完成明确指定的配置中心初始化之后DubboBootstrap 接下来会执行 useRegistryAsConfigCenterIfNecessary() 方法,检测当前 Dubbo 是否要将注册中心也作为一个配置中心使用(常见的注册中心,都可以直接作为配置中心使用,这样可以降低运维成本)。
private void useRegistryAsConfigCenterIfNecessary() {
if (environment.getDynamicConfiguration().isPresent()) {
return; // 如果当前配置中心已经初始化完成,则不会将注册中心作为配置中心
}
if (CollectionUtils.isNotEmpty(configManager.getConfigCenters())) {
return; // 明确指定了配置中心的配置,哪怕配置中心初始化失败,也不会将注册中心作为配置中心
}
// 从ConfigManager中获取注册中心的配置即RegistryConfig并转换成配置中心的配置即ConfigCenterConfig
configManager.getDefaultRegistries().stream()
.filter(registryConfig -> registryConfig.getUseAsConfigCenter() == null || registryConfig.getUseAsConfigCenter())
.forEach(registryConfig -> {
String protocol = registryConfig.getProtocol();
String id = "config-center-" + protocol + "-" + registryConfig.getPort();
ConfigCenterConfig cc = new ConfigCenterConfig();
cc.setId(id);
if (cc.getParameters() == null) {
cc.setParameters(new HashMap<>());
}
if (registryConfig.getParameters() != null) {
cc.getParameters().putAll(registryConfig.getParameters());
}
cc.getParameters().put(CLIENT_KEY, registryConfig.getClient());
cc.setProtocol(registryConfig.getProtocol());
cc.setPort(registryConfig.getPort());
cc.setAddress(registryConfig.getAddress());
cc.setNamespace(registryConfig.getGroup());
cc.setUsername(registryConfig.getUsername());
cc.setPassword(registryConfig.getPassword());
if (registryConfig.getTimeout() != null) {
cc.setTimeout(registryConfig.getTimeout().longValue());
}
cc.setHighestPriority(false); // 这里优先级较低
configManager.addConfigCenter(cc);
});
startConfigCenter(); // 重新调用startConfigCenter()方法,初始化配置中心
}
完成配置中心的初始化之后,后续需要 DynamicConfiguration 的地方直接从 Environment 中获取即可例如DynamicConfigurationServiceNameMapping 就是依赖 DynamicConfiguration 实现 Service ID 与 Service Name 映射的管理。
接下来DubboBootstrap 执行 loadRemoteConfigs() 方法,根据前文更新后的 externalConfigurationMap 和 appExternalConfigurationMap 配置信息,确定是否配置了额外的注册中心或 Protocol如果有则在此处转换成 RegistryConfig 和 ProtocolConfig并记录到 ConfigManager 中,等待后续逻辑使用。
随后DubboBootstrap 执行 checkGlobalConfigs() 方法完成 ProviderConfig、ConsumerConfig、MetadataReportConfig 等一系列 AbstractConfig 的检查和初始化,具体实现比较简单,这里就不再展示。
再紧接着DubboBootstrap 会通过 initMetadataService() 方法初始化 MetadataReport、MetadataReportInstance 以及 MetadataService、MetadataServiceExporter这些元数据相关的组件在前面的课时中已经深入分析过了这里的初始化过程并不复杂你若感兴趣的话可以参考源码进行学习。
在 DubboBootstrap 初始化的最后,会调用 initEventListener() 方法将 DubboBootstrap 作为 EventListener 监听器添加到 EventDispatcher 中。DubboBootstrap 继承了 GenericEventListener 抽象类,如下图所示:
EventListener 继承关系图
GenericEventListener 是一个泛型监听器,它可以让子类监听任意关心的 Event 事件,只需定义相关的 onEvent() 方法即可。在 GenericEventListener 中维护了一个 handleEventMethods 集合,其中 Key 是 Event 的子类即监听器关心的事件Value 是处理该类型 Event 的相应 onEvent() 方法。
在 GenericEventListener 的构造方法中,通过反射将当前 GenericEventListener 实现的全部 onEvent() 方法都查找出来,并记录到 handleEventMethods 字段中。具体查找逻辑在 findHandleEventMethods() 方法中实现:
private Map<Class<?>, Set<Method>> findHandleEventMethods() {
Map<Class<?>, Set<Method>> eventMethods = new HashMap<>();
of(getClass().getMethods()) // 遍历当前GenericEventListener子类的全部方法
// 过滤得到onEvent()方法具体过滤条件在isHandleEventMethod()方法之中:
// 1.方法必须是public的
// 2.方法参数列表只有一个参数且该参数为Event子类
// 3.方法返回值为void且没有声明抛出异常
.filter(this::isHandleEventMethod)
.forEach(method -> {
Class<?> paramType = method.getParameterTypes()[0];
Set<Method> methods = eventMethods.computeIfAbsent(paramType, key -> new LinkedHashSet<>());
methods.add(method);
});
return eventMethods;
}
在 GenericEventListener 的 onEvent() 方法中,会根据收到的 Event 事件的具体类型,从 handleEventMethods 集合中找到相应的 onEvent() 方法进行调用,如下所示:
public final void onEvent(Event event) {
// 获取Event的实际类型
Class<?> eventClass = event.getClass();
// 根据Event的类型获取对应的onEvent()方法并调用
handleEventMethods.getOrDefault(eventClass, emptySet()).forEach(method -> {
ThrowableConsumer.execute(method, m -> {
m.invoke(this, event);
});
});
}
我们可以查看 DubboBootstrap 的所有方法,目前并没有发现符合 isHandleEventMethod() 条件的方法。但在 GenericEventListener 的另一个实现—— LoggingEventListener 中,可以看到多个符合 isHandleEventMethod() 条件的方法(如下图所示),在这些 onEvent() 方法重载中会输出 INFO 日志。
LoggingEventListener 中 onEvent 方法重载
至此DubboBootstrap 整个初始化过程,以及该过程中与配置中心相关的逻辑就介绍完了。
总结
本课时我们重点介绍了 Dubbo 动态配置中心启动的核心流程,以及该流程涉及的重要组件类。
首先,我们介绍了 ConfigManager 和 Environment 这两个非常基础的配置类;然后又讲解了 DubboBootstrap 初始化动态配置中心的核心流程,以及动态配置中心启动的流程;最后,还分析了 GenericEventListener 监听器的相关内容。
关于这部分的内容,如果你有什么问题或者好的经验,欢迎你在留言区和我分享。

View File

@@ -0,0 +1,25 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
49 结束语 认真学习,缩小差距
你好我是杨四正到这里我们已经一起学习了四十多个课时Dubbo 的核心内容也介绍差不多了,你可能也需要一段时间来回顾和消化这些内容。在最后这结束语部分,我还想和你“谈谈心”,从另一个角度来聊聊我们程序员这份工作。
在刚毕业的时候我误打误撞进入一家国营企业很多人认为这是一个“旱涝保收”的养老岗位其实呢也确实如此工资没有互联网企业有竞争力但是工作时长足以让“996”的程序员垂涎三尺。因为是第一份工作所以总会碰到很多问题但我发现在这个环境中很难从旁人那里得到答案于是我就开始一边自己解决问题一边反思与人沟通的方式。自己解决问题让我延续了学校里面的学习“惯性”养成了持续学习的习惯反思沟通方式让我意识到人是有惰性的人更喜欢用选择的方式解决问题所以我养成了提出问题时自带多个解决方案的习惯。这也反过来促使我在提问题之前反复思考和打磨问题毕竟提出一个好问题也是一种能力。
两年之后,我进入一家高速发展的互联网公司,在这里我经历了职业生涯里面的第一个“阵痛期”,可以说是从“闲庭信步”一步跨到“身心俱疲”,技术栈、作息规律、工作节奏等完全变了,其痛苦程度可想而知。
在这段时间,我体会最深的是要顺势而为,抓住行业的红利期,抓住公司的红利期,这可以更快地帮我实现薪资和职位的升级。另一个心得就是要学会适时抛弃“木桶原理”,不要补齐短板。因为我们走的是技术路线,要做的是不可替代,尽量成为一方面的专家,而不是处处稀松平常的通才,毕竟“内卷”越来越严重,“木桶”到处都有。
另外,还有一个非常重要的“点”就是:面对失败的态度。工作了这么多年,面试失败过,晋级失败过,也看过很多人不同的人生轨迹:有人离开奋斗多年的一线城市;有人埋头在西二旗的写字楼里,接收福报的洗礼,已经很久没见过夕阳是什么样子;有人创业失败,负债千万……这些都算是失败吗?可能不同的人有不同的答案,毕竟每个人对失败的定义不同,答案自然也会不同。
不管怎样,人生旅途中难免沟沟坎坎,挫折或失败似乎是人生的主旋律(注意是“似乎”,人生还是很美好的),不用纠结,每个人都会遇到,但如何面对挫折或失败会把我们分成不同的“队伍”:有的人会被击垮,从此一蹶不振;而有的人会站起来继续向前,越挫越勇,直至实现自己的人生目标和价值。所以说,真正的成长从来不是追求,而是正视自己的缺憾。
感谢 2020 年不断学习的你,感谢你的一路陪伴,也期待你继续“认真学习,缩小差距”。
当然如果你觉得我这门课程不错的话,也欢迎你推荐给身边的朋友。

View File

@@ -0,0 +1,140 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 认知ElasticSearch基础概念
为什么需要学习ElasticSearch
根据DB Engine的排名 显示ElasticSearch是最受欢迎的企业级搜索引擎。
下图红色勾选的是我们前面的系列详解的除此之外你可以看到搜索库ElasticSearch在前十名内
所以为什么要学习ElasticSearch呢
1、在当前软件行业中搜索是一个软件系统或平台的基本功能 学习ElasticSearch就可以为相应的软件打造出良好的搜索体验。
2、其次ElasticSearch具备非常强的大数据分析能力。虽然Hadoop也可以做大数据分析但是ElasticSearch的分析能力非常高具备Hadoop不具备的能力。比如有时候用Hadoop分析一个结果可能等待的时间比较长。
3、ElasticSearch可以很方便的进行使用可以将其安装在个人的笔记本电脑也可以在生产环境中将其进行水平扩展。
4、国内比较大的互联网公司都在使用比如小米、滴滴、携程等公司。另外在腾讯云、阿里云的云平台上也都有相应的ElasticSearch云产品可以使用。
5、在当今大数据时代掌握近实时的搜索和分析能力才能掌握核心竞争力洞见未来。
什么是ElasticSearch
ElasticSearch是一款非常强大的、基于Lucene的开源搜索及分析引擎它是一个实时的分布式搜索分析引擎它能让你以前所未有的速度和规模去探索你的数据。
它被用作全文检索、结构化搜索、分析以及这三个功能的组合:
Wikipedia 使用 Elasticsearch 提供带有高亮片段的全文搜索,还有 search-as-you-type 和 did-you-mean 的建议。
卫报 使用 Elasticsearch 将网络社交数据结合到访客日志中,为它的编辑们提供公众对于新文章的实时反馈。
Stack Overflow 将地理位置查询融入全文检索中去,并且使用 more-like-this 接口去查找相关的问题和回答。
GitHub 使用 Elasticsearch 对1300亿行代码进行查询。
除了搜索结合Kibana、Logstash、Beats开源产品Elastic Stack简称ELK还被广泛运用在大数据近实时分析领域包括日志分析、指标监控、信息安全等。它可以帮助你探索海量结构化、非结构化数据按需创建可视化报表对监控数据设置报警阈值通过使用机器学习自动识别异常状况。
ElasticSearch是基于Restful WebApi使用Java语言开发的搜索引擎库类并作为Apache许可条款下的开放源码发布是当前流行的企业级搜索引擎。其客户端在Java、C#、PHP、Python等许多语言中都是可用的。
ElasticSearch的由来
ElasticSearch背后的小故事
许多年前,一个刚结婚的名叫 Shay Banon 的失业开发者,跟着他的妻子去了伦敦,他的妻子在那里学习厨师。 在寻找一个赚钱的工作的时候,为了给他的妻子做一个食谱搜索引擎,他开始使用 Lucene 的一个早期版本。
直接使用 Lucene 是很难的,因此 Shay 开始做一个抽象层Java 开发者使用它可以很简单的给他们的程序添加搜索功能。 他发布了他的第一个开源项目 Compass。
后来 Shay 获得了一份工作,主要是高性能,分布式环境下的内存数据网格。这个对于高性能,实时,分布式搜索引擎的需求尤为突出, 他决定重写 Compass把它变为一个独立的服务并取名 Elasticsearch。
第一个公开版本在2010年2月发布从此以后Elasticsearch 已经成为了 Github 上最活跃的项目之一他拥有超过300名 contributors(目前736名 contributors )。 一家公司已经开始围绕 Elasticsearch 提供商业服务并开发新的特性但是Elasticsearch 将永远开源并对所有人可用。
据说Shay 的妻子还在等着她的食谱搜索引擎…
为什么不是直接使用Lucene
ElasticSearch是基于Lucene的那么为什么不是直接使用Lucene呢
Lucene 可以说是当下最先进、高性能、全功能的搜索引擎库。
但是 Lucene 仅仅只是一个库。为了充分发挥其功能,你需要使用 Java 并将 Lucene 直接集成到应用程序中。 更糟糕的是您可能需要获得信息检索学位才能了解其工作原理。Lucene 非常 复杂。
Elasticsearch 也是使用 Java 编写的,它的内部使用 Lucene 做索引与搜索,但是它的目的是使全文检索变得简单,通过隐藏 Lucene 的复杂性,取而代之的提供一套简单一致的 RESTful API。
然而Elasticsearch 不仅仅是 Lucene并且也不仅仅只是一个全文搜索引擎。 它可以被下面这样准确的形容:
一个分布式的实时文档存储,每个字段 可以被索引与搜索
一个分布式实时分析搜索引擎
能胜任上百个服务节点的扩展,并支持 PB 级别的结构化或者非结构化数据
ElasticSearch的主要功能及应用场景
我们在哪些场景下可以使用ES呢
主要功能:
1海量数据的分布式存储以及集群管理达到了服务与数据的高可用以及水平扩展
2近实时搜索性能卓越。对结构化、全文、地理位置等类型数据的处理
3海量数据的近实时分析聚合功能
应用场景:
1网站搜索、垂直搜索、代码搜索
2日志管理与分析、安全指标监控、应用性能监控、Web抓取舆情分析
ElasticSearch的基础概念
我们还需对比结构化数据库看看ES的基础概念为我们后面学习作铺垫。
Near RealtimeNRT 近实时。数据提交索引后,立马就可以搜索到。
Cluster 集群一个集群由一个唯一的名字标识默认为“elasticsearch”。集群名称非常重要具有相同集群名的节点才会组成一个集群。集群名称可以在配置文件中指定。
Node 节点存储集群的数据参与集群的索引和搜索功能。像集群有名字节点也有自己的名称默认在启动时会以一个随机的UUID的前七个字符作为节点的名字你可以为其指定任意的名字。通过集群名在网络中发现同伴组成集群。一个节点也可是集群。
Index 索引: 一个索引是一个文档的集合等同于solr中的集合。每个索引有唯一的名字通过这个名字来操作它。一个集群中可以有任意多个索引。
Type 类型指在一个索引中可以索引不同类型的文档如用户数据、博客数据。从6.0.0 版本起已废弃,一个索引中只存放一类数据。
Document 文档被索引的一条数据索引的基本信息单元以JSON格式来表示。
Shard 分片:在创建一个索引时可以指定分成多少个分片来存储。每个分片本身也是一个功能完善且独立的“索引”,可以被放置在集群的任意节点上。
Replication 备份: 一个分片可以有多个备份(副本)
为了方便理解作一个ES和数据库的对比
参考文章
https://www.elastic.co/guide/cn/elasticsearch/guide/current/intro.html
https://www.elastic.co/guide/cn/elasticsearch/guide/current/getting-started.html
https://www.cnblogs.com/leeSmall/p/9189078.html

View File

@@ -0,0 +1,173 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 认知Elastic Stack生态和场景方案
Elastic Stack生态
Beats + Logstash + ElasticSearch + Kibana
如下是我从官方博客中找到图这张图展示了ELK生态以及基于ELK的场景最上方
由于Elastic X-Pack是面向收费的所以我们不妨也把X-Pack放进去看看哪些是由X-Pack带来的在阅读官网文档时将方便你甄别重点
Beats
Beats是一个面向轻量型采集器的平台这些采集器可以从边缘机器向Logstash、ElasticSearch发送数据它是由Go语言进行开发的运行效率方面比较快。从下图中可以看出不同Beats的套件是针对不同的数据源。
Logstash
Logstash是动态数据收集管道拥有可扩展的插件生态系统支持从不同来源采集数据转换数据并将数据发送到不同的存储库中。其能够与ElasticSearch产生强大的协同作用后被Elastic公司在2013年收购。
它具有如下特性:
1实时解析和转换数据
2可扩展具有200多个插件
3可靠性、安全性。Logstash会通过持久化队列来保证至少将运行中的事件送达一次同时将数据进行传输加密
4监控
ElasticSearch
ElasticSearch对数据进行搜索、分析和存储其是基于JSON的分布式搜索和分析引擎专门为实现水平可扩展性、高可靠性和管理便捷性而设计的。
它的实现原理主要分为以下几个步骤:
1首先用户将数据提交到ElasticSearch数据库中
2再通过分词控制器将对应的语句分词
3将分词结果及其权重一并存入以备用户在搜索数据时根据权重将结果排名和打分将返回结果呈现给用户
Kibana
Kibana实现数据可视化其作用就是在ElasticSearch中进行民航。Kibana能够以图表的形式呈现数据并且具有可扩展的用户界面可以全方位的配置和管理ElasticSearch。
Kibana最早的时候是基于Logstash创建的工具后被Elastic公司在2013年收购。
1Kibana可以提供各种可视化的图表
2可以通过机器学习的技术对异常情况进行检测用于提前发现可疑问题
从日志收集系统看ES Stack的发展
我们看下ELK技术栈的演化通常体现在日志收集系统中。
一个典型的日志系统包括:
1收集能够采集多种来源的日志数据
2传输能够稳定的把日志数据解析过滤并传输到存储系统
3存储存储日志数据
4分析支持 UI 分析
5警告能够提供错误报告监控机制
beats+elasticsearch+kibana
Beats采集数据后存储在ES中有Kibana可视化的展示。
beats+logstath+elasticsearch+kibana
该框架是在上面的框架的基础上引入了logstash引入logstash带来的好处如下
1Logstash具有基于磁盘的自适应缓冲系统该系统将吸收传入的吞吐量从而减轻背压。
2从其他数据源例如数据库S3或消息传递队列中提取。
3将数据发送到多个目的地例如S3HDFS或写入文件。
4使用条件数据流逻辑组成更复杂的处理管道。
beats结合logstash带来的优势
1水平可扩展性高可用性和可变负载处理beats和logstash可以实现节点之间的负载均衡多个logstash可以实现logstash的高可用
2消息持久性与至少一次交付保证使用beats或Winlogbeat进行日志收集时可以保证至少一次交付。从Filebeat或Winlogbeat到Logstash以及从Logstash到Elasticsearch的两种通信协议都是同步的并且支持确认。Logstash持久队列提供跨节点故障的保护。对于Logstash中的磁盘级弹性确保磁盘冗余非常重要。
3具有身份验证和有线加密的端到端安全传输从Beats到Logstash以及从 Logstash到Elasticsearch的传输都可以使用加密方式传递 。与Elasticsearch进行通讯时有很多安全选项包括基本身份验证TLSPKILDAPAD和其他自定义领域
增加更多的数据源 比如TCPUDP和HTTP协议是将数据输入Logstash的常用方法
beats+MQ+logstash+elasticsearch+kibana
在如上的基础上我们可以在beats和logstash中间添加一些组件redis、kafka、RabbitMQ等添加中间件将会有如下好处
1降低对日志所在机器的影响这些机器上一般都部署着反向代理或应用服务本身负载就很重了所以尽可能的在这些机器上少做事
2如果有很多台机器需要做日志收集那么让每台机器都向Elasticsearch持续写入数据必然会对Elasticsearch造成压力因此需要对数据进行缓冲同时这样的缓冲也可以一定程度的保护数据不丢失
3将日志数据的格式化与处理放到Indexer中统一做可以在一处修改代码、部署避免需要到多台机器上去修改配置
Elastic Stack最佳实践
我们再看下官方开发成员分享的最佳实践。
日志收集系统
PS就是我们上面阐述的
基本的日志系统
增加数据源和使用MQ
Metric收集和APM性能监控
多数据中心方案
通过冗余实现数据高可用
两个数据采集中心(比如采集两个工厂的数据),采集数据后的汇聚
数据分散,跨集群的搜索
参考文章
https://www.elastic.co/cn/elasticsearch/
https://www.elastic.co/pdf/architecture-best-practices.pdf
https://www.elastic.co/guide/en/logstash/current/deploying-and-scaling.html
https://www.cnblogs.com/supersnowyao/p/11110703.html
https://blog.51cto.com/wutengfei/2645627

View File

@@ -0,0 +1,293 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 安装ElasticSearch和Kibana安装
安装ElasticSearch
ElasticSearch 是基于Java平台的所以先要安装Java
平台确认
这里我准备了一台Centos7虚拟机, 为方便选择后续安装的版本,所以需要看下系统版本信息。
[root@VM-0-14-centos ~]# uname -a
Linux VM-0-14-centos 3.10.0-862.el7.x86_64 #1 SMP Fri Apr 20 16:44:24 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
安装Java
安装 Elasticsearch 之前,你需要先安装一个较新的版本的 Java最好的选择是你可以从 www.java.com 获得官方提供的最新版本的 Java。安装以后确认是否安装成功
[root@VM-0-14-centos ~]# java --version
openjdk 14.0.2 2020-07-14
OpenJDK Runtime Environment 20.3 (slowdebug build 14.0.2+12)
OpenJDK 64-Bit Server VM 20.3 (slowdebug build 14.0.2+12, mixed mode, sharing)
下载ElasticSearch
从这里 下载ElasticSearch
比如可以通过curl下载
[root@VM-0-14-centos opt]# curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.12.0-linux-x86_64.tar.gz
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
解压
[root@VM-0-14-centos opt]# tar zxvf /opt/elasticsearch-7.12.0-linux-x86_64.tar.gz
...
[root@VM-0-14-centos opt]# ll | grep elasticsearch
drwxr-xr-x 9 root root 4096 Mar 18 14:21 elasticsearch-7.12.0
-rw-r--r-- 1 root root 327497331 Apr 5 21:05 elasticsearch-7.12.0-linux-x86_64.tar.gz
增加elasticSearch用户
必须创建一个非root用户来运行ElasticSearch(ElasticSearch5及以上版本基于安全考虑强制规定不能以root身份运行。)
如果你使用root用户来启动ElasticSearch则会有如下错误信息
[root@VM-0-14-centos opt]# cd elasticsearch-7.12.0/
[root@VM-0-14-centos elasticsearch-7.12.0]# ./bin/elasticsearch
[2021-04-05T21:36:46,510][ERROR][o.e.b.ElasticsearchUncaughtExceptionHandler] [VM-0-14-centos] uncaught exception in thread [main]
org.elasticsearch.bootstrap.StartupException: java.lang.RuntimeException: can not run elasticsearch as root
at org.elasticsearch.bootstrap.Elasticsearch.init(Elasticsearch.java:163) ~[elasticsearch-7.12.0.jar:7.12.0]
at org.elasticsearch.bootstrap.Elasticsearch.execute(Elasticsearch.java:150) ~[elasticsearch-7.12.0.jar:7.12.0]
at org.elasticsearch.cli.EnvironmentAwareCommand.execute(EnvironmentAwareCommand.java:75) ~[elasticsearch-7.12.0.jar:7.12.0]
at org.elasticsearch.cli.Command.mainWithoutErrorHandling(Command.java:116) ~[elasticsearch-cli-7.12.0.jar:7.12.0]
at org.elasticsearch.cli.Command.main(Command.java:79) ~[elasticsearch-cli-7.12.0.jar:7.12.0]
at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:115) ~[elasticsearch-7.12.0.jar:7.12.0]
at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:81) ~[elasticsearch-7.12.0.jar:7.12.0]
Caused by: java.lang.RuntimeException: can not run elasticsearch as root
at org.elasticsearch.bootstrap.Bootstrap.initializeNatives(Bootstrap.java:101) ~[elasticsearch-7.12.0.jar:7.12.0]
at org.elasticsearch.bootstrap.Bootstrap.setup(Bootstrap.java:168) ~[elasticsearch-7.12.0.jar:7.12.0]
at org.elasticsearch.bootstrap.Bootstrap.init(Bootstrap.java:397) ~[elasticsearch-7.12.0.jar:7.12.0]
at org.elasticsearch.bootstrap.Elasticsearch.init(Elasticsearch.java:159) ~[elasticsearch-7.12.0.jar:7.12.0]
... 6 more
uncaught exception in thread [main]
java.lang.RuntimeException: can not run elasticsearch as root
at org.elasticsearch.bootstrap.Bootstrap.initializeNatives(Bootstrap.java:101)
at org.elasticsearch.bootstrap.Bootstrap.setup(Bootstrap.java:168)
at org.elasticsearch.bootstrap.Bootstrap.init(Bootstrap.java:397)
at org.elasticsearch.bootstrap.Elasticsearch.init(Elasticsearch.java:159)
at org.elasticsearch.bootstrap.Elasticsearch.execute(Elasticsearch.java:150)
at org.elasticsearch.cli.EnvironmentAwareCommand.execute(EnvironmentAwareCommand.java:75)
at org.elasticsearch.cli.Command.mainWithoutErrorHandling(Command.java:116)
at org.elasticsearch.cli.Command.main(Command.java:79)
at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:115)
at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:81)
For complete error details, refer to the log at /opt/elasticsearch-7.12.0/logs/elasticsearch.log
2021-04-05 13:36:46,979269 UTC [8846] INFO Main.cc@106 Parent process died - ML controller exiting
所以我们增加一个独立的elasticsearch用户来运行
# 增加elasticsearch用户
[root@VM-0-14-centos elasticsearch-7.12.0]# useradd elasticsearch
[root@VM-0-14-centos elasticsearch-7.12.0]# passwd elasticsearch
Changing password for user elasticsearch.
New password:
BAD PASSWORD: The password contains the user name in some form
Retype new password:
passwd: all authentication tokens updated successfully.
# 修改目录权限至新增的elasticsearch用户
[root@VM-0-14-centos elasticsearch-7.12.0]# chown -R elasticsearch /opt/elasticsearch-7.12.0
# 增加data和log存放区并赋予elasticsearch用户权限
[root@VM-0-14-centos elasticsearch-7.12.0]# mkdir -p /data/es
[root@VM-0-14-centos elasticsearch-7.12.0]# chown -R elasticsearch /data/es
[root@VM-0-14-centos elasticsearch-7.12.0]# mkdir -p /var/log/es
[root@VM-0-14-centos elasticsearch-7.12.0]# chown -R elasticsearch /var/log/es
然后修改上述的data和log路径vi /opt/elasticsearch-7.12.0/config/elasticsearch.yml
# ----------------------------------- Paths ------------------------------------
#
# Path to directory where to store the data (separate multiple locations by comma):
#
path.data: /data/es
#
# Path to log files:
#
path.logs: /var/log/es
修改Linux系统的限制配置
修改系统中允许应用最多创建多少文件等的限制权限。Linux默认来说一般限制应用最多创建的文件是65535个。但是ES至少需要65536的文件创建权限。
修改系统中允许用户启动的进程开启多少个线程。默认的Linux限制root用户开启的进程可以开启任意数量的线程其他用户开启的进程可以开启1024个线程。必须修改限制数为4096+。因为ES至少需要4096的线程池预备。ES在5.x版本之后强制要求在linux中不能使用root用户启动ES进程。所以必须使用其他用户启动ES进程才可以。
Linux低版本内核为线程分配的内存是128K。4.x版本的内核分配的内存更大。如果虚拟机的内存是1G最多只能开启3000+个线程数。至少为虚拟机分配1.5G以上的内存。
修改如下配置
[root@VM-0-14-centos elasticsearch-7.12.0]# vi /etc/security/limits.conf
elasticsearch soft nofile 65536
elasticsearch hard nofile 65536
elasticsearch soft nproc 4096
elasticsearch hard nproc 4096
启动ElasticSearch
[root@VM-0-14-centos elasticsearch-7.12.0]# su elasticsearch
[elasticsearch@VM-0-14-centos elasticsearch-7.12.0]$ ./bin/elasticsearch -d
[2021-04-05T22:03:38,332][INFO ][o.e.n.Node ] [VM-0-14-centos] version[7.12.0], pid[13197], build[default/tar/78722783c38caa25a70982b5b042074cde5d3b3a/2021-03-18T06:17:15.410153305Z], OS[Linux/3.10.0-862.el7.x86_64/amd64], JVM[AdoptOpenJDK/OpenJDK 64-Bit Server VM/15.0.1/15.0.1+9]
[2021-04-05T22:03:38,348][INFO ][o.e.n.Node ] [VM-0-14-centos] JVM home [/opt/elasticsearch-7.12.0/jdk], using bundled JDK [true]
[2021-04-05T22:03:38,348][INFO ][o.e.n.Node ] [VM-0-14-centos] JVM arguments [-Xshare:auto, -Des.networkaddress.cache.ttl=60, -Des.networkaddress.cache.negative.ttl=10, -XX:+AlwaysPreTouch, -Xss1m, -Djava.awt.headless=true, -Dfile.encoding=UTF-8, -Djna.nosys=true, -XX:-OmitStackTraceInFastThrow, -XX:+ShowCodeDetailsInExceptionMessages, -Dio.netty.noUnsafe=true, -Dio.netty.noKeySetOptimization=true, -Dio.netty.recycler.maxCapacityPerThread=0, -Dio.netty.allocator.numDirectArenas=0, -Dlog4j.shutdownHookEnabled=false, -Dlog4j2.disable.jmx=true, -Djava.locale.providers=SPI,COMPAT, --add-opens=java.base/java.io=ALL-UNNAMED, -XX:+UseG1GC, -Djava.io.tmpdir=/tmp/elasticsearch-17264135248464897093, -XX:+HeapDumpOnOutOfMemoryError, -XX:HeapDumpPath=data, -XX:ErrorFile=logs/hs_err_pid%p.log, -Xlog:gc*,gc+age=trace,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m, -Xms1894m, -Xmx1894m, -XX:MaxDirectMemorySize=993001472, -XX:G1HeapRegionSize=4m, -XX:InitiatingHeapOccupancyPercent=30, -XX:G1ReservePercent=15, -Des.path.home=/opt/elasticsearch-7.12.0, -Des.path.conf=/opt/elasticsearch-7.12.0/config, -Des.distribution.flavor=default, -Des.distribution.type=tar, -Des.bundled_jdk=true]
查看安装是否成功
[root@VM-0-14-centos ~]# netstat -ntlp | grep 9200
tcp6 0 0 127.0.0.1:9200 :::* LISTEN 13549/java
tcp6 0 0 ::1:9200 :::* LISTEN 13549/java
[root@VM-0-14-centos ~]# curl 127.0.0.1:9200
{
"name" : "VM-0-14-centos",
"cluster_name" : "elasticsearch",
"cluster_uuid" : "ihttW8b2TfWSkwf_YgPH2Q",
"version" : {
"number" : "7.12.0",
"build_flavor" : "default",
"build_type" : "tar",
"build_hash" : "78722783c38caa25a70982b5b042074cde5d3b3a",
"build_date" : "2021-03-18T06:17:15.410153305Z",
"build_snapshot" : false,
"lucene_version" : "8.8.0",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
安装Kibana
Kibana是界面化的查询数据的工具下载时尽量下载与ElasicSearch一致的版本。
下载Kibana
从这里 下载Kibana
解压
[root@VM-0-14-centos opt]# tar -vxzf kibana-7.12.0-linux-x86_64.tar.gz
使用elasticsearch用户权限
[root@VM-0-14-centos opt]# chown -R elasticsearch /opt/kibana-7.12.0-linux-x86_64
#配置Kibana的远程访问
[root@VM-0-14-centos opt]# vi /opt/kibana-7.12.0-linux-x86_64/config/kibana.yml
server.host: 0.0.0.0
启动
需要切换至elasticsearch用户
[root@VM-0-14-centos opt]# su elasticsearch
[elasticsearch@VM-0-14-centos opt]$ cd /opt/kibana-7.12.0-linux-x86_64/
[elasticsearch@VM-0-14-centos kibana-7.12.0-linux-x86_64]$ ./bin/kibana
log [22:30:22.185] [info][plugins-service] Plugin "osquery" is disabled.
log [22:30:22.283] [warning][config][deprecation] Config key [monitoring.cluster_alerts.email_notifications.email_address] will be required for email notifications to work in 8.0."
log [22:30:22.482] [info][plugins-system] Setting up [100] plugins: [taskManager,licensing,globalSearch,globalSearchProviders,banners,code,usageCollection,xpackLegacy,telemetryCollectionManager,telemetry,telemetryCollectionXpack,kibanaUsageCollection,securityOss,share,newsfeed,mapsLegacy,kibanaLegacy,translations,legacyExport,embeddable,uiActionsEnhanced,expressions,charts,esUiShared,bfetch,data,home,observability,console,consoleExtensions,apmOss,searchprofiler,painlessLab,grokdebugger,management,indexPatternManagement,advancedSettings,fileUpload,savedObjects,visualizations,visTypeVislib,visTypeVega,visTypeTimelion,features,licenseManagement,watcher,canvas,visTypeTagcloud,visTypeTable,visTypeMetric,visTypeMarkdown,tileMap,regionMap,visTypeXy,graph,timelion,dashboard,dashboardEnhanced,visualize,visTypeTimeseries,inputControlVis,discover,discoverEnhanced,savedObjectsManagement,spaces,security,savedObjectsTagging,maps,lens,reporting,lists,encryptedSavedObjects,dashboardMode,dataEnhanced,cloud,upgradeAssistant,snapshotRestore,fleet,indexManagement,rollup,remoteClusters,crossClusterReplication,indexLifecycleManagement,enterpriseSearch,beatsManagement,transform,ingestPipelines,eventLog,actions,alerts,triggersActionsUi,stackAlerts,ml,securitySolution,case,infra,monitoring,logstash,apm,uptime]
log [22:30:22.483] [info][plugins][taskManager] TaskManager is identified by the Kibana UUID: xxxxxx
...
如果是后台启动:
[elasticsearch@VM-0-14-centos kibana-7.12.0-linux-x86_64]$ nohup ./bin/kibana &
界面访问
可以导入simple data
查看数据
配置密码访问
使用基本许可证时默认情况下禁用Elasticsearch安全功能。由于我测试环境是放在公网上的所以需要设置下密码访问。相关文档可以参考这里
停止kibana和elasticsearch服务
将xpack.security.enabled设置添加到ES_PATH_CONF/elasticsearch.yml文件并将值设置为true
启动elasticsearch (./bin/elasticsearch -d)
执行如下密码设置器,./bin/elasticsearch-setup-passwords interactive来设置各个组件的密码
将elasticsearch.username设置添加到KIB_PATH_CONF/kibana.yml 文件并将值设置给elastic用户 elasticsearch.username: "elastic"
创建kibana keystore, ./bin/kibana-keystore create
在kibana keystore 中添加密码 ./bin/kibana-keystore add elasticsearch.password
重启kibana 服务即可 nohup ./bin/kibana &
然后就可以使用密码登录了:

View File

@@ -0,0 +1,359 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 入门:查询和聚合的基础使用
入门:从索引文档开始
索引一个文档
PUT /customer/_doc/1
{
"name": "John Doe"
}
为了方便测试我们使用kibana的dev tool来进行学习测试
查询刚才插入的文档
学习准备:批量索引文档
ES 还提供了批量操作,比如这里我们可以使用批量操作来插入一些数据,供我们在后面学习使用。
使用批量来批处理文档操作比单独提交请求要快得多,因为它减少了网络往返。
下载测试数据
数据是index为bankaccounts.json 下载地址 如果你无法下载也可以clone ES的官方仓库 ,然后进入/docs/src/test/resources/accounts.json目录获取
数据的格式如下
{
"account_number": 0,
"balance": 16623,
"firstname": "Bradshaw",
"lastname": "Mckenzie",
"age": 29,
"gender": "F",
"address": "244 Columbus Place",
"employer": "Euron",
"email": "[email protected]",
"city": "Hobucken",
"state": "CO"
}
批量插入数据
将accounts.json拷贝至指定目录我这里放在/opt/下面,
然后执行
curl -H "Content-Type: application/json" -XPOST "localhost:9200/bank/_bulk?pretty&refresh" --data-binary "@/opt/accounts.json"
查看状态
[elasticsearch@VM-0-14-centos root]$ curl "localhost:9200/_cat/indices?v=true" | grep bank
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 1524 100 1524 0 0 119k 0 --:--:-- --:--:-- --:--:-- 124k
yellow open bank yq3eSlAWRMO2Td0Sl769rQ 1 1 1000 0 379.2kb 379.2kb
[elasticsearch@VM-0-14-centos root]$
查询数据
我们通过kibana来进行查询测试。
查询所有
match_all表示查询所有的数据sort即按照什么字段排序
GET /bank/_search
{
"query": { "match_all": {} },
"sort": [
{ "account_number": "asc" }
]
}
结果
相关字段解释
took Elasticsearch运行查询所花费的时间以毫秒为单位
timed_out –搜索请求是否超时
_shards - 搜索了多少个碎片,以及成功,失败或跳过了多少个碎片的细目分类。
max_score 找到的最相关文档的分数
hits.total.value - 找到了多少个匹配的文档
hits.sort - 文档的排序位置(不按相关性得分排序时)
hits._score - 文档的相关性得分使用match_all时不适用
分页查询(from+size)
本质上就是from和size两个字段
GET /bank/_search
{
"query": { "match_all": {} },
"sort": [
{ "account_number": "asc" }
],
"from": 10,
"size": 10
}
结果
指定字段查询match
如果要在字段中搜索特定字词可以使用match; 如下语句将查询address 字段中包含 mill 或者 lane的数据
GET /bank/_search
{
"query": { "match": { "address": "mill lane" } }
}
结果
由于ES底层是按照分词索引的所以上述查询结果是address 字段中包含 mill 或者 lane的数据
查询段落匹配match_phrase
如果我们希望查询的条件是 address字段中包含 “mill lane”则可以使用match_phrase
GET /bank/_search
{
"query": { "match_phrase": { "address": "mill lane" } }
}
结果
多条件查询: bool
如果要构造更复杂的查询可以使用bool查询来组合多个查询条件。
例如以下请求在bank索引中搜索40岁客户的帐户但不包括居住在爱达荷州ID的任何人
GET /bank/_search
{
"query": {
"bool": {
"must": [
{ "match": { "age": "40" } }
],
"must_not": [
{ "match": { "state": "ID" } }
]
}
}
}
结果
must, should, must_not 和 filter 都是bool查询的子句。那么filter和上述query子句有啥区别呢
查询条件query or filter
先看下如下查询, 在bool查询的子句中同时具备query/must 和 filter
GET /bank/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"state": "ND"
}
}
],
"filter": [
{
"term": {
"age": "40"
}
},
{
"range": {
"balance": {
"gte": 20000,
"lte": 30000
}
}
}
]
}
}
}
结果
两者都可以写查询条件而且语法也类似。区别在于query 上下文的条件是用来给文档打分的,匹配越好 _score 越高filter 的条件只产生两种结果:符合与不符合,后者被过滤掉。
所以我们进一步看只包含filter的查询
GET /bank/_search
{
"query": {
"bool": {
"filter": [
{
"term": {
"age": "40"
}
},
{
"range": {
"balance": {
"gte": 20000,
"lte": 30000
}
}
}
]
}
}
}
结果显然无_score
聚合查询Aggregation
我们知道SQL中有group by在ES中它叫Aggregation即聚合运算。
简单聚合
比如我们希望计算出account每个州的统计数量 使用aggs关键字对state字段聚合被聚合的字段无需对分词统计所以使用state.keyword对整个字段统计
GET /bank/_search
{
"size": 0,
"aggs": {
"group_by_state": {
"terms": {
"field": "state.keyword"
}
}
}
}
结果
因为无需返回条件的具体数据, 所以设置size=0返回hits为空。
doc_count表示bucket中每个州的数据条数。
嵌套聚合
ES还可以处理个聚合条件的嵌套。
比如承接上个例子, 计算每个州的平均结余。涉及到的就是在对state分组的基础上嵌套计算avg(balance):
GET /bank/_search
{
"size": 0,
"aggs": {
"group_by_state": {
"terms": {
"field": "state.keyword"
},
"aggs": {
"average_balance": {
"avg": {
"field": "balance"
}
}
}
}
}
}
结果
对聚合结果排序
可以通过在aggs中对嵌套聚合的结果进行排序
比如承接上个例子, 对嵌套计算出的avg(balance)这里是average_balance进行排序
GET /bank/_search
{
"size": 0,
"aggs": {
"group_by_state": {
"terms": {
"field": "state.keyword",
"order": {
"average_balance": "desc"
}
},
"aggs": {
"average_balance": {
"avg": {
"field": "balance"
}
}
}
}
}
}
结果

View File

@@ -0,0 +1,245 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 索引:索引管理详解
索引管理的引入
我们在前文中增加文档时如下的语句会动态创建一个customer的index
PUT /customer/_doc/1
{
"name": "John Doe"
}
而这个index实际上已经自动创建了它里面的字段name的类型。我们不妨看下它自动创建的mapping
{
"mappings": {
"_doc": {
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
}
那么如果我们需要对这个建立索引的过程做更多的控制:比如想要确保这个索引有数量适中的主分片,并且在我们索引任何数据之前,分析器和映射已经被建立好。那么就会引入两点:第一个禁止自动创建索引,第二个是手动创建索引。
禁止自动创建索引
可以通过在 config/elasticsearch.yml 的每个节点下添加下面的配置:
action.auto_create_index: false
手动创建索引就是接下来文章的内容。
索引的格式
在请求体里面传入设置或类型映射,如下所示:
PUT /my_index
{
"settings": { ... any settings ... },
"mappings": {
"properties": { ... any properties ... }
}
}
settings: 用来设置分片,副本等配置信息
mappings
字段映射,类型等
properties: 由于type在后续版本中会被Deprecated, 所以无需被type嵌套
索引管理操作
我们通过kibana的devtool来学习索引的管理操作。
创建索引
我们创建一个user 索引test-index-users其中包含三个属性nameage, remarks; 存储在一个分片一个副本上。
PUT /test-index-users
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"age": {
"type": "long"
},
"remarks": {
"type": "text"
}
}
}
}
执行结果
插入测试数据
查看数据
我们再测试下不匹配的数据类型(age)
POST /test-index-users/_doc
{
"name": "test user",
"age": "error_age",
"remarks": "hello eeee"
}
你可以看到无法类型不匹配的错误:
修改索引
查看刚才的索引,curl 'localhost:9200/_cat/indices?v' | grep users
yellow open test-index-users LSaIB57XSC6uVtGQHoPYxQ 1 1 1 0 4.4kb 4.4kb
我们注意到刚创建的索引的状态是yellow的因为我测试的环境是单点环境无法创建副本但是在上述number_of_replicas配置中设置了副本数是1 所以在这个时候我们需要修改索引的配置。
修改副本数量为0
PUT /test-index-users/_settings
{
"settings": {
"number_of_replicas": 0
}
}
再次查看状态:
green open test-index-users LSaIB57XSC6uVtGQHoPYxQ 1 1 1 0 4.4kb 4.4kb
打开/关闭索引
关闭索引
一旦索引被关闭,那么这个索引只能显示元数据信息,不能够进行读写操作。
当关闭以后,再插入数据时:
打开索引
打开后又可以重新写数据了
删除索引
最后我们将创建的test-index-users删除。
DELETE /test-index-users
查看索引
由于test-index-users被删除所以我们看下之前bank的索引的信息
mapping
GET /bank/_mapping
settings
GET /bank/_settings
Kibana管理索引
在Kibana如下路径我们可以查看和管理索引
参考文章
https://www.elastic.co/guide/cn/elasticsearch/guide/current/_creating_an_index.html
https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html
https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html
https://www.cnblogs.com/quanxiaoha/p/11515057.html

View File

@@ -0,0 +1,289 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 索引:索引模板(Index Template)详解
索引模板
索引模板是一种告诉Elasticsearch在创建索引时如何配置索引的方法。
使用方式
在创建索引之前可以先配置模板,这样在创建索引(手动创建索引或通过对文档建立索引)时,模板设置将用作创建索引的基础。
模板类型
模板有两种类型:索引模板和组件模板。
组件模板是可重用的构建块,用于配置映射,设置和别名;它们不会直接应用于一组索引。
索引模板可以包含组件模板的集合,也可以直接指定设置,映射和别名。
索引模板中的优先级
可组合模板优先于旧模板。如果没有可组合模板匹配给定索引,则旧版模板可能仍匹配并被应用。
如果使用显式设置创建索引并且该索引也与索引模板匹配,则创建索引请求中的设置将优先于索引模板及其组件模板中指定的设置。
如果新数据流或索引与多个索引模板匹配,则使用优先级最高的索引模板。
内置索引模板
Elasticsearch具有内置索引模板每个索引模板的优先级为100适用于以下索引模式
logs-*-*
metrics-*-*
synthetics-*-*
所以在涉及内建索引模板时,要避免索引模式冲突。更多可以参考这里
案例
首先创建两个索引组件模板:
PUT _component_template/component_template1
{
"template": {
"mappings": {
"properties": {
"@timestamp": {
"type": "date"
}
}
}
}
}
PUT _component_template/runtime_component_template
{
"template": {
"mappings": {
"runtime": {
"day_of_week": {
"type": "keyword",
"script": {
"source": "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))"
}
}
}
}
}
}
执行结果如下
创建使用组件模板的索引模板
PUT _index_template/template_1
{
"index_patterns": ["bar*"],
"template": {
"settings": {
"number_of_shards": 1
},
"mappings": {
"_source": {
"enabled": true
},
"properties": {
"host_name": {
"type": "keyword"
},
"created_at": {
"type": "date",
"format": "EEE MMM dd HH:mm:ss Z yyyy"
}
}
},
"aliases": {
"mydata": { }
}
},
"priority": 500,
"composed_of": ["component_template1", "runtime_component_template"],
"version": 3,
"_meta": {
"description": "my custom"
}
}
执行结果如下
创建一个匹配bar*的索引bar-test
PUT /bar-test
然后获取mapping
GET /bar-test/_mapping
执行结果如下
模拟多组件模板
由于模板不仅可以由多个组件模板组成还可以由索引模板自身组成那么最终的索引设置将是什么呢ElasticSearch设计者考虑到这个提供了API进行模拟组合后的模板的配置。
模拟某个索引结果
比如上面的template_1, 我们不用创建bar*的索引(这里模拟bar-pdai-test),也可以模拟计算出索引的配置:
POST /_index_template/_simulate_index/bar-pdai-test
执行结果如下
模拟组件模板结果
当然由于template_1模板是由两个组件模板组合的我们也可以模拟出template_1被组合后的索引配置
POST /_index_template/_simulate/template_1
执行结果如下:
{
"template" : {
"settings" : {
"index" : {
"number_of_shards" : "1"
}
},
"mappings" : {
"runtime" : {
"day_of_week" : {
"type" : "keyword",
"script" : {
"source" : "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))",
"lang" : "painless"
}
}
},
"properties" : {
"@timestamp" : {
"type" : "date"
},
"created_at" : {
"type" : "date",
"format" : "EEE MMM dd HH:mm:ss Z yyyy"
},
"host_name" : {
"type" : "keyword"
}
}
},
"aliases" : {
"mydata" : { }
}
},
"overlapping" : [ ]
}
模拟组件模板和自身模板结合后的结果
新建两个模板
PUT /_component_template/ct1
{
"template": {
"settings": {
"index.number_of_shards": 2
}
}
}
PUT /_component_template/ct2
{
"template": {
"settings": {
"index.number_of_replicas": 0
},
"mappings": {
"properties": {
"@timestamp": {
"type": "date"
}
}
}
}
}
模拟在两个组件模板的基础上,添加自身模板的配置
POST /_index_template/_simulate
{
"index_patterns": ["my*"],
"template": {
"settings" : {
"index.number_of_shards" : 3
}
},
"composed_of": ["ct1", "ct2"]
}
执行的结果如下
{
"template" : {
"settings" : {
"index" : {
"number_of_shards" : "3",
"number_of_replicas" : "0"
}
},
"mappings" : {
"properties" : {
"@timestamp" : {
"type" : "date"
}
}
},
"aliases" : { }
},
"overlapping" : [ ]
}
参考文章
https://www.elastic.co/guide/en/elasticsearch/reference/current/index-templates.html
https://www.elastic.co/guide/en/elasticsearch/reference/current/simulate-multi-component-templates.html

View File

@@ -0,0 +1,501 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 查询DSL查询之复合查询详解
复合查询引入
在(前文-多条件查询-bool)中我们使用bool查询来组合多个查询条件。
比如之前介绍的语句
GET /bank/_search
{
"query": {
"bool": {
"must": [
{ "match": { "age": "40" } }
],
"must_not": [
{ "match": { "state": "ID" } }
]
}
}
}
这种查询就是本文要介绍的复合查询并且bool查询只是复合查询一种。
bool query(布尔查询)
通过布尔逻辑将较小的查询组合成较大的查询。
概念
Bool查询语法有以下特点
子查询可以任意顺序出现
可以嵌套多个查询包括bool查询
如果bool查询中没有must条件should中必须至少满足一条才会返回结果。
bool查询包含四种操作符分别是must,should,must_not,filter。他们均是一种数组数组里面是对应的判断条件。
must 必须匹配。贡献算分
must_not过滤子句必须不能匹配但不贡献算分
should 选择性匹配,至少满足一条。贡献算分
filter 过滤子句,必须匹配,但不贡献算分
一些例子
看下官方举例
例子1
POST _search
{
"query": {
"bool" : {
"must" : {
"term" : { "user.id" : "kimchy" }
},
"filter": {
"term" : { "tags" : "production" }
},
"must_not" : {
"range" : {
"age" : { "gte" : 10, "lte" : 20 }
}
},
"should" : [
{ "term" : { "tags" : "env1" } },
{ "term" : { "tags" : "deployed" } }
],
"minimum_should_match" : 1,
"boost" : 1.0
}
}
}
在filter元素下指定的查询对评分没有影响 , 评分返回为0。分数仅受已指定查询的影响。
例子2
GET _search
{
"query": {
"bool": {
"filter": {
"term": {
"status": "active"
}
}
}
}
}
这个例子查询查询为所有文档分配0分因为没有指定评分查询。
例子3
GET _search
{
"query": {
"bool": {
"must": {
"match_all": {}
},
"filter": {
"term": {
"status": "active"
}
}
}
}
}
此bool查询具有match_all查询该查询为所有文档指定1.0分。
例子4
GET /_search
{
"query": {
"bool": {
"should": [
{ "match": { "name.first": { "query": "shay", "_name": "first" } } },
{ "match": { "name.last": { "query": "banon", "_name": "last" } } }
],
"filter": {
"terms": {
"name.last": [ "banon", "kimchy" ],
"_name": "test"
}
}
}
}
}
每个query条件都可以有一个_name属性用来追踪搜索出的数据到底match了哪个条件。
boosting query(提高查询)
不同于bool查询bool查询中只要一个子查询条件不匹配那么搜索的数据就不会出现。而boosting query则是降低显示的权重/优先级即score)。
概念
比如搜索逻辑是 name = apple and type =fruit对于只满足部分条件的数据不是不显示而是降低显示的优先级即score)
例子
首先创建数据
POST /test-dsl-boosting/_bulk
{ "index": { "_id": 1 }}
{ "content":"Apple Mac" }
{ "index": { "_id": 2 }}
{ "content":"Apple Fruit" }
{ "index": { "_id": 3 }}
{ "content":"Apple employee like Apple Pie and Apple Juice" }
对匹配pie的做降级显示处理
GET /test-dsl-boosting/_search
{
"query": {
"boosting": {
"positive": {
"term": {
"content": "apple"
}
},
"negative": {
"term": {
"content": "pie"
}
},
"negative_boost": 0.5
}
}
}
执行结果如下
constant_score固定分数查询
查询某个条件时固定的返回指定的score显然当不需要计算score时只需要filter条件即可因为filter context忽略score。
例子
首先创建数据
POST /test-dsl-constant/_bulk
{ "index": { "_id": 1 }}
{ "content":"Apple Mac" }
{ "index": { "_id": 2 }}
{ "content":"Apple Fruit" }
查询apple
GET /test-dsl-constant/_search
{
"query": {
"constant_score": {
"filter": {
"term": { "content": "apple" }
},
"boost": 1.2
}
}
}
执行结果如下
dis_max(最佳匹配查询)
分离最大化查询Disjunction Max Query指的是 将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回 。
例子
假设有个网站允许用户搜索博客的内容,以下面两篇博客内容文档为例:
POST /test-dsl-dis-max/_bulk
{ "index": { "_id": 1 }}
{"title": "Quick brown rabbits","body": "Brown rabbits are commonly seen."}
{ "index": { "_id": 2 }}
{"title": "Keeping pets healthy","body": "My quick brown fox eats rabbits on a regular basis."}
用户输入词组 “Brown fox” 然后点击搜索按钮。事先,我们并不知道用户的搜索项是会在 title 还是在 body 字段中被找到,但是,用户很有可能是想搜索相关的词组。用肉眼判断,文档 2 的匹配度更高,因为它同时包括要查找的两个词:
现在运行以下 bool 查询:
GET /test-dsl-dis-max/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "Brown fox" }},
{ "match": { "body": "Brown fox" }}
]
}
}
}
为了理解导致这样的原因,需要看下如何计算评分的
should 条件的计算分数
GET /test-dsl-dis-max/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "Brown fox" }},
{ "match": { "body": "Brown fox" }}
]
}
}
}
要计算上述分数首先要计算match的分数
第一个match 中 brown的分数
doc 1 分数 = 0.6931471
title中没有fox所以第一个match 中 brown fox 的分数 = brown分数 + 0 = 0.6931471
doc 1 分数 = 0.6931471 + 0 = 0.6931471
第二个 match 中 brown分数
doc 1 分数 = 0.21110919
doc 2 分数 = 0.160443
第二个 match 中 fox分数
doc 1 分数 = 0
doc 2 分数 = 0.60996956
所以第二个 match 中 brown fox分数 = brown分数 + fox分数
doc 1 分数 = 0.21110919 + 0 = 0.21110919
doc 2 分数 = 0.160443 + 0.60996956 = 0.77041256
所以整个语句分数, should分数 = 第一个match + 第二个match分数
doc 1 分数 = 0.6931471 + 0.21110919 = 0.90425634
doc 2 分数 = 0 + 0.77041256 = 0.77041256
引入了dis_max
不使用 bool 查询,可以使用 dis_max 即分离 最大化查询Disjunction Max Query 。分离Disjunction的意思是 或or 这与可以把结合conjunction理解成 与and 相对应。分离最大化查询Disjunction Max Query指的是 将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回
GET /test-dsl-dis-max/_search
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Brown fox" }},
{ "match": { "body": "Brown fox" }}
],
"tie_breaker": 0
}
}
}
0.77041256怎么来的呢? 下文给你解释它如何计算出来的。
dis_max 条件的计算分数
分数 = 第一个匹配条件分数 + tie_breaker * 第二个匹配的条件的分数 …
GET /test-dsl-dis-max/_search
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Brown fox" }},
{ "match": { "body": "Brown fox" }}
],
"tie_breaker": 0
}
}
}
doc 1 分数 = 0.6931471 + 0.21110919 * 0 = 0.6931471
doc 2 分数 = 0.77041256 = 0.77041256
这样你就能理解通过dis_max将doc 2 置前了, 当然这里如果缺省tie_breaker字段的话默认就是0你还可以设置它的比例在0到1之间来控制排名。显然值为1时和should查询是一致的
function_score(函数查询)
简而言之就是用自定义function的方式来计算_score。
可以ES有哪些自定义function呢
script_score 使用自定义的脚本来完全控制分值计算逻辑。如果你需要以上预定义函数之外的功能,可以根据需要通过脚本进行实现。
weight 对每份文档适用一个简单的提升且该提升不会被归约当weight为2时结果为2 * _score。
random_score 使用一致性随机分值计算来对每个用户采用不同的结果排序方式,对相同用户仍然使用相同的排序方式。
field_value_factor 使用文档中某个字段的值来改变_score比如将受欢迎程度或者投票数量考虑在内。
衰减函数(Decay Function) - linearexpgauss
例子
以最简单的random_score 为例
GET /_search
{
"query": {
"function_score": {
"query": { "match_all": {} },
"boost": "5",
"random_score": {},
"boost_mode": "multiply"
}
}
}
进一步的它还可以使用上述function的组合(functions)
GET /_search
{
"query": {
"function_score": {
"query": { "match_all": {} },
"boost": "5",
"functions": [
{
"filter": { "match": { "test": "bar" } },
"random_score": {},
"weight": 23
},
{
"filter": { "match": { "test": "cat" } },
"weight": 42
}
],
"max_boost": 42,
"score_mode": "max",
"boost_mode": "multiply",
"min_score": 42
}
}
}
script_score 可以使用如下方式
GET /_search
{
"query": {
"function_score": {
"query": {
"match": { "message": "elasticsearch" }
},
"script_score": {
"script": {
"source": "Math.log(2 + doc['my-int'].value)"
}
}
}
}
}
更多相关内容,可以参考官方文档 PS: 形成体系化认知以后,具体用的时候查询下即可。
参考文章
https://www.elastic.co/guide/en/elasticsearch/reference/current/compound-queries.html
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/query-dsl-function-score-query.html

View File

@@ -0,0 +1,491 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 查询DSL查询之全文搜索详解
写在前面:谈谈如何从官网学习
提示
很多读者在看官方文档学习时存在一个误区以DSL中full text查询为例其实内容是非常多的 没有取舍/没重点去阅读, 要么需要花很多时间,要么头脑一片浆糊。所以这里重点谈谈我的理解。@pdai
一些理解:
第一点:全局观,即我们现在学习内容在整个体系的哪个位置?
如下图,可以很方便的帮助你构筑这种体系
第二点: 分类别,从上层理解,而不是本身
比如Full text Query中我们只需要把如下的那么多点分为3大类你的体系能力会大大提升
第三点: 知识点还是API API类型的是可以查询的只需要知道大致有哪些功能就可以了。
Match类型
第一类match 类型
match 查询的步骤
在(指定字段查询)中我们已经介绍了match查询。
准备一些数据
这里我们准备一些数据通过实例看match 查询的步骤
PUT /test-dsl-match
{ "settings": { "number_of_shards": 1 }}
POST /test-dsl-match/_bulk
{ "index": { "_id": 1 }}
{ "title": "The quick brown fox" }
{ "index": { "_id": 2 }}
{ "title": "The quick brown fox jumps over the lazy dog" }
{ "index": { "_id": 3 }}
{ "title": "The quick brown fox jumps over the quick dog" }
{ "index": { "_id": 4 }}
{ "title": "Brown fox brown dog" }
查询数据
GET /test-dsl-match/_search
{
"query": {
"match": {
"title": "QUICK!"
}
}
}
Elasticsearch 执行上面这个 match 查询的步骤是:
检查字段类型 。
标题 title 字段是一个 string 类型( analyzed )已分析的全文字段,这意味着查询字符串本身也应该被分析。
分析查询字符串 。
将查询的字符串 QUICK! 传入标准分析器中,输出的结果是单个项 quick 。因为只有一个单词项,所以 match 查询执行的是单个底层 term 查询。
查找匹配文档 。
用 term 查询在倒排索引中查找 quick 然后获取一组包含该项的文档本例的结果是文档1、2 和 3 。
为每个文档评分 。
用 term 查询计算每个文档相关度评分 _score 这是种将词频term frequency即词 quick 在相关文档的 title 字段中出现的频率和反向文档频率inverse document frequency即词 quick 在所有文档的 title 字段中出现的频率),以及字段的长度(即字段越短相关度越高)相结合的计算方式。
验证结果
match多个词深入
我们在上文中复合查询中已经使用了match多个词比如“Quick pets” 这里我们通过例子带你更深入理解match多个词
match多个词的本质
查询多个词”BROWN DOG!”
GET /test-dsl-match/_search
{
"query": {
"match": {
"title": "BROWN DOG"
}
}
}
因为 match 查询必须查找两个词( [“brown”,“dog”] ),它在内部实际上先执行两次 term 查询,然后将两次查询的结果合并作为最终结果输出。为了做到这点,它将两个 term 查询包入一个 bool 查询中,
所以上述查询的结果,和如下语句查询结果是等同的
GET /test-dsl-match/_search
{
"query": {
"bool": {
"should": [
{
"term": {
"title": "brown"
}
},
{
"term": {
"title": "dog"
}
}
]
}
}
}
match多个词的逻辑
上面等同于should任意一个满足是因为 match还有一个operator参数默认是or, 所以对应的是should。
所以上述查询也等同于
GET /test-dsl-match/_search
{
"query": {
"match": {
"title": {
"query": "BROWN DOG",
"operator": "or"
}
}
}
}
那么我们如果是需要and操作呢即同时满足呢
GET /test-dsl-match/_search
{
"query": {
"match": {
"title": {
"query": "BROWN DOG",
"operator": "and"
}
}
}
}
等同于
GET /test-dsl-match/_search
{
"query": {
"bool": {
"must": [
{
"term": {
"title": "brown"
}
},
{
"term": {
"title": "dog"
}
}
]
}
}
}
控制match的匹配精度
如果用户给定 3 个查询词,想查找只包含其中 2 个的文档,该如何处理?将 operator 操作符参数设置成 and 或者 or 都是不合适的。
match 查询支持 minimum_should_match 最小匹配参数,这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量:
GET /test-dsl-match/_search
{
"query": {
"match": {
"title": {
"query": "quick brown dog",
"minimum_should_match": "75%"
}
}
}
}
当给定百分比的时候, minimum_should_match 会做合适的事情:在之前三词项的示例中, 75% 会自动被截断成 66.6% ,即三个里面两个词。无论这个值设置成什么,至少包含一个词项的文档才会被认为是匹配的。
当然也等同于
GET /test-dsl-match/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "quick" }},
{ "match": { "title": "brown" }},
{ "match": { "title": "dog" }}
],
"minimum_should_match": 2
}
}
}
其它match类型
match_pharse
match_phrase在前文中我们已经有了解我们再看下另外一个例子。
GET /test-dsl-match/_search
{
"query": {
"match_phrase": {
"title": {
"query": "quick brown"
}
}
}
}
很多人对它仍然有误解的,比如如下例子:
GET /test-dsl-match/_search
{
"query": {
"match_phrase": {
"title": {
"query": "quick brown f"
}
}
}
}
这样的查询是查不出任何数据的因为前文中我们知道了match本质上是对term组合match_phrase本质是连续的term的查询所以f并不是一个分词不满足term查询所以最终查不出任何内容了。
match_pharse_prefix
那有没有可以查询出quick brown f的方式呢ELasticSearch在match_phrase基础上提供了一种可以查最后一个词项是前缀的方法这样就可以查询quick brown f了
GET /test-dsl-match/_search
{
"query": {
"match_phrase_prefix": {
"title": {
"query": "quick brown f"
}
}
}
}
(ps: prefix的意思不是整个text的开始匹配而是最后一个词项满足term的prefix查询而已)
match_bool_prefix
除了match_phrase_prefixElasticSearch还提供了match_bool_prefix查询
GET /test-dsl-match/_search
{
"query": {
"match_bool_prefix": {
"title": {
"query": "quick brown f"
}
}
}
}
它们两种方式有啥区别呢match_bool_prefix本质上可以转换为
GET /test-dsl-match/_search
{
"query": {
"bool" : {
"should": [
{ "term": { "title": "quick" }},
{ "term": { "title": "brown" }},
{ "prefix": { "title": "f"}}
]
}
}
}
所以这样你就能理解match_bool_prefix查询中的quick,brown,f是无序的。
multi_match
如果我们期望一次对多个字段查询怎么办呢ElasticSearch提供了multi_match查询的方式
{
"query": {
"multi_match" : {
"query": "Will Smith",
"fields": [ "title", "*_name" ]
}
}
}
*表示前缀匹配字段。
query string类型
第二类query string 类型
query_string
此查询使用语法根据运算符例如AND或来解析和拆分提供的查询字符串NOT。然后查询在返回匹配的文档之前独立分析每个拆分的文本。
可以使用该query_string查询创建一个复杂的搜索其中包括通配符跨多个字段的搜索等等。尽管用途广泛但查询是严格的如果查询字符串包含任何无效语法则返回错误。
例如:
GET /test-dsl-match/_search
{
"query": {
"query_string": {
"query": "(lazy dog) OR (brown dog)",
"default_field": "title"
}
}
}
这里查询结果你需要理解本质上查询这四个分词termor的结果而已所以doc 3和4也在其中
对构筑知识体系已经够了,但是它其实还有很多参数和用法,更多请参考官网
query_string_simple
该查询使用一种简单的语法来解析提供的查询字符串并将其拆分为基于特殊运算符的术语。然后查询在返回匹配的文档之前独立分析每个术语。
尽管其语法比query_string查询更受限制 但simple_query_string 查询不会针对无效语法返回错误。而是,它将忽略查询字符串的任何无效部分。
举例:
GET /test-dsl-match/_search
{
"query": {
"simple_query_string" : {
"query": "\"over the\" + (lazy | quick) + dog",
"fields": ["title"],
"default_operator": "and"
}
}
}
更多请参考官网
Interval类型
第三类interval类型
Intervals是时间间隔的意思本质上将多个规则按照顺序匹配。
比如:
GET /test-dsl-match/_search
{
"query": {
"intervals" : {
"title" : {
"all_of" : {
"ordered" : true,
"intervals" : [
{
"match" : {
"query" : "quick",
"max_gaps" : 0,
"ordered" : true
}
},
{
"any_of" : {
"intervals" : [
{ "match" : { "query" : "jump over" } },
{ "match" : { "query" : "quick dog" } }
]
}
}
]
}
}
}
}
}
因为interval之间是可以组合的所以它可以表现的很复杂。更多请参考官网
参考文章
https://www.elastic.co/guide/en/elasticsearch/reference/current/full-text-queries.html#full-text-queries
https://www.elastic.co/guide/cn/elasticsearch/guide/current/match-multi-word.html

View File

@@ -0,0 +1,242 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 查询DSL查询之Term详解
Term查询引入
如前文所述,查询分基于文本查询和基于词项的查询:
本文主要讲基于词项的查询。
Term查询
很多比较常用,也不难,就是需要结合实例理解。这里综合官方文档的内容,我设计一个测试场景的数据,以覆盖所有例子。@pdai
准备数据
PUT /test-dsl-term-level
{
"mappings": {
"properties": {
"name": {
"type": "keyword"
},
"programming_languages": {
"type": "keyword"
},
"required_matches": {
"type": "long"
}
}
}
}
POST /test-dsl-term-level/_bulk
{ "index": { "_id": 1 }}
{"name": "Jane Smith", "programming_languages": [ "c++", "java" ], "required_matches": 2}
{ "index": { "_id": 2 }}
{"name": "Jason Response", "programming_languages": [ "java", "php" ], "required_matches": 2}
{ "index": { "_id": 3 }}
{"name": "Dave Pdai", "programming_languages": [ "java", "c++", "php" ], "required_matches": 3, "remarks": "hello world"}
字段是否存在:exist
由于多种原因,文档字段的索引值可能不存在:
源JSON中的字段是null或[]
该字段已”index” : false在映射中设置
字段值的长度超出ignore_above了映射中的设置
字段值格式错误并且ignore_malformed已在映射中定义
所以exist表示查找是否存在字段。
id查询:ids
ids 即对id查找
GET /test-dsl-term-level/_search
{
"query": {
"ids": {
"values": [3, 1]
}
}
}
前缀:prefix
通过前缀查找某个字段
GET /test-dsl-term-level/_search
{
"query": {
"prefix": {
"name": {
"value": "Jan"
}
}
}
}
分词匹配:term
前文最常见的根据分词查询
GET /test-dsl-term-level/_search
{
"query": {
"term": {
"programming_languages": "php"
}
}
}
多个分词匹配:terms
按照读个分词term匹配它们是or的关系
GET /test-dsl-term-level/_search
{
"query": {
"terms": {
"programming_languages": ["php","c++"]
}
}
}
按某个数字字段分词匹配:term set
设计这种方式查询的初衷是用文档中的数字字段动态匹配查询满足term的个数
GET /test-dsl-term-level/_search
{
"query": {
"terms_set": {
"programming_languages": {
"terms": [ "java", "php" ],
"minimum_should_match_field": "required_matches"
}
}
}
}
通配符:wildcard
通配符匹配,比如*
GET /test-dsl-term-level/_search
{
"query": {
"wildcard": {
"name": {
"value": "D*ai",
"boost": 1.0,
"rewrite": "constant_score"
}
}
}
}
范围:range
常常被用在数字或者日期范围的查询
GET /test-dsl-term-level/_search
{
"query": {
"range": {
"required_matches": {
"gte": 3,
"lte": 4
}
}
}
}
正则:regexp
通过[正则表达式]查询
以”Jan”开头的name字段
GET /test-dsl-term-level/_search
{
"query": {
"regexp": {
"name": {
"value": "Ja.*",
"case_insensitive": true
}
}
}
}
模糊匹配:fuzzy
官方文档对模糊匹配:编辑距离是将一个术语转换为另一个术语所需的一个字符更改的次数。这些更改可以包括:
更改字符box→ fox
删除字符black→ lack
插入字符sic→ sick
转置两个相邻字符act→ cat
GET /test-dsl-term-level/_search
{
"query": {
"fuzzy": {
"remarks": {
"value": "hell"
}
}
}
}
参考文章
https://www.elastic.co/guide/en/elasticsearch/reference/current/term-level-queries.html

View File

@@ -0,0 +1,602 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 聚合聚合查询之Bucket聚合详解
聚合的引入
我们在SQL结果中常有
SELECT COUNT(color)
FROM table
GROUP BY color
ElasticSearch中桶在概念上类似于 SQL 的分组GROUP BY而指标则类似于 COUNT() 、 SUM() 、 MAX() 等统计方法。
进而引入了两个概念:
Buckets 满足特定条件的文档的集合
指标Metrics 对桶内的文档进行统计计算
所以ElasticSearch包含3种聚合Aggregation)方式
桶聚合Bucket Aggregration) - 本文中详解
指标聚合Metric Aggregration) - 下文中讲解
管道聚合Pipline Aggregration)
- 再下一篇讲解
聚合管道化,简单而言就是上一个聚合的结果成为下个聚合的输入;
PS:指标聚合和桶聚合很多情况下是组合在一起使用的其实你也可以看到桶聚合本质上是一种特殊的指标聚合它的聚合指标就是数据的条数count)
如何理解Bucket聚合
如果你直接去看文档,大概有几十种:
要么你需要花大量时间学习,要么你已经迷失或者即将迷失在知识点中…
所以你需要稍微站在设计者的角度思考下,不难发现设计上大概分为三类(当然有些是第二和第三类的融合)
(图中并没有全部列出内容,因为图要表达的意图我觉得还是比较清楚的,这就够了;有了这种思虑和认知,会大大提升你的认知效率。)
按知识点学习聚合
我们先按照官方权威指南中的一个例子学习Aggregation中的知识点。
准备数据
让我们先看一个例子。我们将会创建一些对汽车经销商有用的聚合,数据是关于汽车交易的信息:车型、制造商、售价、何时被出售等。
首先我们批量索引一些数据:
POST /test-agg-cars/_bulk
{ "index": {}}
{ "price" : 10000, "color" : "red", "make" : "honda", "sold" : "2014-10-28" }
{ "index": {}}
{ "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" }
{ "index": {}}
{ "price" : 30000, "color" : "green", "make" : "ford", "sold" : "2014-05-18" }
{ "index": {}}
{ "price" : 15000, "color" : "blue", "make" : "toyota", "sold" : "2014-07-02" }
{ "index": {}}
{ "price" : 12000, "color" : "green", "make" : "toyota", "sold" : "2014-08-19" }
{ "index": {}}
{ "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" }
{ "index": {}}
{ "price" : 80000, "color" : "red", "make" : "bmw", "sold" : "2014-01-01" }
{ "index": {}}
{ "price" : 25000, "color" : "blue", "make" : "ford", "sold" : "2014-02-12" }
标准的聚合
有了数据,开始构建我们的第一个聚合。汽车经销商可能会想知道哪个颜色的汽车销量最好,用聚合可以轻易得到结果,用 terms 桶操作:
GET /test-agg-cars/_search
{
"size" : 0,
"aggs" : {
"popular_colors" : {
"terms" : {
"field" : "color.keyword"
}
}
}
}
聚合操作被置于顶层参数 aggs 之下(如果你愿意,完整形式 aggregations 同样有效)。
然后,可以为聚合指定一个我们想要名称,本例中是: popular_colors 。
最后,定义单个桶的类型 terms 。
结果如下:
因为我们设置了 size 参数,所以不会有 hits 搜索结果返回。
popular_colors 聚合是作为 aggregations 字段的一部分被返回的。
每个桶的 key 都与 color 字段里找到的唯一词对应。它总会包含 doc_count 字段,告诉我们包含该词项的文档数量。
每个桶的数量代表该颜色的文档数量。
多个聚合
同时计算两种桶的结果对color和对make。
GET /test-agg-cars/_search
{
"size" : 0,
"aggs" : {
"popular_colors" : {
"terms" : {
"field" : "color.keyword"
}
},
"make_by" : {
"terms" : {
"field" : "make.keyword"
}
}
}
}
结果如下:
聚合的嵌套
这个新的聚合层让我们可以将 avg 度量嵌套置于 terms 桶内。实际上,这就为每个颜色生成了平均价格。
GET /test-agg-cars/_search
{
"size" : 0,
"aggs": {
"colors": {
"terms": {
"field": "color.keyword"
},
"aggs": {
"avg_price": {
"avg": {
"field": "price"
}
}
}
}
}
}
结果如下:
正如 颜色 的例子,我们需要给度量起一个名字( avg_price )这样可以稍后根据名字获取它的值。最后,我们指定度量本身( avg )以及我们想要计算平均值的字段( price
动态脚本的聚合
这个例子告诉你ElasticSearch还支持一些基于脚本生成运行时的字段的复杂的动态聚合。
GET /test-agg-cars/_search
{
"runtime_mappings": {
"make.length": {
"type": "long",
"script": "emit(doc['make.keyword'].value.length())"
}
},
"size" : 0,
"aggs": {
"make_length": {
"histogram": {
"interval": 1,
"field": "make.length"
}
}
}
}
结果如下:
histogram可以参考后文内容。
按分类学习Bucket聚合
我们在具体学习时也无需学习每一个点基于上面图的认知我们只需用20%的时间学习最为常用的80%功能即可,其它查查文档而已。@pdai
前置条件的过滤filter
在当前文档集上下文中定义与指定过滤器(Filter)匹配的所有文档的单个存储桶。通常,这将用于将当前聚合上下文缩小到一组特定的文档。
GET /test-agg-cars/_search
{
"size": 0,
"aggs": {
"make_by": {
"filter": { "term": { "type": "honda" } },
"aggs": {
"avg_price": { "avg": { "field": "price" } }
}
}
}
}
结果如下:
对filter进行分组聚合filters
设计一个新的例子, 日志系统中每条日志都是在文本中包含warning/info等信息。
PUT /test-agg-logs/_bulk?refresh
{ "index" : { "_id" : 1 } }
{ "body" : "warning: page could not be rendered" }
{ "index" : { "_id" : 2 } }
{ "body" : "authentication error" }
{ "index" : { "_id" : 3 } }
{ "body" : "warning: connection timed out" }
{ "index" : { "_id" : 4 } }
{ "body" : "info: hello pdai" }
我们需要对包含不同日志类型的日志进行分组这就需要filters:
GET /test-agg-logs/_search
{
"size": 0,
"aggs" : {
"messages" : {
"filters" : {
"other_bucket_key": "other_messages",
"filters" : {
"infos" : { "match" : { "body" : "info" }},
"warnings" : { "match" : { "body" : "warning" }}
}
}
}
}
}
结果如下:
对number类型聚合Range
基于多桶值源的聚合,使用户能够定义一组范围-每个范围代表一个桶。在聚合过程中,将从每个存储区范围中检查从每个文档中提取的值,并“存储”相关/匹配的文档。请注意此聚合包括from值但不包括to每个范围的值。
GET /test-agg-cars/_search
{
"size": 0,
"aggs": {
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 20000 },
{ "from": 20000, "to": 40000 },
{ "from": 40000 }
]
}
}
}
}
结果如下:
对IP类型聚合IP Range
专用于IP值的范围聚合。
GET /ip_addresses/_search
{
"size": 10,
"aggs": {
"ip_ranges": {
"ip_range": {
"field": "ip",
"ranges": [
{ "to": "10.0.0.5" },
{ "from": "10.0.0.5" }
]
}
}
}
}
返回
{
...
"aggregations": {
"ip_ranges": {
"buckets": [
{
"key": "*-10.0.0.5",
"to": "10.0.0.5",
"doc_count": 10
},
{
"key": "10.0.0.5-*",
"from": "10.0.0.5",
"doc_count": 260
}
]
}
}
}
CIDR Mask分组
此外还可以用CIDR Mask分组
GET /ip_addresses/_search
{
"size": 0,
"aggs": {
"ip_ranges": {
"ip_range": {
"field": "ip",
"ranges": [
{ "mask": "10.0.0.0/25" },
{ "mask": "10.0.0.127/25" }
]
}
}
}
}
返回
{
...
"aggregations": {
"ip_ranges": {
"buckets": [
{
"key": "10.0.0.0/25",
"from": "10.0.0.0",
"to": "10.0.0.128",
"doc_count": 128
},
{
"key": "10.0.0.127/25",
"from": "10.0.0.0",
"to": "10.0.0.128",
"doc_count": 128
}
]
}
}
}
增加key显示
GET /ip_addresses/_search
{
"size": 0,
"aggs": {
"ip_ranges": {
"ip_range": {
"field": "ip",
"ranges": [
{ "to": "10.0.0.5" },
{ "from": "10.0.0.5" }
],
"keyed": true // here
}
}
}
}
返回
{
...
"aggregations": {
"ip_ranges": {
"buckets": {
"*-10.0.0.5": {
"to": "10.0.0.5",
"doc_count": 10
},
"10.0.0.5-*": {
"from": "10.0.0.5",
"doc_count": 260
}
}
}
}
}
自定义key显示
GET /ip_addresses/_search
{
"size": 0,
"aggs": {
"ip_ranges": {
"ip_range": {
"field": "ip",
"ranges": [
{ "key": "infinity", "to": "10.0.0.5" },
{ "key": "and-beyond", "from": "10.0.0.5" }
],
"keyed": true
}
}
}
}
返回
{
...
"aggregations": {
"ip_ranges": {
"buckets": {
"infinity": {
"to": "10.0.0.5",
"doc_count": 10
},
"and-beyond": {
"from": "10.0.0.5",
"doc_count": 260
}
}
}
}
}
对日期类型聚合Date Range
专用于日期值的范围聚合。
GET /test-agg-cars/_search
{
"size": 0,
"aggs": {
"range": {
"date_range": {
"field": "sold",
"format": "yyyy-MM",
"ranges": [
{ "from": "2014-01-01" },
{ "to": "2014-12-31" }
]
}
}
}
}
结果如下:
此聚合与Range聚合之间的主要区别在于 from和to值可以在Date Math表达式 中表示并且还可以指定日期格式通过该日期格式将返回from and to响应字段。请注意此聚合包括from值但不包括to每个范围的值。
对柱状图功能Histrogram
直方图 histogram 本质上是就是为柱状图功能设计的。
创建直方图需要指定一个区间,如果我们要为售价创建一个直方图,可以将间隔设为 20,000。这样做将会在每个 $20,000 档创建一个新桶,然后文档会被分到对应的桶中。
对于仪表盘来说,我们希望知道每个售价区间内汽车的销量。我们还会想知道每个售价区间内汽车所带来的收入,可以通过对每个区间内已售汽车的售价求和得到。
可以用 histogram 和一个嵌套的 sum 度量得到我们想要的答案:
GET /test-agg-cars/_search
{
"size" : 0,
"aggs":{
"price":{
"histogram":{
"field": "price.keyword",
"interval": 20000
},
"aggs":{
"revenue": {
"sum": {
"field" : "price"
}
}
}
}
}
}
histogram 桶要求两个参数:一个数值字段以及一个定义桶大小间隔。
sum 度量嵌套在每个售价区间内,用来显示每个区间内的总收入。
如我们所见,查询是围绕 price 聚合构建的,它包含一个 histogram 桶。它要求字段的类型必须是数值型的同时需要设定分组的间隔范围。 间隔设置为 20,000 意味着我们将会得到如 [0-19999, 20000-39999, …] 这样的区间。
接着,我们在直方图内定义嵌套的度量,这个 sum 度量,它会对落入某一具体售价区间的文档中 price 字段的值进行求和。 这可以为我们提供每个售价区间的收入,从而可以发现到底是普通家用车赚钱还是奢侈车赚钱。
响应结果如下:
结果很容易理解,不过应该注意到直方图的键值是区间的下限。键 0 代表区间 0-19999 ,键 20000 代表区间 20000-39999 ,等等。
当然,我们可以为任何聚合输出的分类和统计结果创建条形图,而不只是 直方图 桶。让我们以最受欢迎 10 种汽车以及它们的平均售价、标准差这些信息创建一个条形图。 我们会用到 terms 桶和 extended_stats 度量:
GET /test-agg-cars/_search
{
"size" : 0,
"aggs": {
"makes": {
"terms": {
"field": "make.keyword",
"size": 10
},
"aggs": {
"stats": {
"extended_stats": {
"field": "price"
}
}
}
}
}
}
上述代码会按受欢迎度返回制造商列表以及它们各自的统计信息。我们对其中的 stats.avg 、 stats.count 和 stats.std_deviation 信息特别感兴趣,并用 它们计算出标准差:
std_err = std_deviation / count
对应报表:
参考文章
https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket.html
https://www.elastic.co/guide/cn/elasticsearch/guide/current/_aggregation_test_drive.html

View File

@@ -0,0 +1,928 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 聚合聚合查询之Metric聚合详解
如何理解metric聚合
在[bucket聚合]中我画了一张图辅助你构筑体系那么metric聚合又如何理解呢
如果你直接去看官方文档,大概也有十几种:
那么metric聚合又如何理解呢我认为从两个角度
从分类看Metric聚合分析分为单值分析和多值分析两类
从功能看根据具体的应用场景设计了一些分析api, 比如地理位置,百分数等等
融合上述两个方面我们可以梳理出大致的一个mind图
单值分析
只输出一个分析结果
标准stat型
avg 平均值
max 最大值
min 最小值
sum 和
value_count 数量
其它类型
cardinality 基数distinct去重
weighted_avg 带权重的avg
median_absolute_deviation 中位值
多值分析
单值之外的
stats型
stats 包含avg,max,min,sum和count
matrix_stats 针对矩阵模型
extended_stats
string_stats 针对字符串
百分数型
percentiles 百分数范围
percentile_ranks 百分数排行
地理位置型
geo_bounds Geo bounds
geo_centroid Geo-centroid
geo_line Geo-Line
Top型
top_hits 分桶后的top hits
top_metrics
通过上述列表我就不画图了我们构筑的体系是基于分类和功能而不是具体的项比如avg,percentiles…);这是不同的认知维度: 具体的项是碎片化,分类和功能这种是你需要构筑的体系。@pdai
单值分析: 标准stat类型
avg 平均值
计算班级的平均分
POST /exams/_search?size=0
{
"aggs": {
"avg_grade": { "avg": { "field": "grade" } }
}
}
返回
{
...
"aggregations": {
"avg_grade": {
"value": 75.0
}
}
}
max 最大值
计算销售最高价
POST /sales/_search?size=0
{
"aggs": {
"max_price": { "max": { "field": "price" } }
}
}
返回
{
...
"aggregations": {
"max_price": {
"value": 200.0
}
}
}
min 最小值
计算销售最低价
POST /sales/_search?size=0
{
"aggs": {
"min_price": { "min": { "field": "price" } }
}
}
返回
{
...
"aggregations": {
"min_price": {
"value": 10.0
}
}
}
sum 和
计算销售总价
POST /sales/_search?size=0
{
"query": {
"constant_score": {
"filter": {
"match": { "type": "hat" }
}
}
},
"aggs": {
"hat_prices": { "sum": { "field": "price" } }
}
}
返回
{
...
"aggregations": {
"hat_prices": {
"value": 450.0
}
}
}
value_count 数量
销售数量统计
POST /sales/_search?size=0
{
"aggs" : {
"types_count" : { "value_count" : { "field" : "type" } }
}
}
返回
{
...
"aggregations": {
"types_count": {
"value": 7
}
}
}
单值分析: 其它类型
weighted_avg 带权重的avg
POST /exams/_search
{
"size": 0,
"aggs": {
"weighted_grade": {
"weighted_avg": {
"value": {
"field": "grade"
},
"weight": {
"field": "weight"
}
}
}
}
}
返回
{
...
"aggregations": {
"weighted_grade": {
"value": 70.0
}
}
}
cardinality 基数distinct去重
POST /sales/_search?size=0
{
"aggs": {
"type_count": {
"cardinality": {
"field": "type"
}
}
}
}
返回
{
...
"aggregations": {
"type_count": {
"value": 3
}
}
}
median_absolute_deviation 中位值
GET reviews/_search
{
"size": 0,
"aggs": {
"review_average": {
"avg": {
"field": "rating"
}
},
"review_variability": {
"median_absolute_deviation": {
"field": "rating"
}
}
}
}
返回
{
...
"aggregations": {
"review_average": {
"value": 3.0
},
"review_variability": {
"value": 2.0
}
}
}
非单值分析stats型
stats 包含avg,max,min,sum和count
POST /exams/_search?size=0
{
"aggs": {
"grades_stats": { "stats": { "field": "grade" } }
}
}
返回
{
...
"aggregations": {
"grades_stats": {
"count": 2,
"min": 50.0,
"max": 100.0,
"avg": 75.0,
"sum": 150.0
}
}
}
matrix_stats 针对矩阵模型
以下示例说明了使用矩阵统计量来描述收入与贫困之间的关系。
GET /_search
{
"aggs": {
"statistics": {
"matrix_stats": {
"fields": [ "poverty", "income" ]
}
}
}
}
返回
{
...
"aggregations": {
"statistics": {
"doc_count": 50,
"fields": [ {
"name": "income",
"count": 50,
"mean": 51985.1,
"variance": 7.383377037755103E7,
"skewness": 0.5595114003506483,
"kurtosis": 2.5692365287787124,
"covariance": {
"income": 7.383377037755103E7,
"poverty": -21093.65836734694
},
"correlation": {
"income": 1.0,
"poverty": -0.8352655256272504
}
}, {
"name": "poverty",
"count": 50,
"mean": 12.732000000000001,
"variance": 8.637730612244896,
"skewness": 0.4516049811903419,
"kurtosis": 2.8615929677997767,
"covariance": {
"income": -21093.65836734694,
"poverty": 8.637730612244896
},
"correlation": {
"income": -0.8352655256272504,
"poverty": 1.0
}
} ]
}
}
}
extended_stats
根据从汇总文档中提取的数值计算统计信息。
GET /exams/_search
{
"size": 0,
"aggs": {
"grades_stats": { "extended_stats": { "field": "grade" } }
}
}
上面的汇总计算了所有文档的成绩统计信息。聚合类型为extended_stats并且字段设置定义将在其上计算统计信息的文档的数字字段。
{
...
"aggregations": {
"grades_stats": {
"count": 2,
"min": 50.0,
"max": 100.0,
"avg": 75.0,
"sum": 150.0,
"sum_of_squares": 12500.0,
"variance": 625.0,
"variance_population": 625.0,
"variance_sampling": 1250.0,
"std_deviation": 25.0,
"std_deviation_population": 25.0,
"std_deviation_sampling": 35.35533905932738,
"std_deviation_bounds": {
"upper": 125.0,
"lower": 25.0,
"upper_population": 125.0,
"lower_population": 25.0,
"upper_sampling": 145.71067811865476,
"lower_sampling": 4.289321881345245
}
}
}
}
string_stats 针对字符串
用于计算从聚合文档中提取的字符串值的统计信息。这些值可以从特定的关键字字段中检索。
POST /my-index-000001/_search?size=0
{
"aggs": {
"message_stats": { "string_stats": { "field": "message.keyword" } }
}
}
返回
{
...
"aggregations": {
"message_stats": {
"count": 5,
"min_length": 24,
"max_length": 30,
"avg_length": 28.8,
"entropy": 3.94617750050791
}
}
}
非单值分析:百分数型
percentiles 百分数范围
针对从聚合文档中提取的数值计算一个或多个百分位数。
GET latency/_search
{
"size": 0,
"aggs": {
"load_time_outlier": {
"percentiles": {
"field": "load_time"
}
}
}
}
默认情况下,百分位度量标准将生成一定范围的百分位:[152550759599]。
{
...
"aggregations": {
"load_time_outlier": {
"values": {
"1.0": 5.0,
"5.0": 25.0,
"25.0": 165.0,
"50.0": 445.0,
"75.0": 725.0,
"95.0": 945.0,
"99.0": 985.0
}
}
}
}
percentile_ranks 百分数排行
根据从汇总文档中提取的数值计算一个或多个百分位等级。
GET latency/_search
{
"size": 0,
"aggs": {
"load_time_ranks": {
"percentile_ranks": {
"field": "load_time",
"values": [ 500, 600 ]
}
}
}
}
返回
{
...
"aggregations": {
"load_time_ranks": {
"values": {
"500.0": 90.01,
"600.0": 100.0
}
}
}
}
上述结果表示90.01的页面加载在500ms内完成而100的页面加载在600ms内完成。
非单值分析:地理位置型
geo_bounds Geo bounds
PUT /museums
{
"mappings": {
"properties": {
"location": {
"type": "geo_point"
}
}
}
}
POST /museums/_bulk?refresh
{"index":{"_id":1}}
{"location": "52.374081,4.912350", "name": "NEMO Science Museum"}
{"index":{"_id":2}}
{"location": "52.369219,4.901618", "name": "Museum Het Rembrandthuis"}
{"index":{"_id":3}}
{"location": "52.371667,4.914722", "name": "Nederlands Scheepvaartmuseum"}
{"index":{"_id":4}}
{"location": "51.222900,4.405200", "name": "Letterenhuis"}
{"index":{"_id":5}}
{"location": "48.861111,2.336389", "name": "Musée du Louvre"}
{"index":{"_id":6}}
{"location": "48.860000,2.327000", "name": "Musée d'Orsay"}
POST /museums/_search?size=0
{
"query": {
"match": { "name": "musée" }
},
"aggs": {
"viewport": {
"geo_bounds": {
"field": "location",
"wrap_longitude": true
}
}
}
}
上面的汇总展示了如何针对具有商店业务类型的所有文档计算位置字段的边界框
{
...
"aggregations": {
"viewport": {
"bounds": {
"top_left": {
"lat": 48.86111099738628,
"lon": 2.3269999679178
},
"bottom_right": {
"lat": 48.85999997612089,
"lon": 2.3363889567553997
}
}
}
}
}
geo_centroid Geo-centroid
PUT /museums
{
"mappings": {
"properties": {
"location": {
"type": "geo_point"
}
}
}
}
POST /museums/_bulk?refresh
{"index":{"_id":1}}
{"location": "52.374081,4.912350", "city": "Amsterdam", "name": "NEMO Science Museum"}
{"index":{"_id":2}}
{"location": "52.369219,4.901618", "city": "Amsterdam", "name": "Museum Het Rembrandthuis"}
{"index":{"_id":3}}
{"location": "52.371667,4.914722", "city": "Amsterdam", "name": "Nederlands Scheepvaartmuseum"}
{"index":{"_id":4}}
{"location": "51.222900,4.405200", "city": "Antwerp", "name": "Letterenhuis"}
{"index":{"_id":5}}
{"location": "48.861111,2.336389", "city": "Paris", "name": "Musée du Louvre"}
{"index":{"_id":6}}
{"location": "48.860000,2.327000", "city": "Paris", "name": "Musée d'Orsay"}
POST /museums/_search?size=0
{
"aggs": {
"centroid": {
"geo_centroid": {
"field": "location"
}
}
}
}
上面的汇总显示了如何针对所有具有犯罪类型的盗窃文件计算位置字段的质心。
{
...
"aggregations": {
"centroid": {
"location": {
"lat": 51.00982965203002,
"lon": 3.9662131341174245
},
"count": 6
}
}
}
geo_line Geo-Line
PUT test
{
"mappings": {
"dynamic": "strict",
"_source": {
"enabled": false
},
"properties": {
"my_location": {
"type": "geo_point"
},
"group": {
"type": "keyword"
},
"@timestamp": {
"type": "date"
}
}
}
}
POST /test/_bulk?refresh
{"index": {}}
{"my_location": {"lat":37.3450570, "lon": -122.0499820}, "@timestamp": "2013-09-06T16:00:36"}
{"index": {}}
{"my_location": {"lat": 37.3451320, "lon": -122.0499820}, "@timestamp": "2013-09-06T16:00:37Z"}
{"index": {}}
{"my_location": {"lat": 37.349283, "lon": -122.0505010}, "@timestamp": "2013-09-06T16:00:37Z"}
POST /test/_search?filter_path=aggregations
{
"aggs": {
"line": {
"geo_line": {
"point": {"field": "my_location"},
"sort": {"field": "@timestamp"}
}
}
}
}
将存储桶中的所有geo_point值聚合到由所选排序字段排序的LineString中。
{
"aggregations": {
"line": {
"type" : "Feature",
"geometry" : {
"type" : "LineString",
"coordinates" : [
[
-122.049982,
37.345057
],
[
-122.050501,
37.349283
],
[
-122.049982,
37.345132
]
]
},
"properties" : {
"complete" : true
}
}
}
}
非单值分析Top型
top_hits 分桶后的top hits
POST /sales/_search?size=0
{
"aggs": {
"top_tags": {
"terms": {
"field": "type",
"size": 3
},
"aggs": {
"top_sales_hits": {
"top_hits": {
"sort": [
{
"date": {
"order": "desc"
}
}
],
"_source": {
"includes": [ "date", "price" ]
},
"size": 1
}
}
}
}
}
}
返回
{
...
"aggregations": {
"top_tags": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "hat",
"doc_count": 3,
"top_sales_hits": {
"hits": {
"total" : {
"value": 3,
"relation": "eq"
},
"max_score": null,
"hits": [
{
"_index": "sales",
"_type": "_doc",
"_id": "AVnNBmauCQpcRyxw6ChK",
"_source": {
"date": "2015/03/01 00:00:00",
"price": 200
},
"sort": [
1425168000000
],
"_score": null
}
]
}
}
},
{
"key": "t-shirt",
"doc_count": 3,
"top_sales_hits": {
"hits": {
"total" : {
"value": 3,
"relation": "eq"
},
"max_score": null,
"hits": [
{
"_index": "sales",
"_type": "_doc",
"_id": "AVnNBmauCQpcRyxw6ChL",
"_source": {
"date": "2015/03/01 00:00:00",
"price": 175
},
"sort": [
1425168000000
],
"_score": null
}
]
}
}
},
{
"key": "bag",
"doc_count": 1,
"top_sales_hits": {
"hits": {
"total" : {
"value": 1,
"relation": "eq"
},
"max_score": null,
"hits": [
{
"_index": "sales",
"_type": "_doc",
"_id": "AVnNBmatCQpcRyxw6ChH",
"_source": {
"date": "2015/01/01 00:00:00",
"price": 150
},
"sort": [
1420070400000
],
"_score": null
}
]
}
}
}
]
}
}
}
top_metrics
POST /test/_bulk?refresh
{"index": {}}
{"s": 1, "m": 3.1415}
{"index": {}}
{"s": 2, "m": 1.0}
{"index": {}}
{"s": 3, "m": 2.71828}
POST /test/_search?filter_path=aggregations
{
"aggs": {
"tm": {
"top_metrics": {
"metrics": {"field": "m"},
"sort": {"s": "desc"}
}
}
}
}
返回
{
"aggregations": {
"tm": {
"top": [ {"sort": [3], "metrics": {"m": 2.718280076980591 } } ]
}
}
}
参考文章
https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics.html

View File

@@ -0,0 +1,266 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 聚合聚合查询之Pipline聚合详解
如何理解pipeline聚合
如何理解管道聚合呢?最重要的是要站在设计者角度看这个功能的要实现的目的:让上一步的聚合结果成为下一个聚合的输入,这就是管道。
管道机制的常见场景
首先回顾下Tomcat管道机制中向你介绍的常见的管道机制设计中的应用场景。
责任链模式
管道机制在设计模式上属于责任链模式,如果你不理解,请参看如下文章:
责任链模式: 通过责任链模式, 你可以为某个请求创建一个对象链. 每个对象依序检查此请求并对其进行处理或者将它传给链中的下一个对象。
FilterChain
在软件开发的常接触的责任链模式是FilterChain它体现在很多软件设计中
比如Spring Security框架中
比如HttpServletRequest处理的过滤器中
当一个request过来的时候需要对这个request做一系列的加工使用责任链模式可以使每个加工组件化减少耦合。也可以使用在当一个request过来的时候需要找到合适的加工方式。当一个加工方式不适合这个request的时候传递到下一个加工方法该加工方式再尝试对request加工。
网上找了图这里我们后文将通过Tomcat请求处理向你阐述。
ElasticSearch设计管道机制
简单而言:让上一步的聚合结果成为下一个聚合的输入,这就是管道。
接下来,无非就是对不同类型的聚合有接口的支撑,比如:
第一个维度:管道聚合有很多不同类型,每种类型都与其他聚合计算不同的信息,但是可以将这些类型分为两类:
父级 父级聚合的输出提供了一组管道聚合,它可以计算新的存储桶或新的聚合以添加到现有存储桶中。
兄弟 同级聚合的输出提供的管道聚合,并且能够计算与该同级聚合处于同一级别的新聚合。
第二个维度:根据功能设计的意图
比如前置聚合可能是Bucket聚合后置的可能是基于Metric聚合那么它就可以成为一类管道
进而引出了xxx bucket(是不是很容易理解了 @pdai)
Bucket聚合 -> Metric聚合
bucket聚合的结果成为下一步metric聚合的输入
Average bucket
Min bucket
Max bucket
Sum bucket
Stats bucket
Extended stats bucket
对构建体系而言,理解上面的已经够了,其它的类型不过是锦上添花而言。
一些例子
这里我们通过几个简单的例子看看即可,具体如果需要使用看看文档即可。@pdai
Average bucket 聚合
POST _search
{
"size": 0,
"aggs": {
"sales_per_month": {
"date_histogram": {
"field": "date",
"calendar_interval": "month"
},
"aggs": {
"sales": {
"sum": {
"field": "price"
}
}
}
},
"avg_monthly_sales": {
// tag::avg-bucket-agg-syntax[]
"avg_bucket": {
"buckets_path": "sales_per_month>sales",
"gap_policy": "skip",
"format": "#,##0.00;(#,##0.00)"
}
// end::avg-bucket-agg-syntax[]
}
}
}
嵌套的bucket聚合聚合出按月价格的直方图
Metic聚合对上面的聚合再求平均值。
字段类型:
buckets_path指定聚合的名称支持多级嵌套聚合。
gap_policy 当管道聚合遇到不存在的值有点类似于term等聚合的(missing)时所采取的策略可选择值为skip、insert_zeros。
skip此选项将丢失的数据视为bucket不存在。它将跳过桶并使用下一个可用值继续计算。
format 用于格式化聚合桶的输出(key)。
输出结果如下
{
"took": 11,
"timed_out": false,
"_shards": ...,
"hits": ...,
"aggregations": {
"sales_per_month": {
"buckets": [
{
"key_as_string": "2015/01/01 00:00:00",
"key": 1420070400000,
"doc_count": 3,
"sales": {
"value": 550.0
}
},
{
"key_as_string": "2015/02/01 00:00:00",
"key": 1422748800000,
"doc_count": 2,
"sales": {
"value": 60.0
}
},
{
"key_as_string": "2015/03/01 00:00:00",
"key": 1425168000000,
"doc_count": 2,
"sales": {
"value": 375.0
}
}
]
},
"avg_monthly_sales": {
"value": 328.33333333333333,
"value_as_string": "328.33"
}
}
}
Stats bucket 聚合
进一步的stat bucket也很容易理解了
POST /sales/_search
{
"size": 0,
"aggs": {
"sales_per_month": {
"date_histogram": {
"field": "date",
"calendar_interval": "month"
},
"aggs": {
"sales": {
"sum": {
"field": "price"
}
}
}
},
"stats_monthly_sales": {
"stats_bucket": {
"buckets_path": "sales_per_month>sales"
}
}
}
}
返回
{
"took": 11,
"timed_out": false,
"_shards": ...,
"hits": ...,
"aggregations": {
"sales_per_month": {
"buckets": [
{
"key_as_string": "2015/01/01 00:00:00",
"key": 1420070400000,
"doc_count": 3,
"sales": {
"value": 550.0
}
},
{
"key_as_string": "2015/02/01 00:00:00",
"key": 1422748800000,
"doc_count": 2,
"sales": {
"value": 60.0
}
},
{
"key_as_string": "2015/03/01 00:00:00",
"key": 1425168000000,
"doc_count": 2,
"sales": {
"value": 375.0
}
}
]
},
"stats_monthly_sales": {
"count": 3,
"min": 60.0,
"max": 550.0,
"avg": 328.3333333333333,
"sum": 985.0
}
}
}
参考文章
https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html

View File

@@ -0,0 +1,405 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 原理从图解构筑对ES原理的初步认知
前言
本文先自上而下后自底向上的介绍ElasticSearch的底层工作原理试图回答以下问题
为什么我的搜索 *foo-bar* 无法匹配 foo-bar
为什么增加更多的文件会压缩索引Index
为什么ElasticSearch占用很多内存
版本
elasticsearch版本: elasticsearch-2.2.0
图解ElasticSearch
云上的集群
集群里的盒子
云里面的每个白色正方形的盒子代表一个节点——Node。
节点之间
在一个或者多个节点直接多个绿色小方块组合在一起形成一个ElasticSearch的索引。
索引里的小方块
在一个索引下分布在多个节点里的绿色小方块称为分片——Shard。
ShardLucene Index
一个ElasticSearch的Shard本质上是一个Lucene Index。
Lucene是一个Full Text 搜索库也有很多其他形式的搜索库ElasticSearch是建立在Lucene之上的。接下来的故事要说的大部分内容实际上是ElasticSearch如何基于Lucene工作的。
图解Lucene
Segment
Mini索引——segment
在Lucene里面有很多小的segment我们可以把它们看成Lucene内部的mini-index。
Segment内部
(有着许多数据结构)
Inverted Index
Stored Fields
Document Values
Cache
Inverted Index
最最重要的Inverted Index
Inverted Index主要包括两部分
一个有序的数据字典Dictionary包括单词Term和它出现的频率
与单词Term对应的Postings即存在这个单词的文件
当我们搜索的时候首先将搜索的内容分解然后在字典里找到对应Term从而查找到与搜索相关的文件内容。
查询“the fury”
自动补全AutoCompletion-Prefix
如果想要查找以字母“c”开头的字母可以简单的通过二分查找Binary Search在Inverted Index表中找到例如“choice”、“coming”这样的词Term
昂贵的查找
如果想要查找所有包含“our”字母的单词那么系统会扫描整个Inverted Index这是非常昂贵的。
在此种情况下如果想要做优化那么我们面对的问题是如何生成合适的Term。
问题的转化
对于以上诸如此类的问题,我们可能会有几种可行的解决方案:
* suffix -> xiffus *
如果我们想以后缀作为搜索条件可以为Term做反向处理。
(60.6384, 6.5017) -> u4u8gyykk
对于GEO位置信息可以将它转换为GEO Hash。
123 -> {1-hundreds, 12-tens, 123}
对于简单的数字可以为它生成多重形式的Term。
解决拼写错误
一个Python库 为单词生成了一个包含错误拼写信息的树形状态机,解决拼写错误的问题。
Stored Field字段查找
当我们想要查找包含某个特定标题内容的文件时Inverted Index就不能很好的解决这个问题所以Lucene提供了另外一种数据结构Stored Fields来解决这个问题。本质上Stored Fields是一个简单的键值对key-value。默认情况下ElasticSearch会存储整个文件的JSON source。
Document Values为了排序聚合
即使这样我们发现以上结构仍然无法解决诸如排序、聚合、facet因为我们可能会要读取大量不需要的信息。
所以另一种数据结构解决了此种问题Document Values。这种结构本质上就是一个列式的存储它高度优化了具有相同类型的数据的存储结构。
为了提高效率ElasticSearch可以将索引下某一个Document Value全部读取到内存中进行操作这大大提升访问速度但是也同时会消耗掉大量的内存空间。
总之这些数据结构Inverted Index、Stored Fields、Document Values及其缓存都在segment内部。
搜索发生时
搜索时Lucene会搜索所有的segment然后将每个segment的搜索结果返回最后合并呈现给客户。
Lucene的一些特性使得这个过程非常重要
Segments是不可变的immutable
Delete? 当删除发生时Lucene做的只是将其标志位置为删除但是文件还是会在它原来的地方不会发生改变
Update? 所以对于更新来说本质上它做的工作是先删除然后重新索引Re-index
随处可见的压缩
Lucene非常擅长压缩数据基本上所有教科书上的压缩方式都能在Lucene中找到。
缓存所有的所有
Lucene也会将所有的信息做缓存这大大提高了它的查询效率。
缓存的故事
当ElasticSearch索引一个文件的时候会为文件建立相应的缓存并且会定期每秒刷新这些数据然后这些文件就可以被搜索到。
随着时间的增加我们会有很多segments
所以ElasticSearch会将这些segment合并在这个过程中segment会最终被删除掉
这就是为什么增加文件可能会使索引所占空间变小它会引起merge从而可能会有更多的压缩。
举个栗子
有两个segment将会merge
这两个segment最终会被删除然后合并成一个新的segment
这时这个新的segment在缓存中处于cold状态但是大多数segment仍然保持不变处于warm状态。
以上场景经常在Lucene Index内部发生的。
在Shard中搜索
ElasticSearch从Shard中搜索的过程与Lucene Segment中搜索的过程类似。
与在Lucene Segment中搜索不同的是Shard可能是分布在不同Node上的所以在搜索与返回结果时所有的信息都会通过网络传输。
需要注意的是:
1次搜索查找2个shard 2次分别搜索shard
对于日志文件的处理
当我们想搜索特定日期产生的日志时,通过根据时间戳对日志文件进行分块与索引,会极大提高搜索效率。
当我们想要删除旧的数据时也非常方便,只需删除老的索引即可。
在上种情况下每个index有两个shards
如何Scale
shard不会进行更进一步的拆分但是shard可能会被转移到不同节点上
所以,如果当集群节点压力增长到一定的程度,我们可能会考虑增加新的节点,这就会要求我们对所有数据进行重新索引,这是我们不太希望看到的,所以我们需要在规划的时候就考虑清楚,如何去平衡足够多的节点与不足节点之间的关系。
节点分配与Shard优化
为更重要的数据索引节点,分配性能更好的机器
确保每个shard都有副本信息replica
路由Routing
每个节点每个都存留一份路由表所以当请求到任何一个节点时ElasticSearch都有能力将请求转发到期望节点的shard进一步处理。
一个真实的请求
Query
Query有一个类型filtered以及一个multi_match的查询
Aggregation
根据作者进行聚合得到top10的hits的top10作者的信息
请求分发
这个请求可能被分发到集群里的任意一个节点
上帝节点
这时这个节点就成为当前请求的协调者Coordinator它决定 a) 根据索引信息,判断请求会被路由到哪个核心节点 b) 以及哪个副本是可用的 c) 等等
路由
在真实搜索之前
ElasticSearch 会将Query转换成Lucene Query
然后在所有的segment中执行计算
对于Filter条件本身也会有缓存
但queries不会被缓存所以如果相同的Query重复执行应用程序自己需要做缓存
所以,
a) filters可以在任何时候使用 b) query只有在需要score的时候才使用
返回
搜索结束之后,结果会沿着下行的路径向上逐层返回。
参考来源
SlideShare: Elasticsearch From the Bottom Up
Youtube: Elasticsearch from the bottom up
Wiki: Document-term matrix
Wiki: Search engine indexing
Skip list
Standford Edu: Faster postings list intersection via skip pointers
StackOverflow: how an search index works when querying many words?
StackOverflow: how does lucene calculate intersection of documents so fast?
Lucene and its magical indexes
misspellings 2.0c: A tool to detect misspellings

Some files were not shown because too many files have changed in this diff Show More