Friday, February 18, 2022

SegmentFault 最新的文章

SegmentFault 最新的文章


动辄“耗资过亿”的表格工具,究竟难在哪儿?丨思否专访

Posted: 17 Feb 2022 07:53 PM PST

棋盘上的麦粒问题,相信每个人都听说过。那小小的 64 个棋盘格,因为一个简单的逻辑设定,便能装下全世界的的麦粒。表格工具作为办公场景中最基础也最常用的业务支撑工具,同样具有着无限的可能。

近年来,互联网、大数据、云计算、人工智能、区块链等技术加速创新,日益融入经济社会发展各领域全过程。数字经济发展速度之快、辐射范围之广、影响程度之深前所未有,正在成为重组全球要素资源、重塑全球经济结构、改变全球竞争格局的关键力量。

软件和信息服务业作为数字经济的先导性、基础性和战略性产业,是未来数字世界的构建者和引领者。作为知名的软件开发技术和低代码平台提供商,葡萄城专注开发技术和工具四十余年,一直以来引领着国内控件技术和数据分析工具的发展。

本期 SegmentFault 思否 x 技术人专访,采访嘉宾为西安葡萄城技术布道师姚尧。由他来为我们分享表格工具这一看似简单、却动辄"耗资过亿"的产品,技术究竟难在哪儿?究竟解决了什么问题?在我们的日常工作、生活甚至数字经济浪潮中,会扮演什么样的角色?


采访对象介绍:

姚尧,西安葡萄城技术布道师,SegmentFault D-Day 技术讲师,微软开发者峰会讲师。姚尧毕业于西北工业大学计算机系,一直聚焦于前端电子表格在各行业信息化中的使用,积累了丰富的前端技术和行业解决方案经验,为腾讯、阿里、华为以及平安等企业提供技术咨询服务。

https://v.qq.com/x/page/y3324...

以下为采访 Q&A 的文字内容,为方便阅读略有删改。


Q1:能否用一句话来介绍 SpreadJS?提炼三个关键词,又会是哪三个?

用一句话的话,我会这么说 —— "葡萄城开发的纯前端表格控件 SpreadJS 可以用不到 10 行代码,将传统 Excel 的功能和使用体验完美嵌入到在线文档系统中。"提炼三个关键词的话我会选择匠人、匠心、匠术。

首先,SpreadJS 是一款在界面和功能上都与 Excel 高度类似的开发控件,提供了表格文档协同编辑、 数据填报和类 Excel 报表设计的功能支持,可帮助软件厂商和系统集成商有效应对数据处理、数据共享和数据有效性甄别等业务需求。除了强大的功能外,正如我的一句话介绍,SpreadJS 可帮助开发者在不依赖任何 Excel 组件的情况下,实现"用不到 10 行代码,将 Excel 的功能和使用体验完美嵌入到在线文档系统中。"

之所以能做到这些,在于葡萄城从 1993 年推出支持微软 Visual Basic 的控件产品,再到与微软展开持续深入的合作,应时而变推出前端开发控件和快速开发平台,目标便是打造出完美兼容 Excel 的功能和使用体验并高度匹配在线办公场景需求的 SpreadJS 控件。

通过 30 年的精耕细作以及对行业的理解沉淀,这背后是一群匠人,凭借匠心打磨出的一款工匠级别的产品与技术。


Q2:SpreadJS 现阶段主要的应用场景有什么?主要用来解决哪些问题?

很多企业的 IT 业务都是从一张表格开始的。不仅仅是数据存储,团队中的信息共享也依赖于表格这一表现形式。文档、报告、凭证以及基础数据的汇总计算,大部分是在表格的形式中完成分析与决策的。即便表格的应用场景已经十分广泛,但随着业务的发展,用户对于表格产品的性能和系统兼容度还是能提出更高的需求,这也迫使表格产品不断的优化迭代。

现阶段,SpreadJS 主要聚焦于未来的智慧办公,应用于表格文档线上协同、数据填报、以及类 Excel 报表设计这三大应用场景。借助 SpreadJS 提供的 API 与二次扩展能力,可以让数据处理不再受硬件、操作系统与使用环境的限制,帮助企业实现更为高效的数据处理应用。

比如 SpreadJS 中全新设计的 TableSheet 能力,除了排序、筛选、样式、行和列置顶以及单元格编辑等 WorkSheet 基础功能之外,还提供了关系数据管理、结构化公式和数据分组等实用功能。


Q3:表格工具在用户端看起来似乎很简单、很容易实现,但业内也有人表示这是一个"耗资数亿"才能做出的产品。对于一个表格工具而言,技术层面最大的技术难点是什么?西安葡萄城在表格领域已经深挖数十年,您认为最重要且最坚实的技术积淀是什么?

B/S 作为 Web 兴起之后的一种应用模式,统一了客户端,将系统功能实现的核心部分集中到服务器上。

但随之而来的问题是多浏览器差异、浏览器沙箱机制、内存访问受限、客户端性能低下等。作为数据载体的表格,最直接的影响就是经常会被"吐槽"卡顿,UI 界面"假死",界面操作不流畅等。

引起这些问题的症结在于浏览器渲染引擎的基础原理:当界面元素越多,浏览器的渲染时间会显著增长,内存消耗会越大。这对于强计算逻辑的表格工具来说,无疑是棘手的难题。

由此可见,开发一款前端表格控件需要攻克这四个技术难点:性能、内存消耗、可靠性和操作体验。

当然,开发一款前端表格控件最难的不只是技术,还有对表格产品的熟悉程度。因为纯技术的问题,开发者靠时间与精力的投入总能弥补。然而,一款真正优秀的产品最重要的一点,则是对于应用场景,以及用户使用体验的细节把控,这也是 SpreadJS 最坚实的技术沉淀之一。


Q4:在过去几年里,SpreadJS 备受华为、明源云、远光软件、腾讯、网易等知名企业青睐,丰富的应用场景和解决方案已经覆盖了十几个行业众多头部客户的项目。对于客户而言,表格工具有哪些是最基础最核心的硬需求,又有哪些功能是"额外的惊喜"?

如前面所说,团队沟通中的信息共享大量依赖于表格这一展现形式,而伴随着企业数字化转型的迫切需要,远程办公模式已正式开启,纯在线的表格产品俨然成为了很多企业必备的工具之一。

以某知名保险公司为例,各级机构需要定期向上汇报业务数据,机构人员根据业务将从系统获得的基础数据进行分析汇总,以表格和图表的方式呈现给上级单位。传统的 Excel 线下方式费时费力容易出错;人员流动性较大的基层机构又无法使用繁琐的 BI 系统。而使用 SpreadJS 开发的自助报表系统,可以让基层业务人员按照传统的 Excel 方式在线设计维护数据报表的模板,通过表格数据绑定实现报表的定时发送。自助式报表系统降低人力成本同时也降低了使用人员的门槛,对于报表的查看人员可以直接看到嵌入在邮件中报表结果,或者通过链接进入在线报表查看实时数据。

但随着表格工具的发展,企业和用户对文档协同工具的需求也从「好用」变成了「适用」,如何满足不同场景下的用户需求,是市场对 Saas 企业和系统供应商们提出的挑战。

提到额外的惊喜,除了对于表格依赖较多的金融保险等行业,SpreadJS 还悄悄改变了很多行业的信息化实现方式。在计量检定行业中,吉林省科图科技有限公司是信息化的先行者,公司提供的计量检测云服务 SaaS,其核心证书模块便是和 SpreadJS 产品线一起打磨而成。从最初的证书在线制作,到证书的在线打印,以及后续的证书批量制作。在计量行业,在线证书设计、预览,证书内容自动填充,特殊符号矢量支持,批量 PDF 证书生成等功能,SpreadJS 已经全面覆盖。


Q5:很多企业的 IT 业务都是从一张表格开始的。不仅仅是数据存储,团队中的信息共享也需依赖于表格这一数据结构。在未来的智慧办公场景及数字经济中,一份表格还可以承载哪些需求?可以担任什么样的角色?

了解表格工具的朋友会知道,表格工具的迭代历程,其实正是一部用户需求的演化史。而表格工具保持旺盛生命力的原因,正是因为人们对数据处理的需求始终旺盛。随着互联网的发展,在智慧办公场景中人们对于数据表格工具有着更多的期待,比如基于云服务的在线功能以及企业级的协同需求,就是现阶段的一个重点。尤其是 2022 年数字中国概念的提出,有力推进了线上办公进程,使得表格技术的在线协同能力上升到了新的高度。

未来十年的表格工具,一定会要具备"云端、智能化、数据共享"这几个特点,链接多元的业务数据场景,提升企业的生产力。

对于表格工具担任的角色,我想用"脚踏实地,仰望星空"来形容。表格工具作为最有力的底层支持工具,一定是脚踏实地的发展技术、深入行业,成为业务创新、技术探索最有力的支撑。但同样凭借链接数据的能力,也有可能成为带动办公场景变革、企业生产模式变革的一把钥匙。


今日,葡萄城将践行"赋能开发者"使命,携最新前沿电子表格技术举办"葡萄城表格技术研讨会暨表格产品发布会",旨在分享先进表格技术功能特性,发掘表格技术最佳实践,推动未来办公领域数字化发展。

思否小姐姐邀您一起,走近表格工具里的无穷宇宙。

GPL3.0许可证软件著作权纠纷案例解析

Posted: 17 Feb 2022 07:32 PM PST

作者:陈元熹律师,海华永泰律师事务所 合伙人

工信部 《"十四五"软件和信息技术服务业发展规划的通知》提出了"繁荣国内开源生态"的目标,我国将大力发展国内开源基金会等开源组织,完善开源软件治理规则,普及开源软件文化。开源软件的治理与生态建设,离不开法律对开源软件作者著作权的保护。

本文将通过分析最近中国法院有关GPL3.0许可证的实践判例,初步探讨中国法下开源许可证的性质与法律效力,开源软件侵权纠纷赔偿等问题,以期能为开源软件开发者与使用者保护自身权利、避免纠纷提供参考。

一、 开源软件作者判定与主体适格问题

1. 开源软件受著作权法律保护

计算机软件是《著作权法》明确规定的作品,作品不论是否发表,作者都应享有著作权。开源软件作为一种已经发布在网络平台上,可供用户访问使用的软件,作者享有包括发表、署名、修改、复制、网络信息传播等著作权。所以,开源软件的作者作为著作权人,可以根据法律保护自己的合法权益,并在受到侵权时要求侵权人承担相应的责任。

2. 署名者即为软件作者

根据我国现行的《著作权法》,如无相反证据证明,在作品上署名的即为作者。《计算机软件保护条例》也规定,计算机软件的著作权属于软件开发者,如无响应证明,在软件上署名的自然人、法人或者其他组织为开发者。

除了署名以外,作者可以通过其他方式证明自己的权利。根据《最高人民法院关于审理著作权民事纠纷案件适用法律若干问题的解释》第七条规定:"当事人提供的涉及著作权的底稿、原件、合法出版物、著作权登记证书、认证机构出具的证明、取得权利的合同等,可以作为证据。"其中,作者可以在中国版权保护中心(国家版权登记门户网)提交著作权登记申请,取得登记证书,明确作品的名称、完成日期、发表日期、地点、作者身份等权利归属,并在主张权利时作为拥有权利的初步证明。

在(2018)京民终471号判决书,即数字天堂(北京)网络技术有限公司诉柚子(北京)科技有限公司等侵犯计算机软件著作权纠纷一案(以下简称"数字天堂案")中,数字天堂公司对其开发的HBuilder软件中的三个插件,即代码输入法功能插件、真机运行功能插件、边改边看功能插件分别进行了著作权登记并取得了登记证书,法院认可数字天堂公司有权主张著作权。

3. Github项目人作为诉讼主体是否适格

Github的规则要求项目人允许任意用户查看与复制仓库内容。根据GitHub网站的规则,开源软件项目人将软件源代码上传到网站后,如果将页面和仓库设为公开显示,则表示项目人向每个用户授予非独占、全球许可,允许他们通过 GitHub 服务使用、显示和执行内容。除了Github默认的规则要求外,项目人还可以采用许可证的形式,进一步授予用户更多的权利。

Github规则与开源软件的特性允许这些用户在修复开源软件的bug或增加功能,将修改后的源代码反馈给项目人,向项目人发出pull request,如果项目人同意,修改后的源代码就正式merge到该开源软件的源代码中,成为一个新的版本。这些用户在Github网站上被标记为贡献者。

在(2019)粤73知民初207号判决书,即济宁市罗盒网络科技有限公司诉广州市玩友网络科技有限公司等侵害计算机软件著作权纠纷(以下简称"玩友案")中,法院认为罗盒公司起诉不需要另外32位贡献者的同意。法院认为现有证据无法认定VirtualApp属于合作作品,即使属于合作作品,由于贡献者分布于世界各地,如果要求必须经过所有贡献者的授权才能提起诉讼,那么将导致开源软件维权无从提起。法院认定玩友公司侵犯的是该软件开源版的著作权,同时认为如果其他贡献者可以向罗盒公司主张分割赔偿款。

对此,本文作者认为存在一定争议:罗盒公司主张的是其取得登记证书的VirtualApp开源版的著作权被侵犯,其中已经包含了32位贡献者修改的代码本案的争议作品的发布与修改都是通过Github网站进行的,同样也可以通过该网站或者其他方式取得贡献者的同意,并没有证据证明贡献者与项目人签署协议授权项目人代表其诉讼,也没有任何证据表明罗盒公司对此进行了努力。法院认为贡献者并非著作权人,同时允许贡献者主张分割赔偿款,本文作者认为存在一定矛盾。通过认定超过百分之90的代码由项目人提供,否定贡献者对开源软件独创性的贡献,将项目人认为单一著作权人,将大大打击贡献者的积极性,不利于开源社区生态的发展。实践中,我们发现大型开源项目常常会签署贡献者授权协议来解决授权问题。

二、 GPL开源许可证的法律性质

GNU通用公共许可协议,英文GNU General Pubic License, 缩写为GNU GPL或GPL,是开源软件领域最受欢迎的软件许可证之一。

GPL3.0许可证作为一种Copyleft许可证,具有"高传染性"。GPL3.0许可证允许开源软件用户可以对开源软件进行自由的复制、分发、修改及再发布,但要求用户应同样使用GPL3.0许可证公布相应的源代码。所以,作者通过使用GPL3.0许可证,不仅将部分著作权授予用户,也为用户设定了以相同条件开源的义务。

我国判例倾向于将GPL3.0许可证认定为附条件的合同。玩友案中,广州知识产权法院采用"要约说",认为GPL3.0许可证属于软件权利人与用户之间订立的合同,是一种非典型、通过行为订立的书面格式合同,其中开源软件发布视为要约,用户使用视为承诺,在使用时合同即成立。首先,根据《民法典》及早前实行的相关法律规定,可以认为开源软件作者使用GPL3.0许可证的行为属于一种附条件的民事法律行为;其次,在(2019)粤03民初3928号判决书,即济宁市罗盒网络科技有限公司诉福建风灵创景科技有限公司等侵害计算机软件著作权纠纷一案(以下简称"风灵案")中,深圳市中级人民法院认为,GPL3.0许可证的内容与形式都具备合同的特征。

三、 违反许可证的后果与侵权赔偿

1. 违反GPL3.0许可证的后果

"风灵案"中法院认为"GPL3.0协议规定的使用条件(如开放源代码、标注著作权信息和修改信息等)系授权人许可用户自由使用的前提条件,亦即协议所附的解除条件。一旦用户违反了使用的前提条件,将导致GPL3.0协议在授权人与用户之间自动解除,用户基于协议获得的许可即时终止。"

本文作者认为,根据GPL3.0许可证的文本,违反该许可证的任何传播或修改作品的企图都是无效的,并将自动中止用户通过该许可证获得的权利;但是许可证同时规定了当用户不再违反许可证时,该授权可以恢复。所以,虽然我们可以将GPL3.0许可证作为中国法下附条件的合同,但是当用户违反许可证的前提条件时,许可证这一合同自动中止,而非直接终止而导致合同权利义务消灭。

不论是法院认为的许可终止,或者是可以恢复的中止,违反GPL3.0许可证后,用户对开源软件的复制、使用、发布、修改等行为就失去了权利来源,构成著作权侵权。

2. 侵权损害赔偿

根据2010年版《著作权法》,著作权侵权损害赔偿金额按照:1. 实际损失;2. 违法所得确定,包括合理开支或由法院在五十万元以内酌定赔偿。

我国于2020年对《著作权法》进行了修订,其中对著作权侵权损害赔偿进行了较大修改:1. 法定赔偿中增加了可以参照权利使用费给与赔偿;2. 引入了惩罚性赔偿的机制,情节严重的可以参照法定赔偿金额一到五倍赔偿;适用惩罚性赔偿须符合:侵权人故意侵犯著作权、情节严重,实际损失、违法所得与权利使用费难以计算;3. 酌定赔偿的金额从五十万元提升为五百元以上五百万元以下。

由于案件事实均发生在2020年以前,在"数字天堂案","玩友案"与"风灵案"中,法院均判决被告需赔付原告总金额五十万元。这几个案件几乎都是按照当时实行法律的最高限额判决。可以预见,未来开源软件著作权侵权损害赔偿将很有可能按照五百万元进行判决,甚至可能让被告付出远超违法所得的惩罚性赔偿。

四、结语:

开源软件的发布、共享、修改与再发布,甚至商用等行为,都是离不开开源协议这一基石。GPL3.0许可证作为数以百计的开源协议的一种,已经得到中国法院的承认与保护。在中国法院的判例中,我们看到了知名开源软件项目方在使用开源协议中的混乱行为,而开源软件用户侵犯著作权,违反开源协议的行为也得到了判罚。作为开源软件的参与者,开发者与开源软件用户都应该重视开源协议的选择、使用等问题。

开源软件的企业用户更应主动建立合规体系并做好开源合规,了解各类开源协议的权利义务,做好事前、事中与事后审查,对企业内部使用开源软件进行审查,避免出现无意中侵犯开源软件著作权的行为;如果发现侵权,应及时进行弥补,采取与作者和解、购买商业版本许可等补救措施,以免加重自身责任,付出高额代价。

参考资料:
[1] https://www.gnu.org/licenses/...,访问日期:2022年2月16日
[2] https://zh.wikipedia.org/wiki...通用公共许可证,访问日期:2022年2月16日
[3] https://jxself.org/translatio...,访问日期:2022年2月16日
[4] 《中华人民共和国民法典》
[5] https://ipc.court.gov.cn/zh-c...,访问日期:2022年2月16日
[6] 开源软件著作权侵权现状之实证研究,吴梦晨,青海民族大学,《大国策》2021年06期
[7] 工业和信息化部关于印发"十四五"软件和信息技术服务业发展规划的通知,http://www.gov.cn/zhengce/zhe...,访问日期:2022年2月16日
[8] 版权案例丨GPL3.0协议具有"高传染性"!衍生作品等亦需遵循协议开放其源代码,https://mp.weixin.qq.com/s/fd...,访问日期:2022年2月16日
[9] Alibaba Open Source Individual CLA

XTask 一个拓展性极强的Android任务执行框架

Posted: 17 Feb 2022 10:42 AM PST

背景

很早之前接触了RxJava的任务流操作,觉得这种将复杂业务流通过一个个操作符拆解开来,形成一条条条理清晰的function, 让人写起来直呼过瘾.其实这就是责任链模式的一种应用.

但是RxJava的功能实在是太强大了, 如果仅仅是使用它来处理这些业务流我觉得还是有些大材小用了.

之前也做过一段时间的应用性能优化, 其中当然就包括应用冷启动优化, 中间有涉及过启动器的概念, 当时也查阅了一些现有的开源框架, 也使用过其中一些, 但是总觉得并不是很好用, 用起来不是很顺手.

作为一名资深Android开源框架卷王, 当时我脑海里就萌发一种想法, 为啥我不自己写一个任务流执行的框架呢?想到这, 我就抽出了我的一部分业余时间(女朋友都不陪了), 撸出了这个XTask框架, 自我感觉非常nice, 在这分享给大家.

简介

XTask是一个拓展性极强的Android任务执行框架。

可自由定义和组合任务来实现你想要的功能,尤其适用于处理复杂的业务流程,可灵活添加前置任务或者调整执行顺序。例如:应用的启动初始化流程。

项目地址


特征

  • 支持6种线程类型方式执行任务。
  • 支持任务链中各任务的执行线程调度和控制。
  • 支持快捷任务创建,同时支持自定义任务。
  • 支持串行和并行等组任务。
  • 支持任务间数据共享。
  • 支持自由组合任务执行。
  • 支持任务链执行取消。
  • 支持取消所有任务链和指定名称的任务链。
  • 支持任务链调用顺序记录和查询。
  • 支持自定义任务执行的线程池。

设计思想

框架主体使用责任链的设计模式,辅以建造者模式、工厂模式、适配器模式、组合模式、外观模式以及代理模式来实现。

组成结构

  • 任务链ITaskChainEngine:任务链执行引擎,负责统筹调度各任务步骤。
  • 任务步骤ITaskStep:负责具体任务逻辑处理。
  • 数据存储仓库IDataStore:存放数据的仓库,主要用于保存任务参数中的数据。
  • 任务参数ITaskParam:负责任务路径记录以及任务产生的参数管理。
  • 任务执行结果ITaskResult:存放任务最终执行的结果以及产生的数据。
  • 任务组IGroupTaskStep:负责统筹调度各子任务步骤。

点击查看框架UML设计图

日志一览

task_log.png

task_log2.png


集成指南

添加Gradle依赖

1.先在项目根目录的 build.gradlerepositories 添加:

allprojects {      repositories {         ...         maven { url "https://jitpack.io" }     } }

2.然后在dependencies添加:

dependencies {   ...   // XTask   implementation 'com.github.xuexiangjys:XTask:xtask-core:1.0.0' }

使用方法

XTask作为对外统一的API入口,所有常用的方法都能从中找到。

打开调试模式

当需要定位问题,需要进行调试时,可打开调试模式,这样便可开启框架的日志。

XTask.debug(true);

XTask的API介绍

方法名描述
debug设置是否打开调试
setLogger自定义日志打印
setIsLogTaskRunThreadName设置是否打印任务执行所在的线程名,默认false
getTaskChain获取任务链执行引擎
getTask获取简化的任务
getTaskBuilder获取简化任务的构建者
getConcurrentGroupTask获取并行任务组
getSerialGroupTask获取串行任务组
cancelTaskChain取消指定任务链执行
postToMain执行任务到主线程
submit执行普通异步任务
emergentSubmit执行紧急异步任务
backgroundSubmit执行后台异步任务
ioSubmit执行io耗时的异步任务
groupSubmit执行分组异步任务

如何执行一条任务链

下面是一整个完整的例子:

// 1.创建一条任务链(必须) final TaskChainEngine engine = XTask.getTaskChain(); // 2.设置任务链的初始化参数(可选) engine.setTaskParam(TaskParam.get("chainName", engine.getName())); TaskParam taskParam = TaskParam.get("param1", 100)         .put("param2", true); // 3.创建多个任务,并向任务链中添加(必须) XTaskStep taskStep = XTask.getTask(new TaskCommand() {     @Override     public void run() {         ITaskParam param = getTaskParam();         Log.e(TAG, getName() + "  start, param1:" + param.get("param1") + ", chainName:" + param.get("chainName"));         param.put("param1", 200);         param.put("param3", "this is param3!");     } }, taskParam); engine.addTask(taskStep)         .addTask(XTask.getTask(new TaskCommand() {             @Override             public void run() {                 ITaskParam param = getTaskParam();                 Log.e(TAG, getName() + "  start, param1:" + param.get("param1") + ", param3:" + param.get("param3"));                 param.put("param2", false);             }         })); // 4.设置任务链执行回调(可选) ICanceller canceller = engine.setTaskChainCallback(new TaskChainCallbackAdapter() {     @Override     public void onTaskChainCompleted(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) {         Log.e(TAG, "task chain completed, thread:" + Thread.currentThread().getName());         Map<String, Object> data = result.getDataStore().getData();         for (Map.Entry<String, Object> entry : data.entrySet()) {             Log.e(TAG, "key:" + entry.getKey() + ", value:" + entry.getValue());         }     } // 5.任务链执行(必须) }).start();

1.创建一条任务链.(必须)

TaskChainEngine engine = XTask.getTaskChain();

2.设置任务链的初始化参数.(可选)

engine.setTaskParam(TaskParam.get("chainName", engine.getName()));

3.创建多个任务,并向任务链中添加.(必须)

// 设置任务初始化参数 TaskParam taskParam = TaskParam.get("param1", 100)         .put("param2", true); XTaskStep taskStep = XTask.getTask(new TaskCommand() {     @Override     public void run() {         // ...执行任务     } }, taskParam); engine.addTask(taskStep)         .addTask(XTask.getTask(new TaskCommand() {             @Override             public void run() {                 // ...执行任务             }         }));

【注意】对于任务执行完成,需要注意以下两点:

  • 如果任务执行成功,就调用notifyTaskSucceed,任务执行失败,就调用notifyTaskFailed。这里任务无论成功还是失败,只要执行完成都需要调用notifyTaskXXX通知任务链该任务完成,否则任务将无法正常执行。
  • TaskCommandSimpleTaskStep默认提供了自动通知执行结果的功能,但是AbstractTaskStep没有提供,需要手动通知。

4.设置任务链执行回调.(可选)

调用setTaskChainCallback设置任务链执行回调。

engine.setTaskChainCallback(new TaskChainCallbackAdapter() {      @Override     public boolean isCallBackOnMainThread() {         // 回调是否返回主线程, 默认是true         return false;     }     @Override     public void onTaskChainStart(@NonNull ITaskChainEngine engine) {         Log.e(TAG, "task chain start");     }     @Override     public void onTaskChainCompleted(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) {         Log.e(TAG, "task chain completed, thread:" + Thread.currentThread().getName());     }     @Override     public void onTaskChainError(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) {         Log.e(TAG, "task chain error");     } })

5.任务链执行.(必须)

调用start执行任务链。

ICanceller canceller = engine.start();

任务创建

创建任务有两种方式:

  • 通过XTask.getTask构建
  • 继承SimpleTaskStep/AbstractTaskStep实现任务的自定义

通过XTask创建

通过XTask.getTask, 传入对应的属性进行构建
属性名描述
name任务步骤名称
command任务执行内容
threadType线程执行类型
taskParam任务参数
taskHandler任务处理者
isAutoNotify是否自动通知任务执行结果
XTaskStep taskStep = XTask.getTask(new TaskCommand() {     @Override     public void run() {         // todo     } }, ThreadType.ASYNC, taskParam);

通过继承创建

通过继承SimpleTaskStep或者AbstractTaskStep实现具体功能。
public class StepATask extends SimpleTaskStep {      @Override     public void doTask() throws Exception {         // todo         // 不需要手动通知任务链任务完成     } }  public class StepBTask extends AbstractTaskStep {      @Override     public void doTask() throws Exception {         // todo         // 需手动通知任务链任务完成         notifyTaskSucceed(TaskResult.succeed());     }      @Override     public String getName() {         return "StepATask";     } }

任务执行原则

每一个任务都是依托于任务链进行流程控制。任何任务都需要遵循以下原则:

  • 任何任务无论失败还是成功,都需要调用notifyTaskSucceed或者notifyTaskFailed去通知任务链任务的完成情况。TaskCommandSimpleTaskStep默认提供了自动通知执行结果的功能。
  • 一旦任务链中某个任务执行失败,整个链路都停止工作。
任务类型任务执行说明
TaskCommand自动通知执行结果。如需手动通知,只需设置isAutoNotify为false即可
SimpleTaskStep自动通知执行结果。如需手动通知,只需重写isAutoNotify方法为false即可
AbstractTaskStep需手动通知执行结果

TaskCommand手动通知执行结果

在通过XTask.getTask传入TaskCommand构建Task的时候,设置isAutoNotify为false即可手动通知执行结果。

final TaskChainEngine engine = XTask.getTaskChain(); for (int i = 0; i < 5; i++) {     int finalI = i;     engine.addTask(XTask.getTask(new TaskCommand() {         @Override         public void run() {             try {                 Thread.sleep(1000);             } catch (InterruptedException e) {                 e.printStackTrace();             }             if (finalI == 2) {                 notifyTaskFailed(404, "任务执行失败!");             } else {                 notifyTaskSucceed(TaskResult.succeed());             }         }     }, false)); // 设置手动通知执行结果 } engine.start();

SimpleTaskStep手动通知执行结果

重写SimpleTaskStepisAutoNotify方法为false即可手动通知执行结果。

public class StepATask extends SimpleTaskStep {      @Override     public void doTask() throws Exception {         // todo         // 手动通知任务链任务完成         notifyTaskSucceed();     }      @Override     protected boolean isAutoNotify() {         return false;     } }

参数传递

  • 任何TaskStep我们都可以通过getTaskParam获取任务参数和任务执行结果ITaskParam
  • 上一个TaskStep保存处理过的任务参数会自动带入到下一个TaskStep中去,因此最后一个TaskStep拥有之前所有任务的参数数据。
XTask.getTask(new TaskCommand() {     @Override     public void run() {         ITaskParam param = getTaskParam();         Log.e(TAG, getName() + "  start, param1:" + param.get("param1") + ", param3:" + param.get("param3"));         param.put("param2", false);     } })

线程控制

设置任务的threadType类型,即可完成对任务运行线程的控制。目前支持6种线程处理方式。

类型描述线程池构成
MAIN主线程(UI线程)/
ASYNC异步线程(开子线程,普通线程池)核心线程数和最大线程为CPU数,0s keepTime,LinkedBlockingQueue(128),线程优先级5
ASYNC_IO异步线程(开子线程,io线程池)核心线程数和最大线程为(2*CPU数+1),30s keepTime,LinkedBlockingQueue(128),线程优先级5
ASYNC_EMERGENT异步线程(开子线程,紧急线程池)核心线程数为2,最大线程为∞,60s keepTime,SynchronousQueue(不阻塞),线程优先级10
ASYNC_BACKGROUND异步线程(开子线程,优先级较低线程池)核心线程数和最大线程为2,0s keepTime,LinkedBlockingQueue(128),线程优先级1
SYNC同步线程(直接执行)/
// 1.构造时传入线程 XTaskStep taskStep = XTask.getTask(new SimpleTaskCommand(1000), ThreadType.ASYNC_EMERGENT); // 2.设置线程的方法 taskStep.setThreadType(ThreadType.ASYNC_IO);

任务组

目前共有串行任务组(SerialGroupTaskStep)和并行任务组(ConcurrentGroupTaskStep)

串行任务组

串行任务组是按顺序依次执行,和任务链的处理方式类似。使用XTask.getSerialGroupTask获取。

final TaskChainEngine engine = XTask.getTaskChain(); SerialGroupTaskStep group1 = XTask.getSerialGroupTask("group1"); for (int i = 0; i < 5; i++) {     group1.addTask(XTask.getTask(new SimpleTaskCommand(500))); } SerialGroupTaskStep group2 = XTask.getSerialGroupTask("group2"); for (int i = 0; i < 5; i++) {     group2.addTask(XTask.getTask(new SimpleTaskCommand(1000))); } ICanceller canceller = engine.addTask(group1)         .addTask(group2)         .setTaskChainCallback(new TaskChainCallbackAdapter() {             @Override             public void onTaskChainCompleted(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) {                 Log.e(TAG, "task chain completed, path:" + result.getPath());             }         })         .start(); addCanceller(canceller);

并行任务组

并行任务组是组内所有任务同时执行,待所有任务都完成后才视为任务组完成。使用XTask.getConcurrentGroupTask获取。

final TaskChainEngine engine = XTask.getTaskChain(); ConcurrentGroupTaskStep group1 = XTask.getConcurrentGroupTask("group1"); for (int i = 0; i < 5; i++) {     group1.addTask(XTask.getTask(new SimpleTaskCommand(100 * (i + 1)))); } ConcurrentGroupTaskStep group2 = XTask.getConcurrentGroupTask("group2"); for (int i = 0; i < 5; i++) {     group2.addTask(XTask.getTask(new SimpleTaskCommand(200 * (i + 1)))); } ICanceller canceller = engine.addTask(group1)         .addTask(group2)         .setTaskChainCallback(new TaskChainCallbackAdapter() {             @Override             public void onTaskChainCompleted(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) {                 Log.e(TAG, "task chain completed, path:" + result.getPath());             }         })         .start(); addCanceller(canceller);

最后

如果你觉得这个项目对你有所帮助, 你可以点击star进行收藏或者将其分享出去, 让更多的人知道这个项目!

我是xuexiangjys,一枚热爱学习,爱好编程,致力于Android架构研究以及开源项目经验分享的技术up主。获取更多资讯,欢迎微信搜索公众号:【我的Android开源之旅】

谁动了我的代码!(协同仓库该有的规范)��

Posted: 14 Feb 2022 03:51 AM PST

前言

公司一个老项目,没有做代码提交前的校验,我拿到后,想着老项目嘛也没时间帮它弄这些,反正就是改一点点小东西;尽量跟着它的代码风格写,写完提交就行;

直到某一天,又有一个人加入了进来。好家伙,直接干出事了。
很多个文件一起提交的,然后 commit-msg 不规范,代码缩进也不规范,换行符也不一致,里面还有很多没用的代码,可读性极差;

当你某一天 fetch 代码时,发现很多文件是这样的 👆 你是不是很崩溃?
但问题终归是得去解决的;治理污水的最好方法就是从能污水产生的地方开始整治。我来帮它加上吧。🥰

下面我将带大家一起,先通过husky+eslint完成一个最简单的代码规范校验,然后逐步优化,最后通过husky+eslint+lint-stage+commitlint+prettier实现一个强有力的限制。最后再配合commitizen,通过commitlint-config-cz+cz-customizable实现自定义的提交模板和限制规则来实现团队最终的项目提交限制规约

eslint+prettier

这里我为该项目适配了一套 eslit 规则,按照这套规则提交的代码不会有冲突。
eslint 和 prettier 大家平时项目应该都用到过,应该都很熟悉了,这里将不占用篇幅。(想看的可以私聊我,我可以单独出一篇 eslint 和 prettier 主题的文章)

🦄 在这篇文章中,主要讲解如何在团队协同工作时,在 git 提交代码更改前,对不规范的代码和提交信息进行校验,修复,并限制不规范的提交。

husky

首先要介绍的是husky,搞工程化,我们肯定都少不了 husky,它能很方便的帮我们阻挡小可爱们的进攻,不,是为我们的项目添加git hooks

具体方法

首先我们将 husky 安装到开发依赖中

npm i husky -D # or yarn add husky -D

注意

目前我所安装的版本是husky@7.0.4,由于husky@6.0.0 后做了breaking change,所以在6.0.0版本之前的那种设置钩子的方法已经不适用了,这里我们只介绍最新的方式

安装完后,我们需要在当前项目中创建一个.husky目录并指定该目录为 git hooks 所在的目录。

使用以下命令快速创建 👇

#--no-install 参数表示强制npx使用项目中node_modules目录下的husky依赖包 npx --no-install husky install

为了让其他人在此项目中安装依赖后也能自动创建.husky目录并指定该目录为 git hooks 所在的目录,我们需要在 package.json 里面添加一条脚本"prepare": "husky install"

使用以下命令快速添加 👇

npm set-script prepare "husky install"
prepare 脚本会在 npm i或者其他yarn or yarn add 之后自动执行。也就是说当我们安装依赖后会自动执行 husky install 命令,从而自动创建.husky目录并指定该目录为 git hooks 所在的目录。

使用以下命令快速创建 👇

npx --no-instal husky add .husky/pre-commit "npm run [你要执行的命令]"

完成后可以看到.husky目录下新增了一个pre-commit文件,其中的内容为 👇

这里我用的是npm run lint,这样我们就可以配合 Eslint 的代码校验,来限制不规范代码的提交了


可以看到,不符合 Eslint 校验规则的代码是没法提交的;

当然,这里的报错问题只是由于缩进不规范引起的,类似这种的问题还有引号,句尾分号,换行符等等...都可以通过 eslint 的参数 --fix来自动修复,这样就可以在提交前,先将能实现自动修复的简单代码风格问题后 commit。复杂的情况还是要自己去手动处理的。

说到换行符,这里我们需要了解的是:在 Windows 上默认的是回车换行(Carriage Return Line Feed, CRLF),然而,在 Linux/MacOS 上则是换行(Line Feed, LF)。

我们可以试一下将原先换行符为crlf的文件格式化为换行符为lf后,执行git add .的情况。

可以看到最终 LF 换行符还是被 CRLF 转化了;

如果你们不会跨平台协作(都在 Mac/Linux,或者都在 Windows 上协同),只需要在当前项目中通过git config core.autocrlf false来阻止这种情况的发生。

为了保险起见,你需要新建一个.gitattributes文件(主要用于定义每种文件的属性,以方便 git 帮我们统一管理。),设置 eol(end of line)为指定的换行符(lf/crlf),这里我把所有文件*.*的换行符都设置为了 LF,并且将一些非文本文件进行了标记(排除它们),你也可以对每一种文件类型分别设置对应的属性 => *.js eol=lf*.ts eol=lf...

*.* eol=lf *.jpg -text *.png -text   ... # 或者👇  *.js eol=lf *.ts eol=lf *.json eol=lf   ...

文件内容如下 👇

image.png
这样,我们不管在什么平台上开发,文件换行符都统一为 LF。

可以看到使用.gitattributes配置文件后执行git add,所有不是指定换行符的文件都会被自动更换为你指定的换行符,例如我这里指定了lf,那么 git add . 后,不是以 lf 换行的文件都会被转换为 lf ,并在终端输出warning: CRLF will be replaced by LF in xxxx/filename,如图 👇

.gitattributes有很多用处,具体可以查看 👉gitattributes

🥰 到这里,一个最简单的代码风格限制方法就已经实现了。

既然做了,就肯定要做一套完整的,且好用的。下面我们来继续完善其他功能 ~


lint-staged

什么是lint-staged?顾名思义,借助这个工具只是用来检查 git 暂存区文件的,就是在你git add file1,2,3... 后的暂存区文件中运行 lint 的一个工具。

每次提交一两个文件,却都要 lint 所有文件话,是很没有必要的,我们只对需要提交的代码进行 lint,这样可以减少很多没必要的时间开销。(如果你每次修改一个文件,都要去 lint 所有文件,这个工具对你来说就没有什么意义了,husky 就管够 🤐)

使用方法

我们将.husky/pre-commit中之前写代码改为 👇

#!/bin/sh . "$(dirname "$0")/_/husky.sh" - npm run lint + npx --no-install lint-staged

然后在 package.json 中添加以下代码,lint-staged对象中采用键值对的方式进行配置,键名是你想处理的单个文件或一个文件类型,多个类型可以写在{}中,用逗号分隔;键值是一个数组,数组中为 lint 时需要执行的命令组。

{   "lint-staged": {     "*.{ts,js,vue}": ["eslint", "echo '没问题!'"]   } }

添加玩上述代码后,我们通过测试,将两个文件的缩进改为不符合规范的情况,然后将其中一个文件暂存后,我们运行git commit后发现终端的报错中,只有一个文件的 lint 报错信息,另一个文件的报错并没有出现。

当所有暂存区代码都符合规范时 👇,才会通过校验执行提交。

✔ Preparing lint-staged... ✔ Hiding unstaged changes to partially staged files... ✔ Running tasks for staged files... ✔ Restoring unstaged changes to partially staged files... ✔ Cleaning up temporary files...

commitizen

Commitizen 是一个撰写符合上面 Commit Message 标准的一款工具。通过它可以实现交互式撰写规范的 Commit Message。

如果只在本仓库使用 👇

npm install commitizen -D

如果你想全局都用 commitizen 来帮你做 commit

npm install commitizen -g

安装完成后,一般我们都采用符合 Angular 的 Commit message 格式的提交规范(当然也可以自定义,后面会讲到~),运行以下命令生成符合 Angular 提交规范格式的 Commit message。

如果你项目用的是 npm 👇

# 如果你项目用的是npm npx --no-install commitizen init cz-conventional-changelog --save-dev --save-exact

如果你项目用的是 yarn 👇

# 如果你项目用的是yarn npx --no-install commitizen init cz-conventional-changelog --save-dev --save-exact

运行了上述命令后,它将为你项目安装 cz-conventional-changelog 适配器模块,把 config.commitizen 的密钥添加到文件的根目录添加到 package.json

可以在package.json 中看到,自动的新增了以下内容 👇

{   ...   "config": {     "commitizen": {       "path": "./node_modules/cz-conventional-changelog"     }   }   ... }

完成后,通过命令yarn cz,你如果是全局安装的 commitizen,那你直接 git cz,都可以通过以下交互式的撰写 commit messag 然后提交

限制 commitlint

由于 commitizen 并不是强制使用的,仍然可以通过git commit来提交,所以我们必须在不管是通过cz还是git commit提交前,都要对 commit messag 进行一次校验,不符合规范的情况下是不允许进行 commit 的

首先我们需要安装commitlint,commitlint/config-conventional

yarn add @commitlint/cli @commitlint/config-conventional -D

使用以下命令快速创建 git hooks 的 commit-msg 钩子 👇
这样每次 commit 的时候都会由 commitlint 对 commit message 进行一次检验

npx --no-instal husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

然后我们创建一个 commitlint 配置文件到项目根目录 👇

echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

以上将会在项目目录中生成 commitlint.config.js,代码如下,他将继承@commitlint/config-conventional中的 Commit message 规范。("feat", "fix", "perf", "refactor"...)

module.exports = {   extends: ["@commitlint/config-conventional"], };

然后我们在终端进行测试

echo 'feat: bar' | npx --no-install commitlint

如果你执行上面这个行命令后出现了以上这种报错。

将文件更改问 UTF-8 的格式即可解决;这个问题目前已经在 👉 Issues中,有不少人遇到了(我也是 😂)。
最简单的方法就是,把文件用记事本打开,选择另存为,然后在弹窗的右下角更改字符编码为 UTF-8 (Windows 用户运行echo "xxx" > xx.js时,文件编码可能为 UTF-16 LE),更改好后,把原来的commitlint.config.js文件替换掉即可。

解决以上问题后,我们再测试一下,可以看到,不符合规范的 commit-msg 是会导致报错的,也就 commit 不了了,说明我们的 commitlint 已经生效了~ 👏👏👏

到此,commit-msg 的校验也已经完成 ✔

如果,你想自定义 commitlint 的交互文本(不用 feat,fix...,很多人都喜欢在 commit message 前面加一个 emoji 表情符号),当然也可以。

我们需要安装 cz-customizable来实现自定义 commit message 规则,以及安装对应的commitlint-config-cz来配套校验(直接从自定义的文件里读取规则)

运行以下命令 👇

yarn add commitlint-config-cz  cz-customizable -D

在项目根目录,创建一个.cz-config.js文件,并复制cz-config-EXAMPLE.js 中的内容到其中。然后改成你自己想要的规则即可。

当然,你也可以用我写好的:

module.exports = {   types: [     { value: "feat", name: "feat: 一个新的特性" },     { value: "fix", name: "fix: 修复一个Bug" },     { value: "docs", name: "docs: 变更的只有文档" },     { value: "style", name: "style: 代码风格,格式修复" },     { value: "refactor", name: "refactor: 代码重构,注意和feat、fix区分开" },     { value: "perf", name: "perf: 码优化,改善性能" },     { value: "test", name: "test: 测试" },     { alue: "chore", name: "chore: 变更构建流程或辅助工具" },     { value: "revert", name: "revert: 代码回退" },     { value: "init", name: "init: 项目初始化" },     { value: "build", name: "build: 变更项目构建或外部依赖" },     { value: "WIP", name: "WIP: 进行中的工作" },   ],   scopes: [],   allowTicketNumber: false,   isTicketNumberRequired: false,   ticketNumberPrefix: "TICKET-",   ticketNumberRegExp: "\\d{1,5}",   // it needs to match the value for field type. Eg.: 'fix'   /*   scopeOverrides: {     fix: [       {name: 'merge'},       {name: 'style'},       {name: 'e2eTest'},       {name: 'unitTest'}     ]   },   */   // override the messages, defaults are as follows   messages: {     type: "选择一种你的提交类型:",     scope: "选择一个scope (可选):",     // used if allowCustomScopes is true     customScope: "Denote the SCOPE of this change:",     subject: "简短说明(最多40个字):",     body: '长说明,使用"|"换行(可选):\n',     breaking: "非兼容性说明 (可选):\n",     footer: "关联关闭的issue,例如:#12, #34(可选):\n",     confirmCommit: "确定提交?",   },   allowCustomScopes: true,   allowBreakingChanges: ["feat", "fix"],   // skip any questions you want   skipQuestions: ["scope", "body", "breaking"],   // limit subject length   subjectLimit: 100,   // breaklineChar: '|', // It is supported for fields body and footer.   // footerPrefix : 'ISSUES CLOSED:'   // askForBreakingChangeFirst : true, // default is false };

创建完.cz-config.js文件后,我们需要回到 package.json 文件中,将 config.commitizen.path 更改为"node_modules/cz-customizable",如果你的.cz-config.js文件在项目根目录下,那么可以不配置下面这条,commitlint-config-cz 会自动在项目根目录下寻找: .cz-config.js.config/cz-config.js

... {   "config": {     "commitizen": {       "path": "node_modules/cz-customizable"     },     // 如果你的.cz-config.js文件在项目根目录下,那么可以不配置下面这条,commitlint-config-cz会自动在项目根目录下寻找: .cz-config.js 或 .config/cz-config.js     "cz-customizable": {       "config": "你的文件路径/xxxconfig.js"     }   } } ...

关于commitlint-config-cz更高级的用法可以查看 👉commitlint-config-cz

最后我们将之前创建过的commitlint.config.js中的代码进行更改 👇

module.exports = { - extends: ["@commitlint/config-conventional"], + extends: ["cz"], };

或者你也可以在commitlint.config.js中手动添加自定义的规则 👇,他将覆盖 extends 中的规则

module.exports = {   extends: ["@commitlint/config-conventional","cz"],   rules: {     "type-enum": [       2,       "always",       [         "init",         "build",         "ci",         "chore",         "docs",         "feat",         "fix",         "perf",         "refactor",         "revert",         "style",         "test",       ],     ],   }, };

到这里,自定义的 commit message 的校验也 ok 了 ✅

最后

提醒:项目的代码风格和规则要和团队一起制定哦 ~

至此,在团队协同的项目中,不符合规范的提交就被扼杀在摇篮里面了。我们大家不管是从书写代码还是提交代码最好都要规范哦~ 不给自己惹麻烦的同时,也不会给他人或公司带来麻烦。这就是本篇的全部内容啦~如果对你有帮助,记得点赞鼓励 ~

我是荣顶,一个面向快乐编程的前端开发 🥰
如果你也非常热爱前端相关技术!扫描二维码~ 加入前端超人技术交流群 🦄

回复 [加群],将拉你进学习交流群,与其他前端爱好者共同进步!
回复 [书籍],获取大量前端pdf书籍。
朋友圈不定期举行送书活动。一起加油,冲!

网络编程懒人入门(十四):到底什么是Socket?一文即懂!

Posted: 15 Feb 2022 09:27 PM PST

本文由cxuan分享,原题"原来这才是 Socket",有修订。

1、引言

本系列文章前面那些主要讲解的是计算机网络的理论基础,但对于即时通讯IM这方面的应用层开发者来说,跟计算机网络打道的其实是各种API接口。

本篇文章就来聊一下网络应用程序员最熟悉的Socket这个东西,抛开生涩的计算机网络理论,从应用层的角度来理解到底什么是Socket。

对于 Socket 的认识,本文将从以下几个方面着手介绍:

1)Socket 是什么;
2)Socket 是如何创建的;
3)Socket 是如何连接的;
4)Socket 是如何收发数据的;
5)Socket 是如何断开连接的;
6)Socket 套接字的删除等。

特别说明:本文中提到的"Socket"、"网络套接字"、"套接字",如无特殊指明,指的都是同一个东西哦。

学习交流:

(本文已同步发布于:http://www.52im.net/thread-38...

2、Socket 是什么

一个数据包经由应用程序产生,进入到协议栈中进行各种报文头的包装,然后操作系统调用网卡驱动程序指挥硬件,把数据发送到对端主机。

整个过程的大体的图示如下:

我们大家知道,协议栈其实是位于操作系统中的一些协议的堆叠,这些协议包括 TCP、UDP、ARP、ICMP、IP等。

通常某个协议的设计都是为了解决特定问题的,比如:

1)TCP 的设计就负责安全可靠的传输数据;
2)UDP 设计就是报文小,传输效率高;
3)ARP 的设计是能够通过 IP 地址查询物理(Mac)地址;
4)ICMP 的设计目的是返回错误报文给主机;
5)IP 设计的目的是为了实现大规模主机的互联互通。

应用程序比如浏览器、电子邮件、文件传输服务器等产生的数据,会通过传输层协议进行传输。而应用程序是不会和传输层直接建立联系的,而是有一个能够连接应用层和传输层之间的套件,这个套件就是 Socket。

在上面这幅图中,应用程序包含 Socket 和解析器,解析器的作用就是向 DNS 服务器发起查询,查询目标 IP 地址(关于DNS请见《理论联系实际,全方位深入理解DNS》)。

应用程序的下面:就是操作系统内部,操作系统内部包括协议栈,协议栈是一系列协议的堆叠。

操作系统下面:就是网卡驱动程序,网卡驱动程序负责控制网卡硬件,驱动程序驱动网卡硬件完成收发工作。

在操作系统内部有一块用于存放控制信息的存储空间,这块存储空间记录了用于控制通信的控制信息。其实这些控制信息就是 Socket 的实体,或者说存放控制信息的内存空间就是Socket的实体。

这里大家有可能不太清楚所以然,所以我用了一下 netstat 命令来给大伙看一下Socket是啥玩意。

我们在 Windows 的命令提示符中输入:

netstat-ano

  • netstat 用于显示Socket内容 , -ano 是可选选项
  • a 不仅显示正在通信的Socket,还显示包括尚未开始通信等状态的所有Socket
  • n 显示 IP 地址和端口号
  • o 显示Socket的程序 PID

我的计算机会出现下面结果:

如上图所示:

1)每一行都相当于一个Socket;
2)每一列也被称为一个元组。

所以,一个Socket就是五元组:

1)协议;
2)本地地址;
3)外部地址;
4)状态;
5)PID。

PS:有的时候也被叫做四元组,四元组不包括协议。

我们来解读一下上图中的数据,比如图中的第一行:

1)它的协议就是 TCP,本地地址和远程地址都是 0.0.0.0(这表示通信还没有开始,IP 地址暂时还未确定)。

2)而本地端口已知是 135,但是远程端口还未知,此时的状态是 LISTENING(LISTENING 表示应用程序已经打开,正在等待与远程主机建立连接。关于各种状态之间的转换,大家可以阅读《通俗易懂-深入理解TCP协议(上):理论基础》)。

3)最后一个元组是 PID,即进程标识符,PID 就像我们的身份证号码,能够精确定位唯一的进程。

3、Socket 是如何创建的

通过上节的讲解,现在你可能对 Socket 有了一个基本的认识,先喝口水,休息一下,让我们继续探究 Socket。

现在我有个问题,Socket 是如何创建的呢?

Socket 是和应用程序一起创建的。

应用程序中有一个 socket 组件,在应用程序启动时,会调用 socket 申请创建Socket,协议栈会根据应用程序的申请创建Socket:首先分配一个Socket所需的内存空间,这一步相当于是为控制信息准备一个容器,但只有容器并没有实际作用,所以你还需要向容器中放入控制信息;如果你不申请创建Socket所需要的内存空间,你创建的控制信息也没有地方存放,所以分配内存空间,放入控制信息缺一不可。至此Socket的创建就已经完成了。

Socket创建完成后,会返回一个Socket描述符给应用程序,这个描述符相当于是区分不同Socket的号码牌。根据这个描述符,应用程序在委托协议栈收发数据时就需要提供这个描述符。

4、Socket 是如何连接的

Socket创建完成后,最终还是为数据收发服务的。但是,在数据收发之前,还需要进行一步"连接"(术语就是 connect),建立连接有一整套过程。

这个"连接"并不是真实的连接(用一根水管插在两个电脑之间?不是你想的这样。。。)。

实际上这个"连接"是应用程序通过 TCP/IP 协议标准从一个主机通过网络介质传输到另一个主机的过程。

Socket刚刚创建完成后,还没有数据,也不知道通信对象。

在这种状态下:即使你让客户端应用程序委托协议栈发送数据,它也不知道发送到哪里。所以浏览器需要根据网址来查询服务器的 IP 地址(做这项工作的协议是 DNS),查询到目标主机后,再把目标主机的 IP 告诉协议栈。至此,客户端这边就准备好了。

在服务器上:与客户端一样也需要创建Socket,但是同样的它也不知道通信对象是谁,所以我们需要让客户端向服务器告知客户端的必要信息:IP 地址和端口号。

现在通信双方建立连接的必要信息已经具备,可以开始"连接"过程了。

首先:客户端应用程序需要调用 Socket 库中的 connect 方法,提供 socket 描述符和服务器 IP 地址、端口号。

以下是connect的伪码调用:

connect(<描述符>、<服务器IP地址和端口号>)

这些信息会传递给协议栈中的 TCP 模块,TCP 模块会对请求报文进行封装,再传递给 IP 模块,进行 IP 报文头的封装,然后传递给物理层,进行帧头封装。

之后通过网络介质传递给服务器,服务器上会对帧头、IP 模块、TCP 模块的报文头进行解析,从而找到对应的Socket。

Socket收到请求后,会写入相应的信息,并且把状态改为正在连接。

请求过程完成后:服务器的 TCP 模块会返回响应,这个过程和客户端是一样的(如果大家不太清楚报文头的封装过程,可以阅读《快速理解TCP协议一篇就够》)。

在一个完整的请求和响应过程中,控制信息起到非常关键的作用:

1)SYN 就是同步的缩写,客户端会首先发送 SYN 数据包,请求服务端建立连接;
2)ACK 就是相应的意思,它是对发送 SYN 数据包的响应;
3)FIN 是终止的意思,它表示客户端/服务器想要终止连接。

由于网络环境的复杂多变,经常会存在数据包丢失的情况,所以双方通信时需要相互确认对方的数据包是否已经到达,而判断的标准就是 ACK 的值。

上面的文字不够生动,动画可以更好的说明这个过程:

▲ 上图引用自《跟着动画来学TCP三次握手和四次挥手》

(PS:这个"连接"的详细理论知识,可以阅读《理论经典:TCP协议的3次握手与4次挥手过程详解》、《跟着动画来学TCP三次握手和四次挥手》,这里不再赘述。)

当所有建立连接的报文都能够正常收发之后,此时套接字就已经进入可收发状态了,此时可以认为用一根管理把两个套接字连接了起来。当然,实际上并不存在这个管子。建立连接之后,协议栈的连接操作就结束了,也就是说 connect 已经执行完毕,控制流程被交回给应用程序。

另外:如果你对Socket代码更熟悉的话,可以先读读这篇《手把手教你写基于TCP的Socket长连接》。

5、Socket 是如何收发数据的

当控制流程上节中的连接过程回到应用程序之后,接下来就会直接进入数据收发阶段。

数据收发操作是从应用程序调用 write 将要发送的数据交给协议栈开始的,协议栈收到数据之后执行发送操作。

协议栈不会关心应用程序传输过来的是什么数据,因为这些数据最终都会转换为二进制序列,协议栈在收到数据之后并不会马上把数据发送出去,而是会将数据放在发送缓冲区,再等待应用程序发送下一条数据。

为什么收到数据包不会直接发送出去,而是放在缓冲区中呢?

因为只要一旦收到数据就会发送,就有可能发送大量的小数据包,导致网络效率下降(所以协议栈需要将数据积攒到一定数量才能将其发送出去)。

至于协议栈会向缓冲区放多少数据,这个不同版本和种类的操作系统有不同的说法。

不过,所有的操作系统都会遵循下面这几个标准:

1)第一个判断要素:是每个网络包能够容纳的数据长度,判断的标准是 MTU,它表示的是一个网络包的最大长度。最大长度包含头部,所以如果单论数据区的话,就会用 MTU - 包头长度,由此的出来的最大数据长度被称为 MSS。

2)另一个判断标准:是时间,当应用程序产生的数据比较少,协议栈向缓冲区放置数据效率不高时,如果每次都等到 MSS 再发送的话,可能因为等待时间太长造成延迟。在这种情况下,即使数据长度没有到达 MSS,也应该把数据发送出去。

但协议栈并没有告诉我们怎样平衡这两个因素,如果数据长度优先,那么效率有可能比较低;如果时间优先,那又会降低网络的效率。

经过了一段时间。。。。。。

假设我们使用的是长度有限法则:此时缓冲区已满,协议栈要发送数据了,协议栈刚要把数据发送出去,却发现无法一次性传输这么大数据量(相对的)的数据,那怎么办呢?

在这种情况下,发送缓冲区中的数据就会超过 MSS 的长度,发送缓冲区中的数据会以 MSS 大小为一个数据包进行拆分,拆分出来的每块数据都会加上 TCP,IP,以太网头部,然后被放进单独的网络包中。

到现在,网络包已经准备好发往服务器了,但是数据发送操作还没有结束,因为服务器还未确认是否已经收到网络包。因此在客户端发送数据包之后,还需要服务器进行确认。

TCP 模块在拆分数据时,会计算出网络包偏移量,这个偏移量就是相对于数据从头开始计算的第几个字节,并将算好的字节数写在 TCP 头部,TCP 模块还会生成一个网络包的序号(SYN),这个序号是唯一的,这个序号就是用来让服务器进行确认的。

服务器会对客户端发送过来的数据包进行确认,确认无误之后,服务器会生成一个序号和确认号(ACK)并一起发送给客户端,客户端确认之后再发送确认号给服务器。

我们来看一下实际的工作过程:

首先:客户端在连接时需要计算出序号初始值,并将这个值发送给服务器。

接下来:服务器通过这个初始值计算出确认号并返回给客户端(初始值在通信过程中有可能会丢弃,因此当服务器收到初始值后需要返回确认号用于确认)。

同时:服务器也需要计算出从服务器到客户端方向的序号初始值,并将这个值发送给客户端。然后,客户端也需要根据服务器发来的初始值计算出确认号发送给服务器。

至此:连接建立完成,接下来就可以进入数据收发阶段了。

数据收发阶段中,通信双方可以同时发送请求和响应,双方也可以同时对请求进行确认。

请求 - 确认机制非常强大:通过这一机制,我们可以确认接收方有没有收到某个包,如果没有收到则重新发送,这样一来,但凡网络中出现的任何错误,我们都可以即使发现并补救。

上面的文字不够生动,动画可以更好的理解请求 - 确认机制:

▲ 上图引用自《跟着动画来学TCP三次握手和四次挥手》

网卡、集线器、路由器(见《史上最通俗的集线器、交换机、路由器功能原理入门》)都没有错误补救机制,一旦检测到错误就会直接丢弃数据包,应用程序也没有这种机制,起作用的只是 TCP/IP 模块。

由于网络环境复杂多变,所以数据包会存在丢失情况,因此发送序号和确认号也存在一定规则,TCP 会通过窗口管理确认号,我们这篇文章不再赘述,大家可以阅读《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》来寻找答案。

PS:另一篇《我们在读写Socket时,究竟在读写什么?》中用动画详细说明了这个过程,有兴趣可以读一读。

6、Socket 是如何断开连接的

当通信双方不再需要收发数据时,需要断开连接。不同的应用程序断开连接的时机不同。

以 Web 为例:浏览器向 Web 服务器发送请求消息,Web 服务器再返回响应消息,这时收发数据就全部结束了,服务器可能会首先发起断开响应,当然客户端也有可能会首先发起(谁先断开连接是应用程序做出的判断),与协议栈无关。

无论哪一方发起断开连接的请求,都会调用 Socket 库的 close 程序。

我们以服务器断开连接为例:服务器发起断开连接请求,协议栈会生成断开连接的 TCP 头部,其实就是设置 FIN 位,然后委托 IP 模块向客户端发送数据,与此同时,服务器的Socket会记录下断开连接的相关信息。

收到服务器发来 FIN 请求后:客户端协议栈会将Socket标记为断开连接状态,然后,客户端会向服务器返回一个确认号,这是断开连接的第一步,在这一步之后,应用程序还会调用 read 来读取数据。等到服务器数据发送完成后,协议栈会通知客户端应用程序数据已经接收完毕。

只要收到服务器返回的所有数据,客户端就会调用 close 程序来结束收发操作,这时客户端会生成一个 FIN 发送给服务器,一段时间后服务器返回 ACK 号。至此,客户端和服务器的通信就结束了。

上面的文字不够生动,动画可以更好的说明这个过程:

▲ 上图引用自《跟着动画来学TCP三次握手和四次挥手》
PS:断开连接的详细理论知识,可以阅读《理论经典:TCP协议的3次握手与4次挥手过程详解》、《跟着动画来学TCP三次握手和四次挥手》,这里不再赘述。

7、Socket的删除

上述通信过程完成后,用来通信的Socket就不再会使用了,此时我们就可以删除这个Socket了。

不过,这时候Socket不会马上删除,而是等过一段时间再删除。

等待这段时间是为了防止误操作,最常见的误操作就是客户端返回的确认号丢失,至于等待多长时间,和数据包重传的方式有关,这里我们就深入展开讨论了。

关于Socket操作的全过程,如果从系统的角度来看,可能会更深入一些,建议可以深入阅读张彦飞的《深入操作系统,从内核理解网络包的接收过程(Linux篇)》一文。

8、系列文章

本文是系列文章中的第14篇,本系列文章的大纲如下:

[1] 网络编程懒人入门(一):快速理解网络通信协议(上篇)
[2] 网络编程懒人入门(二):快速理解网络通信协议(下篇)
[3] 网络编程懒人入门(三):快速理解TCP协议一篇就够
[4] 网络编程懒人入门(四):快速理解TCP和UDP的差异
[5] 网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势
[6] 网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门
[7] 网络编程懒人入门(七):深入浅出,全面理解HTTP协议
[8] 网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接
[9] 网络编程懒人入门(九):通俗讲解,有了IP地址,为何还要用MAC地址?
[10] 网络编程懒人入门(十):一泡尿的时间,快速读懂QUIC协议
[11] 网络编程懒人入门(十一):一文读懂什么是IPv6
[12] 网络编程懒人入门(十二):快速读懂Http/3协议,一篇就够!
[13] 网络编程懒人入门(十三):一泡尿的时间,快速搞懂TCP和UDP的区别
[14] 网络编程懒人入门(十四):到底什么是Socket?一文即懂!(* 本文)

9、参考资料

[1] TCP/IP详解 - 第17章·TCP:传输控制协议
[2] TCP/IP详解 - 第18章·TCP连接的建立与终止
[3] TCP/IP详解 - 第21章·TCP的超时与重传
[4] 快速理解网络通信协议(上篇)
[5] 快速理解网络通信协议(下篇)
[6] 面视必备,史上最通俗计算机网络分层详解
[7] 假如你来设计网络,会怎么做?
[8] 假如你来设计TCP协议,会怎么做?
[10] 浅析TCP协议中的疑难杂症(下篇)
[11] 关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT
[12] 从底层入手,深度分析TCP连接耗时的秘密

(本文已同步发布于:http://www.52im.net/thread-38...

首屏时间,你说你优化了,那你倒是计算出给给我看啊!

Posted: 16 Feb 2022 07:36 PM PST

前言

大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心

背景

当我们在做项目的性能优化的时候,优化首屏时间是一个避不过去的优化方向,但是又有多少人想过这两个东西的区别呢:

  • 白屏时间
  • 首屏时间

并且这两个时间的计算方式又有什么区别呢?接下来我就给大家讲一下吧!

白屏时间

是什么?

白屏时间指的是:页面开始显示内容的时间。也就是:浏览器显示第一个字符或者元素的时间

怎么算?

我们只需要知道浏览器开始显示内容的时间点,即页面白屏结束时间点即可获取到页面的白屏时间。

因此,我们通常认为浏览器开始渲染 <body> 标签或者解析完 <head> 标签的时刻就是页面白屏结束的时间点。

  • 浏览器支持 performance.timing

    <head> <title>Document</title> </head> <script type="text/javascript"> // 白屏时间结束点 var firstPaint = Date.now() var start = performance.timing.navigationStart console.log(firstPaint - start) </script>
  • 浏览器不支持 performance.timing

    <head> <title>Document</title> <script type="text/javascript">   window.start = Date.now(); </script> </head> <script type="text/javascript"> // 白屏时间结束点 var firstPaint = Date.now() console.log(firstPaint - window.start) </script>

    首屏时间

    是什么?

    首屏时间是指用户打开网站开始,到浏览器首屏内容渲染完成的时间。对于用户体验来说,首屏时间是用户对一个网站的重要体验因素

为什么不直接用生命周期?

有些小伙伴会说:为啥不直接在App.vue的 mounted 生命周期里计算时间呢?大家可以看看,官网说了 mounted 执行并不代表首屏所有元素加载完毕,所以 mounted 计算出来的时间会偏短。

为什么不直接用nextTick?

nextTick 回调的时候,首屏的DOM都渲染出来了,但是计算 首屏时间 并不需要渲染所有DOM,所以计算出来的时间会偏长

怎么算?

我们需要利用 MutationObserver 监控DOM的变化,监控每一次DOM变化的分数,计算的规则为:
(1 + 层数 * 0.5),我举个例子:

<body>     <div>       <div>1</div>       <div>2</div>     </div> </body>

以上DOM结构的分数为:

1.5 + 2 + 2.5 + 2.5 = 8.5(分)

其实在首屏的加载中,会涉及到DOM的增加、修改、删除,所以会触发多次 MutationObserver ,所以会统计出不同阶段的score,我们把这些score存放在一个数组 observerData 中,后面大有用处

首屏时间实践

现在我们开始计算首屏时间吧!

前置准备

  • index.html:html页面

    <!DOCTYPE html> <html lang="en"> <head> </head> <body>   <div>     <div>       <div>1</div>       <div>2</div>     </div>     <div>3</div>     <div>4</div>   </div>   <ul id="ulbox"></ul> </body> <script src="./computed.js"></script> <script src="./request.js"></script> </html>
  • computed.js :计算首屏时间的文件

    const observerData = []  let observer = new MutationObserver(() => { // 计算每次DOM修改时,距离页面刚开始加载的时间 const start = window.performance.timing.navigationStart const time = new Date().getTime() - start  const body = document.querySelector('body') const score = computedScore(body, 1) // 加到数组 observerData 中 observerData.push({   score,   time }) }) observer.observe( document, {   childList: true,   subtree: true } )  function computedScore(element, layer) { let score = 0 const tagName = element.tagName // 排除这些标签的情况 if (   tagName !== 'SCRIPT' &&   tagName !== 'STYLE' &&   tagName !== 'META' &&   tagName !== 'HEAD' ) {   const children = element.children   if (children && children.length) {     // 递归计算分数     for (let i = 0; i < children.length; i++) {       score += computedScore(children[i], layer + 1)     }   }    score += 1 + 0.5 * layer } return score }
  • request.js :模拟请求修改DOM

    // 模拟请求列表 const requestList = () => { return new Promise((resolve) => {   setTimeout(() => {     resolve(       [1, 2, 3,         4, 5, 6,         7, 8, 9       ]     )   }, 1000) }) }  const ulbox = document.getElementById('ulbox')  // 模拟请求数据渲染列表 const renderList = async () => { const list = await requestList() const fragment = document.createDocumentFragment() for (let i = 0; i < list.length; i++) {   const li = document.createElement('li')   li.innerText = list[i]   fragment.appendChild(li) } ulbox.appendChild(fragment) }  // 模拟对列表进行轻微修改 const addList = async () => { const li = document.createElement('li') li.innerText = '加上去' ulbox.appendChild(li) }  (async () => { // 模拟请求数据渲染列表 await renderList() // 模拟对列表进行轻微修改 addList() })()

observerData

当我们一切准备就绪后运行代码,我们获得了 observerData ,我们看看它长什么样?

计算首屏时间

我们怎么根据 observerData 来计算首屏时间呢?我们可以这么算:下次分数比上次分数增加幅度最大的时间作为首屏时间

很多人会问了,为什么不是取最后一项的时间来当做首屏时间呢?大家要注意了:首屏并不是所有DOM都渲染,我就拿刚刚的代码来举例吧,我们渲染完了列表,然后再去增加一个li,那你是觉得哪个时间段算是首屏呢?应该是渲染完列表后算首屏完成,因为后面只增加了一个li,分数的涨幅较小,可以忽略不计

所以我们开始计算吧:

const observerData = []  let observer = new MutationObserver(() => {   // 计算每次DOM修改时,距离页面刚开始加载的时间   const start = window.performance.timing.navigationStart   const time = new Date().getTime() - start   const body = document.querySelector('body')   const score = computedScore(body, 1)   observerData.push({     score,     time   })    // complete时去调用 unmountObserver   if (document.readyState === 'complete') {     // 只计算10秒内渲染时间     unmountObserver(10000)   } }) observer.observe(   document, {     childList: true,     subtree: true   } )  function computedScore(element, layer) {   let score = 0   const tagName = element.tagName   // 排除这些标签的情况   if (     tagName !== 'SCRIPT' &&     tagName !== 'STYLE' &&     tagName !== 'META' &&     tagName !== 'HEAD'   ) {     const children = element.children     if (children && children.length) {       // 递归计算分数       for (let i = 0; i < children.length; i++) {         score += computedScore(children[i], layer + 1)       }     }      score += 1 + 0.5 * layer   }   return score }  // 计算首屏时间 function getFirstScreenTime() {   let data = null   for (let i = 1; i < observerData.length; i++) {     // 计算幅度     const differ = observerData[i].score - observerData[i - 1].score     // 取最大幅度,记录对应时间     if (!data || data.rate <= differ) {       data = {         time: observerData[i].time,         rate: differ       }     }   }   return data }  let timer = null  function unmountObserver(delay) {   if (timer) return   timer = setTimeout(() => {     // 输出首屏时间     console.log(getFirstScreenTime())     // 终止MutationObserver的监控     observer.disconnect()     observer = null     clearTimeout(timer)   }, delay) }

计算出首屏时间 1020ms

总结

我这个计算方法其实很多漏洞,没把删除元素也考虑进去,但是想让大家知道计算首屏时间的计算思想,这才是最重要的,希望大家能理解这个计算思想

结语

我是林三心,一个热心的前端菜鸟程序员。如果你上进,喜欢前端,想学习前端,那咱们可以交朋友,一起摸鱼哈哈,摸鱼群,加我请备注【思否】

image.png

React 源码解析系列 - React 的 render 异常处理机制

Posted: 16 Feb 2022 06:32 PM PST

系列文章目录(同步更新)

本系列文章均为讨论 React v17.0.0-alpha 的源码

错误边界(Error Boundaries)

在解释 React 内部实现前,我想先从一个 React API —— 错误边界(Error Boundaries) 这一 React 异常处理机制 的"冰山一角"开始介绍。

错误边界是什么

在 React 16 之前,React 并没有对开发者提供 API 来处理组件渲染过程中抛出的异常:

  • 这里的"组件渲染过程",实际指的是 jsx 代码段;
  • 因为 命令式 的代码,可以使用 try/catch 来处理异常;
  • 但 React 的组件是"声明式"的,开发者无法在组件内直接使用 try/catch 来处理异常。

而 React 16 带来了 错误边界 这一全新的概念,向开发者提供一种能力来更精细地处理组件维度抛出的异常。

错误边界就像一堵 防火墙 (命名上也有点像),我们可以在整个组件树中"放置"若干这样的"防火墙",那么一旦某个组件出现异常,该异常会被离它最近的错误边界给拦截住,避免影响组件树的其它分支;而我们也可以通过错误边界来渲染更"用户友好"的 UI 界面。

什么样的组件才能被称为错误边界

错误边界 也是一个组件(目前只支持 类组件 ),因此我们可以插入任意数量的错误边界到组件树中的任意位置。

错误边界包含两个 API :类组件静态方法 getDerivedStateFromError 和类组件成员方法 componentDidCatch(也可以理解成是生命周期方法),只要一个类组件(ClassComponent)包含这两者或其中之一,那么这个类组件就成为了错误边界。

贴一段 React 官方文档的示例:

class ErrorBoundary extends React.Component {   constructor(props) {     super(props);     this.state = { hasError: false };   }    static getDerivedStateFromError(error) {     // 更新 state 使下一次渲染能够显示降级后的 UI     return { hasError: true };   }    componentDidCatch(error, errorInfo) {     // 你同样可以将错误日志上报给服务器     logErrorToMyService(error, errorInfo);   }    render() {     if (this.state.hasError) {       // 你可以自定义降级后的 UI 并渲染       return <h1>Something went wrong.</h1>;     }      return this.props.children;    } }

错误边界能达到什么效果

早期版本的错误边界只有 componentDidCatch 一个 API ,后增加了 getDerivedStateFromError ,这两个 API 各司其职。

getDerivedStateFromError

getDerivedStateFromError 的主要功能是在捕获到异常后,返回当前组件的最新 state 。通常的做法就如上文的实例,设置一个 hasError 开关,通过开关来控制是展示"错误提示"还是正常的子组件树;这点还是比较重要的,因为此时子组件树中的某个子组件异常,必须将其从页面上排除出去,否则还是会影响整棵组件树的渲染。

static getDerivedStateFromError(error) {   // 更新 state 使下一次渲染能够显示降级后的 UI   return { hasError: true }; }

由于 getDerivedStateFromError 会在 render 阶段被调用,因此不应在此处做任何副作用操作;若有需要,应在 componentDidCatch 生命周期方法中执行相应操作。

componentDidCatch

componentDidCatch 会在 commit 阶段被调用,因此完全可以用来执行副作用操作,比如上报错误日志。

在早期还没有 getDerivedStateFromError 这个 API 的时候,需要在 componentDidCatch 这个生命周期里通过 setState 方法来更新 state ,但现在已经完全不推荐这么做了,因为通过 getDerivedStateFromError ,在 render 阶段就已经处理好了,何必等到 commit 阶段呢?这块内容在下文会详细介绍。

React 的 render 异常处理机制

之所以优先介绍"错误边界",一方面是因为这是直接面向开发者的 API ,更好理解;另一方面则是 React 为了实现这样的能力,让 render 异常处理机制变得更复杂了,不然直接用 try/catch 捕获异常后统一处理掉就非常简单粗暴了。

异常是如何产生的

上文中提到,错误边界处理的是组件渲染过程中抛出的异常,其实这本质上也是 React 的 render 异常处理机制所决定的;而其它诸如事件回调方法、 setTimeout/setInterval 等异步方法,由于并不会影响 React 组件树的渲染,因此也就不是 render 异常处理机制的目标了。

什么样的异常会被 render 异常处理机制捕获

简单来说,类组件的 render 方法、函数组件这样的会在 render 阶段被同步执行的代码,一旦抛出异常就会被 render 的异常处理机制捕获(无论是否有错误边界)。举一个实际开发中很常遇到的场景:

function App() {     return (         <div>             <ErrorComponent />         </div>     ); }  function ErrorComponent(props) {     // 父组件并没有传option参数,此时就会抛出异常:     // Uncaught TypeError: Cannot read properties of undefined (reading 'text')     return <p>{props.option.text}</p>; }  React.render(<App />, document.getElementById('app'));

在 React 的 render 过程中,上述两个函数组件先后会被执行,而当执行到props.foo.text时就会抛出异常,下面是 <ErrorComponent /> 的 jsx 代码经过转译后的,形成的可执行的 js 代码:

function ErrorComponent(props) {   return /*#__PURE__*/Object(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__["jsxDEV"])("p", {     children: props.foo.text // 抛出异常   }, void 0, false, { // debug相关,可忽略     fileName: _jsxFileName,     lineNumber: 35,     columnNumber: 10   }, this); }

组件本身抛异常的具体位置

以下内容需要你对 React 的 render 过程有一定的了解,请先阅读《React 源码解析系列 - React 的 render 阶段(二):beginWork》

beginWork 方法中,若判断当前 Fiber 节点无法 bailout (剪枝),那么就会创建/更新 Fiber 子节点:

switch (workInProgress.tag) {   case IndeterminateComponent:      // ...省略   case LazyComponent:      // ...省略   case FunctionComponent: {       const Component = workInProgress.type;       const unresolvedProps = workInProgress.pendingProps;       const resolvedProps =         workInProgress.elementType === Component           ? unresolvedProps           : resolveDefaultProps(Component, unresolvedProps);       return updateFunctionComponent(         current,         workInProgress,         Component,         resolvedProps,         renderLanes,       );     }   case ClassComponent: {     const Component = workInProgress.type;     const unresolvedProps = workInProgress.pendingProps;     const resolvedProps =       workInProgress.elementType === Component         ? unresolvedProps         : resolveDefaultProps(Component, unresolvedProps);     return updateClassComponent(       current,       workInProgress,       Component,       resolvedProps,       renderLanes,     );   }   case HostRoot:     // ...省略   case HostComponent:     // ...省略   case HostText:     // ...省略   // ...省略其他类型 }
ClassComponent 抛异常的位置

从上面 beginWork 这代码段可以看到执行了 updateClassComponent 方法,并且传入了名为 Component 的参数,此参数实际上就是类组件的 class ,此时由于尚未执行 render 方法,因此仍未抛出异常。

循着 updateClassComponent,我们可以看到执行了 finishClassComponent 来创建 Fiber 子节点:

function updateClassComponent(   current: Fiber | null,   workInProgress: Fiber,   Component: any,   nextProps: any,   renderLanes: Lanes, ) {     // 省略     const nextUnitOfWork = finishClassComponent(       current,       workInProgress,       Component,       shouldUpdate,       hasContext,       renderLanes,     );     // 省略 }

finishClassComponent 中,我们可以看到 nextChildren = instance.render() ,这里的 instance 就是实例化后的类对象,而调用 render 成员方法后,便得到了 nextChildren 这一 ReactElement 对象。

在后续过程中,React 会根据这一 ReactElement 对象来创建/更新 Fiber 子节点,但这不是本文所关心的;我们关心的是,这里执行了 render 成员方法,也就有可能抛出 React 异常处理机制所针对的异常。

FunctionComponent 抛异常的位置

接下来我们来定位与 FunctionComponent 抛异常的位置:有了 ClassComponent 的经验,我们一路循着 updateFunctionComponentrenderWithHooks ,在该方法中,我们可以看到 let children = Component(props, secondArg); ,这里的 Component 就是函数组件的 function 本身,而 children 则是执行函数组件后得到的 ReactElement 对象。

如何捕获 render 异常

当我们被问到"如何捕获异常",本能就会回答"用 try/catch 呀",那 React 是如何捕获这组件渲染过程中抛出的异常的呢?

  • 在生产环境下, React 使用 try/catch 来捕获异常
  • 在开发环境下, React 没有使用 try/catch ,而是实现了一套更为精细的捕获机制

为什么不能直接使用 try/catch 呢

React 原先就是直接使用 try/catch 来捕获 render 异常的,结果收到了大量的 issue ,详情是这样的:

  • Chrome devtools 有个名为 Pause on exceptions 的功能,该功能可以快速定位到抛出异常的代码位置,效果就相当于在该行代码上打了断点一样;但只有未被捕获的异常能够使用这种方法来定位
  • 开发者们投诉无法通过 Chrome devtools 定位到 React 组件渲染过程中抛出异常的代码
  • 有人发现只要打开 Pause On Caught Exceptions 便能定位到抛出异常的代码位置;这个功能开启后,即便异常被捕获也可以定位到目标位置,由此判断 React 把异常给"吞"了

为了解决这个问题,React 需要提供一套满足以下条件的异常捕获方案:

  • 依然需要捕获异常,捕获后交给错误边界来处理
  • 不使用 try/catch ,避免影响 Chrome devtools 的 Pause on exceptions 功能

如何不使用 try/catch 来捕获 render 异常

当 JavaScript 运行时错误(包括语法错误)发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror() 。

上述这段描述出自 MDN GlobalEventHandlers.onerror 的文档,这便是除 try/catch 外的捕获异常的方法:我们可以给 window 对象的 error 事件挂上回调处理方法,那么只要页面上任意 javascript 代码抛出异常,我们都可以捕获到。

但这样做的话,岂不是也捕获到许多与 React 组件渲染过程无关的异常?其实,我们只需要在执行 React 组件渲染前监听 error 事件,而在组件结束渲染后取消监听该事件即可:

function mockTryCatch(renderFunc, handleError) {     window.addEventListener('error', handleError); // handleError可以理解成是启用"错误边界"的入口方法     renderFunc();     window.removeEventListener('error', handleError); // 清除副作用 }

那是不是这样就大功告成了呢?且慢!这套方案的原意是要在开发环境取代原先使用的 try/catch 代码,但现在却有一个 try/catch 的重要特性没有还原:那就是 try/catch 在捕获异常后,会继续执行 try/catch 以外的代码:

try {     throw '异常'; } catch () {     console.log('捕获到异常!') } console.log('继续正常运行');  // 上述代码运行的结果是: // 捕获到异常! // 继续正常运行

而使用上述的 mockTryCatch 来试图替代 try/catch 的话:

mockTryCatch(() => {     throw '异常'; }, () => {     console.log('捕获到异常!') }); console.log('继续正常运行');  // 上述代码运行的结果是: // 捕获到异常!

显而易见, mockTryCatch 并不能完全替代 try/catch ,因为 mockTryCatch 在抛出异常后,后续同步代码的执行就会被强制终止。

如何像 try/catch 一样不影响后续代码执行

前端领域总是有各种各样的骚套路,还真让 React 开发者找到这样的方法: EventTarget.dispatchEvent ;那么,为什么说 dispatchEvent 就能模拟并替代 try/catch 呢?

dispatchEvent 能够同步执行代码
与浏览器原生事件不同,原生事件是由 DOM 派发的,并通过 event loop 异步调用事件处理程序,而 dispatchEvent() 则是同步调用事件处理程序。在调用 dispatchEvent() 后,所有监听该事件的事件处理程序将在代码继续前执行并返回。

上文出自 dispatchEvent 的 MDN 文档,由此可见: dispatchEvent 能够同步执行代码 ,这意味着在事件处理方法执行完成前,可以阻塞 dispatchEvent 后续的代码执行,同时这也是 try/catch 的特征之一。

dispatchEvent 抛的异常不冒泡
这些 event handlers 运行在一个嵌套的调用栈中:他们会阻塞调用直到他们处理完毕,但是异常不会冒泡。

准确来说,是:通过 dispatchEvent 触发的事件回调方法,异常不会冒泡;这意味着,即便抛出异常,也只是会终止事件回调方法本身的执行,而 dispatchEvent() 上下文的代码并不会收到影响。下面写个 DEMO 验证下这个特性:

function cb() {   console.log('开始执行回调');   throw 'dispatchEvent的事件处理函数抛异常了';   console.log('走不到这里的'); }  /* 准备一个虚拟事件 */ const eventType = 'this-is-a-custom-event'; const fakeEvent = document.createEvent('Event'); fakeEvent.initEvent(eventType, false, false);  /* 准备一个虚拟DOM节点 */ const fakeNode = document.createElement('fake'); fakeNode.addEventListener(eventType, cb, false); // 挂载  console.log('dispatchEvent执行前'); fakeNode.dispatchEvent(fakeEvent); console.log('dispatchEvent执行后');  // 上述代码运行的结果是: // dispatchEvent执行前 // 开始执行回调 // Uncaught dispatchEvent的事件处理函数抛异常了 // dispatchEvent执行后

从上述 DEMO 可以看出,尽管 dispatchEvent 的事件处理函数抛了异常,但依然还是能够继续执行 dispatchEvent 后续的代码(即 DEMO 中的 console.log())。

实现一个简易版的 render 异常捕获器

接下来,让我们把 GlobalEventHandlers.onerrorEventTarget.dispatchEvent 结合起来,就能够实现一个简易版的 render 异常捕获器:

function exceptionCatcher(func) {     /* 准备一个虚拟事件 */     const eventType = 'this-is-a-custom-event';     const fakeEvent = document.createEvent('Event');     fakeEvent.initEvent(eventType, false, false);      /* 准备一个虚拟DOM节点 */     const fakeNode = document.createElement('fake');     fakeNode.addEventListener(eventType, excuteFunc, false); // 挂载      window.addEventListener('error', handleError);     fakeNode.dispatchEvent(fakeEvent); // 触发执行目标方法     window.addEventListener('error', handleError); // 清除副作用          function excuteFunc() {         func();         fakeNode.removeEventListener(evtType, excuteFunc, false);      }          function handleError() {         // 将异常交给错误边界来处理     } }

React 源码中具体是如何捕获 render 异常的

上文介绍完捕获 render 异常的原理,也实现了个简易版 DEMO ,下面就可以来具体分析 React 源码了。

捕获目标:beginWork

上文提到, React 组件渲染维度的异常是在 beginWork 阶段抛出,因此我们捕获异常的目标显然就是 beginWork 了。

对 beginWork 进行包装

React 针对开发环境对 beginWork 方法进行了一个封装,添上了 捕获异常 的功能:

  1. 在执行 bginWork 前,先"备份"一下当前的 Fiber 节点(unitOfWork)的属性,复制到一个专门用于"备份"的 Fiber 节点上。
  2. 执行 beginWork 并使用 try/catch 捕获异常;看到这你也许会很疑惑,不是说不用 try/catch 来捕获异常吗,这怎么又用上了?还是继续往下看吧。
  3. 若 beginWork 抛出了异常,自然就会被捕获到,然后执行 catch 的代码段:

    1. 从备份中恢复当前 Fiber 节点(unitOfWork)到执行 beginWork 前的状态。
    2. 在当前 Fiber 节点上调用 invokeGuardedCallback 方法来重新执行一遍 beginWork ,这个 invokeGuardedCallback 方法会应用我们上文中提到的 GlobalEventHandlers.onerrorEventTarget.dispatchEvent 联合方法来捕获异常。
    3. 重新抛出捕获到的异常,后续可以针对异常进行处理;这里虽然抛出异常,并且这个异常会被外层的 try/catch 给捕获,但这不会影响 Pause on exceptions 功能,因为 invokeGuardedCallback 方法内产生的异常,并没有被外层的 try/catch 捕获。

beginWork的封装

let beginWork; if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {     // 开发环境会走到这个分支     beginWork = (current, unitOfWork, lanes) => {         /*             把当前Fiber节点(unitOfWork)的所有属性,拷贝到一个额外的Fiber节点(dummyFiber)中             这个dummyFiber节点仅仅作为备份使用,并且永远不会被插入到Fiber树中          */         const originalWorkInProgressCopy = assignFiberPropertiesInDEV(           dummyFiber,           unitOfWork,         );         try {           return originalBeginWork(current, unitOfWork, lanes); // 执行真正的beginWork方法         } catch (originalError) {             // ...省略                          // 从备份中恢复当前Fiber节点(unitOfWork)到执行beginWork前的状态             assignFiberPropertiesInDEV(unitOfWork, originalWorkInProgressCopy);                          // ...省略                          // 重新在当前Fiber节点上执行一遍beginWork,这里是本文介绍捕获异常的重点             invokeGuardedCallback(               null,               originalBeginWork,               null,               current,               unitOfWork,               lanes,             );              // 重新抛出捕获到的异常,后续可以针对异常进行处理,下文会介绍         }     }; } else {     // 生产环境会走到这个分支     beginWork = originalBeginWork; }
invokeGuardedCallback

接下来看 invokeGuardedCallback 方法,这个方法其实并非核心,它跟它所在的 ReactErrorUtils.js 文件内的其它方法,形成了一个"存/取"异常的工具,我们关注的核心在 invokeGuardedCallbackImpl 方法。

let hasError: boolean = false; let caughtError: mixed = null;  const reporter = {   onError(error: mixed) {     hasError = true;     caughtError = error;   }, };  export function invokeGuardedCallback<A, B, C, D, E, F, Context>(   name: string | null,   func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,   context: Context,   a: A,   b: B,   c: C,   d: D,   e: E,   f: F, ): void {   hasError = false;   caughtError = null;   invokeGuardedCallbackImpl.apply(reporter, arguments); }
invokeGuardedCallbackImpl

这个 invokeGuardedCallbackImpl 也分生产环境和开发环境的实现,我们只看开发环境的实现即可:

invokeGuardedCallbackImpl = function invokeGuardedCallbackDev<   A,   B,   C,   D,   E,   F,   Context, >(   name: string | null,   func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,   context: Context,   a: A,   b: B,   c: C,   d: D,   e: E,   f: F, ) {   // 省略...      const evt = document.createEvent('Event'); // 创建自定义事件    // 省略...    const windowEvent = window.event;    // 省略...    function restoreAfterDispatch() {     fakeNode.removeEventListener(evtType, callCallback, false); // 取消自定义事件的监听,清除副作用     // 省略...   }    const funcArgs = Array.prototype.slice.call(arguments, 3); // 取出需要传给beginWork的参数   function callCallback() {     // 省略...     restoreAfterDispatch();     func.apply(context, funcArgs); // 执行beginWork     // 省略...   }    function handleWindowError(event) {     error = event.error; // 捕获到异常     // 省略...   }    // 自定义事件名称   const evtType = `react-${name ? name : 'invokeguardedcallback'}`;    window.addEventListener('error', handleWindowError);   fakeNode.addEventListener(evtType, callCallback, false);    evt.initEvent(evtType, false, false); // 初始化一个自定义事件   fakeNode.dispatchEvent(evt); // 触发自定义事件,也可以认为是触发同步执行beginWork    // 省略...   this.onError(error); // 将捕获到的异常交给外层处理    window.removeEventListener('error', handleWindowError); // 取消error事件的监听,清除副作用 };

以上是我精简后的 invokeGuardedCallbackImpl 方法,是不是跟我们上述实现的简易版 React 异常捕获器相差无几呢?当然,该方法内其实还包括了很多异常情况的处理,这些异常情况都是由 issues 提出,然后以"打补丁"的方式来处理的,例如在测试环境中缺失 document ,又或是碰到跨域异常(cross-origin error)等,这里就不一一细说了。

处理异常

上文介绍了异常是怎么产生的,也介绍了异常是怎么被捕获的,下面就来简单介绍一下异常被捕获到后是怎么处理的:

  1. 从抛异常的 Fiber 节点开始,往根节点方向遍历,寻找能处理本次异常的错误边界;如果找不到,就只能交给根节点来处理异常。
  2. 如果由错误边界来处理异常,则创建一个 payloadgetDerivedStateFromError 方法执行后返回的 state 值、 callbackcomponentDidCatch 的更新任务;如果是由根节点来处理异常,则创建一个卸载整个组件树的更新任务。
  3. 进入处理异常的节点的 render 过程中(也即 performUnitOfWork ),在该过程中会执行刚刚创建的更新任务。
  4. 最终,如果由错误边界来处理异常,那么根据错误边界 state 的变化,会卸载掉带有异常 Fiber 节点的子组件树,改为渲染含有友好异常提示的 UI 界面;而如果由根节点来处理异常,则会卸载掉整个组件树,导致白屏。

React 处理 render 异常的简单流程图

React 中处理异常的源码实现

上文说到在(开发环境)封装的 beginWork 里,会把 invokeGuardedCallback 捕获到的异常重新抛出,那这个异常会在哪里被截住呢?答案是 renderRootSync

do {   try {     workLoopSync(); // workLoopSync中会调用beginWork     break;   } catch (thrownValue) {     handleError(root, thrownValue); // 处理异常   } } while (true);
handleError

下面来介绍 handleError

  • handleError 又是一个 React 惯用的 do...while(true) 的死循环结构,那么满足什么条件才能退出循环呢?
  • 在循环体内,有一个 try/catch 代码段,一旦 try 中的代码段抛异常被 catch 拦截住,那么就会回退到当前节点的父节点(React 的老套路了)继续尝试;如果某次执行中未抛异常,就能结束该循环,也即结束整个 handleError 方法。
  • try 代码段中,主要执行了 3 段逻辑:

    1. 判断当前节点或当前节点的父节点是否为 null ,如果是的话,则表明当前可能处在 Fiber 根节点,不可能有错误边界能够处理异常,直接作为致命异常来处理,结束当前方法。
    2. 执行 throwException 方法,遍历寻找一个能处理当前异常的节点(错误边界),下文将详细介绍。
    3. 执行 completeUnitOfWork ,这是 render 过程中最重要的方法之一,但这里主要是执行其中关于异常处理的代码分支,下文将详细介绍。

handleError 流程图

function handleError(root, thrownValue): void {   do {     let erroredWork = workInProgress;     try {       // 重置render过程中修改过的一些状态,省略...        if (erroredWork === null || erroredWork.return === null) {         // 若走到这个分支,则表明当前可能处在Fiber根节点,不可能有错误边界能够处理异常,直接作为致命异常来处理         workInProgressRootExitStatus = RootFatalErrored;         workInProgressRootFatalError = thrownValue;         workInProgress = null;         return;       }        // 省略...              // throwException是关键所在,下文介绍       throwException(         root,         erroredWork.return,         erroredWork,         thrownValue, // 异常本身         workInProgressRootRenderLanes,       );       completeUnitOfWork(erroredWork); // 处理异常的Fiber节点(erroredWork)     } catch (yetAnotherThrownValue) {       // 如果上述代码段依然无法处理当前异常Fiber节点(erroredWork) —— 还是抛了异常,那么就尝试用异常节点(erroredWork)的Fiber父节点来处理       // 这是一个循环过程,一直向上遍历父级节点,直到找到可以处理异常的Fiber节点,又或是到达Fiber根节点(确定无错误边界能够处理当前异常)       thrownValue = yetAnotherThrownValue;       if (workInProgress === erroredWork && erroredWork !== null) {         erroredWork = erroredWork.return;         workInProgress = erroredWork;       } else {         erroredWork = workInProgress;       }       continue;     }     return;   } while (true); }
throwException

下面来介绍 throwExceptionthrowException 主要做了以下事情:

  • 给当前抛异常的 Fiber 节点打上 Incomplete 这个 EffectTag ,后续会根据这个 Incomplete 标识走到异常处理的代码分支里。
  • 从当前抛异常的 Fiber 节点的父节点开始,往根节点方向遍历,找一个可以处理异常的节点;目前只有错误边界和 Fiber 根节点可以处理异常;根据遍历的方向,如果这个遍历路径中有错误边界的话,肯定会先找到错误边界,也就是优先让错误边界来处理异常。

    • "判断错误边界的标准"在这里就可以体现:必须是一个 ClassComponent ,且包含 getDerivedStateFromErrorcomponentDidCatch 两者或其中之一。
  • 找到可以处理异常的节点后,也会根据不同的类型来执行不同的代码分支,不过大概思路是一样的:

    1. 给该节点打上 ShouldCapture 的 EffectTag ,后续会根据这个EffectTag走到异常处理的代码分支。
    2. 针对当前异常新建一个更新任务,并给该更新任务找一个优先级最高的 lane ,保证在本次 render 时必定会执行;其中,错误边界会调用 createRootErrorUpdate 方法来创建更新任务,而根节点则是调用 createRootErrorUpdate 方法来创建更新任务,这两个方法下文都会详细介绍的。
function throwException(   root: FiberRoot,   returnFiber: Fiber,   sourceFiber: Fiber,   value: mixed, // 异常本身   rootRenderLanes: Lanes, ) {   // 给当前异常的Fiber节点打上Incomplete这个EffectTag,后续就根据这个Incomplete标识走到异常处理的代码分支里   sourceFiber.effectTag |= Incomplete;   sourceFiber.firstEffect = sourceFiber.lastEffect = null;    // 一大段针对Suspense场景的处理,省略...    renderDidError(); // 将workInProgressRootExitStatus置为RootErrored    value = createCapturedValue(value, sourceFiber); // 获取从Fiber根节点到异常节点的完整节点路径,挂载到异常上,方便后续打印   /*     尝试往异常节点的父节点方向遍历,找一个可以处理异常的错误边界,如果找不到的话那就只能交给根节点来处理了     注意,这里并不是从异常节点开始找的,因此即便异常节点自己是错误边界,也不能处理当前异常    */   let workInProgress = returnFiber;   do {     switch (workInProgress.tag) {       case HostRoot: {         // 进到这个代码分支意味着没能找到一个能够处理本次异常的错误边界,只能让Fiber根节点来处理异常         // 给该节点打上ShouldCapture的EffectTag,后续会根据这个EffectTag走到异常处理的代码分支         const errorInfo = value;         workInProgress.effectTag |= ShouldCapture;         // 针对当前异常新建一个更新任务,并给该更新任务找一个优先级最高的lane,保证在本次render时必定会执行         const lane = pickArbitraryLane(rootRenderLanes);         workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);         const update = createRootErrorUpdate(workInProgress, errorInfo, lane); // 关键,下文会介绍         enqueueCapturedUpdate(workInProgress, update);         return;       }       case ClassComponent:         const errorInfo = value;         const ctor = workInProgress.type;         const instance = workInProgress.stateNode;                  // 判断该节点是否为错误边界         if (           (workInProgress.effectTag & DidCapture) === NoEffect &&           (typeof ctor.getDerivedStateFromError === 'function' ||             (instance !== null &&               typeof instance.componentDidCatch === 'function' &&               !isAlreadyFailedLegacyErrorBoundary(instance)))         ) {           // 确定该节点是错误边界           // 给该节点打上ShouldCapture的EffectTag,后续会根据这个EffectTag走到异常处理的代码分支           workInProgress.effectTag |= ShouldCapture;            // 针对当前异常新建一个更新任务,并给该更新任务找一个优先级最高的lane,保证在本次render时必定会执行           const lane = pickArbitraryLane(rootRenderLanes);           workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);           const update = createClassErrorUpdate( // 关键,下文会介绍             workInProgress,             errorInfo,             lane,           );           enqueueCapturedUpdate(workInProgress, update);           return;         }         break;       default:         break;     }     workInProgress = workInProgress.return;   } while (workInProgress !== null); }
createRootErrorUpdatecreateClassErrorUpdate

当遇到无错误边界能处理的致命异常时,会调用 createRootErrorUpdate 方法来创建一个状态更新任务,该任务会将根节点置为 null ,即卸载整棵 React 组件树。 React 官方认为,与其渲染一个异常的界面误导用户,还不如直接显示白屏;我无法否定官方的这种思想,但更肯定了错误边界的重要性。

function createRootErrorUpdate(   fiber: Fiber,   errorInfo: CapturedValue<mixed>,   lane: Lane, ): Update<mixed> {   const update = createUpdate(NoTimestamp, lane, null);   update.tag = CaptureUpdate;   // 将根节点置为null,即卸载整棵React组件树   update.payload = {element: null};   const error = errorInfo.value;   update.callback = () => {     // 打印错误信息     onUncaughtError(error);     logCapturedError(fiber, errorInfo);   };   return update; }

当发现有错误边界可以处理当前异常时,会调用 createClassErrorUpdate 方法来创建一个状态更新任务,该更新任务的 payloadgetDerivedStateFromError 执行后返回的结果,而在更新任务的 callback 中,则执行了 componentDidCatch 方法(通常用来执行一些带有副作用的操作)。

function createClassErrorUpdate(   fiber: Fiber,   errorInfo: CapturedValue<mixed>,   lane: Lane, ): Update<mixed> {   const update = createUpdate(NoTimestamp, lane, null);   update.tag = CaptureUpdate;   // 注意这里的getDerivedStateFromError是取类组件本身的静态方法   const getDerivedStateFromError = fiber.type.getDerivedStateFromError;   if (typeof getDerivedStateFromError === 'function') {     const error = errorInfo.value;     // 在新创建的状态更新任务中,将state设置为getDerivedStateFromError方法执行后返回的结果     update.payload = () => {       logCapturedError(fiber, errorInfo);       return getDerivedStateFromError(error);     };   }    const inst = fiber.stateNode;   if (inst !== null && typeof inst.componentDidCatch === 'function') {     // 设置更新任务的callback     update.callback = function callback() {       // 省略...       if (typeof getDerivedStateFromError !== 'function') {         // 兼容早期的错误边界版本,当时并没有getDerivedStateFromError这个API         // 省略...       }       const error = errorInfo.value;       const stack = errorInfo.stack;       // 执行类组件的componentDidCatch成员方法,通常用来执行一些带有副作用的操作       this.componentDidCatch(error, {         componentStack: stack !== null ? stack : '',       });       // 省略...     };   }   // 省略...   return update; }
completeUnitOfWork

上面讲完了 throwException ,下面继续看 handleError 方法中的最后一个步骤 —— completeUnitOfWork ,该方法会对异常的 Fiber 节点进行处理,在异常场景中该方法的唯一参数是 抛出异常的 Fiber 节点

function handleError(root, thrownValue): void {   do {     let erroredWork = workInProgress;     try {       // 省略...              // throwException是关键所在,下文介绍       throwException(         root,         erroredWork.return,         erroredWork,         thrownValue, // 异常本身         workInProgressRootRenderLanes,       );       completeUnitOfWork(erroredWork); // 抛出异常的Fiber节点(erroredWork)     } catch (yetAnotherThrownValue) {         // 省略...     }     return;   } while (true); }

在之前的文章中,我们已经介绍过 completeUnitOfWork 方法了,但介绍的是正常的流程,直接把异常处理的流程给忽略了,下面我们来补上这一块:

  • completeUnitOfWork 跟上文介绍的 throwException 有点像,是从当前 Fiber 节点(在异常场景指的是抛异常的节点)往根节点方向遍历,找一个可以处理异常的节点;由于 completeUnitOfWork 同时包含了正常流程和异常处理流程,因此是通过 Incomplete 这个 EffectTag 来进入到异常处理的代码分支里的。
  • 一旦发现可以处理异常的 Fiber 节点,则将其设置为下一轮 work(performUnitOfWork)循环主体(workInProgres),然后立即终止本 completeUnitOfWork 方法;后续就会回到 performUnitOfWork 并进入到该(可以处理异常的) Fiber 节点的 beginWork 阶段。
  • 在遍历过程中,如果发现当前节点无法处理异常,那么就会给当前节点的父节点也打上 Incomplete ,保证父节点也会进入到异常处理的代码分支。
  • completeUnitOfWork 中针对 sibling 节点的逻辑并没有区分是否为正常流程,这点我有点意外:因为如果当前节点有异常,那么它的 sibling 节点即便是正常的,在后续的异常处理过程中也会被重新 render ,此时又何必去 render 它的 sibling 节点呢;但反过来想,这样做也不会产生问题,因为 sibling 节点在 completeUnitOfWork 回退到父节点时,由于父节点已经被设置为 Incomplete 了,所以也依然会走异常处理的流程。

completeUnitOfWork 的异常处理流程

这里还有个问题:为什么要重新 render 可以处理异常的节点 呢?我们不看后续的操作其实就能猜到 React 的做法:假设这个 可以处理异常的节点 是一个错误边界,在上文介绍的 throwException 中已经根据 getDerivedStateFromError 执行后返回的 state 值来创建了一个更新任务,那么后续只需要更新错误边界的 state ,根据 state 卸载掉抛异常的组件并渲染错误提示的组件,那这不就是一个很正常的 render 流程了吗。

function completeUnitOfWork(unitOfWork: Fiber): void {   let completedWork = unitOfWork; // 这里的unitOfWork指的是抛出异常的Fiber节点   do {     const current = completedWork.alternate;     const returnFiber = completedWork.return;      // 判断当前Fiber节点是否被打上Incomplete这个EffectTag     if ((completedWork.effectTag & Incomplete) === NoEffect) {     // 正常的Fiber节点的处理流程,省略...     } else {       // 当前Fiber节点是否被打上Incomplete这个EffectTag,即当前Fiber节点因为异常,未能完成render过程,尝试走进处理异常的流程        // 判断当前Fiber节点(completeWork)能否处理异常,如果可以的话就赋给next变量       const next = unwindWork(completedWork, subtreeRenderLanes);        // Because this fiber did not complete, don't reset its expiration time.        if (next !== null) {         // 发现当前Fiber节点能够处理异常,将其设置为下一轮work(performUnitOfWork)的循环主体(workInProgres),         // 然后立即终止当前的completeWork阶段,后续将进入到当前Fiber节点的beginWork阶段(render的"递"阶段)         next.effectTag &= HostEffectMask;         workInProgress = next;         return;       }        // 省略...        // 走到这个分支意味着当前Fiber节点(completeWork)并不能处理异常,       // 因此把Fiber父节点也打上Incomplete的EffectTag,后续将继续尝试走进处理异常的流程       if (returnFiber !== null) {         // Mark the parent fiber as incomplete and clear its effect list.         returnFiber.firstEffect = returnFiber.lastEffect = null;         returnFiber.effectTag |= Incomplete;       }     }      // 处理当前Fiber节点的sibling节点,可以正常进入sibling节点的beginWork阶段     // 后续会继续通过sibling节点的completeUnitOfWork回退到父节点来判断是否能够处理异常          // 在当前循环中回退到父节点,继续尝试走进处理异常的流程     completedWork = returnFiber;     workInProgress = completedWork;   } while (completedWork !== null);   // 省略... }
unwindWork

这里介绍一下 unwindWork 方法是怎么判断当前 Fiber 节点(completeWork)能否处理异常的:

  • 根据 completeWork.tag 即 Fiber 节点类型来判断,仅有 ClassComponent / HostRoot / SuspenseComponent / DehydratedSuspenseComponent 这 4 类 Fiber 节点类型能够处理异常
  • 根据 completeWork.effectTag 中是否包含 ShouldCapture 来判断,这个 EffectTag 是在上文介绍的 throwException 方法打上的。

unwindWork 方法中,一旦判断当前 Fiber 节点能够处理异常,那么则清除其 ShouldCapture ,并添上 DidCapture 的 EffectTag ,该 EffectTag 也会成为后续异常处理的判断标准。

function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {   switch (workInProgress.tag) {     case ClassComponent: {       // 省略...       const effectTag = workInProgress.effectTag;       // 判断是否包含ShouldCapture这个EffectTag       if (effectTag & ShouldCapture) {         // 确定当前Fiber节点能够处理异常,即确定为错误边界         // 清除当前Fiber节点的ShouldCapture,并添上DidCapture的EffectTag          workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;         // 省略...         return workInProgress;       }       return null;     }     case HostRoot: {       // 进到当前代码分支,意味着在当前Fiber树中没有能够处理本次异常的错误边界       // 因此交由Fiber根节点来统一处理异常       // 省略...       const effectTag = workInProgress.effectTag;       // 省略...       // 清除Fiber根节点的ShouldCapture,并添上DidCapture的EffectTag        workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;       return workInProgress;     }     // 省略...     default:       return null;   } }
重新 render 错误边界 Fiber 节点

在 completeUnitOfWork 方法中,我们通过 do...while 循环配合 unwindWork 方法,寻找在 throwException 方法中已经标记过可以处理当前异常的 错误边界 节点;下面假设的确有这样的一个 错误边界 节点,那么 completeUnitOfWork 方法会被结束,然后就进入到该节点的第二次 render :

workLoopSync --> performUnitOfWork --> beginWork --> updateClassComponent -> updateClassInstance / finishClassComponent

上面这都是正常 render 一个 ClassComponent 的过程,首先我们需要关注到 updateClassInstance ,在这个方法中,会针对当前节点的更新任务,来更新节点的 state ;还记得在 createClassErrorUpdate 中根据类组件静态方法 getDerivedStateFromError 返回的 state 值来创建的一个更新任务吗,该更新任务还被赋予了最高优先级:pickArbitraryLane(rootRenderLanes) ,因此在 updateClassInstance 就会根据这个更新任务来更新 state (也就是 getDerivedStateFromError 返回的 state 值)。

然后,我们进入到 finishClassComponent 方法的逻辑里,本方法针对异常处理其实就做了两个事情:

  1. 兼容老版错误边界的API

    • 判断是否为老版错误边界的依据是:当前节点的 ClassComponent 是否存在 getDerivedStateFromError 这个类静态方法;在老版错误边界中,没有 getDerivedStateFromError 这个 API ,统一是在 componentDidCatch 中发起 setState() 来修改 state 的,
    • 兼容的方法是:在本次 render 过程中,把 nextChildren 设置为 null,即卸载掉所有的子节点,这样的话就能避免本次 render 抛异常;而在 commit 阶段,会执行更新任务的 callback ,即 componentDidCatch ,到时候可以发起新一轮 render 。
  2. 强制重新创建子节点,这块其实与正常逻辑调用 reconcileChildren 差别不大,但做了一些小手段来禁止复用 current 树上的子节点,下文会详细介绍。
function finishClassComponent(   current: Fiber | null,   workInProgress: Fiber,   Component: any,   shouldUpdate: boolean,   hasContext: boolean,   renderLanes: Lanes, ) {   // 省略...   // 判断是否有DidCapture这个EffectTag,若带有该EffectTag,则表示当前Fiber节点为处理异常的错误边界   const didCaptureError = (workInProgress.effectTag & DidCapture) !== NoEffect;    // 正常的render流程代码分支,省略...    const instance = workInProgress.stateNode;   ReactCurrentOwner.current = workInProgress;   let nextChildren;   if (     didCaptureError &&     typeof Component.getDerivedStateFromError !== 'function'   ) {     // 若当前为处理异常的错误边界,但又没有定义getDerivedStateFromError这方法,则进入到本代码分支     // 这个代码分支主要是为了兼容老版错误边界的API,在老版错误边界中,是在componentDidCatch发起setState()来修改state的     // 兼容的方法是,在本次render过程中,把nextChildren设置为null,即卸载掉所有的子节点,这样的话就能避免本次render抛异常     nextChildren = null;     // 省略...   } else {     // 正常的render流程代码分支,省略...   }    // 省略...      if (current !== null && didCaptureError) {     // 强制重新创建子节点,禁止复用current树上的子节点;     forceUnmountCurrentAndReconcile(       current,       workInProgress,       nextChildren,       renderLanes,     );   } else {     // 正常的render流程代码分支     reconcileChildren(current, workInProgress, nextChildren, renderLanes);   }    // 省略...    return workInProgress.child; }
如何强制重新渲染子节点

在介绍 finishClassComponent 时我们提到可以用 forceUnmountCurrentAndReconcile 方法,与正常的 render 逻辑类似,该方法中也会调用 reconcileChildFibers ,但却非常巧妙地调用了两次:

  1. 第一次调用 reconcileChildFibers 时,会把原本应该传"子节点 ReactElement 对象"的参数改为传 null,相当于卸载掉所有子节点 ;这样的话就会给 current 树上的所有子节点都标记上"删除"的 EffectTag 。
  2. 第二次调用 reconcileChildFibers 时,会把原本应该传" current 树上对应子节点"的参数改为传 null ;这样的话就能保证本次 render 后,当前节点(错误边界)的所有子节点都是新创建的,不会复用 current 树节点

至于为什么要这么做呢, React 官方的解释是"从概念上来说,处理异常时与正常渲染时是不同的两套 UI ,不应该复用任何子节点(即使该节点的特征 —— key/props 等是一致的)";简单来理解的话,就是"一刀切"避免复用到异常的 Fiber 节点吧。

function forceUnmountCurrentAndReconcile(   current: Fiber,   workInProgress: Fiber,   nextChildren: any,   renderLanes: Lanes, ) {   // 只有在render处理异常的错误边界时,才会进入到当前方法;当然正常逻辑下也是会执行reconcileChildFibers   // 在处理异常时,应该拒绝复用current树上对应的current子节点,避免复用到异常的子节点;为此,会调用两次reconcileChildFibers      // 第一次调用reconcileChildFibers,会把原本应该传子节点ReactElement对象的参数改为传null   // 这样的话就会给current树上的所有子节点都标记上"删除"的EffectTag   workInProgress.child = reconcileChildFibers(     workInProgress,     current.child,     null,     renderLanes,   );    // 第二次调用reconcileChildFibers,会把原本应该传current树上对应子节点的参数改为传null   // 这样就能保证本次render后的所有子节点都是新创建的,不会复用   workInProgress.child = reconcileChildFibers(     workInProgress,     null,     nextChildren,     renderLanes,   ); }

写在最后

以上便是对 React render 异常处理机制的介绍,通过本文,补全了前面几篇介绍 render 的文章的疏漏(前文仅介绍了 render 的正常流程),让我们在开发过程中做到对异常处理"心里有数",快给你的应用加几个错误边界吧(笑)。

VuePress 博客如何开启本地 HTTPS 访问

Posted: 16 Feb 2022 02:26 AM PST

前言

《一篇带你用 VuePress + Github Pages 搭建博客》中,我们使用 VuePress 搭建了一个博客,最终的效果查看:TypeScript 中文文档

如果我们在本地运行项目,运行地址类似于http://localhost:8080/learn-typescript/,以 http 开头,这在大部分时候都满足了需要,但有的时候,比如兼容 PWA,就需要以 https 开头,那我们如何在本地使用 https 地址呢?

开启 HTTPS

在 VuePress 官方文档里,我们并没有搜到直接的答案,但我们可以在 StackOverflow 搜到一个回答,其实可以直接在 config.js添加:

module.exports = {   devServer: {     https: true   } }

我们试一下,访问地址,会出现不安全提示:

我们点击 「高级」里的 「继续前往localhost(不安全)」,可以访问到页面,只是地址栏会显示一个"不安全":

HTTPS 原理

想想确实如此,我们都没有 SSL 证书,怎么就能判断为安全连接呢?​

那如何才能让浏览器判断为安全连接呢?我们先简单复习一下 HTTPS 的原理:

首先是 CA,英文全称:Certificate Authority,中文翻译为:数字证书认证机构,是负责发放和管理数字证书的权威机构,是受到信任的第三方机构。电脑系统、浏览器里会内置 CA 颁发的根证书。

然后是 HTTPS 建立的过程,当客户端向服务端发起一个 HTTPS 连接的时候,服务器会将自己的证书发给客户端,证书中包含公钥,客户端会寻找是否有这个证书签发的 CA 的根证书,如果有,再对证书进行解密验证,防止证书被篡改,如果通过,客户端会生成一个随机串,然后使用服务器证书中的公钥进行加密,然后发送给服务器,服务器利用私钥进行对这个密文进行解密,得到随机串,然后两端使用这个随机值进行加密通信。

所以对于服务器来说,需要有两个东西,一个是包含公钥的服务器证书,一个是私钥。

对于客户端来说,则需要 CA 根证书。

mkcert

为了实现本地 HTTPS 连接,我们可以借助 mkcert 这个工具来实现证书的配置:

mkcert 是一个用于创建本地信任的开发证书的便捷工具。在本地开发环境中使用真实的CA(Certificate Authority,证书颁发机构)签发的证书,是非常困难的,特别是对于像 example.net、localhost 或者 127.0.0.1 这样的主机来说,使用真实的CA签发的证书是不可能的。在这样的情况下,自签发的证书可能是唯一的选择。mkcert 可以生成自签发的证书,并把本地 CA 安装到系统根证书库中。

1. 安装 mkcert

brew install mkcert

2. 创建本地 CA

mkcert -install

生成后,在 Mac 中,我们可以通过 「钥匙串访问」查看到这个证书:

3. 生成证书

mkcert localhost 127.0.0.1

这会在当前目录下生成两个证书文件:localhost+1-key.pemlocalhost+1.pem,其中 localhost+1.pem就是服务端证书,localhost+1-key.pem就是私钥。​

4. 修改 config.js

然后我们将这两个文件拷贝到 config.js 文件的同级目录里,然后修改 config.js

const fs = require('fs') const path = require('path');  module.exports = {     devServer: {         https: true,         key: fs.readFileSync(path.resolve(__dirname, './localhost+1-key.pem')),         cert: fs.readFileSync(path.resolve(__dirname, './localhost+1.pem'))   } }

5. 重新运行项目

然后重新运行项目,你就会看到:

如果证书显示有效,但依然显示不安全连接,浏览器重启或者开一个隐私窗口试试。

系列文章

博客搭建系列是我至今写的唯一一个偏实战的系列教程,预计 20 篇左右,讲解如何使用 VuePress 搭建、优化博客,并部署到 GitHub、Gitee、私有服务器等平台。本篇为第 22 篇,全系列文章地址:https://github.com/mqyqingfeng/Blog

微信:「mqyqingfeng」,加我进冴羽唯一的读者群。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

Spring Boot 发送邮件,端口号大有玄机!

Posted: 15 Feb 2022 07:45 PM PST

@[toc]
Spring Boot 发送邮件,松哥之前专门写过文章,这里就不啰嗦了。还不懂 Spring Boot 如何发送邮件的小伙伴,翻到本文后半部分,有介绍。

今天想和大家聊一下 SMTP 服务器的端口问题,这个也是一个小伙伴提的问题,SMTP 服务器有众多端口:25、465、587 各自间有什么区别?可以随意使用吗?希望今天这篇文章能给你答案。

1. 什么是 SMTP

SMTP 代表简单邮件传输协议,简而言之,它是通过 Internet 发送电子邮件的过程。计算机端口是个人计算机连接到网络并完成数据传输的方式。SMTP 端口是两者的组合:设计用于通过网络向其收件人发送电子邮件的端口。

下图展示了 SMTP 协议在邮件发送过程的作用:

当然,就像有多个计算机端口一样,可以使用的 SMTP 端口也有很多。

2. SMTP Port

2.1 25

1982 年,南加州大学向 Internet 工程任务组 (IETF) 提交了一份提案,即 Request For Comments (RFC) 821,将端口 25 建立为 Internet 电子邮件的默认传输通道。40 年过去了,如今我们依然可以使用 25 这个端口在两个邮件服务器之间传输邮件。

不过最初的设计没有考虑安全问题,在 1998 年 12 月,R. Gellens 和 J. Klensin 提交了 RFC2476,在这个规范中,RFC 提议将传统的消息提交和消息中继概念分开,RFC 定义消息提交应通过端口 587 进行(即我们通过邮件客户端等工具提交邮件的时候,应该使用 587 端口),以确保新的策略和安全要求不会干扰消息中继端口 25 上的传统中继流量。

这么一拆分,端口 25 就主要用于 SMTP 中继,也就是将邮件从一个电子邮件服务器传输到另一个电子邮件服务器。

在大多数情况下,SMTP 电子邮件客户端(Foxmail、Microsoft Outlook、Mail、Thunderbird 等)不应使用 25 端口,以遏制垃圾邮件的数量,所以这个 25 端口和我们个人使用的关系就不大。

2.2 587

这是默认的邮件提交端口,当用户提交一封电子邮件到邮件服务器时,可以使用该端口,我们自己通过 Java 代码发送邮件,也可以使用该端口。

端口 587 与 TLS 加密相结合,可确保安全提交电子邮件并遵循 IETF 制定的指导方针。

2.3 465

那按理说我们发送邮件的时候就该使用 587 端口呀,465 又是干嘛的?

IETF 从未将端口 465 发布为官方 SMTP 传输或提交端口,然而维护大部分核心互联网基础设施的 IANA 为 SMTPS 分配了端口 465。目的是为 SMTP 建立一个端口,以便使用安全套接字层 (SSL) 进行操作,这样使得邮件发送更加安全。

所以 465 和 587 其实都是为了邮件安全,但是两者的思路不一样,465 是 SSL,587 则是 TLS,SSL 和 TLS 有啥区别呢?这个就说来话长了,简单一句话就是:TLS(传输层安全)是更为安全的升级版 SSL,TLS 是 SSL 标准化后的产物。

按理说 465 应该被撤销,大家都用 587,但是由于 465 曾经被 IANA 认定为有效,因此可能存在仅能够使用此端口连接的遗留系统,所以该端口并没有被废弃,也可以使用。

2.4 小结

好啦,这就是这几个端口的区别。一般来说,我们用 Spring Boot 发送邮件的时候,465 和 587 都能用,但是不建议使用 25。另外在使用 465 或者 587 的时候,有的个别邮箱如 139 邮箱需要配置如下属性:

spring.mail.properties.mail.smtp.ssl.enable=true

3. 号外

可能还有小伙伴不懂 Spring Boot 邮件发送,再来回顾下。

邮件发送其实是一个非常常见的需求,用户注册,找回密码等地方,都会用到,使用 JavaSE 代码发送邮件,步骤还是挺繁琐的,Spring Boot 中对于邮件发送,提供了相关的自动化配置类,使得邮件发送变得非常容易,接下来我们就来一探究竟!看看使用 Spring Boot 发送邮件的 5 中姿势。

3.1 邮件基础

我们经常会听到各种各样的邮件协议,比如 SMTP、POP3、IMAP ,那么这些协议有什么作用,有什么区别?我们先来讨论一下这个问题。

SMTP 是一个基于 TCP/IP 的应用层协议,江湖地位有点类似于 HTTP,SMTP 服务器默认监听的端口号为 25 。看到这里,小伙伴们可能会想到既然 SMTP 协议是基于 TCP/IP 的应用层协议,那么我是不是也可以通过 Socket 发送一封邮件呢?回答是肯定的。

生活中我们投递一封邮件要经过如下几个步骤:

  1. 深圳的小王先将邮件投递到深圳的邮局
  2. 深圳的邮局将邮件运送到上海的邮局
  3. 上海的小张来邮局取邮件

这是一个缩减版的生活中邮件发送过程。这三个步骤可以分别对应我们的邮件发送过程,假设从 aaa@qq.com 发送邮件到 111@163.com

  1. aaa@qq.com 先将邮件投递到腾讯的邮件服务器
  2. 腾讯的邮件服务器将我们的邮件投递到网易的邮件服务器
  3. 111@163.com 登录网易的邮件服务器查看邮件

邮件投递大致就是这个过程,这个过程就涉及到了多个协议,我们来分别看一下。

SMTP 协议全称为 Simple Mail Transfer Protocol,译作简单邮件传输协议,它定义了邮件客户端软件与 SMTP 服务器之间,以及 SMTP 服务器与 SMTP 服务器之间的通信规则。

也就是说 aaa@qq.com 用户先将邮件投递到腾讯的 SMTP 服务器这个过程就使用了 SMTP 协议,然后腾讯的 SMTP 服务器将邮件投递到网易的 SMTP 服务器这个过程也依然使用了 SMTP 协议,SMTP 服务器就是用来收邮件。

而 POP3 协议全称为 Post Office Protocol ,译作邮局协议,它定义了邮件客户端与 POP3 服务器之间的通信规则,那么该协议在什么场景下会用到呢?当邮件到达网易的 SMTP 服务器之后, 111@163.com 用户需要登录服务器查看邮件,这个时候就该协议就用上了:邮件服务商都会为每一个用户提供专门的邮件存储空间,SMTP 服务器收到邮件之后,就将邮件保存到相应用户的邮件存储空间中,如果用户要读取邮件,就需要通过邮件服务商的 POP3 邮件服务器来完成。

最后,可能也有小伙伴们听说过 IMAP 协议,这个协议是对 POP3 协议的扩展,功能更强,作用类似,这里不再赘述。

3.2 准备工作

目前国内大部分的邮件服务商都不允许直接使用用户名/密码的方式来在代码中发送邮件,都是要先申请授权码,这里以 QQ 邮箱为例,向大家演示授权码的申请流程:首先我们需要先登录 QQ 邮箱网页版,点击上方的设置按钮:

然后点击账户选项卡:

在账户选项卡中找到开启POP3/SMTP选项,如下:

点击开启,开启相关功能,开启过程需要手机号码验证,按照步骤操作即可,不赘述。开启成功之后,即可获取一个授权码,将该号码保存好,一会使用。

3.3 项目创建

接下来,我们就可以创建项目了,Spring Boot 中,对于邮件发送提供了自动配置类,开发者只需要加入相关依赖,然后配置一下邮箱的基本信息,就可以发送邮件了。

  • 首先创建一个 Spring Boot 项目,引入邮件发送依赖:

创建完成后,项目依赖如下:

<dependency>     <groupId>org.springframework.boot</groupId>     <artifactId>spring-boot-starter-mail</artifactId> </dependency> <dependency>     <groupId>org.springframework.boot</groupId>     <artifactId>spring-boot-starter-web</artifactId> </dependency>
  • 配置邮箱基本信息

项目创建成功后,接下来在 application.properties 中配置邮箱的基本信息:

spring.mail.host=smtp.qq.com spring.mail.port=587 spring.mail.username=1510161612@qq.com spring.mail.password=ubknfzhjkhrbbabe spring.mail.default-encoding=UTF-8 spring.mail.properties.mail.smtp.socketFactoryClass=javax.net.ssl.SSLSocketFactory spring.mail.properties.mail.debug=true

配置含义分别如下:

  • 配置 SMTP 服务器地址
  • SMTP 服务器的端口
  • 配置邮箱用户名
  • 配置密码,注意,不是真正的密码,而是刚刚申请到的授权码
  • 默认的邮件编码
  • 配饰 SSL 加密工厂
  • 表示开启 DEBUG 模式,这样,邮件发送过程的日志会在控制台打印出来,方便排查错误

如果不知道 smtp 服务器的端口或者地址的的话,可以参考 腾讯的邮箱文档

做完这些之后,Spring Boot 就会自动帮我们配置好邮件发送类,相关的配置在 org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration 类中,部分源码如下:

@Configuration @ConditionalOnClass({ MimeMessage.class, MimeType.class, MailSender.class }) @ConditionalOnMissingBean(MailSender.class) @Conditional(MailSenderCondition.class) @EnableConfigurationProperties(MailProperties.class) @Import({ MailSenderJndiConfiguration.class, MailSenderPropertiesConfiguration.class }) public class MailSenderAutoConfiguration { }

从这段代码中,可以看到,导入了另外一个配置 MailSenderPropertiesConfiguration 类,这个类中,提供了邮件发送相关的工具类:

@Configuration @ConditionalOnProperty(prefix = "spring.mail", name = "host") class MailSenderPropertiesConfiguration {         private final MailProperties properties;         MailSenderPropertiesConfiguration(MailProperties properties) {                 this.properties = properties;         }         @Bean         @ConditionalOnMissingBean         public JavaMailSenderImpl mailSender() {                 JavaMailSenderImpl sender = new JavaMailSenderImpl();                 applyProperties(sender);                 return sender;         } }

可以看到,这里创建了一个 JavaMailSenderImpl 的实例, JavaMailSenderImplJavaMailSender 的一个实现,我们将使用 JavaMailSenderImpl 来完成邮件的发送工作。

做完如上两步,邮件发送的准备工作就算是完成了,接下来就可以直接发送邮件了。

具体的发送,有 5 种不同的方式,我们一个一个来看。

3.3.1 发送简单邮件

简单邮件就是指邮件内容是一个普通的文本文档:

@Autowired JavaMailSender javaMailSender; @Test public void sendSimpleMail() {     SimpleMailMessage message = new SimpleMailMessage();     message.setSubject("这是一封测试邮件");     message.setFrom("1510161612@qq.com");     message.setTo("25xxxxx755@qq.com");     message.setCc("37xxxxx37@qq.com");     message.setBcc("14xxxxx098@qq.com");     message.setSentDate(new Date());     message.setText("这是测试邮件的正文");     javaMailSender.send(message); }

从上往下,代码含义分别如下:

  1. 构建一个邮件对象
  2. 设置邮件主题
  3. 设置邮件发送者
  4. 设置邮件接收者,可以有多个接收者
  5. 设置邮件抄送人,可以有多个抄送人
  6. 设置隐秘抄送人,可以有多个
  7. 设置邮件发送日期
  8. 设置邮件的正文
  9. 发送邮件

最后执行该方法,就可以实现邮件的发送,发送效果图如下:

3.3.2 发送带附件的邮件

邮件的附件可以是图片,也可以是普通文件,都是支持的。

@Test public void sendAttachFileMail() throws MessagingException {     MimeMessage mimeMessage = javaMailSender.createMimeMessage();     MimeMessageHelper helper = new MimeMessageHelper(mimeMessage,true);     helper.setSubject("这是一封测试邮件");     helper.setFrom("1510161612@qq.com");     helper.setTo("25xxxxx755@qq.com");     helper.setCc("37xxxxx37@qq.com");     helper.setBcc("14xxxxx098@qq.com");     helper.setSentDate(new Date());     helper.setText("这是测试邮件的正文");     helper.addAttachment("javaboy.jpg",new File("C:\\Users\\sang\\Downloads\\javaboy.png"));     javaMailSender.send(mimeMessage); }

注意这里在构建邮件对象上和前文有所差异,这里是通过 javaMailSender 来获取一个复杂邮件对象,然后再利用 MimeMessageHelper 对邮件进行配置,MimeMessageHelper 是一个邮件配置的辅助工具类,创建时候的 true 表示构建一个 multipart message 类型的邮件,有了 MimeMessageHelper 之后,我们针对邮件的配置都是由 MimeMessageHelper 来代劳。

最后通过 addAttachment 方法来添加一个附件。

执行该方法,邮件发送效果图如下:

3.3.3 发送带图片资源的邮件

图片资源和附件有什么区别呢?图片资源是放在邮件正文中的,即一打开邮件,就能看到图片。但是一般来说,不建议使用这种方式,一些公司会对邮件内容的大小有限制(因为这种方式是将图片一起发送的)。

@Test public void sendImgResMail() throws MessagingException {     MimeMessage mimeMessage = javaMailSender.createMimeMessage();     MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);     helper.setSubject("这是一封测试邮件");     helper.setFrom("1510161612@qq.com");     helper.setTo("25xxxxx755@qq.com");     helper.setCc("37xxxxx37@qq.com");     helper.setBcc("14xxxxx098@qq.com");     helper.setSentDate(new Date());     helper.setText("<p>hello 大家好,这是一封测试邮件,这封邮件包含两种图片,分别如下</p><p>第一张图片:</p><img src='cid:p01'/><p>第二张图片:</p><img src='cid:p02'/>",true);     helper.addInline("p01",new FileSystemResource(new File("C:\\Users\\sang\\Downloads\\javaboy.png")));     helper.addInline("p02",new FileSystemResource(new File("C:\\Users\\sang\\Downloads\\javaboy2.png")));     javaMailSender.send(mimeMessage); }

这里的邮件 text 是一个 HTML 文本,里边涉及到的图片资源先用一个占位符占着,setText 方法的第二个参数 true 表示第一个参数是一个 HTML 文本。

setText 之后,再通过 addInline 方法来添加图片资源。

最后执行该方法,发送邮件,效果如下:

在公司实际开发中,第一种和第三种都不是使用最多的邮件发送方案。因为正常来说,邮件的内容都是比较的丰富的,所以大部分邮件都是通过 HTML 来呈现的,如果直接拼接 HTML 字符串,这样以后不好维护,为了解决这个问题,一般邮件发送,都会有相应的邮件模板。最具代表性的两个模板就是 Freemarker 模板和 Thyemeleaf 模板了。

3.3.4 使用 Freemarker 作邮件模板

首先需要引入 Freemarker 依赖:

<dependency>     <groupId>org.springframework.boot</groupId>     <artifactId>spring-boot-starter-freemarker</artifactId> </dependency>

然后在 resources/templates 目录下创建一个 mail.ftl 作为邮件发送模板:

<!DOCTYPE html> <html lang="en"> <head>     <meta charset="UTF-8">     <title>Title</title> </head> <body> <p>hello 欢迎加入 xxx 大家庭,您的入职信息如下:</p> <table border="1">     <tr>         <td>姓名</td>         <td>${username}</td>     </tr>     <tr>         <td>工号</td>         <td>${num}</td>     </tr>     <tr>         <td>薪水</td>         <td>${salary}</td>     </tr> </table> <div style="color: #ff1a0e">一起努力创造辉煌</div> </body> </html>

接下来,将邮件模板渲染成 HTML ,然后发送即可。

@Test public void sendFreemarkerMail() throws MessagingException, IOException, TemplateException {     MimeMessage mimeMessage = javaMailSender.createMimeMessage();     MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);     helper.setSubject("这是一封测试邮件");     helper.setFrom("1510161612@qq.com");     helper.setTo("25xxxxx755@qq.com");     helper.setCc("37xxxxx37@qq.com");     helper.setBcc("14xxxxx098@qq.com");     helper.setSentDate(new Date());     //构建 Freemarker 的基本配置     Configuration configuration = new Configuration(Configuration.VERSION_2_3_0);     // 配置模板位置     ClassLoader loader = MailApplication.class.getClassLoader();     configuration.setClassLoaderForTemplateLoading(loader, "templates");     //加载模板     Template template = configuration.getTemplate("mail.ftl");     User user = new User();     user.setUsername("javaboy");     user.setNum(1);     user.setSalary((double) 99999);     StringWriter out = new StringWriter();     //模板渲染,渲染的结果将被保存到 out 中 ,将out 中的 html 字符串发送即可     template.process(user, out);     helper.setText(out.toString(),true);     javaMailSender.send(mimeMessage); }

需要注意的是,虽然引入了 Freemarker 的自动化配置,但是我们在这里是直接 new Configuration 来重新配置 Freemarker 的,所以 Freemarker 默认的配置这里不生效,因此,在填写模板位置时,值为 templates

调用该方法,发送邮件,效果图如下:

3.3.5 使用 Thymeleaf 作邮件模板

推荐在 Spring Boot 中使用 Thymeleaf 来构建邮件模板。因为 Thymeleaf 的自动化配置提供了一个 TemplateEngine,通过 TemplateEngine 可以方便的将 Thymeleaf 模板渲染为 HTML ,同时,Thymeleaf 的自动化配置在这里是继续有效的 。

首先,引入 Thymeleaf 依赖:

<dependency>     <groupId>org.springframework.boot</groupId>     <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>

然后,创建 Thymeleaf 邮件模板:

<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head>     <meta charset="UTF-8">     <title>Title</title> </head> <body> <p>hello 欢迎加入 xxx 大家庭,您的入职信息如下:</p> <table border="1">     <tr>         <td>姓名</td>         <td th:text="${username}"></td>     </tr>     <tr>         <td>工号</td>         <td th:text="${num}"></td>     </tr>     <tr>         <td>薪水</td>         <td th:text="${salary}"></td>     </tr> </table> <div style="color: #ff1a0e">一起努力创造辉煌</div> </body> </html>

接下来发送邮件:

@Autowired TemplateEngine templateEngine;  @Test public void sendThymeleafMail() throws MessagingException {     MimeMessage mimeMessage = javaMailSender.createMimeMessage();     MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);     helper.setSubject("这是一封测试邮件");     helper.setFrom("1510161612@qq.com");     helper.setTo("25xxxxx755@qq.com");     helper.setCc("37xxxxx37@qq.com");     helper.setBcc("14xxxxx098@qq.com");     helper.setSentDate(new Date());     Context context = new Context();     context.setVariable("username", "javaboy");     context.setVariable("num","000001");     context.setVariable("salary", "99999");     String process = templateEngine.process("mail.html", context);     helper.setText(process,true);     javaMailSender.send(mimeMessage); }

调用该方法,发送邮件,效果图如下:

好了,这就是我们今天说的 5 种邮件发送姿势,不知道你掌握了没有呢?

本文案例已经上传到 GitHub:https://github.com/lenve/javaboy-code-samples

有问题欢迎留言讨论。

参考资料:

【高并发】深入解析Callable接口

Posted: 15 Feb 2022 05:43 PM PST

大家好,我是冰河~~

本文纯干货,从源码角度深入解析Callable接口,希望大家踏下心来,打开你的IDE,跟着文章看源码,相信你一定收获不小。

1.Callable接口介绍

Callable接口是JDK1.5新增的泛型接口,在JDK1.8中,被声明为函数式接口,如下所示。

@FunctionalInterface public interface Callable<V> {     V call() throws Exception; }

在JDK 1.8中只声明有一个方法的接口为函数式接口,函数式接口可以使用@FunctionalInterface注解修饰,也可以不使用@FunctionalInterface注解修饰。只要一个接口中只包含有一个方法,那么,这个接口就是函数式接口。

在JDK中,实现Callable接口的子类如下图所示。

默认的子类层级关系图看不清,这里,可以通过IDEA右键Callable接口,选择"Layout"来指定Callable接口的实现类图的不同结构,如下所示。

这里,可以选择"Organic Layout"选项,选择后的Callable接口的子类的结构如下图所示。

在实现Callable接口的子类中,有几个比较重要的类,如下图所示。

分别是:Executors类中的静态内部类:PrivilegedCallable、PrivilegedCallableUsingCurrentClassLoader、RunnableAdapter和Task类下的TaskCallable。

2.实现Callable接口的重要类分析

接下来,分析的类主要有:PrivilegedCallable、PrivilegedCallableUsingCurrentClassLoader、RunnableAdapter和Task类下的TaskCallable。虽然这些类在实际工作中很少被直接用到,但是作为一名合格的开发工程师,设置是秃顶的资深专家来说,了解并掌握这些类的实现有助你进一步理解Callable接口,并提高专业技能(头发再掉一批,哇哈哈哈。。。)。

  • PrivilegedCallable

PrivilegedCallable类是Callable接口的一个特殊实现类,它表明Callable对象有某种特权来访问系统的某种资源,PrivilegedCallable类的源代码如下所示。

/**  * A callable that runs under established access control settings  */ static final class PrivilegedCallable<T> implements Callable<T> {     private final Callable<T> task;     private final AccessControlContext acc;      PrivilegedCallable(Callable<T> task) {         this.task = task;         this.acc = AccessController.getContext();     }      public T call() throws Exception {         try {             return AccessController.doPrivileged(                 new PrivilegedExceptionAction<T>() {                     public T run() throws Exception {                         return task.call();                     }                 }, acc);         } catch (PrivilegedActionException e) {             throw e.getException();         }     } }

从PrivilegedCallable类的源代码来看,可以将PrivilegedCallable看成是对Callable接口的封装,并且这个类也继承了Callable接口。

在PrivilegedCallable类中有两个成员变量,分别是Callable接口的实例对象和AccessControlContext类的实例对象,如下所示。

private final Callable<T> task; private final AccessControlContext acc;

其中,AccessControlContext类可以理解为一个具有系统资源访问决策的上下文类,通过这个类可以访问系统的特定资源。通过类的构造方法可以看出,在实例化AccessControlContext类的对象时,只需要传递Callable接口子类的对象即可,如下所示。

PrivilegedCallable(Callable<T> task) {     this.task = task;     this.acc = AccessController.getContext(); }

AccessControlContext类的对象是通过AccessController类的getContext()方法获取的,这里,查看AccessController类的getContext()方法,如下所示。

public static AccessControlContext getContext(){     AccessControlContext acc = getStackAccessControlContext();     if (acc == null) {         return new AccessControlContext(null, true);     } else {         return acc.optimize();     } }

通过AccessController的getContext()方法可以看出,首先通过getStackAccessControlContext()方法来获取AccessControlContext对象实例。如果获取的AccessControlContext对象实例为空,则通过调用AccessControlContext类的构造方法实例化,否则,调用AccessControlContext对象实例的optimize()方法返回AccessControlContext对象实例。

这里,我们先看下getStackAccessControlContext()方法是个什么鬼。

private static native AccessControlContext getStackAccessControlContext();

原来是个本地方法,方法的字面意思就是获取能够访问系统栈的决策上下文对象。

接下来,我们回到PrivilegedCallable类的call()方法,如下所示。

public T call() throws Exception {     try {         return AccessController.doPrivileged(             new PrivilegedExceptionAction<T>() {                 public T run() throws Exception {                     return task.call();                 }             }, acc);     } catch (PrivilegedActionException e) {         throw e.getException();     } }

通过调用AccessController.doPrivileged()方法,传递PrivilegedExceptionAction。接口对象和AccessControlContext对象,并最终返回泛型的实例对象。

首先,看下AccessController.doPrivileged()方法,如下所示。

@CallerSensitive public static native <T> T     doPrivileged(PrivilegedExceptionAction<T> action,                  AccessControlContext context)     throws PrivilegedActionException;

可以看到,又是一个本地方法。也就是说,最终的执行情况是将PrivilegedExceptionAction接口对象和AccessControlContext对象实例传递给这个本地方法执行。并且在PrivilegedExceptionAction接口对象的run()方法中调用Callable接口的call()方法来执行最终的业务逻辑,并且返回泛型对象。

  • PrivilegedCallableUsingCurrentClassLoader

此类表示为在已经建立的特定访问控制和当前的类加载器下运行的Callable类,源代码如下所示。

/**  * A callable that runs under established access control settings and  * current ClassLoader  */ static final class PrivilegedCallableUsingCurrentClassLoader<T> implements Callable<T> {     private final Callable<T> task;     private final AccessControlContext acc;     private final ClassLoader ccl;      PrivilegedCallableUsingCurrentClassLoader(Callable<T> task) {         SecurityManager sm = System.getSecurityManager();         if (sm != null) {             sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);             sm.checkPermission(new RuntimePermission("setContextClassLoader"));         }         this.task = task;         this.acc = AccessController.getContext();         this.ccl = Thread.currentThread().getContextClassLoader();     }      public T call() throws Exception {         try {             return AccessController.doPrivileged(                 new PrivilegedExceptionAction<T>() {                     public T run() throws Exception {                         Thread t = Thread.currentThread();                         ClassLoader cl = t.getContextClassLoader();                         if (ccl == cl) {                             return task.call();                         } else {                             t.setContextClassLoader(ccl);                             try {                                 return task.call();                             } finally {                                 t.setContextClassLoader(cl);                             }                         }                     }                 }, acc);         } catch (PrivilegedActionException e) {             throw e.getException();         }     } }

这个类理解起来比较简单,首先,在类中定义了三个成员变量,如下所示。

private final Callable<T> task; private final AccessControlContext acc; private final ClassLoader ccl;

接下来,通过构造方法注入Callable对象,在构造方法中,首先获取系统安全管理器对象实例,通过系统安全管理器对象实例检查是否具有获取ClassLoader和设置ContextClassLoader的权限。并在构造方法中为三个成员变量赋值,如下所示。

PrivilegedCallableUsingCurrentClassLoader(Callable<T> task) {     SecurityManager sm = System.getSecurityManager();     if (sm != null) {         sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);         sm.checkPermission(new RuntimePermission("setContextClassLoader"));     }     this.task = task;     this.acc = AccessController.getContext();     this.ccl = Thread.currentThread().getContextClassLoader(); }

接下来,通过调用call()方法来执行具体的业务逻辑,如下所示。

public T call() throws Exception {     try {         return AccessController.doPrivileged(             new PrivilegedExceptionAction<T>() {                 public T run() throws Exception {                     Thread t = Thread.currentThread();                     ClassLoader cl = t.getContextClassLoader();                     if (ccl == cl) {                         return task.call();                     } else {                         t.setContextClassLoader(ccl);                         try {                             return task.call();                         } finally {                             t.setContextClassLoader(cl);                         }                     }                 }             }, acc);     } catch (PrivilegedActionException e) {         throw e.getException();     } }

在call()方法中同样是通过调用AccessController类的本地方法doPrivileged,传递PrivilegedExceptionAction接口的实例对象和AccessControlContext类的对象实例。

具体执行逻辑为:在PrivilegedExceptionAction对象的run()方法中获取当前线程的ContextClassLoader对象,如果在构造方法中获取的ClassLoader对象与此处的ContextClassLoader对象是同一个对象(不止对象实例相同,而且内存地址也相同),则直接调用Callable对象的call()方法返回结果。否则,将PrivilegedExceptionAction对象的run()方法中的当前线程的ContextClassLoader设置为在构造方法中获取的类加载器对象,接下来,再调用Callable对象的call()方法返回结果。最终将当前线程的ContextClassLoader重置为之前的ContextClassLoader。

  • RunnableAdapter

RunnableAdapter类比较简单,给定运行的任务和结果,运行给定的任务并返回给定的结果,源代码如下所示。

/**  * A callable that runs given task and returns given result  */ static final class RunnableAdapter<T> implements Callable<T> {     final Runnable task;     final T result;     RunnableAdapter(Runnable task, T result) {         this.task = task;         this.result = result;     }     public T call() {         task.run();         return result;     } }
  • TaskCallable

TaskCallable类是javafx.concurrent.Task类的静态内部类,TaskCallable类主要是实现了Callable接口并且被定义为FutureTask的类,并且在这个类中允许我们拦截call()方法来更新task任务的状态。源代码如下所示。

private static final class TaskCallable<V> implements Callable<V> {      private Task<V> task;     private TaskCallable() { }      @Override      public V call() throws Exception {         task.started = true;         task.runLater(() -> {             task.setState(State.SCHEDULED);             task.setState(State.RUNNING);         });         try {             final V result = task.call();             if (!task.isCancelled()) {                 task.runLater(() -> {                     task.updateValue(result);                     task.setState(State.SUCCEEDED);                 });                 return result;             } else {                 return null;             }         } catch (final Throwable th) {             task.runLater(() -> {                 task._setException(th);                 task.setState(State.FAILED);             });             if (th instanceof Exception) {                 throw (Exception) th;             } else {                 throw new Exception(th);             }         }     } }

从TaskCallable类的源代码可以看出,只定义了一个Task类型的成员变量。下面主要分析TaskCallable类的call()方法。

当程序的执行进入到call()方法时,首先将task对象的started属性设置为true,表示任务已经开始,并且将任务的状态依次设置为State.SCHEDULED和State.RUNNING,依次触发任务的调度事件和运行事件。如下所示。

task.started = true; task.runLater(() -> {     task.setState(State.SCHEDULED);     task.setState(State.RUNNING); });

接下来,在try代码块中执行Task对象的call()方法,返回泛型对象。如果任务没有被取消,则更新任务的缓存,将调用call()方法返回的泛型对象绑定到Task对象中的ObjectProperty<V>对象中,其中,ObjectProperty<V>在Task类中的定义如下。

private final ObjectProperty<V> value = new SimpleObjectProperty<>(this, "value");

接下来,将任务的状态设置为成功状态。如下所示。

try {     final V result = task.call();     if (!task.isCancelled()) {         task.runLater(() -> {             task.updateValue(result);             task.setState(State.SUCCEEDED);         });         return result;     } else {         return null;     } }

如果程序抛出了异常或者错误,会进入catch()代码块,设置Task对象的Exception信息并将状态设置为State.FAILED,也就是将任务标记为失败。接下来,判断异常或错误的类型,如果是Exception类型的异常,则直接强转为Exception类型的异常并抛出。否则,将异常或者错误封装为Exception对象并抛出,如下所示。

catch (final Throwable th) {     task.runLater(() -> {         task._setException(th);         task.setState(State.FAILED);     });     if (th instanceof Exception) {         throw (Exception) th;     } else {         throw new Exception(th);     } }

记住:你比别人强的地方,不是你做过多少年的CRUD工作,而是你比别人掌握了更多深入的技能。不要总停留在CRUD的表面工作,理解并掌握底层原理并熟悉源码实现,并形成自己的抽象思维能力,做到灵活运用,才是你突破瓶颈,脱颖而出的重要方向!

最后,作为一名合格(发际线比较高)的开发人员或者资深(秃顶)的工程师和架构师来说,理解原理和掌握源码,并形成自己的抽象思维能力,灵活运用是你必须掌握的技能。

好了,今天就到这儿吧,我是冰河,我们下期见~~

No comments:

Post a Comment